diff --git a/.gitignore b/.gitignore index 53802ba..b595913 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,9 @@ node_modules/ frontend/dist/ build/ dist/ +*.egg-info/ packaging/npm/vendor/ .artifacts/runtime/ -playwright-report/ -test-results/ .artifacts/ task_plan.md findings.md diff --git a/README.md b/README.md index 08f5e09..3fd50e7 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,89 @@ # skill-manager

- skill-manager hero + Skill Manager

+

+ A local-first control center for AI extensions.
+ Use, review, and discover Skills, MCP servers, and CLI tools across agent harnesses. +

License: MIT - Release v0.1.0 + Latest release npm version Install with Homebrew macOS - Local-first + Local-first

-Manage AI skills across Codex, Claude, Cursor, OpenCode, and OpenClaw from one local app. +![skill-market-overview](./assets/skill-manager-skill-unification.svg) -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 -

- skill-manager managed skills view -

+### 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. -

- skill-manager marketplace view -

+![skill-market-overview](./assets/skill-manager-overview.png) -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. + +![skill-market-skill-matrxi](./assets/skill-manager-skill-matrix.png) + +### 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. + +![skill-market-skill-matrxi](./assets/skill-manager-mcp-matrix.png) + +### 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. + +![skill-market-skill-matrxi](./assets/skill-manager-marketplace.png) ## 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 and after skill management flow -

+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. + +![skill-market-overview](./assets/skill-manager-skill-unification.svg) + +### 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. + +![skill-market-overview](./assets/skill-manager-mcp-translation.svg) + +### 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 @@ + +How Skill Manager translates MCP server configs across harnesses +Skill Manager imports a valid config from the source harness, normalizes it into a single record with named fields, then projects that record into each harness's own config format using verified codecs. OpenClaw is capability-gated and may be skipped if the local client doesn't support the required config surface. + + + + + + + + + + +SOURCE HARNESS +Where you installed the MCP server + + + + + + +Codex CLI · ~/.codex/config.toml +TOML +[mcp_servers.fs] +command = "npx" +args = ["-y", "@mcp/fs"] + + + + + + +import + normalize + + + +SKILL MANAGER +One normalized record, source of truth + + + +NORMALIZED RECORD +"fs" + + +name + +command + +args + +env + +transport + + + + + + + +project · verified codecs + + + +PROJECTED CONFIGS +Each harness gets its own format + + + + + +Claude Code + + +"mcpServers" +{ "fs": {...} } + + +written · JSON + + + + + +Cursor + + +"mcpServers" +{ "fs": {...} } + + +written · JSON + + + + + +OpenCode + + +mcp.fs +type, command... + + +written · typed + + + + + +OpenClaw + + +capability-gated + +⚠ may be skipped + + + \ 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 @@ + +How Skill Manager unifies skills across harnesses +Before adoption, each harness keeps its own copy of the same skill, and over time the copies drift to different versions. After adoption, Skill Manager keeps one canonical copy in a shared store and each enabled harness links back to it; per-harness toggles control which harnesses see the skill. + + + + + + + + + +BEFORE +Each harness keeps its own copy + + + + +copies drift over time + + + + +Codex +~/.agents/skills + + + + +code-review +v1.2.0 + + + + +Claude Code +~/.claude/skills + + + + +code-review +v1.0.3 + + + + +Cursor +~/.cursor/skills + + + + +code-review +v1.2.0 + + + + +OpenCode +~/.config/opencode + + + + +code-review +v1.1.5 + + +Same skill, four local copies — versions diverging + + + + +adopt + + + + +AFTER +One canonical copy, harnesses link to it + + + +SHARED STORE + + + + +code-review +v1.2.0 · canonical + + + + + + + + + +Codex + + + + + + + + +Claude Code + + + + + + + + +Cursor + + + + + + + + +OpenCode + + + + + + + \ 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 @@ - -Before and after skill management across five harnesses -Before: skills are duplicated with version drift across Codex CLI, Claude Code, Cursor, OpenCode, and OpenClaw. After: one managed inventory, harnesses symlink in. - - - - - - -Before management -Each harness keeps its own copies — versions drift - - - -Codex CLI - -Superpowers - -Frontend Design - -Remotion -mixed versions - - - -Claude Code - -Superpowers - -Frontend Design - -Syst. Debug -mixed versions - - - -Cursor - -Superpowers - -Remotion - -Syst. Debug -mixed versions - - - -OpenCode - -Superpowers - -Frontend Design -mixed versions - - - -OpenClaw - -Remotion - -Syst. Debug -mixed versions - - - - - -After management -One canonical copy per skill — harnesses symlink in - - - -Managed inventory - - -Superpowers - -Frontend Design - -Remotion - -Systematic Debugging - -Update once, every harness gets it - - - -Codex CLI - -Claude Code - -Cursor - -OpenCode - -OpenClaw - - - - - - - - - -symlink -symlink -symlink -symlink -symlink - \ 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 @@ + + Skill Manager logo, refined lockup + Refined horizontal lockup. Custom-drawn square brackets with subtle pixel-style outer chamfers flank a small amber rounded-corner square at the bracket mark's center. To the right, a monospace wordmark sets skill and manager separated by a second identical amber rounded square. The two amber squares align at x-height center and share the same geometry as the favicon's grid cells, unifying the system. + + + + + + + + + + skill + + + + manager + 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; - }>(); - let skillsRequestCount = 0; - - fetchMock.mockImplementation(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url === "/api/skills") { - skillsRequestCount += 1; - if (skillsRequestCount === 1) { - return { - ok: true, - json: async () => ({ - summary: { managed: 1, unmanaged: 0, custom: 0, builtIn: 0 }, - harnessColumns: [{ harness: "codex", label: "Codex", logoKey: "codex" }], - rows: [{ - skillRef: "shared:shared-audit", - name: "Shared Audit", - description: "Shared audit workflow", - displayStatus: "Managed", - attentionMessage: null, - actions: { canManage: false }, - cells: [{ harness: "codex", label: "Codex", logoKey: "codex", state: "enabled", interactive: true }], - }], - }), - }; - } - return pendingRefresh.promise; - } - throw new Error(`Unhandled URL ${url}`); - }); - - renderApp("/"); - - await waitFor(() => expect(screen.getByText("Shared Audit")).toBeInTheDocument()); - - const refreshButton = screen.getByRole("button", { name: "Refresh Data" }); - fireEvent.click(refreshButton); - - await waitFor(() => expect(refreshButton).toHaveAttribute("aria-busy", "true")); - expect(refreshButton).toBeDisabled(); - expect(within(refreshButton).getByRole("status", { name: "Refreshing data" })).toBeInTheDocument(); - expect(screen.queryByLabelText("Refreshing skills")).not.toBeInTheDocument(); - - pendingRefresh.resolve({ - ok: true, - json: async () => ({ - summary: { managed: 1, unmanaged: 0, custom: 0, builtIn: 0 }, - harnessColumns: [{ harness: "codex", label: "Codex", logoKey: "codex" }], - rows: [{ - skillRef: "shared:shared-audit", - name: "Shared Audit", - description: "Shared audit workflow", - displayStatus: "Managed", - attentionMessage: null, - actions: { canManage: false }, - cells: [{ harness: "codex", label: "Codex", logoKey: "codex", state: "enabled", interactive: true }], - }], - }), - }); - - await waitFor(() => expect(refreshButton).not.toBeDisabled()); - expect(screen.getByRole("button", { name: "Refresh Data" })).toBeInTheDocument(); - }); - - it("navigates to the Settings page", async () => { - mockSkillsPage(); - renderApp("/"); - fireEvent.click(screen.getByRole("link", { name: "Open settings" })); - await waitFor(() => expect(screen.getByRole("heading", { name: "Harnesses" })).toBeInTheDocument()); - expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Harnesses" })).toBeInTheDocument(); - expect(screen.getByRole("switch", { name: "Enable Codex support" })).toBeInTheDocument(); - expect(screen.getByText("/tmp/home/.agents/skills")).toBeInTheDocument(); - expect(screen.queryByText("Support toggles are non-destructive.")).not.toBeInTheDocument(); - expect(screen.queryByText("Ready for skill discovery and management on this computer.")).not.toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Store Network" })).not.toBeInTheDocument(); - expect(screen.queryByText("filesystem")).not.toBeInTheDocument(); - }); - - it("shows the enabled-state harness support tooltip on toggle focus", async () => { - mockSkillsPage(); - renderApp("/settings"); - - const codexSwitch = await screen.findByRole("switch", { name: "Enable Codex support" }); - fireEvent.focus(codexSwitch); - - expect( - screen.getByText("Turn off to make skill-manager ignore this harness. Your local files stay unchanged."), - ).toBeInTheDocument(); - }); - - it("shows the disabled-state harness support tooltip on toggle focus", async () => { - mockSkillsPage({ codexSupportEnabled: false }); - renderApp("/settings"); - - const codexSwitch = await screen.findByRole("switch", { name: "Enable Codex support" }); - fireEvent.focus(codexSwitch); - - expect( - screen.getByText("Turn on to let skill-manager discover and manage skills for this harness. Nothing is moved or deleted."), - ).toBeInTheDocument(); - }); - - it("clears settings toggle pending state after a successful support update", async () => { - mockSkillsPage(); - renderApp("/settings"); - - const codexSwitch = await screen.findByRole("switch", { name: "Enable Codex support" }); - fireEvent.click(codexSwitch); - - await waitFor(() => expect(codexSwitch).not.toBeDisabled()); - }); - - it("reuses the cached skills workspace and preserves managed search when returning from marketplace", async () => { - mockSkillsPage(); - renderApp("/skills/managed"); - - await waitFor(() => expect(screen.getByLabelText("Search managed skills")).toBeInTheDocument()); - - fireEvent.change(screen.getByLabelText("Search managed skills"), { - target: { value: "Audit" }, - }); - - fireEvent.click(screen.getByRole("link", { name: "Marketplace" })); - await waitFor(() => expect(screen.getByText("All-time leaderboard")).toBeInTheDocument()); - - fireEvent.click(screen.getByRole("link", { name: "Skills" })); - await waitFor(() => expect(screen.getByLabelText("Search managed skills")).toBeInTheDocument()); - - expect(screen.getByDisplayValue("Audit")).toBeInTheDocument(); - expect(screen.queryByText("Loading managed skills")).not.toBeInTheDocument(); - - const skillListRequests = fetchMock.mock.calls.filter(([input]) => { - const url = typeof input === "string" ? input : input.toString(); - return url === "/api/skills"; - }); - expect(skillListRequests).toHaveLength(1); - }); - - it("opens the inline detail panel from the skills URL query param", async () => { - mockSkillsPage(); - const scrollSpy = vi.fn(); - Object.defineProperty(window, "scrollTo", { - writable: true, - configurable: true, - value: scrollSpy, - }); - renderApp("/skills/managed?skill=shared:shared-audit"); - - await waitFor(() => expect(screen.getByLabelText("Skill details panel")).toBeInTheDocument()); - await waitFor(() => - expect( - screen.getByText("Run the shared audit workflow."), - ).toBeInTheDocument(), - ); - const panel = screen.getByLabelText("Skill details panel"); - expect(within(panel).getByRole("button", { name: /SKILL\.md/i })).toHaveAttribute("aria-expanded", "true"); - expect(within(panel).getByText("Source")).toBeInTheDocument(); - expect(within(panel).getByRole("link", { name: /mode-io\/shared-audit/i })).toHaveAttribute("href", "https://github.com/mode-io/shared-audit"); - expect(within(panel).getByRole("link", { name: /Open skill folder/i })).toHaveAttribute("href", "https://github.com/mode-io/shared-audit/tree/main/shared-audit"); - expect(within(panel).getByText("No Update Available")).toBeInTheDocument(); - expect(within(panel).getByText("Harness access")).toBeInTheDocument(); - expect(within(panel).getByRole("switch", { name: "Enable Shared Audit for Codex" })).toBeInTheDocument(); - expect(within(panel).getByRole("switch", { name: "Enable Shared Audit for Claude" })).toBeInTheDocument(); - expect(within(panel).getByRole("switch", { name: "Enable Shared Audit for Cursor" })).toBeInTheDocument(); - expect(within(panel).getByRole("switch", { name: "Enable Shared Audit for OpenCode" })).toBeInTheDocument(); - expect(within(panel).getByRole("switch", { name: "Enable Shared Audit for OpenClaw" })).toBeInTheDocument(); - expect(within(panel).getByRole("button", { name: "Stop Managing" })).toBeInTheDocument(); - expect(within(panel).getByRole("button", { name: "Delete Skill" })).toBeInTheDocument(); - expect(within(panel).queryByRole("button", { name: /Advanced details/i })).not.toBeInTheDocument(); - expect(within(panel).queryByText("Overview")).not.toBeInTheDocument(); - expect(within(panel).getByText("Shared Store is the canonical physical package. Tool locations are symlinks to it when enabled.")).toBeInTheDocument(); - expect(within(panel).getByText("Canonical physical package")).toBeInTheDocument(); - expect(within(panel).getByText("Symlink to Shared Store")).toBeInTheDocument(); - expect(screen.queryByLabelText("Skill details drawer")).not.toBeInTheDocument(); - expect(scrollSpy).not.toHaveBeenCalled(); - }); - - it("routes detail-panel harness toggles through the enable mutation endpoint", async () => { - mockSkillsPage(); - renderApp("/skills/managed?skill=shared:shared-audit"); - - const panel = await screen.findByLabelText("Skill details panel"); - await waitFor(() => expect(within(panel).getByText("Harness access")).toBeInTheDocument()); - const toggle = within(panel).getByRole("switch", { name: "Enable Shared Audit for Codex" }); - - fireEvent.click(toggle); - - await waitFor(() => - expect( - fetchMock.mock.calls.some(([input, init]) => { - const url = typeof input === "string" ? input : input.toString(); - return url === "/api/skills/shared%3Ashared-audit/enable" && init?.method === "POST"; - }), - ).toBe(true), - ); - }); - - it("confirms destructive delete from the detail panel and removes the skill", async () => { - mockSkillsPage(); - renderApp("/skills/managed?skill=shared:shared-audit"); - - const panel = await screen.findByLabelText("Skill details panel"); - const deleteButton = await within(panel).findByRole("button", { name: "Delete Skill" }); - fireEvent.click(deleteButton); - - const dialog = await screen.findByRole("dialog", { name: "Delete managed skill?" }); - expect(within(dialog).getByText("Affected harnesses: Codex")).toBeInTheDocument(); - expect(within(dialog).getByRole("button", { name: "Still Delete" })).toBeInTheDocument(); - - fireEvent.click(within(dialog).getByRole("button", { name: "Still Delete" })); - - await waitFor(() => - expect( - fetchMock.mock.calls.some(([input, init]) => { - const url = typeof input === "string" ? input : input.toString(); - return url === "/api/skills/shared%3Ashared-audit/delete" && init?.method === "POST"; - }), - ).toBe(true), - ); - - await waitFor(() => - expect(screen.getByLabelText("Skill details panel")).toHaveAttribute("aria-hidden", "true"), - ); - expect(within(screen.getByLabelText("Managed skills list")).queryByText("Shared Audit")).not.toBeInTheDocument(); - }); - - it("shows stop-managing help and moves the skill back to unmanaged", async () => { - mockSkillsPage(); - renderApp("/skills/managed?skill=shared:shared-audit"); - - const panel = await screen.findByLabelText("Skill details panel"); - const stopManagingButton = await within(panel).findByRole("button", { name: "Stop Managing" }); - - fireEvent.mouseEnter(stopManagingButton); - - await waitFor(() => expect(screen.getByText("Moves this skill out of the shared managed store and restores local copies only for the harnesses that are currently enabled.")).toBeInTheDocument()); - - fireEvent.click(stopManagingButton); - - const dialog = await screen.findByRole("dialog", { name: "Move skill back to unmanaged?" }); - expect(within(dialog).getByText("Will restore to: Codex")).toBeInTheDocument(); - expect(within(dialog).getByRole("button", { name: "Stop Managing" })).toBeInTheDocument(); - - fireEvent.click(within(dialog).getByRole("button", { name: "Stop Managing" })); - - await waitFor(() => - expect( - fetchMock.mock.calls.some(([input, init]) => { - const url = typeof input === "string" ? input : input.toString(); - return url === "/api/skills/shared%3Ashared-audit/unmanage" && init?.method === "POST"; - }), - ).toBe(true), - ); - - await waitFor(() => - expect(screen.getByLabelText("Skill details panel")).toHaveAttribute("aria-hidden", "true"), - ); - }); - - it("does not auto-scroll the window on desktop user selection", async () => { - mockSkillsPage(); - const scrollSpy = vi.fn(); - Object.defineProperty(window, "scrollTo", { - writable: true, - configurable: true, - value: scrollSpy, - }); - - renderApp("/skills/managed"); - await waitFor(() => expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument()); - - fireEvent.click(screen.getByText("Shared audit workflow")); - await waitFor(() => expect(screen.getByLabelText("Skill details panel")).toBeInTheDocument()); - expect(scrollSpy).not.toHaveBeenCalled(); - }); - - it("closes the inline detail panel when the selected card is clicked again", async () => { - mockSkillsPage(); - renderApp("/skills/managed"); - - await waitFor(() => expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument()); - - fireEvent.click(screen.getByText("Shared audit workflow")); - await waitFor(() => - expect(screen.getByLabelText("Skill details panel")).toHaveAttribute("aria-hidden", "false"), - ); - - fireEvent.click(screen.getByText("Shared audit workflow")); - await waitFor(() => - expect(screen.getByLabelText("Skill details panel")).toHaveAttribute("aria-hidden", "true"), - ); - }); -}); - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 2f6afea..2b79b68 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -21,6 +21,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/marketplace/clis/items/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Cli Marketplace Detail */ + get: operations["get_cli_marketplace_detail_api_marketplace_clis_items__slug__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/clis/popular": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Popular Cli Marketplace */ + get: operations["popular_cli_marketplace_api_marketplace_clis_popular_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/clis/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search Cli Marketplace */ + get: operations["search_cli_marketplace_api_marketplace_clis_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/marketplace/install": { parameters: { query?: never; @@ -72,6 +123,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/marketplace/mcp/install-targets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Mcp Install Targets */ + get: operations["get_mcp_install_targets_api_marketplace_mcp_install_targets_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/mcp/items/{qualified_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Mcp Marketplace Detail */ + get: operations["get_mcp_marketplace_detail_api_marketplace_mcp_items__qualified_name__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/mcp/popular": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Popular Mcp Marketplace */ + get: operations["popular_mcp_marketplace_api_marketplace_mcp_popular_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/marketplace/mcp/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search Mcp Marketplace */ + get: operations["search_mcp_marketplace_api_marketplace_mcp_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/marketplace/popular": { parameters: { query?: never; @@ -106,6 +225,144 @@ export interface paths { patch?: never; trace?: never; }; + "/api/mcp/servers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Mcp Servers */ + get: operations["list_mcp_servers_api_mcp_servers_get"]; + put?: never; + /** Install Mcp Server */ + post: operations["install_mcp_server_api_mcp_servers_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/servers/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Mcp Server */ + get: operations["get_mcp_server_api_mcp_servers__name__get"]; + put?: never; + post?: never; + /** Uninstall Mcp Server */ + delete: operations["uninstall_mcp_server_api_mcp_servers__name__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/servers/{name}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable Mcp Server */ + post: operations["disable_mcp_server_api_mcp_servers__name__disable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/servers/{name}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable Mcp Server */ + post: operations["enable_mcp_server_api_mcp_servers__name__enable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/servers/{name}/reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reconcile Mcp Server */ + post: operations["reconcile_mcp_server_api_mcp_servers__name__reconcile_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/servers/{name}/set-harnesses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set Mcp Server Harnesses */ + post: operations["set_mcp_server_harnesses_api_mcp_servers__name__set_harnesses_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/unmanaged/adopt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Adopt Mcp Server */ + post: operations["adopt_mcp_server_api_mcp_unmanaged_adopt_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mcp/unmanaged/by-server": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Unmanaged By Server */ + get: operations["list_unmanaged_by_server_api_mcp_unmanaged_by_server_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/settings": { parameters: { query?: never; @@ -259,6 +516,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/skills/{skill_ref}/set-harnesses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set Skill Harnesses */ + post: operations["set_skill_harnesses_api_skills__skill_ref__set_harnesses_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/skills/{skill_ref}/source-status": { parameters: { query?: never; @@ -283,95 +557,1386 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; - put?: never; - /** Unmanage Skill */ - post: operations["unmanage_skill_api_skills__skill_ref__unmanage_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; + get?: never; + put?: never; + /** Unmanage Skill */ + post: operations["unmanage_skill_api_skills__skill_ref__unmanage_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/skills/{skill_ref}/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Update Skill */ + post: operations["update_skill_api_skills__skill_ref__update_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AddMcpServerRequest */ + AddMcpServerRequest: { + /** Qualifiedname */ + qualifiedName: string; + /** Sourceharness */ + sourceHarness: string; + }; + /** AdoptMcpRequest */ + AdoptMcpRequest: { + /** Harnesses */ + harnesses?: string[] | null; + /** Name */ + name: string; + /** Sourceharness */ + sourceHarness?: string | null; + }; + /** BulkManageFailureResponse */ + BulkManageFailureResponse: { + /** Error */ + error: string; + /** Name */ + name: string; + /** Skillref */ + skillRef: string; + }; + /** BulkManageResultResponse */ + BulkManageResultResponse: { + /** Failures */ + failures: components["schemas"]["BulkManageFailureResponse"][]; + /** Managedcount */ + managedCount: number; + /** Ok */ + ok: boolean; + /** Skippedcount */ + skippedCount: number; + }; + /** CliMarketplaceDetailResponse */ + CliMarketplaceDetailResponse: { + /** Category */ + category?: string | null; + /** Description */ + description: string; + /** Githuburl */ + githubUrl?: string | null; + /** Hasmcp */ + hasMcp: boolean; + /** Hasskill */ + hasSkill: boolean; + /** Iconurl */ + iconUrl?: string | null; + /** Id */ + id: string; + /** Installcommand */ + installCommand?: string | null; + /** Isofficial */ + isOfficial: boolean; + /** Istui */ + isTui: boolean; + /** Language */ + language?: string | null; + /** Longdescription */ + longDescription?: string | null; + /** Marketplaceurl */ + marketplaceUrl: string; + /** Name */ + name: string; + /** Slug */ + slug: string; + /** Sourcetype */ + sourceType?: string | null; + /** Stars */ + stars?: number | null; + /** Vendorname */ + vendorName?: string | null; + /** Websiteurl */ + websiteUrl?: string | null; + }; + /** CliMarketplaceItemResponse */ + CliMarketplaceItemResponse: { + /** Category */ + category?: string | null; + /** Description */ + description: string; + /** Githuburl */ + githubUrl?: string | null; + /** Hasmcp */ + hasMcp: boolean; + /** Hasskill */ + hasSkill: boolean; + /** Iconurl */ + iconUrl?: string | null; + /** Id */ + id: string; + /** Isofficial */ + isOfficial: boolean; + /** Istui */ + isTui: boolean; + /** Language */ + language?: string | null; + /** Marketplaceurl */ + marketplaceUrl: string; + /** Name */ + name: string; + /** Slug */ + slug: string; + /** Sourcetype */ + sourceType?: string | null; + /** Stars */ + stars?: number | null; + /** Vendorname */ + vendorName?: string | null; + /** Websiteurl */ + websiteUrl?: string | null; + }; + /** CliMarketplacePageResponse */ + CliMarketplacePageResponse: { + /** Hasmore */ + hasMore: boolean; + /** Items */ + items: components["schemas"]["CliMarketplaceItemResponse"][]; + /** Nextoffset */ + nextOffset?: number | null; + }; + /** DisableMcpServerRequest */ + DisableMcpServerRequest: { + /** + * Harness + * @description Harness identifier + */ + harness: string; + }; + /** DisableSkillRequest */ + DisableSkillRequest: { + /** + * Harness + * @description Harness identifier + */ + harness: string; + }; + /** EnableMcpServerRequest */ + EnableMcpServerRequest: { + /** + * Harness + * @description Harness identifier + */ + harness: string; + }; + /** EnableSkillRequest */ + EnableSkillRequest: { + /** + * Harness + * @description Harness identifier + */ + harness: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** HarnessCellResponse */ + HarnessCellResponse: { + /** Harness */ + harness: string; + /** Interactive */ + interactive: boolean; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** + * State + * @enum {string} + */ + state: "enabled" | "disabled" | "found" | "empty"; + }; + /** HarnessColumnResponse */ + HarnessColumnResponse: { + /** Harness */ + harness: string; + /** Installed */ + installed: boolean; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + }; + /** InstallMarketplaceSkillRequest */ + InstallMarketplaceSkillRequest: { + /** Installtoken */ + installToken: string; + }; + /** McpAdoptionIssueResponse */ + McpAdoptionIssueResponse: { + /** Configpath */ + configPath?: string | null; + /** Harness */ + harness: string; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** Name */ + name: string; + /** Payloadpreview */ + payloadPreview?: { + [key: string]: unknown; + } | null; + /** Reason */ + reason: string; + }; + /** McpApplyConfigResponse */ + McpApplyConfigResponse: { + /** Failed */ + failed: components["schemas"]["McpMutationFailureResponse"][]; + /** Ok */ + ok: boolean; + server: components["schemas"]["McpServerSpecResponse"]; + /** Succeeded */ + succeeded: string[]; + }; + /** McpBindingResponse */ + McpBindingResponse: { + /** Driftdetail */ + driftDetail?: string | null; + /** Harness */ + harness: string; + /** + * State + * @enum {string} + */ + state: "managed" | "drifted" | "unmanaged" | "missing"; + }; + /** McpConfigChoiceResponse */ + McpConfigChoiceResponse: { + /** Configpath */ + configPath?: string | null; + /** Env */ + env?: components["schemas"]["McpEnvEntryResponse"][]; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** Payloadpreview */ + payloadPreview: { + [key: string]: unknown; + }; + /** Sourceharness */ + sourceHarness?: string | null; + /** + * Sourcekind + * @enum {string} + */ + sourceKind: "managed" | "harness"; + spec: components["schemas"]["McpServerSpecResponse"]; + }; + /** McpEnvEntryResponse */ + McpEnvEntryResponse: { + /** Isenvref */ + isEnvRef: boolean; + /** Key */ + key: string; + /** Value */ + value?: string | null; + }; + /** McpIdentityGroupResponse */ + McpIdentityGroupResponse: { + canonicalSpec?: components["schemas"]["McpServerSpecResponse"] | null; + /** Identical */ + identical: boolean; + marketplaceLink?: components["schemas"]["McpMarketplaceLinkResponse"] | null; + /** Name */ + name: string; + /** Sightings */ + sightings: components["schemas"]["McpIdentitySightingResponse"][]; + }; + /** McpIdentitySightingResponse */ + McpIdentitySightingResponse: { + /** Configpath */ + configPath?: string | null; + /** Env */ + env?: components["schemas"]["McpEnvEntryResponse"][]; + /** Harness */ + harness: string; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** Payloadpreview */ + payloadPreview: { + [key: string]: unknown; + }; + spec: components["schemas"]["McpServerSpecResponse"]; + }; + /** McpInstallTargetResponse */ + McpInstallTargetResponse: { + /** Harness */ + harness: string; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** Reason */ + reason?: string | null; + /** Smitheryclient */ + smitheryClient?: string | null; + /** Supported */ + supported: boolean; + }; + /** McpInstallTargetsResponse */ + McpInstallTargetsResponse: { + /** Targets */ + targets: components["schemas"]["McpInstallTargetResponse"][]; + }; + /** McpInventoryColumnResponse */ + McpInventoryColumnResponse: { + /** Configpresent */ + configPresent: boolean; + /** Harness */ + harness: string; + /** Installed */ + installed: boolean; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** Mcpunavailablereason */ + mcpUnavailableReason?: string | null; + /** + * Mcpwritable + * @default true + */ + mcpWritable: boolean; + }; + /** McpInventoryEntryResponse */ + McpInventoryEntryResponse: { + /** Canenable */ + canEnable: boolean; + /** Displayname */ + displayName: string; + /** + * Kind + * @enum {string} + */ + kind: "managed" | "unmanaged"; + /** Name */ + name: string; + /** Sightings */ + sightings: components["schemas"]["McpBindingResponse"][]; + spec?: components["schemas"]["McpServerSpecResponse"] | null; + }; + /** McpInventoryIssueResponse */ + McpInventoryIssueResponse: { + /** Name */ + name: string; + /** Reason */ + reason: string; + }; + /** McpInventoryResponse */ + McpInventoryResponse: { + /** Columns */ + columns: components["schemas"]["McpInventoryColumnResponse"][]; + /** Entries */ + entries: components["schemas"]["McpInventoryEntryResponse"][]; + /** Issues */ + issues?: components["schemas"]["McpInventoryIssueResponse"][]; + }; + /** McpMarketplaceCapabilityCountsResponse */ + McpMarketplaceCapabilityCountsResponse: { + /** Prompts */ + prompts: number; + /** Resources */ + resources: number; + /** Tools */ + tools: number; + }; + /** McpMarketplaceConnectionResponse */ + McpMarketplaceConnectionResponse: { + /** Bundleurl */ + bundleUrl?: string | null; + /** Configschema */ + configSchema?: { + [key: string]: unknown; + } | null; + /** Deploymenturl */ + deploymentUrl?: string | null; + /** Kind */ + kind: string; + /** Runtime */ + runtime?: string | null; + /** Stdioargs */ + stdioArgs?: string[] | null; + /** Stdiocommand */ + stdioCommand?: string | null; + /** Stdiofunction */ + stdioFunction?: string | null; + }; + /** McpMarketplaceDetailResponse */ + McpMarketplaceDetailResponse: { + capabilityCounts: components["schemas"]["McpMarketplaceCapabilityCountsResponse"]; + /** Connections */ + connections: components["schemas"]["McpMarketplaceConnectionResponse"][]; + /** Deploymenturl */ + deploymentUrl?: string | null; + /** Description */ + description: string; + /** Displayname */ + displayName: string; + /** Externalurl */ + externalUrl: string; + /** Iconurl */ + iconUrl?: string | null; + /** Isremote */ + isRemote: boolean; + /** Managedname */ + managedName: string; + /** Prompts */ + prompts: components["schemas"]["McpMarketplacePromptResponse"][]; + /** Qualifiedname */ + qualifiedName: string; + /** Resources */ + resources: components["schemas"]["McpMarketplaceResourceResponse"][]; + /** Tools */ + tools: components["schemas"]["McpMarketplaceToolResponse"][]; + }; + /** McpMarketplaceItemResponse */ + McpMarketplaceItemResponse: { + /** Createdat */ + createdAt?: string | null; + /** Description */ + description: string; + /** Displayname */ + displayName: string; + /** Externalurl */ + externalUrl: string; + /** Homepage */ + homepage?: string | null; + /** Iconurl */ + iconUrl?: string | null; + /** Isdeployed */ + isDeployed: boolean; + /** Isremote */ + isRemote: boolean; + /** Isverified */ + isVerified: boolean; + /** Namespace */ + namespace: string; + /** Qualifiedname */ + qualifiedName: string; + /** Usecount */ + useCount: number; + }; + /** McpMarketplaceLinkResponse */ + McpMarketplaceLinkResponse: { + /** Description */ + description: string; + /** Displayname */ + displayName: string; + /** Externalurl */ + externalUrl: string; + /** Iconurl */ + iconUrl?: string | null; + /** Isremote */ + isRemote: boolean; + /** Isverified */ + isVerified: boolean; + /** Qualifiedname */ + qualifiedName: string; + }; + /** McpMarketplacePageResponse */ + McpMarketplacePageResponse: { + /** Hasmore */ + hasMore: boolean; + /** Items */ + items: components["schemas"]["McpMarketplaceItemResponse"][]; + /** Nextoffset */ + nextOffset?: number | null; + }; + /** McpMarketplaceParameterResponse */ + McpMarketplaceParameterResponse: { + /** Default */ + default?: unknown | null; + /** Description */ + description: string; + /** Enum */ + enum?: unknown[] | null; + /** Maxitems */ + maxItems?: number | null; + /** Maxlength */ + maxLength?: number | null; + /** Maximum */ + maximum?: number | null; + /** Minitems */ + minItems?: number | null; + /** Minlength */ + minLength?: number | null; + /** Minimum */ + minimum?: number | null; + /** Name */ + name: string; + /** Required */ + required: boolean; + /** Type */ + type: string; + }; + /** McpMarketplacePromptArgumentResponse */ + McpMarketplacePromptArgumentResponse: { + /** Description */ + description: string; + /** Name */ + name: string; + /** Required */ + required: boolean; + }; + /** McpMarketplacePromptResponse */ + McpMarketplacePromptResponse: { + /** Arguments */ + arguments: components["schemas"]["McpMarketplacePromptArgumentResponse"][]; + /** Description */ + description: string; + /** Name */ + name: string; + }; + /** McpMarketplaceResourceResponse */ + McpMarketplaceResourceResponse: { + /** Description */ + description: string; + /** Mimetype */ + mimeType?: string | null; + /** Name */ + name: string; + /** Uri */ + uri: string; + }; + /** McpMarketplaceToolResponse */ + McpMarketplaceToolResponse: { + /** Description */ + description: string; + /** Name */ + name: string; + /** Parameters */ + parameters: components["schemas"]["McpMarketplaceParameterResponse"][]; + }; + /** McpMutationFailureResponse */ + McpMutationFailureResponse: { + /** Error */ + error: string; + /** Harness */ + harness: string; + }; + /** McpServerDetailResponse */ + McpServerDetailResponse: { + /** Canenable */ + canEnable: boolean; + /** Configchoices */ + configChoices?: components["schemas"]["McpConfigChoiceResponse"][]; + /** Displayname */ + displayName: string; + /** Env */ + env?: components["schemas"]["McpEnvEntryResponse"][]; + /** + * Kind + * @enum {string} + */ + kind: "managed" | "unmanaged"; + marketplaceLink?: components["schemas"]["McpMarketplaceLinkResponse"] | null; + /** Name */ + name: string; + /** Sightings */ + sightings: components["schemas"]["McpBindingResponse"][]; + spec?: components["schemas"]["McpServerSpecResponse"] | null; + }; + /** McpServerMutationResponse */ + McpServerMutationResponse: { + /** Ok */ + ok: boolean; + server: components["schemas"]["McpServerSpecResponse"]; + }; + /** McpServerSpecResponse */ + McpServerSpecResponse: { + /** Args */ + args?: string[] | null; + /** Command */ + command?: string | null; + /** Displayname */ + displayName: string; + /** Env */ + env?: { + [key: string]: string; + } | null; + /** Headers */ + headers?: { + [key: string]: string; + } | null; + /** Installedat */ + installedAt: string; + /** Name */ + name: string; + /** Revision */ + revision: string; + source: components["schemas"]["McpSourceResponse"]; + /** + * Transport + * @enum {string} + */ + transport: "stdio" | "http" | "sse"; + /** Url */ + url?: string | null; + }; + /** McpSetHarnessesResultResponse */ + McpSetHarnessesResultResponse: { + /** Failed */ + failed: components["schemas"]["McpMutationFailureResponse"][]; + /** Ok */ + ok: boolean; + /** Succeeded */ + succeeded: string[]; + }; + /** McpSourceResponse */ + McpSourceResponse: { + /** + * Kind + * @enum {string} + */ + kind: "marketplace" | "adopted" | "manual"; + /** Locator */ + locator: string; + }; + /** McpUnmanagedByServerResponse */ + McpUnmanagedByServerResponse: { + /** Harnesses */ + harnesses: components["schemas"]["McpUnmanagedHarnessResponse"][]; + /** Issues */ + issues?: components["schemas"]["McpAdoptionIssueResponse"][]; + /** Servers */ + servers: components["schemas"]["McpIdentityGroupResponse"][]; + }; + /** McpUnmanagedHarnessResponse */ + McpUnmanagedHarnessResponse: { + /** Configpath */ + configPath?: string | null; + /** Configpresent */ + configPresent: boolean; + /** Harness */ + harness: string; + /** Installed */ + installed: boolean; + /** Label */ + label: string; + /** Logokey */ + logoKey?: string | null; + /** Mcpunavailablereason */ + mcpUnavailableReason?: string | null; + /** + * Mcpwritable + * @default true + */ + mcpWritable: boolean; + }; + /** OkResponse */ + OkResponse: { + /** Ok */ + ok: boolean; + }; + /** ReconcileMcpServerRequest */ + ReconcileMcpServerRequest: { + /** Harnesses */ + harnesses?: string[] | null; + /** Sourceharness */ + sourceHarness?: string | null; + /** + * Sourcekind + * @enum {string} + */ + sourceKind: "managed" | "harness"; + }; + /** SetHarnessSupportRequest */ + SetHarnessSupportRequest: { + /** Enabled */ + enabled: boolean; + }; + /** SetMcpServerHarnessesRequest */ + SetMcpServerHarnessesRequest: { + /** + * Target + * @enum {string} + */ + target: "enabled" | "disabled"; + }; + /** SetSkillHarnessesFailureResponse */ + SetSkillHarnessesFailureResponse: { + /** Error */ + error: string; + /** Harness */ + harness: string; + }; + /** SetSkillHarnessesRequest */ + SetSkillHarnessesRequest: { + /** + * Target + * @description Target state to apply to every interactive harness cell on this skill + * @enum {string} + */ + target: "enabled" | "disabled"; + }; + /** SetSkillHarnessesResultResponse */ + SetSkillHarnessesResultResponse: { + /** Failed */ + failed: components["schemas"]["SetSkillHarnessesFailureResponse"][]; + /** Ok */ + ok: boolean; + /** Succeeded */ + succeeded: string[]; + }; + /** SkillDetailActionsResponse */ + SkillDetailActionsResponse: { + /** Candelete */ + canDelete: boolean; + /** Canmanage */ + canManage: boolean; + /** Deleteharnesslabels */ + deleteHarnessLabels: string[]; + /** Stopmanagingharnesslabels */ + stopManagingHarnessLabels: string[]; + /** Stopmanagingstatus */ + stopManagingStatus: ("available" | "disabled_no_enabled") | null; + }; + /** SkillDetailResponse */ + SkillDetailResponse: { + actions: components["schemas"]["SkillDetailActionsResponse"]; + /** Attentionmessage */ + attentionMessage: string | null; + /** Description */ + description: string; + /** + * Displaystatus + * @enum {string} + */ + displayStatus: "Managed" | "Unmanaged"; + /** Documentmarkdown */ + documentMarkdown: string | null; + /** Harnesscells */ + harnessCells: components["schemas"]["HarnessCellResponse"][]; + /** Locations */ + locations: components["schemas"]["SkillLocationResponse"][]; + /** Name */ + name: string; + /** Skillref */ + skillRef: string; + sourceLinks: components["schemas"]["SkillSourceLinksResponse"] | null; + }; + /** SkillLocationResponse */ + SkillLocationResponse: { + /** Detail */ + detail: string | null; + /** Harness */ + harness: string | null; + /** + * Kind + * @enum {string} + */ + kind: "shared" | "harness"; + /** Label */ + label: string; + /** Path */ + path: string | null; + /** Revision */ + revision: string | null; + /** Scope */ + scope: string | null; + /** Sourcekind */ + sourceKind: string; + /** Sourcelocator */ + sourceLocator: string; + }; + /** SkillRowActionsResponse */ + SkillRowActionsResponse: { + /** Candelete */ + canDelete: boolean; + /** Canmanage */ + canManage: boolean; + /** Canstopmanaging */ + canStopManaging: boolean; + }; + /** SkillSourceLinksResponse */ + SkillSourceLinksResponse: { + /** Folderurl */ + folderUrl: string | null; + /** Repolabel */ + repoLabel: string; + /** Repourl */ + repoUrl: string; + }; + /** SkillSourceStatusResponse */ + SkillSourceStatusResponse: { + /** Updatestatus */ + updateStatus: ("update_available" | "no_update_available" | "no_source_available" | "local_changes_detected") | null; + }; + /** SkillTableRowResponse */ + SkillTableRowResponse: { + actions: components["schemas"]["SkillRowActionsResponse"]; + /** Cells */ + cells: components["schemas"]["HarnessCellResponse"][]; + /** Description */ + description: string; + /** + * Displaystatus + * @enum {string} + */ + displayStatus: "Managed" | "Unmanaged"; + /** Name */ + name: string; + /** Skillref */ + skillRef: string; + }; + /** SkillsPageResponse */ + SkillsPageResponse: { + /** Harnesscolumns */ + harnessColumns: components["schemas"]["HarnessColumnResponse"][]; + /** Rows */ + rows: components["schemas"]["SkillTableRowResponse"][]; + summary: components["schemas"]["SkillsSummaryResponse"]; + }; + /** SkillsSummaryResponse */ + SkillsSummaryResponse: { + /** Managed */ + managed: number; + /** Unmanaged */ + unmanaged: number; + }; + /** ValidationError */ + ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health_api_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + get_cli_marketplace_detail_api_marketplace_clis_items__slug__get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CliMarketplaceDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + popular_cli_marketplace_api_marketplace_clis_popular_get: { + parameters: { + query?: { + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CliMarketplacePageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_cli_marketplace_api_marketplace_clis_search_get: { + parameters: { + query?: { + q?: string; + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CliMarketplacePageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + install_marketplace_skill_api_marketplace_install_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InstallMarketplaceSkillRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_marketplace_detail_api_marketplace_items__item_id__get: { + parameters: { + query?: never; + header?: never; + path: { + item_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_marketplace_document_api_marketplace_items__item_id__document_get: { + parameters: { + query?: never; + header?: never; + path: { + item_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_mcp_install_targets_api_marketplace_mcp_install_targets_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpInstallTargetsResponse"]; + }; + }; + }; + }; + get_mcp_marketplace_detail_api_marketplace_mcp_items__qualified_name__get: { + parameters: { + query?: never; + header?: never; + path: { + qualified_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpMarketplaceDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + popular_mcp_marketplace_api_marketplace_mcp_popular_get: { + parameters: { + query?: { + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpMarketplacePageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_mcp_marketplace_api_marketplace_mcp_search_get: { + parameters: { + query?: { + q?: string; + limit?: number | null; + offset?: number; + remote?: boolean | null; + verified?: boolean | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpMarketplacePageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + popular_marketplace_api_marketplace_popular_get: { + parameters: { + query?: { + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search_marketplace_api_marketplace_search_get: { + parameters: { + query: { + q: string; + limit?: number | null; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_mcp_servers_api_mcp_servers_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpInventoryResponse"]; + }; + }; + }; + }; + install_mcp_server_api_mcp_servers_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddMcpServerRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpServerMutationResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; }; - "/api/skills/{skill_ref}/update": { + get_mcp_server_api_mcp_servers__name__get: { parameters: { query?: never; header?: never; - path?: never; + path: { + name: string; + }; cookie?: never; }; - get?: never; - put?: never; - /** Update Skill */ - post: operations["update_skill_api_skills__skill_ref__update_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - /** DisableSkillRequest */ - DisableSkillRequest: { - /** - * Harness - * @description Harness identifier - */ - harness: string; - }; - /** EnableSkillRequest */ - EnableSkillRequest: { - /** - * Harness - * @description Harness identifier - */ - harness: string; - }; - /** HTTPValidationError */ - HTTPValidationError: { - /** Detail */ - detail?: components["schemas"]["ValidationError"][]; - }; - /** InstallMarketplaceSkillRequest */ - InstallMarketplaceSkillRequest: { - /** Installtoken */ - installToken: string; - }; - /** SetHarnessSupportRequest */ - SetHarnessSupportRequest: { - /** Enabled */ - enabled: boolean; - }; - /** ValidationError */ - ValidationError: { - /** Context */ - ctx?: Record; - /** Input */ - input?: unknown; - /** Location */ - loc: (string | number)[]; - /** Message */ - msg: string; - /** Error Type */ - type: string; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpServerDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - health_api_health_get: { + uninstall_mcp_server_api_mcp_servers__name__delete: { parameters: { query?: never; header?: never; - path?: never; + path: { + name: string; + }; cookie?: never; }; requestBody?: never; @@ -382,23 +1947,32 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["McpSetHarnessesResultResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; }; }; }; }; - install_marketplace_skill_api_marketplace_install_post: { + disable_mcp_server_api_mcp_servers__name__disable_post: { parameters: { query?: never; header?: never; - path?: never; + path: { + name: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["InstallMarketplaceSkillRequest"]; + "application/json": components["schemas"]["DisableMcpServerRequest"]; }; }; responses: { @@ -408,9 +1982,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ @@ -424,16 +1996,20 @@ export interface operations { }; }; }; - get_marketplace_detail_api_marketplace_items__item_id__get: { + enable_mcp_server_api_mcp_servers__name__enable_post: { parameters: { query?: never; header?: never; path: { - item_id: string; + name: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["EnableMcpServerRequest"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -441,9 +2017,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ @@ -457,16 +2031,20 @@ export interface operations { }; }; }; - get_marketplace_document_api_marketplace_items__item_id__document_get: { + reconcile_mcp_server_api_mcp_servers__name__reconcile_post: { parameters: { query?: never; header?: never; path: { - item_id: string; + name: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["ReconcileMcpServerRequest"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -474,9 +2052,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["McpApplyConfigResponse"]; }; }; /** @description Validation Error */ @@ -490,17 +2066,20 @@ export interface operations { }; }; }; - popular_marketplace_api_marketplace_popular_get: { + set_mcp_server_harnesses_api_mcp_servers__name__set_harnesses_post: { parameters: { - query?: { - limit?: number | null; - offset?: number; - }; + query?: never; header?: never; - path?: never; + path: { + name: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SetMcpServerHarnessesRequest"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -508,9 +2087,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["McpSetHarnessesResultResponse"]; }; }; /** @description Validation Error */ @@ -524,18 +2101,18 @@ export interface operations { }; }; }; - search_marketplace_api_marketplace_search_get: { + adopt_mcp_server_api_mcp_unmanaged_adopt_post: { parameters: { - query: { - q: string; - limit?: number | null; - offset?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdoptMcpRequest"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -543,9 +2120,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["McpApplyConfigResponse"]; }; }; /** @description Validation Error */ @@ -559,6 +2134,26 @@ export interface operations { }; }; }; + list_unmanaged_by_server_api_mcp_unmanaged_by_server_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["McpUnmanagedByServerResponse"]; + }; + }; + }; + }; settings_api_settings_get: { parameters: { query?: never; @@ -633,9 +2228,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["SkillsPageResponse"]; }; }; }; @@ -655,9 +2248,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["BulkManageResultResponse"]; }; }; }; @@ -679,9 +2270,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["SkillDetailResponse"]; }; }; /** @description Validation Error */ @@ -712,9 +2301,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ @@ -749,9 +2336,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ @@ -786,9 +2371,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ @@ -819,9 +2402,42 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_skill_harnesses_api_skills__skill_ref__set_harnesses_post: { + parameters: { + query?: never; + header?: never; + path: { + skill_ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetSkillHarnessesRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SetSkillHarnessesResultResponse"]; }; }; /** @description Validation Error */ @@ -852,9 +2468,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: unknown; - }; + "application/json": components["schemas"]["SkillSourceStatusResponse"]; }; }; /** @description Validation Error */ @@ -885,9 +2499,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ @@ -918,9 +2530,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: boolean; - }; + "application/json": components["schemas"]["OkResponse"]; }; }; /** @description Validation Error */ diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 771d93b..8109aa6 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -52,3 +52,7 @@ export async function putJson(path: string, body?: object): Promise { }), ); } + +export async function deleteJson(path: string): Promise { + return expectJson(fetch(apiPath(path), { method: "DELETE" })); +} diff --git a/frontend/src/api/openapi.json b/frontend/src/api/openapi.json index da92e97..4b8d228 100644 --- a/frontend/src/api/openapi.json +++ b/frontend/src/api/openapi.json @@ -1,6 +1,471 @@ { "components": { "schemas": { + "AddMcpServerRequest": { + "properties": { + "qualifiedName": { + "minLength": 1, + "title": "Qualifiedname", + "type": "string" + }, + "sourceHarness": { + "minLength": 1, + "title": "Sourceharness", + "type": "string" + } + }, + "required": [ + "qualifiedName", + "sourceHarness" + ], + "title": "AddMcpServerRequest", + "type": "object" + }, + "AdoptMcpRequest": { + "additionalProperties": false, + "properties": { + "harnesses": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Harnesses" + }, + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "sourceHarness": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sourceharness" + } + }, + "required": [ + "name" + ], + "title": "AdoptMcpRequest", + "type": "object" + }, + "BulkManageFailureResponse": { + "properties": { + "error": { + "title": "Error", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "skillRef": { + "title": "Skillref", + "type": "string" + } + }, + "required": [ + "skillRef", + "name", + "error" + ], + "title": "BulkManageFailureResponse", + "type": "object" + }, + "BulkManageResultResponse": { + "properties": { + "failures": { + "items": { + "$ref": "#/components/schemas/BulkManageFailureResponse" + }, + "title": "Failures", + "type": "array" + }, + "managedCount": { + "title": "Managedcount", + "type": "integer" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "skippedCount": { + "title": "Skippedcount", + "type": "integer" + } + }, + "required": [ + "ok", + "managedCount", + "skippedCount", + "failures" + ], + "title": "BulkManageResultResponse", + "type": "object" + }, + "CliMarketplaceDetailResponse": { + "properties": { + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "description": { + "title": "Description", + "type": "string" + }, + "githubUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Githuburl" + }, + "hasMcp": { + "title": "Hasmcp", + "type": "boolean" + }, + "hasSkill": { + "title": "Hasskill", + "type": "boolean" + }, + "iconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iconurl" + }, + "id": { + "title": "Id", + "type": "string" + }, + "installCommand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installcommand" + }, + "isOfficial": { + "title": "Isofficial", + "type": "boolean" + }, + "isTui": { + "title": "Istui", + "type": "boolean" + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Language" + }, + "longDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Longdescription" + }, + "marketplaceUrl": { + "title": "Marketplaceurl", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "sourceType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sourcetype" + }, + "stars": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Stars" + }, + "vendorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Vendorname" + }, + "websiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Websiteurl" + } + }, + "required": [ + "id", + "slug", + "name", + "description", + "marketplaceUrl", + "hasMcp", + "hasSkill", + "isOfficial", + "isTui" + ], + "title": "CliMarketplaceDetailResponse", + "type": "object" + }, + "CliMarketplaceItemResponse": { + "properties": { + "category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + }, + "description": { + "title": "Description", + "type": "string" + }, + "githubUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Githuburl" + }, + "hasMcp": { + "title": "Hasmcp", + "type": "boolean" + }, + "hasSkill": { + "title": "Hasskill", + "type": "boolean" + }, + "iconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iconurl" + }, + "id": { + "title": "Id", + "type": "string" + }, + "isOfficial": { + "title": "Isofficial", + "type": "boolean" + }, + "isTui": { + "title": "Istui", + "type": "boolean" + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Language" + }, + "marketplaceUrl": { + "title": "Marketplaceurl", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "slug": { + "title": "Slug", + "type": "string" + }, + "sourceType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sourcetype" + }, + "stars": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Stars" + }, + "vendorName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Vendorname" + }, + "websiteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Websiteurl" + } + }, + "required": [ + "id", + "slug", + "name", + "description", + "marketplaceUrl", + "hasMcp", + "hasSkill", + "isOfficial", + "isTui" + ], + "title": "CliMarketplaceItemResponse", + "type": "object" + }, + "CliMarketplacePageResponse": { + "properties": { + "hasMore": { + "title": "Hasmore", + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/CliMarketplaceItemResponse" + }, + "title": "Items", + "type": "array" + }, + "nextOffset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Nextoffset" + } + }, + "required": [ + "items", + "hasMore" + ], + "title": "CliMarketplacePageResponse", + "type": "object" + }, + "DisableMcpServerRequest": { + "properties": { + "harness": { + "description": "Harness identifier", + "minLength": 1, + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness" + ], + "title": "DisableMcpServerRequest", + "type": "object" + }, "DisableSkillRequest": { "properties": { "harness": { @@ -16,6 +481,21 @@ "title": "DisableSkillRequest", "type": "object" }, + "EnableMcpServerRequest": { + "properties": { + "harness": { + "description": "Harness identifier", + "minLength": 1, + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness" + ], + "title": "EnableMcpServerRequest", + "type": "object" + }, "EnableSkillRequest": { "properties": { "harness": { @@ -44,109 +524,3020 @@ "title": "HTTPValidationError", "type": "object" }, - "InstallMarketplaceSkillRequest": { + "HarnessCellResponse": { "properties": { - "installToken": { - "minLength": 1, - "title": "Installtoken", + "harness": { + "title": "Harness", + "type": "string" + }, + "interactive": { + "title": "Interactive", + "type": "boolean" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "state": { + "enum": [ + "enabled", + "disabled", + "found", + "empty" + ], + "title": "State", "type": "string" } }, "required": [ - "installToken" + "harness", + "label", + "state", + "interactive" ], - "title": "InstallMarketplaceSkillRequest", + "title": "HarnessCellResponse", "type": "object" }, - "SetHarnessSupportRequest": { + "HarnessColumnResponse": { "properties": { - "enabled": { - "title": "Enabled", + "harness": { + "title": "Harness", + "type": "string" + }, + "installed": { + "title": "Installed", "type": "boolean" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" } }, "required": [ - "enabled" + "harness", + "label", + "installed" ], - "title": "SetHarnessSupportRequest", + "title": "HarnessColumnResponse", "type": "object" }, - "ValidationError": { + "InstallMarketplaceSkillRequest": { "properties": { - "ctx": { - "title": "Context", - "type": "object" + "installToken": { + "minLength": 1, + "title": "Installtoken", + "type": "string" + } + }, + "required": [ + "installToken" + ], + "title": "InstallMarketplaceSkillRequest", + "type": "object" + }, + "McpAdoptionIssueResponse": { + "properties": { + "configPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Configpath" }, - "input": { - "title": "Input" + "harness": { + "title": "Harness", + "type": "string" }, - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "title": "Location", - "type": "array" + "label": { + "title": "Label", + "type": "string" }, - "msg": { - "title": "Message", + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "name": { + "title": "Name", "type": "string" }, - "type": { - "title": "Error Type", + "payloadPreview": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Payloadpreview" + }, + "reason": { + "title": "Reason", "type": "string" } }, "required": [ - "loc", - "msg", - "type" + "harness", + "label", + "name", + "reason" ], - "title": "ValidationError", + "title": "McpAdoptionIssueResponse", "type": "object" + }, + "McpApplyConfigResponse": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/McpMutationFailureResponse" + }, + "title": "Failed", + "type": "array" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "server": { + "$ref": "#/components/schemas/McpServerSpecResponse" + }, + "succeeded": { + "items": { + "type": "string" + }, + "title": "Succeeded", + "type": "array" + } + }, + "required": [ + "ok", + "server", + "succeeded", + "failed" + ], + "title": "McpApplyConfigResponse", + "type": "object" + }, + "McpBindingResponse": { + "properties": { + "driftDetail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Driftdetail" + }, + "harness": { + "title": "Harness", + "type": "string" + }, + "state": { + "enum": [ + "managed", + "drifted", + "unmanaged", + "missing" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "harness", + "state" + ], + "title": "McpBindingResponse", + "type": "object" + }, + "McpConfigChoiceResponse": { + "properties": { + "configPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Configpath" + }, + "env": { + "items": { + "$ref": "#/components/schemas/McpEnvEntryResponse" + }, + "title": "Env", + "type": "array" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "payloadPreview": { + "additionalProperties": true, + "title": "Payloadpreview", + "type": "object" + }, + "sourceHarness": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sourceharness" + }, + "sourceKind": { + "enum": [ + "managed", + "harness" + ], + "title": "Sourcekind", + "type": "string" + }, + "spec": { + "$ref": "#/components/schemas/McpServerSpecResponse" + } + }, + "required": [ + "sourceKind", + "label", + "payloadPreview", + "spec" + ], + "title": "McpConfigChoiceResponse", + "type": "object" + }, + "McpEnvEntryResponse": { + "properties": { + "isEnvRef": { + "title": "Isenvref", + "type": "boolean" + }, + "key": { + "title": "Key", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "key", + "isEnvRef" + ], + "title": "McpEnvEntryResponse", + "type": "object" + }, + "McpIdentityGroupResponse": { + "properties": { + "canonicalSpec": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpServerSpecResponse" + }, + { + "type": "null" + } + ] + }, + "identical": { + "title": "Identical", + "type": "boolean" + }, + "marketplaceLink": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpMarketplaceLinkResponse" + }, + { + "type": "null" + } + ] + }, + "name": { + "title": "Name", + "type": "string" + }, + "sightings": { + "items": { + "$ref": "#/components/schemas/McpIdentitySightingResponse" + }, + "title": "Sightings", + "type": "array" + } + }, + "required": [ + "name", + "identical", + "sightings" + ], + "title": "McpIdentityGroupResponse", + "type": "object" + }, + "McpIdentitySightingResponse": { + "properties": { + "configPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Configpath" + }, + "env": { + "items": { + "$ref": "#/components/schemas/McpEnvEntryResponse" + }, + "title": "Env", + "type": "array" + }, + "harness": { + "title": "Harness", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "payloadPreview": { + "additionalProperties": true, + "title": "Payloadpreview", + "type": "object" + }, + "spec": { + "$ref": "#/components/schemas/McpServerSpecResponse" + } + }, + "required": [ + "harness", + "label", + "payloadPreview", + "spec" + ], + "title": "McpIdentitySightingResponse", + "type": "object" + }, + "McpInstallTargetResponse": { + "properties": { + "harness": { + "title": "Harness", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "smitheryClient": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Smitheryclient" + }, + "supported": { + "title": "Supported", + "type": "boolean" + } + }, + "required": [ + "harness", + "label", + "supported" + ], + "title": "McpInstallTargetResponse", + "type": "object" + }, + "McpInstallTargetsResponse": { + "properties": { + "targets": { + "items": { + "$ref": "#/components/schemas/McpInstallTargetResponse" + }, + "title": "Targets", + "type": "array" + } + }, + "required": [ + "targets" + ], + "title": "McpInstallTargetsResponse", + "type": "object" + }, + "McpInventoryColumnResponse": { + "properties": { + "configPresent": { + "title": "Configpresent", + "type": "boolean" + }, + "harness": { + "title": "Harness", + "type": "string" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "mcpUnavailableReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mcpunavailablereason" + }, + "mcpWritable": { + "default": true, + "title": "Mcpwritable", + "type": "boolean" + } + }, + "required": [ + "harness", + "label", + "installed", + "configPresent" + ], + "title": "McpInventoryColumnResponse", + "type": "object" + }, + "McpInventoryEntryResponse": { + "properties": { + "canEnable": { + "title": "Canenable", + "type": "boolean" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "kind": { + "enum": [ + "managed", + "unmanaged" + ], + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "sightings": { + "items": { + "$ref": "#/components/schemas/McpBindingResponse" + }, + "title": "Sightings", + "type": "array" + }, + "spec": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpServerSpecResponse" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "displayName", + "kind", + "canEnable", + "sightings" + ], + "title": "McpInventoryEntryResponse", + "type": "object" + }, + "McpInventoryIssueResponse": { + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "name", + "reason" + ], + "title": "McpInventoryIssueResponse", + "type": "object" + }, + "McpInventoryResponse": { + "properties": { + "columns": { + "items": { + "$ref": "#/components/schemas/McpInventoryColumnResponse" + }, + "title": "Columns", + "type": "array" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/McpInventoryEntryResponse" + }, + "title": "Entries", + "type": "array" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/McpInventoryIssueResponse" + }, + "title": "Issues", + "type": "array" + } + }, + "required": [ + "columns", + "entries" + ], + "title": "McpInventoryResponse", + "type": "object" + }, + "McpMarketplaceCapabilityCountsResponse": { + "properties": { + "prompts": { + "title": "Prompts", + "type": "integer" + }, + "resources": { + "title": "Resources", + "type": "integer" + }, + "tools": { + "title": "Tools", + "type": "integer" + } + }, + "required": [ + "tools", + "resources", + "prompts" + ], + "title": "McpMarketplaceCapabilityCountsResponse", + "type": "object" + }, + "McpMarketplaceConnectionResponse": { + "properties": { + "bundleUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bundleurl" + }, + "configSchema": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Configschema" + }, + "deploymentUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deploymenturl" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "runtime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Runtime" + }, + "stdioArgs": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Stdioargs" + }, + "stdioCommand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stdiocommand" + }, + "stdioFunction": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stdiofunction" + } + }, + "required": [ + "kind" + ], + "title": "McpMarketplaceConnectionResponse", + "type": "object" + }, + "McpMarketplaceDetailResponse": { + "properties": { + "capabilityCounts": { + "$ref": "#/components/schemas/McpMarketplaceCapabilityCountsResponse" + }, + "connections": { + "items": { + "$ref": "#/components/schemas/McpMarketplaceConnectionResponse" + }, + "title": "Connections", + "type": "array" + }, + "deploymentUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deploymenturl" + }, + "description": { + "title": "Description", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "externalUrl": { + "title": "Externalurl", + "type": "string" + }, + "iconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iconurl" + }, + "isRemote": { + "title": "Isremote", + "type": "boolean" + }, + "managedName": { + "title": "Managedname", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/components/schemas/McpMarketplacePromptResponse" + }, + "title": "Prompts", + "type": "array" + }, + "qualifiedName": { + "title": "Qualifiedname", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/components/schemas/McpMarketplaceResourceResponse" + }, + "title": "Resources", + "type": "array" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/McpMarketplaceToolResponse" + }, + "title": "Tools", + "type": "array" + } + }, + "required": [ + "qualifiedName", + "managedName", + "displayName", + "description", + "isRemote", + "connections", + "tools", + "resources", + "prompts", + "capabilityCounts", + "externalUrl" + ], + "title": "McpMarketplaceDetailResponse", + "type": "object" + }, + "McpMarketplaceItemResponse": { + "properties": { + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Createdat" + }, + "description": { + "title": "Description", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "externalUrl": { + "title": "Externalurl", + "type": "string" + }, + "homepage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Homepage" + }, + "iconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iconurl" + }, + "isDeployed": { + "title": "Isdeployed", + "type": "boolean" + }, + "isRemote": { + "title": "Isremote", + "type": "boolean" + }, + "isVerified": { + "title": "Isverified", + "type": "boolean" + }, + "namespace": { + "title": "Namespace", + "type": "string" + }, + "qualifiedName": { + "title": "Qualifiedname", + "type": "string" + }, + "useCount": { + "title": "Usecount", + "type": "integer" + } + }, + "required": [ + "qualifiedName", + "namespace", + "displayName", + "description", + "isVerified", + "isRemote", + "isDeployed", + "useCount", + "externalUrl" + ], + "title": "McpMarketplaceItemResponse", + "type": "object" + }, + "McpMarketplaceLinkResponse": { + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "externalUrl": { + "title": "Externalurl", + "type": "string" + }, + "iconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iconurl" + }, + "isRemote": { + "title": "Isremote", + "type": "boolean" + }, + "isVerified": { + "title": "Isverified", + "type": "boolean" + }, + "qualifiedName": { + "title": "Qualifiedname", + "type": "string" + } + }, + "required": [ + "qualifiedName", + "displayName", + "externalUrl", + "description", + "isRemote", + "isVerified" + ], + "title": "McpMarketplaceLinkResponse", + "type": "object" + }, + "McpMarketplacePageResponse": { + "properties": { + "hasMore": { + "title": "Hasmore", + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/McpMarketplaceItemResponse" + }, + "title": "Items", + "type": "array" + }, + "nextOffset": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Nextoffset" + } + }, + "required": [ + "items", + "hasMore" + ], + "title": "McpMarketplacePageResponse", + "type": "object" + }, + "McpMarketplaceParameterResponse": { + "properties": { + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Default" + }, + "description": { + "title": "Description", + "type": "string" + }, + "enum": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Enum" + }, + "maxItems": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxitems" + }, + "maxLength": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxlength" + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maximum" + }, + "minItems": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Minitems" + }, + "minLength": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Minlength" + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Minimum" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type", + "description", + "required" + ], + "title": "McpMarketplaceParameterResponse", + "type": "object" + }, + "McpMarketplacePromptArgumentResponse": { + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "name", + "description", + "required" + ], + "title": "McpMarketplacePromptArgumentResponse", + "type": "object" + }, + "McpMarketplacePromptResponse": { + "properties": { + "arguments": { + "items": { + "$ref": "#/components/schemas/McpMarketplacePromptArgumentResponse" + }, + "title": "Arguments", + "type": "array" + }, + "description": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "description", + "arguments" + ], + "title": "McpMarketplacePromptResponse", + "type": "object" + }, + "McpMarketplaceResourceResponse": { + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "name": { + "title": "Name", + "type": "string" + }, + "uri": { + "title": "Uri", + "type": "string" + } + }, + "required": [ + "name", + "uri", + "description" + ], + "title": "McpMarketplaceResourceResponse", + "type": "object" + }, + "McpMarketplaceToolResponse": { + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/McpMarketplaceParameterResponse" + }, + "title": "Parameters", + "type": "array" + } + }, + "required": [ + "name", + "description", + "parameters" + ], + "title": "McpMarketplaceToolResponse", + "type": "object" + }, + "McpMutationFailureResponse": { + "properties": { + "error": { + "title": "Error", + "type": "string" + }, + "harness": { + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness", + "error" + ], + "title": "McpMutationFailureResponse", + "type": "object" + }, + "McpServerDetailResponse": { + "properties": { + "canEnable": { + "title": "Canenable", + "type": "boolean" + }, + "configChoices": { + "items": { + "$ref": "#/components/schemas/McpConfigChoiceResponse" + }, + "title": "Configchoices", + "type": "array" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "env": { + "items": { + "$ref": "#/components/schemas/McpEnvEntryResponse" + }, + "title": "Env", + "type": "array" + }, + "kind": { + "enum": [ + "managed", + "unmanaged" + ], + "title": "Kind", + "type": "string" + }, + "marketplaceLink": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpMarketplaceLinkResponse" + }, + { + "type": "null" + } + ] + }, + "name": { + "title": "Name", + "type": "string" + }, + "sightings": { + "items": { + "$ref": "#/components/schemas/McpBindingResponse" + }, + "title": "Sightings", + "type": "array" + }, + "spec": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpServerSpecResponse" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "displayName", + "kind", + "canEnable", + "sightings" + ], + "title": "McpServerDetailResponse", + "type": "object" + }, + "McpServerMutationResponse": { + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + }, + "server": { + "$ref": "#/components/schemas/McpServerSpecResponse" + } + }, + "required": [ + "ok", + "server" + ], + "title": "McpServerMutationResponse", + "type": "object" + }, + "McpServerSpecResponse": { + "properties": { + "args": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Args" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Command" + }, + "displayName": { + "title": "Displayname", + "type": "string" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Env" + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers" + }, + "installedAt": { + "title": "Installedat", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/McpSourceResponse" + }, + "transport": { + "enum": [ + "stdio", + "http", + "sse" + ], + "title": "Transport", + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + } + }, + "required": [ + "name", + "displayName", + "source", + "transport", + "installedAt", + "revision" + ], + "title": "McpServerSpecResponse", + "type": "object" + }, + "McpSetHarnessesResultResponse": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/McpMutationFailureResponse" + }, + "title": "Failed", + "type": "array" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "succeeded": { + "items": { + "type": "string" + }, + "title": "Succeeded", + "type": "array" + } + }, + "required": [ + "ok", + "succeeded", + "failed" + ], + "title": "McpSetHarnessesResultResponse", + "type": "object" + }, + "McpSourceResponse": { + "properties": { + "kind": { + "enum": [ + "marketplace", + "adopted", + "manual" + ], + "title": "Kind", + "type": "string" + }, + "locator": { + "title": "Locator", + "type": "string" + } + }, + "required": [ + "kind", + "locator" + ], + "title": "McpSourceResponse", + "type": "object" + }, + "McpUnmanagedByServerResponse": { + "properties": { + "harnesses": { + "items": { + "$ref": "#/components/schemas/McpUnmanagedHarnessResponse" + }, + "title": "Harnesses", + "type": "array" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/McpAdoptionIssueResponse" + }, + "title": "Issues", + "type": "array" + }, + "servers": { + "items": { + "$ref": "#/components/schemas/McpIdentityGroupResponse" + }, + "title": "Servers", + "type": "array" + } + }, + "required": [ + "harnesses", + "servers" + ], + "title": "McpUnmanagedByServerResponse", + "type": "object" + }, + "McpUnmanagedHarnessResponse": { + "properties": { + "configPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Configpath" + }, + "configPresent": { + "title": "Configpresent", + "type": "boolean" + }, + "harness": { + "title": "Harness", + "type": "string" + }, + "installed": { + "title": "Installed", + "type": "boolean" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "mcpUnavailableReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mcpunavailablereason" + }, + "mcpWritable": { + "default": true, + "title": "Mcpwritable", + "type": "boolean" + } + }, + "required": [ + "harness", + "label", + "installed", + "configPresent" + ], + "title": "McpUnmanagedHarnessResponse", + "type": "object" + }, + "OkResponse": { + "properties": { + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "OkResponse", + "type": "object" + }, + "ReconcileMcpServerRequest": { + "additionalProperties": false, + "properties": { + "harnesses": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Harnesses" + }, + "sourceHarness": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sourceharness" + }, + "sourceKind": { + "enum": [ + "managed", + "harness" + ], + "title": "Sourcekind", + "type": "string" + } + }, + "required": [ + "sourceKind" + ], + "title": "ReconcileMcpServerRequest", + "type": "object" + }, + "SetHarnessSupportRequest": { + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "title": "SetHarnessSupportRequest", + "type": "object" + }, + "SetMcpServerHarnessesRequest": { + "properties": { + "target": { + "enum": [ + "enabled", + "disabled" + ], + "title": "Target", + "type": "string" + } + }, + "required": [ + "target" + ], + "title": "SetMcpServerHarnessesRequest", + "type": "object" + }, + "SetSkillHarnessesFailureResponse": { + "properties": { + "error": { + "title": "Error", + "type": "string" + }, + "harness": { + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness", + "error" + ], + "title": "SetSkillHarnessesFailureResponse", + "type": "object" + }, + "SetSkillHarnessesRequest": { + "properties": { + "target": { + "description": "Target state to apply to every interactive harness cell on this skill", + "enum": [ + "enabled", + "disabled" + ], + "title": "Target", + "type": "string" + } + }, + "required": [ + "target" + ], + "title": "SetSkillHarnessesRequest", + "type": "object" + }, + "SetSkillHarnessesResultResponse": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/SetSkillHarnessesFailureResponse" + }, + "title": "Failed", + "type": "array" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "succeeded": { + "items": { + "type": "string" + }, + "title": "Succeeded", + "type": "array" + } + }, + "required": [ + "ok", + "succeeded", + "failed" + ], + "title": "SetSkillHarnessesResultResponse", + "type": "object" + }, + "SkillDetailActionsResponse": { + "properties": { + "canDelete": { + "title": "Candelete", + "type": "boolean" + }, + "canManage": { + "title": "Canmanage", + "type": "boolean" + }, + "deleteHarnessLabels": { + "items": { + "type": "string" + }, + "title": "Deleteharnesslabels", + "type": "array" + }, + "stopManagingHarnessLabels": { + "items": { + "type": "string" + }, + "title": "Stopmanagingharnesslabels", + "type": "array" + }, + "stopManagingStatus": { + "anyOf": [ + { + "enum": [ + "available", + "disabled_no_enabled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopmanagingstatus" + } + }, + "required": [ + "canManage", + "stopManagingStatus", + "stopManagingHarnessLabels", + "canDelete", + "deleteHarnessLabels" + ], + "title": "SkillDetailActionsResponse", + "type": "object" + }, + "SkillDetailResponse": { + "properties": { + "actions": { + "$ref": "#/components/schemas/SkillDetailActionsResponse" + }, + "attentionMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Attentionmessage" + }, + "description": { + "title": "Description", + "type": "string" + }, + "displayStatus": { + "enum": [ + "Managed", + "Unmanaged" + ], + "title": "Displaystatus", + "type": "string" + }, + "documentMarkdown": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Documentmarkdown" + }, + "harnessCells": { + "items": { + "$ref": "#/components/schemas/HarnessCellResponse" + }, + "title": "Harnesscells", + "type": "array" + }, + "locations": { + "items": { + "$ref": "#/components/schemas/SkillLocationResponse" + }, + "title": "Locations", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "skillRef": { + "title": "Skillref", + "type": "string" + }, + "sourceLinks": { + "anyOf": [ + { + "$ref": "#/components/schemas/SkillSourceLinksResponse" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "skillRef", + "name", + "description", + "displayStatus", + "attentionMessage", + "actions", + "harnessCells", + "locations", + "sourceLinks", + "documentMarkdown" + ], + "title": "SkillDetailResponse", + "type": "object" + }, + "SkillLocationResponse": { + "properties": { + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "harness": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Harness" + }, + "kind": { + "enum": [ + "shared", + "harness" + ], + "title": "Kind", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + }, + "revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revision" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scope" + }, + "sourceKind": { + "title": "Sourcekind", + "type": "string" + }, + "sourceLocator": { + "title": "Sourcelocator", + "type": "string" + } + }, + "required": [ + "kind", + "harness", + "label", + "scope", + "path", + "revision", + "sourceKind", + "sourceLocator", + "detail" + ], + "title": "SkillLocationResponse", + "type": "object" + }, + "SkillRowActionsResponse": { + "properties": { + "canDelete": { + "title": "Candelete", + "type": "boolean" + }, + "canManage": { + "title": "Canmanage", + "type": "boolean" + }, + "canStopManaging": { + "title": "Canstopmanaging", + "type": "boolean" + } + }, + "required": [ + "canManage", + "canStopManaging", + "canDelete" + ], + "title": "SkillRowActionsResponse", + "type": "object" + }, + "SkillSourceLinksResponse": { + "properties": { + "folderUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Folderurl" + }, + "repoLabel": { + "title": "Repolabel", + "type": "string" + }, + "repoUrl": { + "title": "Repourl", + "type": "string" + } + }, + "required": [ + "repoLabel", + "repoUrl", + "folderUrl" + ], + "title": "SkillSourceLinksResponse", + "type": "object" + }, + "SkillSourceStatusResponse": { + "properties": { + "updateStatus": { + "anyOf": [ + { + "enum": [ + "update_available", + "no_update_available", + "no_source_available", + "local_changes_detected" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatestatus" + } + }, + "required": [ + "updateStatus" + ], + "title": "SkillSourceStatusResponse", + "type": "object" + }, + "SkillTableRowResponse": { + "properties": { + "actions": { + "$ref": "#/components/schemas/SkillRowActionsResponse" + }, + "cells": { + "items": { + "$ref": "#/components/schemas/HarnessCellResponse" + }, + "title": "Cells", + "type": "array" + }, + "description": { + "title": "Description", + "type": "string" + }, + "displayStatus": { + "enum": [ + "Managed", + "Unmanaged" + ], + "title": "Displaystatus", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "skillRef": { + "title": "Skillref", + "type": "string" + } + }, + "required": [ + "skillRef", + "name", + "description", + "displayStatus", + "actions", + "cells" + ], + "title": "SkillTableRowResponse", + "type": "object" + }, + "SkillsPageResponse": { + "properties": { + "harnessColumns": { + "items": { + "$ref": "#/components/schemas/HarnessColumnResponse" + }, + "title": "Harnesscolumns", + "type": "array" + }, + "rows": { + "items": { + "$ref": "#/components/schemas/SkillTableRowResponse" + }, + "title": "Rows", + "type": "array" + }, + "summary": { + "$ref": "#/components/schemas/SkillsSummaryResponse" + } + }, + "required": [ + "summary", + "harnessColumns", + "rows" + ], + "title": "SkillsPageResponse", + "type": "object" + }, + "SkillsSummaryResponse": { + "properties": { + "managed": { + "title": "Managed", + "type": "integer" + }, + "unmanaged": { + "title": "Unmanaged", + "type": "integer" + } + }, + "required": [ + "managed", + "unmanaged" + ], + "title": "SkillsSummaryResponse", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "skill-manager", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/health": { + "get": { + "operationId": "health_api_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Health Api Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health" + } + }, + "/api/marketplace/clis/items/{slug}": { + "get": { + "operationId": "get_cli_marketplace_detail_api_marketplace_clis_items__slug__get", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "title": "Slug", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CliMarketplaceDetailResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Cli Marketplace Detail" + } + }, + "/api/marketplace/clis/popular": { + "get": { + "operationId": "popular_cli_marketplace_api_marketplace_clis_popular_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CliMarketplacePageResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Popular Cli Marketplace" + } + }, + "/api/marketplace/clis/search": { + "get": { + "operationId": "search_cli_marketplace_api_marketplace_clis_search_get", + "parameters": [ + { + "in": "query", + "name": "q", + "required": false, + "schema": { + "default": "", + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CliMarketplacePageResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Search Cli Marketplace" + } + }, + "/api/marketplace/install": { + "post": { + "operationId": "install_marketplace_skill_api_marketplace_install_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallMarketplaceSkillRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Response Install Marketplace Skill Api Marketplace Install Post", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Install Marketplace Skill" + } + }, + "/api/marketplace/items/{item_id}": { + "get": { + "operationId": "get_marketplace_detail_api_marketplace_items__item_id__get", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Marketplace Detail Api Marketplace Items Item Id Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Marketplace Detail" + } + }, + "/api/marketplace/items/{item_id}/document": { + "get": { + "operationId": "get_marketplace_document_api_marketplace_items__item_id__document_get", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Marketplace Document Api Marketplace Items Item Id Document Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Marketplace Document" + } + }, + "/api/marketplace/mcp/install-targets": { + "get": { + "operationId": "get_mcp_install_targets_api_marketplace_mcp_install_targets_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpInstallTargetsResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Mcp Install Targets" + } + }, + "/api/marketplace/mcp/items/{qualified_name}": { + "get": { + "operationId": "get_mcp_marketplace_detail_api_marketplace_mcp_items__qualified_name__get", + "parameters": [ + { + "in": "path", + "name": "qualified_name", + "required": true, + "schema": { + "title": "Qualified Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpMarketplaceDetailResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Mcp Marketplace Detail" + } + }, + "/api/marketplace/mcp/popular": { + "get": { + "operationId": "popular_mcp_marketplace_api_marketplace_mcp_popular_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpMarketplacePageResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Popular Mcp Marketplace" + } + }, + "/api/marketplace/mcp/search": { + "get": { + "operationId": "search_mcp_marketplace_api_marketplace_mcp_search_get", + "parameters": [ + { + "in": "query", + "name": "q", + "required": false, + "schema": { + "default": "", + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "remote", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Remote" + } + }, + { + "in": "query", + "name": "verified", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Verified" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpMarketplacePageResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Search Mcp Marketplace" } - } - }, - "info": { - "title": "skill-manager", - "version": "0.1.0" - }, - "openapi": "3.1.0", - "paths": { - "/api/health": { + }, + "/api/marketplace/popular": { "get": { - "operationId": "health_api_health_get", + "operationId": "popular_marketplace_api_marketplace_popular_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Popular Marketplace Api Marketplace Popular Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Popular Marketplace" + } + }, + "/api/marketplace/search": { + "get": { + "operationId": "search_marketplace_api_marketplace_search_get", + "parameters": [ + { + "in": "query", + "name": "q", + "required": true, + "schema": { + "title": "Q", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "title": "Offset", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Search Marketplace Api Marketplace Search Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Search Marketplace" + } + }, + "/api/mcp/servers": { + "get": { + "operationId": "list_mcp_servers_api_mcp_servers_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpInventoryResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Mcp Servers" + }, + "post": { + "operationId": "install_mcp_server_api_mcp_servers_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMcpServerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerMutationResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Install Mcp Server" + } + }, + "/api/mcp/servers/{name}": { + "delete": { + "operationId": "uninstall_mcp_server_api_mcp_servers__name__delete", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpSetHarnessesResultResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Uninstall Mcp Server" + }, + "get": { + "operationId": "get_mcp_server_api_mcp_servers__name__get", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Health Api Health Get", - "type": "object" + "$ref": "#/components/schemas/McpServerDetailResponse" } } }, "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" } }, - "summary": "Health" + "summary": "Get Mcp Server" } }, - "/api/marketplace/install": { + "/api/mcp/servers/{name}/disable": { "post": { - "operationId": "install_marketplace_skill_api_marketplace_install_post", + "operationId": "disable_mcp_server_api_mcp_servers__name__disable_post", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "title": "Name", + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InstallMarketplaceSkillRequest" + "$ref": "#/components/schemas/DisableMcpServerRequest" } } }, @@ -157,11 +3548,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Install Marketplace Skill Api Marketplace Install Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -178,31 +3565,39 @@ "description": "Validation Error" } }, - "summary": "Install Marketplace Skill" + "summary": "Disable Mcp Server" } }, - "/api/marketplace/items/{item_id}": { - "get": { - "operationId": "get_marketplace_detail_api_marketplace_items__item_id__get", + "/api/mcp/servers/{name}/enable": { + "post": { + "operationId": "enable_mcp_server_api_mcp_servers__name__enable_post", "parameters": [ { "in": "path", - "name": "item_id", + "name": "name", "required": true, "schema": { - "title": "Item Id", + "title": "Name", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnableMcpServerRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Get Marketplace Detail Api Marketplace Items Item Id Get", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -219,31 +3614,39 @@ "description": "Validation Error" } }, - "summary": "Get Marketplace Detail" + "summary": "Enable Mcp Server" } }, - "/api/marketplace/items/{item_id}/document": { - "get": { - "operationId": "get_marketplace_document_api_marketplace_items__item_id__document_get", + "/api/mcp/servers/{name}/reconcile": { + "post": { + "operationId": "reconcile_mcp_server_api_mcp_servers__name__reconcile_post", "parameters": [ { "in": "path", - "name": "item_id", + "name": "name", "required": true, "schema": { - "title": "Item Id", + "title": "Name", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReconcileMcpServerRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Get Marketplace Document Api Marketplace Items Item Id Document Get", - "type": "object" + "$ref": "#/components/schemas/McpApplyConfigResponse" } } }, @@ -260,48 +3663,39 @@ "description": "Validation Error" } }, - "summary": "Get Marketplace Document" + "summary": "Reconcile Mcp Server" } }, - "/api/marketplace/popular": { - "get": { - "operationId": "popular_marketplace_api_marketplace_popular_get", + "/api/mcp/servers/{name}/set-harnesses": { + "post": { + "operationId": "set_mcp_server_harnesses_api_mcp_servers__name__set_harnesses_post", "parameters": [ { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Limit" - } - }, - { - "in": "query", - "name": "offset", - "required": false, + "in": "path", + "name": "name", + "required": true, "schema": { - "default": 0, - "title": "Offset", - "type": "integer" + "title": "Name", + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetMcpServerHarnessesRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Popular Marketplace Api Marketplace Popular Get", - "type": "object" + "$ref": "#/components/schemas/McpSetHarnessesResultResponse" } } }, @@ -318,57 +3712,28 @@ "description": "Validation Error" } }, - "summary": "Popular Marketplace" + "summary": "Set Mcp Server Harnesses" } }, - "/api/marketplace/search": { - "get": { - "operationId": "search_marketplace_api_marketplace_search_get", - "parameters": [ - { - "in": "query", - "name": "q", - "required": true, - "schema": { - "title": "Q", - "type": "string" - } - }, - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Limit" + "/api/mcp/unmanaged/adopt": { + "post": { + "operationId": "adopt_mcp_server_api_mcp_unmanaged_adopt_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptMcpRequest" + } } }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "title": "Offset", - "type": "integer" - } - } - ], + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Search Marketplace Api Marketplace Search Get", - "type": "object" + "$ref": "#/components/schemas/McpApplyConfigResponse" } } }, @@ -385,7 +3750,25 @@ "description": "Validation Error" } }, - "summary": "Search Marketplace" + "summary": "Adopt Mcp Server" + } + }, + "/api/mcp/unmanaged/by-server": { + "get": { + "operationId": "list_unmanaged_by_server_api_mcp_unmanaged_by_server_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnmanagedByServerResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Unmanaged By Server" } }, "/api/settings": { @@ -467,9 +3850,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response List Skills Api Skills Get", - "type": "object" + "$ref": "#/components/schemas/SkillsPageResponse" } } }, @@ -487,9 +3868,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Manage All Skills Api Skills Manage All Post", - "type": "object" + "$ref": "#/components/schemas/BulkManageResultResponse" } } }, @@ -518,9 +3897,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Get Skill Detail Api Skills Skill Ref Get", - "type": "object" + "$ref": "#/components/schemas/SkillDetailResponse" } } }, @@ -559,11 +3936,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Delete Skill Api Skills Skill Ref Delete Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -612,11 +3985,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Disable Skill Api Skills Skill Ref Disable Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -665,11 +4034,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Enable Skill Api Skills Skill Ref Enable Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -708,11 +4073,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Manage Skill Api Skills Skill Ref Manage Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -732,6 +4093,55 @@ "summary": "Manage Skill" } }, + "/api/skills/{skill_ref}/set-harnesses": { + "post": { + "operationId": "set_skill_harnesses_api_skills__skill_ref__set_harnesses_post", + "parameters": [ + { + "in": "path", + "name": "skill_ref", + "required": true, + "schema": { + "title": "Skill Ref", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSkillHarnessesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSkillHarnessesResultResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Set Skill Harnesses" + } + }, "/api/skills/{skill_ref}/source-status": { "get": { "operationId": "get_skill_source_status_api_skills__skill_ref__source_status_get", @@ -751,9 +4161,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Get Skill Source Status Api Skills Skill Ref Source Status Get", - "type": "object" + "$ref": "#/components/schemas/SkillSourceStatusResponse" } } }, @@ -792,11 +4200,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Unmanage Skill Api Skills Skill Ref Unmanage Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, @@ -835,11 +4239,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "boolean" - }, - "title": "Response Update Skill Api Skills Skill Ref Update Post", - "type": "object" + "$ref": "#/components/schemas/OkResponse" } } }, diff --git a/frontend/src/app/capability-registry/import-boundary.test.ts b/frontend/src/app/capability-registry/import-boundary.test.ts new file mode 100644 index 0000000..525bf0f --- /dev/null +++ b/frontend/src/app/capability-registry/import-boundary.test.ts @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const FORBIDDEN = [ + ["../..", "mcp", "api"].join("/"), + ["../..", "skills", "api"].join("/"), + ["../..", "settings", "queries"].join("/"), + ["..", "mcp", "api"].join("/"), + ["..", "skills", "api"].join("/"), + ["..", "settings", "queries"].join("/"), +]; + +describe("feature public import boundaries", () => { + it("keeps cross-feature imports on public APIs", () => { + const root = join(process.cwd(), "frontend", "src"); + const violations: string[] = []; + for (const file of sourceFiles(root)) { + const source = readFileSync(file, "utf8"); + if (FORBIDDEN.some((pattern) => source.includes(pattern))) { + violations.push(relative(root, file)); + } + } + + expect(violations).toEqual([]); + }); +}); + +function sourceFiles(dir: string): string[] { + const result: string[] = []; + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + result.push(...sourceFiles(path)); + } else if (/\.(ts|tsx)$/.test(entry)) { + result.push(path); + } + } + return result; +} diff --git a/frontend/src/app/capability-registry/index.ts b/frontend/src/app/capability-registry/index.ts new file mode 100644 index 0000000..54763c2 --- /dev/null +++ b/frontend/src/app/capability-registry/index.ts @@ -0,0 +1,20 @@ +export { invalidateCapabilityQueries } from "./invalidation"; +export { + buildOverviewModel, + invalidateOverviewData, + useOverviewData, + useOverviewModel, + type OverviewExtensionKind, + type OverviewHarnessRow, + type OverviewMarketplaceEntry, + type OverviewModel, + type OverviewReviewItem, + type OverviewStats, +} from "./overview"; +export { + useSidebarModel, + type SidebarGroupModel, + type SidebarIconKey, + type SidebarLinkModel, + type SidebarModel, +} from "./sidebar"; diff --git a/frontend/src/app/capability-registry/invalidation.test.ts b/frontend/src/app/capability-registry/invalidation.test.ts new file mode 100644 index 0000000..28d6062 --- /dev/null +++ b/frontend/src/app/capability-registry/invalidation.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import type { QueryClient } from "@tanstack/react-query"; + +import { invalidateCapabilityQueries } from "./invalidation"; + +describe("capability invalidation", () => { + it("invalidates every capability-backed app surface", async () => { + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + const queryClient = { invalidateQueries } as unknown as QueryClient; + + await invalidateCapabilityQueries(queryClient); + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["skills", "list"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["skills", "detail"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["skills", "source-status"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["mcp"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["settings"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["marketplace"] }); + }); +}); diff --git a/frontend/src/app/capability-registry/invalidation.ts b/frontend/src/app/capability-registry/invalidation.ts new file mode 100644 index 0000000..d7d0c91 --- /dev/null +++ b/frontend/src/app/capability-registry/invalidation.ts @@ -0,0 +1,15 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { invalidateMarketplaceQueries } from "../../features/marketplace/public"; +import { invalidateMcpQueries } from "../../features/mcp/public"; +import { invalidateSettingsQueries } from "../../features/settings/public"; +import { invalidateSkillsQueries } from "../../features/skills/public"; + +export async function invalidateCapabilityQueries(queryClient: QueryClient): Promise { + await Promise.all([ + invalidateSkillsQueries(queryClient), + invalidateMcpQueries(queryClient), + invalidateSettingsQueries(queryClient), + invalidateMarketplaceQueries(queryClient), + ]); +} diff --git a/frontend/src/app/capability-registry/overview.test.ts b/frontend/src/app/capability-registry/overview.test.ts new file mode 100644 index 0000000..cef980b --- /dev/null +++ b/frontend/src/app/capability-registry/overview.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { buildOverviewModel } from "./overview"; + +describe("capability overview model", () => { + it("keeps CLIs as discover-only and local lifecycle rows for Skills/MCP", () => { + const model = buildOverviewModel( + { + summary: { managed: 2, unmanaged: 1 }, + harnessColumns: [], + rows: [], + }, + { + columns: [], + entries: [ + { name: "exa", displayName: "Exa", kind: "managed", spec: null, canEnable: true, sightings: [] }, + { name: "firecrawl", displayName: "firecrawl", kind: "unmanaged", spec: null, canEnable: false, sightings: [] }, + ], + issues: [], + }, + ); + + expect(model.extensions.map((entry) => entry.key)).toEqual(["skills", "mcp"]); + expect(model.marketplaceEntries.map((entry) => entry.key)).toEqual(["skills", "mcp", "clis"]); + expect(model.marketplaceEntries.find((entry) => entry.key === "clis")).toMatchObject({ + badge: "Preview only", + action: { to: "/marketplace/clis" }, + }); + expect(model.stats.inUse.value).toBe(3); + expect(model.stats.needsReview.value).toBe(2); + }); +}); diff --git a/frontend/src/app/capability-registry/overview.ts b/frontend/src/app/capability-registry/overview.ts new file mode 100644 index 0000000..4ebea0f --- /dev/null +++ b/frontend/src/app/capability-registry/overview.ts @@ -0,0 +1,466 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { + invalidateMcpQueries, + isMcpHarnessAddressable, + mcpRoutes, + useMcpInventoryQuery, + type McpInventoryDto, +} from "../../features/mcp/public"; +import { + invalidateSkillsQueries, + skillsRoutes, + useSkillsListQuery, + type SkillsWorkspaceData, +} from "../../features/skills/public"; +import { marketplaceRoutes } from "../../features/marketplace/public"; + +export interface OverviewStatMetric { + value: number | null; + detail: string; +} + +export interface OverviewStats { + inUse: OverviewStatMetric; + needsReview: OverviewStatMetric; + harnesses: OverviewStatMetric; +} + +export interface OverviewExtensionAction { + label: string; + to: string; + primary?: boolean; +} + +export interface OverviewExtensionFact { + label: string; + value: number | null; + tone?: "normal" | "warning"; +} + +export interface OverviewExtensionKind { + key: "skills" | "mcp"; + label: string; + iconKey: "skills" | "mcp"; + facts: OverviewExtensionFact[]; + actions: OverviewExtensionAction[]; +} + +export interface OverviewMarketplaceAction { + label: string; + to: string; + primary?: boolean; +} + +export interface OverviewMarketplaceEntry { + key: "skills" | "mcp" | "clis"; + label: string; + iconKey: "skills" | "mcp" | "clis"; + sourceLabel: string; + badge?: string; + tone?: "normal" | "accent"; + action: OverviewMarketplaceAction; +} + +export interface OverviewReviewItem { + key: string; + label: string; + description: string; + count: number; + to: string; + tone: "neutral" | "warning" | "danger"; +} + +export interface OverviewHarnessRow { + harness: string; + label: string; + logoKey: string | null; + enabledSkills: number; + foundSkills: number; + managedMcpServers: number; + differentConfigMcpServers: number; + unmanagedMcpServers: number; + mcpWritable: boolean | null; + mcpUnavailableReason: string | null; +} + +export interface OverviewModel { + stats: OverviewStats; + extensions: OverviewExtensionKind[]; + marketplaceEntries: OverviewMarketplaceEntry[]; + reviewItems: OverviewReviewItem[]; + harnessRows: OverviewHarnessRow[]; +} + +export function useOverviewData() { + const skillsQuery = useSkillsListQuery(); + const mcpQuery = useMcpInventoryQuery(); + const model = useOverviewModel(skillsQuery.data, mcpQuery.data); + + return { + skillsQuery, + mcpQuery, + model, + }; +} + +export async function invalidateOverviewData(queryClient: QueryClient): Promise { + await Promise.all([ + invalidateSkillsQueries(queryClient), + invalidateMcpQueries(queryClient), + ]); +} + +interface HarnessAccumulator extends OverviewHarnessRow { + order: number; +} + +export function useOverviewModel( + skills: SkillsWorkspaceData | null | undefined, + mcp: McpInventoryDto | null | undefined, +): OverviewModel { + return useMemo(() => buildOverviewModel(skills, mcp), [skills, mcp]); +} + +export function buildOverviewModel( + skills: SkillsWorkspaceData | null | undefined, + mcp: McpInventoryDto | null | undefined, +): OverviewModel { + const inUseSkills = skills?.summary.managed ?? null; + const skillsToReview = skills?.summary.unmanaged ?? null; + const inUseMcpServers = mcp?.entries.filter((entry) => entry.kind === "managed").length ?? null; + const mcpConfigsToReview = mcp?.entries.filter((entry) => entry.kind === "unmanaged").length ?? null; + const differentConfigMcpServers = + mcp?.entries.filter( + (entry) => + entry.kind === "managed" && + entry.sightings.some((sighting) => sighting.state === "drifted"), + ).length ?? null; + const inventoryIssues = mcp?.issues?.length ?? null; + const unavailableHarnesses = mcp?.columns.filter((column) => column.mcpWritable === false).length ?? null; + const reviewItems = buildReviewItems({ + skillsToReview, + mcpConfigsToReview, + differentConfigMcpServers, + inventoryIssues, + unavailableHarnesses, + }); + const harnessRows = buildHarnessRows(skills, mcp); + const hasOverviewData = Boolean(skills || mcp); + + return { + stats: buildStats({ + inUseSkills, + inUseMcpServers, + needsReview: hasOverviewData ? reviewItems.reduce((total, item) => total + item.count, 0) : null, + harnesses: hasOverviewData ? harnessRows.length : null, + }), + extensions: buildExtensions({ + inUseSkills, + skillsToReview, + inUseMcpServers, + mcpConfigsToReview, + differentConfigMcpServers, + inventoryIssues, + unavailableHarnesses, + }), + marketplaceEntries: buildMarketplaceEntries(), + reviewItems, + harnessRows, + }; +} + +function buildStats({ + inUseSkills, + inUseMcpServers, + needsReview, + harnesses, +}: { + inUseSkills: number | null; + inUseMcpServers: number | null; + needsReview: number | null; + harnesses: number | null; +}): OverviewStats { + return { + inUse: { + value: sumKnown(inUseSkills, inUseMcpServers), + detail: [ + formatMetricPart(inUseSkills, "skill", "skills"), + formatMetricPart(inUseMcpServers, "MCP", "MCP"), + ].join(" · "), + }, + needsReview: { + value: needsReview, + detail: "adoption · config · inventory", + }, + harnesses: { + value: harnesses, + detail: `${formatCount(harnesses)} observed`, + }, + }; +} + +function sumKnown(...values: Array): number | null { + const known = values.filter((value): value is number => value != null); + if (known.length === 0) { + return null; + } + return known.reduce((total, value) => total + value, 0); +} + +function buildExtensions({ + inUseSkills, + skillsToReview, + inUseMcpServers, + mcpConfigsToReview, + differentConfigMcpServers, + inventoryIssues, + unavailableHarnesses, +}: { + inUseSkills: number | null; + skillsToReview: number | null; + inUseMcpServers: number | null; + mcpConfigsToReview: number | null; + differentConfigMcpServers: number | null; + inventoryIssues: number | null; + unavailableHarnesses: number | null; +}): OverviewExtensionKind[] { + return [ + { + key: "skills", + label: "Skills", + iconKey: "skills", + facts: [ + { label: "in use", value: inUseSkills }, + { label: "review", value: skillsToReview, tone: "warning" }, + ], + actions: [ + { label: "In use", to: skillsRoutes.inUse, primary: true }, + { label: "Needs review", to: skillsRoutes.needsReview }, + ], + }, + { + key: "mcp", + label: "MCP Servers", + iconKey: "mcp", + facts: [ + { label: "in use", value: inUseMcpServers }, + { + label: "review", + value: sumKnown( + mcpConfigsToReview, + differentConfigMcpServers, + inventoryIssues, + unavailableHarnesses, + ), + tone: "warning", + }, + ], + actions: [ + { label: "In use", to: mcpRoutes.inUse, primary: true }, + { label: "Needs review", to: mcpRoutes.needsReview }, + ], + }, + ]; +} + +function buildMarketplaceEntries(): OverviewMarketplaceEntry[] { + return [ + { + key: "skills", + label: "Skills Marketplace", + iconKey: "skills", + sourceLabel: "skills.sh", + action: { label: "Browse", to: marketplaceRoutes.skills, primary: true }, + }, + { + key: "mcp", + label: "MCP Marketplace", + iconKey: "mcp", + sourceLabel: "smithery.ai", + action: { label: "Browse", to: marketplaceRoutes.mcp, primary: true }, + }, + { + key: "clis", + label: "CLI Marketplace", + iconKey: "clis", + sourceLabel: "CLIs.dev", + badge: "Preview only", + tone: "accent", + action: { label: "Browse", to: marketplaceRoutes.clis, primary: true }, + }, + ]; +} + +function formatMetricPart(value: number | null, singular: string, plural: string): string { + const label = value === 1 ? singular : plural; + return `${formatCount(value)} ${label}`; +} + +function formatCount(value: number | null): string { + return value == null ? "-" : value.toLocaleString(); +} + +function buildReviewItems({ + skillsToReview, + mcpConfigsToReview, + differentConfigMcpServers, + inventoryIssues, + unavailableHarnesses, +}: { + skillsToReview: number | null; + mcpConfigsToReview: number | null; + differentConfigMcpServers: number | null; + inventoryIssues: number | null; + unavailableHarnesses: number | null; +}): OverviewReviewItem[] { + const items: OverviewReviewItem[] = []; + if (skillsToReview && skillsToReview > 0) { + items.push({ + key: "skills-review", + label: "Skills to review", + description: "Adopt local skills so they can be enabled consistently.", + count: skillsToReview, + to: skillsRoutes.needsReview, + tone: "neutral", + }); + } + if (mcpConfigsToReview && mcpConfigsToReview > 0) { + items.push({ + key: "mcp-review", + label: "MCP configs to review", + description: "Adopt existing harness configs into Skill Manager.", + count: mcpConfigsToReview, + to: mcpRoutes.needsReview, + tone: "neutral", + }); + } + if (differentConfigMcpServers && differentConfigMcpServers > 0) { + items.push({ + key: "different-mcp-configs", + label: "Different MCP configs", + description: "Resolve which config should become the source of truth.", + count: differentConfigMcpServers, + to: mcpRoutes.inUse, + tone: "warning", + }); + } + if (inventoryIssues && inventoryIssues > 0) { + items.push({ + key: "mcp-inventory-issues", + label: "MCP inventory issues", + description: "Some Skill Manager MCP records could not be loaded cleanly.", + count: inventoryIssues, + to: mcpRoutes.inUse, + tone: "danger", + }); + } + if (unavailableHarnesses && unavailableHarnesses > 0) { + items.push({ + key: "unavailable-mcp-harnesses", + label: "MCP harness unavailable", + description: "At least one harness cannot safely receive MCP writes.", + count: unavailableHarnesses, + to: "/settings", + tone: "warning", + }); + } + return items; +} + +function buildHarnessRows( + skills: SkillsWorkspaceData | null | undefined, + mcp: McpInventoryDto | null | undefined, +): OverviewHarnessRow[] { + const harnesses = new Map(); + let nextOrder = 0; + + const ensureHarness = (args: { + harness: string; + label?: string | null; + logoKey?: string | null; + }): HarnessAccumulator => { + const existing = harnesses.get(args.harness); + if (existing) { + if (!existing.logoKey && args.logoKey) existing.logoKey = args.logoKey; + if (existing.label === args.harness && args.label) existing.label = args.label; + return existing; + } + const row: HarnessAccumulator = { + harness: args.harness, + label: args.label ?? args.harness, + logoKey: args.logoKey ?? null, + enabledSkills: 0, + foundSkills: 0, + managedMcpServers: 0, + differentConfigMcpServers: 0, + unmanagedMcpServers: 0, + mcpWritable: null, + mcpUnavailableReason: null, + order: nextOrder, + }; + nextOrder += 1; + harnesses.set(args.harness, row); + return row; + }; + + for (const column of skills?.harnessColumns ?? []) { + ensureHarness({ + harness: column.harness, + label: column.label, + logoKey: column.logoKey ?? column.harness, + }); + } + + for (const row of skills?.rows ?? []) { + for (const cell of row.cells) { + const harness = ensureHarness({ + harness: cell.harness, + label: cell.label, + logoKey: cell.logoKey ?? cell.harness, + }); + if (cell.state === "enabled") harness.enabledSkills += 1; + if (cell.state === "found") harness.foundSkills += 1; + } + } + + for (const column of mcp?.columns ?? []) { + const harness = ensureHarness({ + harness: column.harness, + label: column.label, + logoKey: column.logoKey ?? column.harness, + }); + harness.mcpWritable = column.mcpWritable; + harness.mcpUnavailableReason = column.mcpUnavailableReason ?? null; + } + + for (const entry of mcp?.entries ?? []) { + for (const sighting of entry.sightings) { + const column = mcp?.columns.find((candidate) => candidate.harness === sighting.harness); + const harness = ensureHarness({ + harness: sighting.harness, + label: column?.label, + logoKey: column?.logoKey ?? sighting.harness, + }); + if (entry.kind === "managed" && sighting.state === "managed") { + harness.managedMcpServers += 1; + } + if (entry.kind === "managed" && sighting.state === "drifted") { + harness.differentConfigMcpServers += 1; + } + if (entry.kind === "unmanaged" && sighting.state === "unmanaged") { + harness.unmanagedMcpServers += 1; + } + } + } + + return Array.from(harnesses.values()) + .sort((a, b) => a.order - b.order) + .map(({ order: _order, ...row }) => row); +} + +export function inUseMcpHarnessCount(mcp: McpInventoryDto | null | undefined): number | null { + if (!mcp) return null; + return mcp.columns.filter(isMcpHarnessAddressable).length; +} diff --git a/frontend/src/app/capability-registry/sidebar.ts b/frontend/src/app/capability-registry/sidebar.ts new file mode 100644 index 0000000..4457a02 --- /dev/null +++ b/frontend/src/app/capability-registry/sidebar.ts @@ -0,0 +1,126 @@ +import { useMemo } from "react"; + +import { productLanguage } from "../../lib/product-language"; +import { mcpRoutes, useMcpInventoryQuery } from "../../features/mcp/public"; +import { skillsRoutes, useSkillsListQuery } from "../../features/skills/public"; +import { marketplaceRoutes } from "../../features/marketplace/public"; + +export type SidebarIconKey = "overview" | "skills" | "mcp" | "marketplace"; + +export interface SidebarLinkModel { + key: string; + to: string; + label: string; + count?: number | null; +} + +export interface SidebarGroupModel { + key: string; + label: string; + iconKey: SidebarIconKey; + count?: number | null; + links: SidebarLinkModel[]; +} + +export interface SidebarModel { + topLinks: SidebarLinkModel[]; + groups: SidebarGroupModel[]; +} + +export function useSidebarModel(): SidebarModel { + const skillsQuery = useSkillsListQuery(); + const mcpQuery = useMcpInventoryQuery(); + + const inUseSkills = skillsQuery.data?.summary.managed ?? null; + const needsReviewSkills = skillsQuery.data?.summary.unmanaged ?? null; + const mcpCounts = mcpSidebarCounts(mcpQuery.data); + + return useMemo( + () => ({ + topLinks: [ + { + key: "overview", + to: "/overview", + label: "Overview", + }, + ], + groups: [ + { + key: "skills", + label: "Skills", + iconKey: "skills", + count: sumLoadedCounts(inUseSkills, needsReviewSkills), + links: [ + { key: "skills-use", to: skillsRoutes.inUse, label: productLanguage.inUse, count: inUseSkills }, + { + key: "skills-review", + to: skillsRoutes.needsReview, + label: productLanguage.needsReview, + count: needsReviewSkills, + }, + ], + }, + { + key: "mcp", + label: "MCP Servers", + iconKey: "mcp", + count: mcpCounts.total, + links: [ + { key: "mcp-use", to: mcpRoutes.inUse, label: productLanguage.inUse, count: mcpCounts.inUse }, + { + key: "mcp-review", + to: mcpRoutes.needsReview, + label: productLanguage.needsReview, + count: mcpCounts.needsReview, + }, + ], + }, + { + key: "marketplace", + label: "Marketplace", + iconKey: "marketplace", + links: [ + { key: "marketplace-skills", to: marketplaceRoutes.skills, label: "Skills" }, + { key: "marketplace-mcp", to: marketplaceRoutes.mcp, label: "MCP" }, + { key: "marketplace-clis", to: marketplaceRoutes.clis, label: "CLIs" }, + ], + }, + ], + }), + [ + inUseSkills, + mcpCounts.inUse, + mcpCounts.needsReview, + mcpCounts.total, + needsReviewSkills, + ], + ); +} + +function sumLoadedCounts(...counts: Array): number | null { + let total = 0; + for (const count of counts) { + if (count == null) { + return null; + } + total += count; + } + return total; +} + +function mcpSidebarCounts(inventory: ReturnType["data"]): { + inUse: number | null; + needsReview: number | null; + total: number | null; +} { + if (!inventory) { + return { inUse: null, needsReview: null, total: null }; + } + const inUse = inventory.entries.filter((entry) => entry.kind === "managed").length; + const needsReview = inventory.entries.filter((entry) => entry.kind === "unmanaged").length; + return { + inUse, + needsReview, + total: sumLoadedCounts(inUse, needsReview), + }; +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx deleted file mode 100644 index be1ad58..0000000 --- a/frontend/src/components/AppShell.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { ReactNode } from "react"; -import { Settings } from "lucide-react"; -import { NavLink, useLocation } from "react-router-dom"; - -import { LoadingSpinner } from "./LoadingSpinner"; - -interface AppShellProps { - children: ReactNode; - onRefreshData?: () => void | Promise; - refreshPending?: boolean; -} - -export function AppShell({ children, onRefreshData, refreshPending = false }: AppShellProps) { - const location = useLocation(); - const isSkillsRoute = location.pathname.startsWith("/skills"); - - return ( -
-
-
-

Universal Skill Manager

-

skill-manager

-
- -
- - `icon-button app-header__settings${isActive ? " is-active" : ""}`} - aria-label="Open settings" - > - - -
-
-
{children}
-
- ); -} diff --git a/frontend/src/components/BulkActionBar.tsx b/frontend/src/components/BulkActionBar.tsx new file mode 100644 index 0000000..429cb71 --- /dev/null +++ b/frontend/src/components/BulkActionBar.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { Check, CircleSlash2, Trash2, X } from "lucide-react"; + +import { ConfirmActionDialog } from "./ConfirmActionDialog"; +import { LoadingSpinner } from "./LoadingSpinner"; + +export type MultiSelectAction = "enable-all" | "disable-all" | "delete"; + +interface BulkActionBarProps { + selectedCount: number; + pending: MultiSelectAction | null; + onClear: () => void; + onEnableAll: () => Promise; + onDisableAll: () => Promise; + onDelete: () => Promise; + destructive: { + /** Button aria-label + confirm button text (e.g. "Delete" / "Uninstall"). */ + actionLabel: string; + /** Confirm dialog title (e.g. "Delete 3 skills?"). */ + confirmTitle: string; + /** Confirm dialog body paragraph. */ + confirmDescription: string; + /** Optional quieter secondary note below the main body. */ + confirmNote?: string; + }; +} + +export function BulkActionBar({ + selectedCount, + pending, + onClear, + onEnableAll, + onDisableAll, + onDelete, + destructive, +}: BulkActionBarProps) { + const [visible, setVisible] = useState(selectedCount > 0); + const [confirmOpen, setConfirmOpen] = useState(false); + + useEffect(() => { + if (selectedCount > 0) { + setVisible(true); + } else { + const timer = window.setTimeout(() => setVisible(false), 220); + return () => window.clearTimeout(timer); + } + return undefined; + }, [selectedCount]); + + if (!visible) { + return null; + } + + const disabled = pending !== null; + const active = selectedCount > 0; + + return ( + <> +
+
+
+
+ + {selectedCount} selected + + +
+ +
+
+ + { + setConfirmOpen(false); + await onDelete(); + }} + /> + + ); +} diff --git a/frontend/src/components/ConfirmActionDialog.test.tsx b/frontend/src/components/ConfirmActionDialog.test.tsx new file mode 100644 index 0000000..9aad98c --- /dev/null +++ b/frontend/src/components/ConfirmActionDialog.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConfirmActionDialog } from "./ConfirmActionDialog"; + +function renderDialog(props: Partial[0]> = {}) { + const onOpenChange = vi.fn(); + const onConfirm = vi.fn(); + + const utils = render( + , + ); + + return { ...utils, onOpenChange, onConfirm }; +} + +describe("ConfirmActionDialog", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the title, description, and secondary note in the standard dialog body", () => { + renderDialog(); + expect(screen.getByRole("heading", { name: /uninstall exa search\?/i })).toBeInTheDocument(); + expect(screen.getByText(/remove this server from your central catalog/i)).toBeInTheDocument(); + expect(screen.getByText(/updates local harness config files/i)).toBeInTheDocument(); + }); + + it("closes through the cancel button when not pending", () => { + const { onOpenChange } = renderDialog(); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("disables the footer while pending", () => { + renderDialog({ isPending: true }); + expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /uninstalling/i })).toBeDisabled(); + }); +}); diff --git a/frontend/src/features/skills/components/dialogs/SkillActionDialog.tsx b/frontend/src/components/ConfirmActionDialog.tsx similarity index 68% rename from frontend/src/features/skills/components/dialogs/SkillActionDialog.tsx rename to frontend/src/components/ConfirmActionDialog.tsx index b3acfda..80ac634 100644 --- a/frontend/src/features/skills/components/dialogs/SkillActionDialog.tsx +++ b/frontend/src/components/ConfirmActionDialog.tsx @@ -1,37 +1,33 @@ import * as Dialog from "@radix-ui/react-dialog"; import type { ReactNode } from "react"; -import { LoadingSpinner } from "../../../../components/LoadingSpinner"; +import { LoadingSpinner } from "./LoadingSpinner"; -interface SkillActionDialogProps { +interface ConfirmActionDialogProps { open: boolean; - eyebrow: string; title: string; description: ReactNode; note?: ReactNode; - tone?: "neutral" | "danger"; confirmLabel: string; - confirmClassName: string; pendingLabel: string; isPending: boolean; + confirmTone?: "primary" | "danger"; onOpenChange: (open: boolean) => void; onConfirm: () => void | Promise; } -export function SkillActionDialog({ +export function ConfirmActionDialog({ open, - eyebrow, title, description, note, - tone = "neutral", confirmLabel, - confirmClassName, pendingLabel, isPending, + confirmTone = "danger", onOpenChange, onConfirm, -}: SkillActionDialogProps) { +}: ConfirmActionDialogProps) { return ( { if (isPending) { event.preventDefault(); @@ -61,18 +57,17 @@ export function SkillActionDialog({ } }} > -
-

{eyebrow}

- {title} +
+ {title}
- + {description} - {note ?

{note}

: null} -
+ {note ?
{note}
: null} +
+ ))} +
+ ) : null} + + {trailing} +
+ ); +} diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..f527cce --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + subtitle?: string; + actions?: ReactNode; +} + +export function PageHeader({ title, subtitle, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx deleted file mode 100644 index ed049c9..0000000 --- a/frontend/src/components/SearchInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Search } from "lucide-react"; - -import { LoadingSpinner } from "./LoadingSpinner"; - -interface SearchInputProps { - value: string; - onChange: (value: string) => void; - onSubmit: () => void; - placeholder?: string; - submitPending?: boolean; - disabled?: boolean; -} - -export function SearchInput({ - value, - onChange, - onSubmit, - placeholder = "Search...", - submitPending = false, - disabled, -}: SearchInputProps) { - return ( -
-
- - onChange(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onSubmit()} - disabled={disabled} - /> -
- -
- ); -} diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx new file mode 100644 index 0000000..f8a7da1 --- /dev/null +++ b/frontend/src/components/Shell.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +import { Sidebar } from "./Sidebar"; + +interface ShellProps { + children: ReactNode; + onRefresh: () => void | Promise; + refreshPending: boolean; +} + +export function Shell({ children, onRefresh, refreshPending }: ShellProps) { + return ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..688839c --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,297 @@ +import { + type RefObject, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + BookOpen, + ChevronDown, + LayoutDashboard, + RefreshCw, + Settings, + Store, + SunMedium, + Terminal, +} from "lucide-react"; +import { Link, NavLink, useLocation } from "react-router-dom"; + +import { useSidebarModel, type SidebarIconKey } from "../app/capability-registry"; +import { LoadingSpinner } from "./LoadingSpinner"; +import { useToast } from "./Toast"; + +interface SidebarProps { + onRefresh: () => void | Promise; + refreshPending: boolean; +} + +export function Sidebar({ onRefresh, refreshPending }: SidebarProps) { + const model = useSidebarModel(); + const { toast } = useToast(); + + return ( + + ); +} + +function sidebarIcon(iconKey: SidebarIconKey): ReactNode { + if (iconKey === "skills") return ; + if (iconKey === "mcp") return ; + if (iconKey === "marketplace") return ; + return ; +} + +function NavGroup({ + label, + icon, + count, + children, +}: { + label: string; + icon: ReactNode; + count?: number | null; + children: ReactNode; +}) { + const [collapsed, setCollapsed] = useState(false); + const listRef = useRef(null); + const indicator = useNavIndicator(listRef, collapsed); + + return ( +
+ + {!collapsed ? ( +
+
+ ) : null} +
+ ); +} + +interface IndicatorRect { + top: number; + left: number; + width: number; + height: number; +} + +function measureActive(list: HTMLDivElement): IndicatorRect | null { + const active = list.querySelector(".sidebar-link.is-active"); + if (!active) { + return null; + } + return { + top: active.offsetTop, + left: active.offsetLeft, + width: active.offsetWidth, + height: active.offsetHeight, + }; +} + +function measureLink(link: HTMLElement): IndicatorRect { + return { + top: link.offsetTop, + left: link.offsetLeft, + width: link.offsetWidth, + height: link.offsetHeight, + }; +} + +function useNavIndicator( + listRef: RefObject, + collapsed: boolean, +): IndicatorRect | null { + const location = useLocation(); + const [activeRect, setActiveRect] = useState(null); + const [hoverRect, setHoverRect] = useState(null); + + const refreshActive = useCallback(() => { + const list = listRef.current; + if (!list || collapsed) { + setActiveRect(null); + return; + } + setActiveRect(measureActive(list)); + }, [listRef, collapsed]); + + useLayoutEffect(() => { + refreshActive(); + }, [refreshActive, location.pathname]); + + useEffect(() => { + const list = listRef.current; + if (!list || collapsed || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => refreshActive()); + observer.observe(list); + for (const child of Array.from(list.querySelectorAll(".sidebar-link"))) { + observer.observe(child); + } + return () => observer.disconnect(); + }, [listRef, collapsed, refreshActive]); + + useEffect(() => { + const list = listRef.current; + if (!list || collapsed) { + return; + } + + const handlePointerMove = (event: Event): void => { + const target = (event.target as HTMLElement | null)?.closest(".sidebar-link"); + if (!target || !list.contains(target)) { + return; + } + setHoverRect(measureLink(target)); + }; + + const clearHover = (): void => { + setHoverRect(null); + }; + + const handleFocusOut = (event: FocusEvent): void => { + if (!list.contains(event.relatedTarget as Node | null)) { + setHoverRect(null); + } + }; + + list.addEventListener("mouseover", handlePointerMove); + list.addEventListener("focusin", handlePointerMove); + list.addEventListener("mouseleave", clearHover); + list.addEventListener("focusout", handleFocusOut); + + return () => { + list.removeEventListener("mouseover", handlePointerMove); + list.removeEventListener("focusin", handlePointerMove); + list.removeEventListener("mouseleave", clearHover); + list.removeEventListener("focusout", handleFocusOut); + }; + }, [listRef, collapsed]); + + return hoverRect ?? activeRect; +} + +function SidebarTopLink({ + to, + label, + icon, +}: { + to: string; + label: string; + icon: ReactNode; +}) { + return ( + `sidebar-top-link${isActive ? " is-active" : ""}`}> + {icon} + {label} + + ); +} + +function SidebarLink({ + to, + label, + count, +}: { + to: string; + label: string; + count?: number | null; +}) { + return ( + `sidebar-link${isActive ? " is-active" : ""}`}> + + ); +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..3743a07 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,55 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react"; + +interface ToastItem { + id: number; + message: string; +} + +interface ToastContextValue { + toast: (message: string) => void; +} + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + const idRef = useRef(0); + + const toast = useCallback((message: string) => { + const id = ++idRef.current; + setItems((prev) => [...prev, { id, message }]); + setTimeout(() => { + setItems((prev) => prev.filter((item) => item.id !== id)); + }, 3000); + }, []); + + const value = useMemo(() => ({ toast }), [toast]); + + return ( + + {children} +
+ {items.map((item) => ( +
+ {item.message} +
+ ))} +
+
+ ); +} + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) { + // Soft fallback so components still render outside provider (e.g. unit tests). + return { + toast: (message: string) => { + if (typeof console !== "undefined") { + console.info("[toast]", message); + } + }, + }; + } + return ctx; +} diff --git a/frontend/src/components/ViewModeToggle.tsx b/frontend/src/components/ViewModeToggle.tsx new file mode 100644 index 0000000..00a7c0f --- /dev/null +++ b/frontend/src/components/ViewModeToggle.tsx @@ -0,0 +1,42 @@ +import type { LucideIcon } from "lucide-react"; + +export interface ViewModeOption { + value: T; + label: string; + icon: LucideIcon; +} + +interface ViewModeToggleProps { + mode: T; + options: readonly ViewModeOption[]; + ariaLabel: string; + onChange: (next: T) => void; +} + +export function ViewModeToggle({ + mode, + options, + ariaLabel, + onChange, +}: ViewModeToggleProps) { + return ( +
+ {options.map(({ value, label, icon: Icon }) => { + const active = mode === value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/cards/CardMenu.test.tsx b/frontend/src/components/cards/CardMenu.test.tsx new file mode 100644 index 0000000..d545f6d --- /dev/null +++ b/frontend/src/components/cards/CardMenu.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CardMenu } from "./CardMenu"; + +describe("CardMenu", () => { + it("renders shared menu-surface actions and closes after selection", async () => { + const onDelete = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "More actions" })); + + const deleteButton = screen.getByRole("button", { name: "Delete" }); + expect(deleteButton.closest(".ui-popup--menu")).not.toBeNull(); + expect(deleteButton).toHaveAttribute("data-destructive"); + + fireEvent.click(deleteButton); + expect(onDelete).toHaveBeenCalledTimes(1); + + await waitFor(() => + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/components/cards/CardMenu.tsx b/frontend/src/components/cards/CardMenu.tsx new file mode 100644 index 0000000..4e18c79 --- /dev/null +++ b/frontend/src/components/cards/CardMenu.tsx @@ -0,0 +1,82 @@ +import { type ReactNode, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { MoreHorizontal } from "lucide-react"; + +export interface CardMenuItem { + key: string; + label: string; + icon?: ReactNode; + onSelect: () => void; + destructive?: boolean; + disabled?: boolean; +} + +interface CardMenuProps { + label: string; + items: readonly CardMenuItem[]; + disabled?: boolean; +} + +/** + * 3-dot icon button + shared popup menu surface used on in-use cards. + */ +export function CardMenu({ label, items, disabled = false }: CardMenuProps) { + const [open, setOpen] = useState(false); + if (items.length === 0) { + return null; + } + return ( + + + + + + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > +
    + {items.map((item) => ( +
  • + + + +
  • + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/cards/CardSelectCheckbox.tsx b/frontend/src/components/cards/CardSelectCheckbox.tsx new file mode 100644 index 0000000..978d334 --- /dev/null +++ b/frontend/src/components/cards/CardSelectCheckbox.tsx @@ -0,0 +1,59 @@ +import { type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; + +interface CardSelectCheckboxProps { + checked: boolean; + label: string; + onToggle: () => void; + disabled?: boolean; +} + +/** + * Square 14×14 select checkbox used on in-use cards (skills + mcp). + * Stops propagation so it doesn't fire the card's click-to-open handler. + */ +export function CardSelectCheckbox({ + checked, + label, + onToggle, + disabled = false, +}: CardSelectCheckboxProps) { + function handleClick(event: ReactPointerEvent): void { + event.stopPropagation(); + if (disabled) return; + onToggle(); + } + function handleKey(event: KeyboardEvent): void { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + if (disabled) return; + onToggle(); + } + + return ( + event.stopPropagation()} + onClick={handleClick} + onKeyDown={handleKey} + > + {checked ? ( + + ) : null} + + ); +} diff --git a/frontend/src/components/cards/NeedsReviewRow.tsx b/frontend/src/components/cards/NeedsReviewRow.tsx new file mode 100644 index 0000000..50a3489 --- /dev/null +++ b/frontend/src/components/cards/NeedsReviewRow.tsx @@ -0,0 +1,108 @@ +import { type ReactNode } from "react"; +import { Loader2, Plus } from "lucide-react"; + +import { OverflowTooltipText } from "../ui/OverflowTooltipText"; +import { UiTooltip } from "../ui/UiTooltip"; +import { UiTooltipTriggerBoundary } from "../ui/UiTooltipTriggerBoundary"; + +interface NeedsReviewRowProps { + name: string; + /** Pre-rendered harness logo stack rendered inline next to the name. */ + logos: ReactNode; + /** Primary meta line, e.g. "Found in 3 harnesses". */ + metaText: string; + /** Optional inline chip(s) after the meta line (Identical / Differs / Match). */ + statusChip?: ReactNode; + /** Optional long-form copy. Line-clamped to 2 lines. */ + description?: string; + actionLabel: string; + /** Shared tooltip copy for the action button. */ + actionTitle?: string; + pending?: boolean; + /** Disables the action button (does not gate the row click-to-detail). */ + actionDisabled?: boolean; + onOpen: () => void; + onAction: () => void; +} + +export function NeedsReviewRow({ + name, + logos, + metaText, + statusChip, + description, + actionLabel, + actionTitle, + pending = false, + actionDisabled = false, + onOpen, + onAction, +}: NeedsReviewRowProps) { + const isActionUnavailable = pending || actionDisabled; + const actionButton = ( + + ); + + const actionControl = !actionTitle + ? actionButton + : isActionUnavailable + ? ( + + {actionButton} + + ) + : ( + + {actionButton} + + ); + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }} + aria-label={`Open detail for ${name}`} + > +
+
+

{name}

+ {logos} +
+

{metaText}

+ {description ? ( + + {description} + + ) : null} +
+ +
+ {statusChip} + {actionControl} +
+
+ ); +} diff --git a/frontend/src/components/detail/DetailBindingIdentity.tsx b/frontend/src/components/detail/DetailBindingIdentity.tsx new file mode 100644 index 0000000..bfa13fe --- /dev/null +++ b/frontend/src/components/detail/DetailBindingIdentity.tsx @@ -0,0 +1,63 @@ +import { HarnessAvatar } from "../harness/HarnessAvatar"; +import { UiTooltip } from "../ui/UiTooltip"; + +export type DetailBindingTone = "enabled" | "disabled" | "warning"; + +interface DetailBindingIdentityProps { + harness: string; + label: string; + logoKey?: string | null; + statusLabel: string; + tone: DetailBindingTone; + visibleStatus?: string | null; + detail?: string | null; +} + +export function DetailBindingIdentity({ + harness, + label, + logoKey, + statusLabel, + tone, + visibleStatus = null, + detail = null, +}: DetailBindingIdentityProps) { + return ( + <> + + + +
+
+ + ); +} diff --git a/frontend/src/components/detail/DetailDisclosure.tsx b/frontend/src/components/detail/DetailDisclosure.tsx index 5de8d9f..55fc6bf 100644 --- a/frontend/src/components/detail/DetailDisclosure.tsx +++ b/frontend/src/components/detail/DetailDisclosure.tsx @@ -3,7 +3,6 @@ import { ChevronDown } from "lucide-react"; interface DetailDisclosureProps { title: string; - eyebrow?: string; defaultOpen?: boolean; className?: string; children: ReactNode; @@ -11,7 +10,6 @@ interface DetailDisclosureProps { export function DetailDisclosure({ title, - eyebrow, defaultOpen = false, className = "", children, @@ -21,19 +19,20 @@ export function DetailDisclosure({ return (
- +

+ +

{children}
diff --git a/frontend/src/components/detail/DetailHeader.tsx b/frontend/src/components/detail/DetailHeader.tsx index f1e448d..bdda376 100644 --- a/frontend/src/components/detail/DetailHeader.tsx +++ b/frontend/src/components/detail/DetailHeader.tsx @@ -8,7 +8,6 @@ interface DetailHeaderProps { titleAction?: ReactNode; meta?: ReactNode; utility?: ReactNode; - eyebrow?: string | null; closeLabel?: string; } @@ -18,13 +17,11 @@ export function DetailHeader({ titleAction, meta, utility, - eyebrow = "Details", closeLabel = "Close detail view", }: DetailHeaderProps) { return (
- {eyebrow ?

{eyebrow}

: null}
{utility ?
{utility}
: null} diff --git a/frontend/src/components/detail/DetailNote.tsx b/frontend/src/components/detail/DetailNote.tsx new file mode 100644 index 0000000..f73dee0 --- /dev/null +++ b/frontend/src/components/detail/DetailNote.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +interface DetailNoteProps { + children: ReactNode; + className?: string; +} + +export function DetailNote({ children, className }: DetailNoteProps) { + const classes = ["detail-note", className].filter(Boolean).join(" "); + return
{children}
; +} diff --git a/frontend/src/components/detail/DetailSection.tsx b/frontend/src/components/detail/DetailSection.tsx new file mode 100644 index 0000000..3dc7c97 --- /dev/null +++ b/frontend/src/components/detail/DetailSection.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +interface DetailSectionProps { + heading: string; + children: ReactNode; + className?: string; +} + +/** + * Body section for any detail-sheet modal (skill, MCP server, marketplace). + * Imposes a consistent heading + spacing rhythm so the four-or-five-section + * body of every detail view reads the same way. + * + * Layout-only: doesn't dictate which sections appear, only that whichever + * does appear gets the same heading and gap. + */ +export function DetailSection({ + heading, + children, + className, +}: DetailSectionProps) { + const sectionClass = className + ? `detail-sheet__section ${className}` + : "detail-sheet__section"; + + return ( +
+

{heading}

+ {children} +
+ ); +} diff --git a/frontend/src/components/detail/DetailSourceLinks.tsx b/frontend/src/components/detail/DetailSourceLinks.tsx index a1b7a9d..8e29aa1 100644 --- a/frontend/src/components/detail/DetailSourceLinks.tsx +++ b/frontend/src/components/detail/DetailSourceLinks.tsx @@ -1,64 +1,47 @@ import { ExternalLink, FolderGit2 } from "lucide-react"; +export type DetailSourceLinkKind = "repo" | "folder" | "marketplace" | "external" | "website"; + +export interface DetailSourceLink { + href: string; + label: string; + kind?: DetailSourceLinkKind; +} + interface DetailSourceLinksProps { - sourceLinks: { - repoLabel: string; - repoUrl: string; - folderUrl: string | null; - } | null; - externalUrl?: string | null; - externalLabel?: string; + links: DetailSourceLink[]; + ariaLabel: string; label?: string; } export function DetailSourceLinks({ - sourceLinks, - externalUrl = null, - externalLabel = "Open external detail", + links, + ariaLabel, label = "Source", }: DetailSourceLinksProps) { - if (!sourceLinks) { + if (links.length === 0) { return null; } return ( -
-
+
+
-
- - {sourceLinks.repoLabel} - - {sourceLinks.folderUrl ? ( - - Open Skill Folder - - ) : null} - {externalUrl ? ( +
+ {links.map((link) => ( - {externalLabel} + {link.label} - ) : null} + ))}
); diff --git a/frontend/src/components/detail/index.css b/frontend/src/components/detail/index.css index 94b6b25..c1ed17d 100644 --- a/frontend/src/components/detail/index.css +++ b/frontend/src/components/detail/index.css @@ -1,3 +1,5 @@ +@layer components { + @keyframes detail-skeleton-shimmer { 100% { transform: translateX(100%); @@ -23,6 +25,61 @@ flex-shrink: 0; } +.detail-source-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + min-width: 0; +} + +.detail-source-label { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.detail-source-links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + min-width: 0; +} + +.detail-source-link { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(18, 22, 30, 0.62); + color: var(--color-text-muted); + transition: border-color 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease; +} + +.detail-source-link:hover { + border-color: rgba(240, 163, 107, 0.28); + background: rgba(240, 163, 107, 0.09); + color: var(--color-text); + transform: translateY(-1px); +} + +.detail-source-link--repo { + color: var(--color-accent); +} + +.detail-source-link--repo:hover { + color: var(--color-accent); +} + .detail-skeleton-paragraph { display: grid; gap: 10px; @@ -97,7 +154,7 @@ .detail-skeleton--button { width: 220px; height: 42px; - border-radius: var(--radius); + border-radius: var(--radius-md); } .detail-skeleton--button-secondary { @@ -109,3 +166,5 @@ animation: none; } } + +} diff --git a/frontend/src/components/harness/HarnessAvatar.tsx b/frontend/src/components/harness/HarnessAvatar.tsx new file mode 100644 index 0000000..c0459a5 --- /dev/null +++ b/frontend/src/components/harness/HarnessAvatar.tsx @@ -0,0 +1,32 @@ +import { getHarnessPresentation } from "./harnessPresentation"; + +interface HarnessAvatarProps { + harness: string; + label: string; + logoKey?: string | null; + className?: string; +} + +export function HarnessAvatar({ + harness, + label, + logoKey, + className, +}: HarnessAvatarProps) { + const presentation = getHarnessPresentation(logoKey ?? harness); + const classes = ["harness-avatar", className].filter(Boolean).join(" "); + + if (!presentation) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/features/skills/components/harness/harnessPresentation.ts b/frontend/src/components/harness/harnessPresentation.ts similarity index 68% rename from frontend/src/features/skills/components/harness/harnessPresentation.ts rename to frontend/src/components/harness/harnessPresentation.ts index 50b6dc9..7abbca7 100644 --- a/frontend/src/features/skills/components/harness/harnessPresentation.ts +++ b/frontend/src/components/harness/harnessPresentation.ts @@ -1,8 +1,8 @@ -import claudeLogo from "../../../../assets/harness-logos/claude-code-logo.svg"; -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 claudeLogo from "../../assets/harness-logos/claude-code-logo.svg"; +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"; export type HarnessLogoKey = "claude" | "codex" | "cursor" | "opencode" | "openclaw"; diff --git a/frontend/src/components/matrix/MatrixHarnessCellTarget.tsx b/frontend/src/components/matrix/MatrixHarnessCellTarget.tsx new file mode 100644 index 0000000..e0f1771 --- /dev/null +++ b/frontend/src/components/matrix/MatrixHarnessCellTarget.tsx @@ -0,0 +1,61 @@ +import type { MouseEventHandler, ReactNode } from "react"; + +type MatrixHarnessCellTargetProps = { + children: ReactNode; + ariaLabel: string; + state?: string; + pending?: boolean; + disabled?: boolean; + ariaPressed?: boolean; + title?: string; + className?: string; + onClick?: MouseEventHandler; +}; + +export function MatrixHarnessCellTarget({ + children, + ariaLabel, + state, + pending = false, + disabled = false, + ariaPressed, + title, + className, + onClick, +}: MatrixHarnessCellTargetProps) { + const classNames = ["matrix-harness-target"]; + if (onClick) classNames.push("matrix-harness-target--interactive"); + if (className) classNames.push(className); + + if (!onClick) { + return ( + + {children} + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/matrix/MatrixHarnessHeader.tsx b/frontend/src/components/matrix/MatrixHarnessHeader.tsx new file mode 100644 index 0000000..f65cd97 --- /dev/null +++ b/frontend/src/components/matrix/MatrixHarnessHeader.tsx @@ -0,0 +1,20 @@ +import { UiTooltip } from "../ui/UiTooltip"; +import { MatrixHarnessIcon } from "./MatrixHarnessIcon"; + +interface MatrixHarnessHeaderProps { + label: string; + logoKey?: string | null; + harness?: string; +} + +export function MatrixHarnessHeader({ label, logoKey, harness }: MatrixHarnessHeaderProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/matrix/MatrixHarnessIcon.tsx b/frontend/src/components/matrix/MatrixHarnessIcon.tsx new file mode 100644 index 0000000..e79ebc9 --- /dev/null +++ b/frontend/src/components/matrix/MatrixHarnessIcon.tsx @@ -0,0 +1,24 @@ +import { getHarnessPresentation } from "../harness/harnessPresentation"; + +interface MatrixHarnessIconProps { + label: string; + logoKey?: string | null; + harness?: string; +} + +export function MatrixHarnessIcon({ label, logoKey, harness }: MatrixHarnessIconProps) { + const presentation = getHarnessPresentation(logoKey ?? harness ?? label); + + if (presentation) { + return ( + + ); + } + + return {label.slice(0, 1)}; +} diff --git a/frontend/src/components/matrix/MatrixSortableHeader.tsx b/frontend/src/components/matrix/MatrixSortableHeader.tsx new file mode 100644 index 0000000..b1f7f43 --- /dev/null +++ b/frontend/src/components/matrix/MatrixSortableHeader.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { UiTooltip } from "../ui/UiTooltip"; + +export type MatrixSortDirection = "asc" | "desc"; + +interface MatrixSortableHeaderProps { + label: string; + leading?: ReactNode; + active: boolean; + direction: MatrixSortDirection; + align?: "start" | "identity" | "harness" | "end"; + logoOnly?: boolean; + srLabel?: string; + onClick: () => void; +} + +export function MatrixSortableHeader({ + label, + leading, + active, + direction, + align = "start", + logoOnly = false, + srLabel, + onClick, +}: MatrixSortableHeaderProps) { + const buttonClassName = logoOnly + ? "matrix-table__sort-btn matrix-table__sort-btn--harness" + : "matrix-table__sort-btn"; + + const button = ( + + ); + + return ( + + {logoOnly ? {button} : button} + + ); +} diff --git a/frontend/src/components/matrix/MatrixTable.test.tsx b/frontend/src/components/matrix/MatrixTable.test.tsx new file mode 100644 index 0000000..75e611b --- /dev/null +++ b/frontend/src/components/matrix/MatrixTable.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { MatrixHarnessHeader } from "./MatrixHarnessHeader"; +import { MatrixTable } from "./MatrixTable"; + +describe("MatrixTable", () => { + it("renders the shared column structure", () => { + render( + + + + Select + Name + Codex + Claude + Harnesses + Active + + + , + ); + + const table = screen.getByRole("table", { name: "Example matrix" }); + const cols = table.querySelectorAll("col"); + + expect(table).toHaveClass("matrix-table"); + expect(table).not.toHaveClass("matrix-table--panel"); + expect(table.closest(".matrix-table-wrapper")).not.toHaveClass("matrix-table-wrapper--panel"); + expect(cols).toHaveLength(6); + expect(cols[0]).toHaveClass("matrix-table__col-checkbox"); + expect(cols[1]).toHaveClass("matrix-table__col-identity"); + expect(cols[2]).toHaveClass("matrix-table__col-harness"); + expect(cols[3]).toHaveClass("matrix-table__col-harness"); + expect(cols[4]).toHaveClass("matrix-table__col-compact"); + expect(cols[5]).toHaveClass("matrix-table__col-coverage"); + }); + + it("renders harness headers through the centered matrix target", () => { + render( + + + + + + +
, + ); + + const trigger = screen.getByLabelText("Codex"); + expect(trigger).toHaveClass("matrix-harness-target"); + expect(trigger).toHaveClass("matrix-harness-target--header"); + }); +}); diff --git a/frontend/src/components/matrix/MatrixTable.tsx b/frontend/src/components/matrix/MatrixTable.tsx new file mode 100644 index 0000000..72c1862 --- /dev/null +++ b/frontend/src/components/matrix/MatrixTable.tsx @@ -0,0 +1,45 @@ +import type { CSSProperties, ReactNode } from "react"; + +interface MatrixTableProps { + ariaLabel: string; + harnessColumnCount: number; + children: ReactNode; + harnessColumnWidth?: string; + compactColumnWidth?: string; + coverageColumnWidth?: string; + minWidth?: string; +} + +export function MatrixTable({ + ariaLabel, + harnessColumnCount, + children, + harnessColumnWidth = "52px", + compactColumnWidth = "140px", + coverageColumnWidth = "64px", + minWidth, +}: MatrixTableProps) { + const style = { + "--matrix-harness-column-width": harnessColumnWidth, + "--matrix-compact-column-width": compactColumnWidth, + "--matrix-coverage-column-width": coverageColumnWidth, + ...(minWidth ? { "--matrix-table-min-width": minWidth } : {}), + } as CSSProperties; + + return ( +
+ + + + + {Array.from({ length: harnessColumnCount }, (_, index) => ( + + ))} + + + + {children} +
+
+ ); +} diff --git a/frontend/src/components/matrix/index.ts b/frontend/src/components/matrix/index.ts new file mode 100644 index 0000000..3cab118 --- /dev/null +++ b/frontend/src/components/matrix/index.ts @@ -0,0 +1,6 @@ +export { MatrixHarnessCellTarget } from "./MatrixHarnessCellTarget"; +export { MatrixHarnessHeader } from "./MatrixHarnessHeader"; +export { MatrixHarnessIcon } from "./MatrixHarnessIcon"; +export { MatrixSortableHeader } from "./MatrixSortableHeader"; +export { MatrixTable } from "./MatrixTable"; +export type { MatrixSortDirection } from "./MatrixSortableHeader"; diff --git a/frontend/src/components/matrix/matrix.css b/frontend/src/components/matrix/matrix.css new file mode 100644 index 0000000..31b95bd --- /dev/null +++ b/frontend/src/components/matrix/matrix.css @@ -0,0 +1,334 @@ +@layer features { + +/* -------------------------------------------------------------------------- */ +/* Shared extension × harness matrix */ +/* -------------------------------------------------------------------------- */ + +.matrix-table-wrapper { + width: 100%; + overflow-x: auto; +} + +.matrix-table { + width: 100%; + min-width: var(--matrix-table-min-width, 0); + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + font-size: var(--font-size-sm); +} + +.matrix-table__col-checkbox { + width: 44px; +} + +.matrix-table__col-harness { + width: var(--matrix-harness-column-width, 52px); +} + +.matrix-table__col-compact { + width: 0; +} + +.matrix-table__col-coverage { + width: var(--matrix-coverage-column-width, 64px); +} + +.matrix-table__head { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-bg); +} + +.matrix-table__th { + height: 54px; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); + background: inherit; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-weight: 500; + text-align: left; + white-space: nowrap; + vertical-align: middle; +} + +.matrix-table__th--checkbox, +.matrix-table__cell--checkbox { + width: 44px; + padding-left: var(--space-3); + padding-right: 0; +} + +.matrix-table__th--identity, +.matrix-table__cell--identity { + padding-left: var(--space-4); + padding-right: var(--space-4); +} + +.matrix-table__cell--identity { + cursor: pointer; + max-width: 0; +} + +.matrix-table__th--harness, +.matrix-table__cell--harness { + width: var(--matrix-harness-column-width, 52px); + padding-left: 0; + padding-right: 0; + text-align: center; +} + +.matrix-table__th--end, +.matrix-table__cell--coverage { + width: var(--matrix-coverage-column-width, 64px); + padding-right: var(--space-4); + text-align: right; +} + +.matrix-table__th--compact, +.matrix-table__cell--compact { + display: none; + width: 0; +} + +.matrix-table__cell { + height: 48px; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.matrix-table__row { + transition: background 120ms ease; +} + +.matrix-table__row:last-child .matrix-table__cell { + border-bottom: none; +} + +.matrix-table__row:hover { + background: var(--color-surface-raised); +} + +.matrix-table__row[data-checked="true"], +.matrix-table__row[data-selected="true"] { + background: var(--color-surface); + box-shadow: inset 3px 0 0 var(--color-accent); +} + +.matrix-table__sort-btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + margin: 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: color 120ms ease, background 120ms ease; +} + +.matrix-table__sort-btn:hover, +.matrix-table__sort-btn:focus-visible { + color: var(--color-text); + outline: none; +} + +.matrix-table__sort-btn[data-active="true"] { + color: var(--color-text); +} + +.matrix-table__sort-btn--harness { + justify-content: center; + width: 40px; + height: 40px; + padding: 0; +} + +.matrix-table__th--harness .matrix-table__sort-btn { + justify-content: center; +} + +.matrix-table__th--end .matrix-table__sort-btn { + justify-content: flex-end; +} + +.matrix-table__sort-label { + white-space: nowrap; +} + +.matrix-table__name-row { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.matrix-table__name-text { + color: var(--color-text); + font-size: var(--font-size-md); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.matrix-table__description { + display: block; + margin: 2px 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.matrix-table__coverage { + display: inline-flex; + align-items: baseline; + justify-content: flex-end; + font-variant-numeric: tabular-nums; +} + +.matrix-table__coverage-count { + color: var(--color-text); + font-size: var(--font-size-md); + font-weight: 600; +} + +.matrix-table__coverage-total { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 400; + margin-left: 2px; + white-space: pre; +} + +.matrix-harness-target { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + width: 40px; + height: 40px; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + vertical-align: middle; + cursor: default; + transition: background 120ms ease, opacity 120ms ease, filter 120ms ease, color 120ms ease; +} + +.matrix-harness-target--interactive { + cursor: pointer; +} + +.matrix-harness-target__logo { + display: block; + width: 26px; + height: 26px; + object-fit: contain; +} + +.matrix-harness-target__fallback { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 700; +} + +.matrix-harness-target[data-state="empty"] { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: var(--font-size-xs); +} + +.matrix-harness-target[data-state="disabled"] .matrix-harness-target__logo { + filter: grayscale(1); + opacity: 0.35; +} + +.matrix-harness-target[data-state="observed"] .matrix-harness-target__logo { + filter: grayscale(0.45); + opacity: 0.75; +} + +.matrix-harness-target[data-state="different"] { + color: var(--color-warning, #f59e0b); +} + +.matrix-harness-target[data-state="different"]::after, +.matrix-harness-target[data-state="observed"]::after { + content: ""; + position: absolute; + top: 5px; + right: 5px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-warning, #f59e0b); + border: 1.5px solid var(--color-surface); + box-sizing: content-box; +} + +.matrix-harness-target[data-state="unavailable"] { + color: var(--color-text-muted); + opacity: 0.6; +} + +.matrix-harness-target--interactive:hover, +.matrix-harness-target--interactive:focus-visible { + background: var(--color-surface-raised); + outline: none; +} + +.matrix-harness-target--interactive:hover .matrix-harness-target__logo, +.matrix-harness-target--interactive:focus-visible .matrix-harness-target__logo { + filter: none; + opacity: 1; +} + +.matrix-harness-target[data-pending="true"] { + opacity: 0.4; + cursor: progress; +} + +@media (max-width: 900px) { + .matrix-table__col-harness { + width: 0; + } + + .matrix-table__col-compact { + width: var(--matrix-compact-column-width, 140px); + } + + .matrix-table__th--compact, + .matrix-table__cell--compact { + display: table-cell; + width: var(--matrix-compact-column-width, 140px); + padding-left: var(--space-3); + padding-right: var(--space-3); + } + + .matrix-table__th--harness, + .matrix-table__cell--harness { + display: none; + } +} + +} diff --git a/frontend/src/components/ui/HelpPopover.tsx b/frontend/src/components/ui/HelpPopover.tsx deleted file mode 100644 index d851115..0000000 --- a/frontend/src/components/ui/HelpPopover.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { type ReactNode } from "react"; -import * as Popover from "@radix-ui/react-popover"; - -import { useHoverSurfaceState } from "./useHoverSurfaceState"; - -interface HelpPopoverProps { - title: string; - copy: string; - children: ReactNode; - side?: "top" | "right" | "bottom" | "left"; - align?: "start" | "center" | "end"; - sideOffset?: number; - collisionPadding?: number; -} - -export function HelpPopover({ - title, - copy, - children, - side = "bottom", - align = "center", - sideOffset = 8, - collisionPadding = 16, -}: HelpPopoverProps) { - const hover = useHoverSurfaceState(); - - return ( - - - - {children} - - - - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - {...hover.contentProps} - > -

{title}

-

{copy}

-
-
-
- ); -} diff --git a/frontend/src/components/ui/HoverTooltip.tsx b/frontend/src/components/ui/HoverTooltip.tsx deleted file mode 100644 index e75ddd4..0000000 --- a/frontend/src/components/ui/HoverTooltip.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { type ReactNode } from "react"; -import * as Popover from "@radix-ui/react-popover"; - -import { useHoverSurfaceState } from "./useHoverSurfaceState"; - -interface HoverTooltipProps { - copy: string; - children: ReactNode; - disabled?: boolean; - side?: "top" | "right" | "bottom" | "left"; - align?: "start" | "center" | "end"; - sideOffset?: number; - collisionPadding?: number; -} - -export function HoverTooltip({ - copy, - children, - disabled = false, - side = "top", - align = "center", - sideOffset = 8, - collisionPadding = 16, -}: HoverTooltipProps) { - const hover = useHoverSurfaceState(); - - if (disabled) { - return <>{children}; - } - - return ( - - - - {children} - - - - event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - {...hover.contentProps} - > -

{copy}

-
-
-
- ); -} diff --git a/frontend/src/components/ui/OverflowTooltipText.test.tsx b/frontend/src/components/ui/OverflowTooltipText.test.tsx new file mode 100644 index 0000000..6807362 --- /dev/null +++ b/frontend/src/components/ui/OverflowTooltipText.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { OverflowTooltipText } from "./OverflowTooltipText"; +import { UiTooltipProvider } from "./UiTooltipProvider"; + +const sizeState = { + clientWidth: 80, + scrollWidth: 240, + clientHeight: 20, + scrollHeight: 20, +}; + +const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"); +const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollWidth"); +const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); +const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + +describe("OverflowTooltipText", () => { + beforeAll(() => { + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: () => sizeState.clientWidth, + }); + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + get: () => sizeState.scrollWidth, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get: () => sizeState.clientHeight, + }); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get: () => sizeState.scrollHeight, + }); + }); + + beforeEach(() => { + sizeState.clientWidth = 80; + sizeState.scrollWidth = 240; + sizeState.clientHeight = 20; + sizeState.scrollHeight = 20; + }); + + afterAll(() => { + vi.unstubAllGlobals(); + + restoreDescriptor("clientWidth", originalClientWidth); + restoreDescriptor("scrollWidth", originalScrollWidth); + restoreDescriptor("clientHeight", originalClientHeight); + restoreDescriptor("scrollHeight", originalScrollHeight); + }); + + it("reveals clipped text through the shared tooltip surface", async () => { + render( + + + A very long skill name + + , + ); + + await waitFor(() => { + expect(screen.getByText("A very long skill name")).toHaveAttribute("data-state", "closed"); + }); + + const text = screen.getByText("A very long skill name"); + fireEvent.focus(text); + + await waitFor(() => { + const bubble = document.querySelector(".ui-popup--tooltip"); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent("A very long skill name"); + }); + }); + + it("stays silent when the text fits", async () => { + sizeState.clientWidth = 240; + sizeState.scrollWidth = 240; + + render( + + + Short name + + , + ); + + const text = screen.getByText("Short name"); + fireEvent.focus(text); + + await waitFor(() => { + expect(screen.queryByText("Short name", { selector: ".ui-popup--tooltip" })).toBeNull(); + }); + }); +}); + +function restoreDescriptor( + key: "clientWidth" | "scrollWidth" | "clientHeight" | "scrollHeight", + descriptor: PropertyDescriptor | undefined, +) { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, key, descriptor); + return; + } + delete (HTMLElement.prototype as unknown as Record)[key]; +} diff --git a/frontend/src/components/ui/OverflowTooltipText.tsx b/frontend/src/components/ui/OverflowTooltipText.tsx new file mode 100644 index 0000000..1cbb912 --- /dev/null +++ b/frontend/src/components/ui/OverflowTooltipText.tsx @@ -0,0 +1,109 @@ +import { + createElement, + useLayoutEffect, + useRef, + useState, + type HTMLAttributes, + type ReactNode, +} from "react"; + +import { UiTooltip, type UiTooltipProps } from "./UiTooltip"; + +type OverflowTooltipTag = "span" | "p" | "h3" | "code"; + +interface OverflowTooltipTextProps + extends Omit, "children"> { + as?: OverflowTooltipTag; + children: ReactNode; + tooltipContent?: ReactNode; + disabled?: boolean; + side?: UiTooltipProps["side"]; + align?: UiTooltipProps["align"]; + sideOffset?: UiTooltipProps["sideOffset"]; +} + +export function OverflowTooltipText({ + as = "span", + children, + tooltipContent, + disabled = false, + side = "top", + align = "center", + sideOffset = 6, + ...rest +}: OverflowTooltipTextProps) { + const elementRef = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const element = elementRef.current; + if (!element) { + return; + } + + let frame = 0; + + const measure = () => { + const next = + element.scrollWidth > element.clientWidth + 1 || + element.scrollHeight > element.clientHeight + 1; + setOverflowing((current) => (current === next ? current : next)); + }; + + const scheduleMeasure = () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame); + } + frame = window.requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }; + + scheduleMeasure(); + + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(scheduleMeasure); + resizeObserver?.observe(element); + if (element.parentElement) { + resizeObserver?.observe(element.parentElement); + } + + window.addEventListener("resize", scheduleMeasure); + const fontFaceSet = + typeof document === "undefined" || !("fonts" in document) + ? null + : document.fonts; + fontFaceSet?.addEventListener?.("loadingdone", scheduleMeasure); + + return () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame); + } + resizeObserver?.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + fontFaceSet?.removeEventListener?.("loadingdone", scheduleMeasure); + }; + }, [children, tooltipContent]); + + const element = createElement(as, { + ...rest, + ref: (node: HTMLElement | null) => { + elementRef.current = node; + }, + }, children); + + return ( + + {element} + + ); +} diff --git a/frontend/src/components/ui/SelectionMenu.test.tsx b/frontend/src/components/ui/SelectionMenu.test.tsx new file mode 100644 index 0000000..bd8f9ba --- /dev/null +++ b/frontend/src/components/ui/SelectionMenu.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SelectionMenu } from "./SelectionMenu"; + +describe("SelectionMenu", () => { + it("renders the shared selection menu surface and forwards changes", () => { + const onChange = vi.fn(); + + render( + , + ); + + const trigger = screen.getByRole("button", { name: "Filter: Enabled" }); + expect(trigger).toHaveTextContent("Enabled"); + + fireEvent.click(trigger); + + expect(screen.getByText("All").closest(".ui-popup--menu")).not.toBeNull(); + expect(screen.getByText("3")).toBeInTheDocument(); + + const allButton = screen.getByText("All").closest("button"); + expect(allButton).not.toBeNull(); + + fireEvent.click(allButton!); + expect(onChange).toHaveBeenCalledWith("all"); + }); +}); diff --git a/frontend/src/components/ui/SelectionMenu.tsx b/frontend/src/components/ui/SelectionMenu.tsx new file mode 100644 index 0000000..1bdff22 --- /dev/null +++ b/frontend/src/components/ui/SelectionMenu.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { Check, ListFilter } from "lucide-react"; + +export interface SelectionMenuOption { + value: T; + label: string; + meta?: ReactNode; +} + +interface SelectionMenuProps { + value: T; + options: readonly SelectionMenuOption[]; + active: boolean; + ariaLabel: string; + onChange: (next: T) => void; + align?: "start" | "center" | "end"; + sideOffset?: number; +} + +export function SelectionMenu({ + value, + options, + active, + ariaLabel, + onChange, + align = "end", + sideOffset = 6, +}: SelectionMenuProps) { + const activeLabel = options.find((option) => option.value === value)?.label ?? ""; + + return ( + + + + + + +
    + {options.map((option) => { + const selected = option.value === value; + return ( +
  • + + + +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/UiTooltip.test.tsx b/frontend/src/components/ui/UiTooltip.test.tsx new file mode 100644 index 0000000..0f9948e --- /dev/null +++ b/frontend/src/components/ui/UiTooltip.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { UiTooltip } from "./UiTooltip"; +import { UiTooltipProvider } from "./UiTooltipProvider"; + +describe("UiTooltip", () => { + it("renders the shared tooltip bubble on hover", async () => { + render( + + + + + , + ); + + fireEvent.focus(screen.getByRole("button", { name: "Harness" })); + + await waitFor(() => { + const bubble = document.querySelector(".ui-popup--tooltip"); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent("Codex CLI"); + }); + }); + + it("applies custom tooltip content classes when provided", async () => { + render( + + + + + , + ); + + fireEvent.focus(screen.getByRole("button", { name: "Harness" })); + + await waitFor(() => { + const bubble = document.querySelector(".ui-popup--tooltip--hint"); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent("Codex CLI"); + }); + }); + + it("does not render when disabled", () => { + render( + + + + + , + ); + + fireEvent.focus(screen.getByRole("button", { name: "Harness" })); + expect(screen.queryByText("Codex CLI")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/UiTooltip.tsx b/frontend/src/components/ui/UiTooltip.tsx new file mode 100644 index 0000000..cde50ba --- /dev/null +++ b/frontend/src/components/ui/UiTooltip.tsx @@ -0,0 +1,73 @@ +import { useContext, type ReactElement, type ReactNode } from "react"; +import * as Tooltip from "@radix-ui/react-tooltip"; + +import { + DEFAULT_TOOLTIP_DELAY_DURATION, + DEFAULT_TOOLTIP_SKIP_DELAY_DURATION, + UiTooltipContext, + UiTooltipProvider, +} from "./UiTooltipProvider"; + +export interface UiTooltipProps { + content: ReactNode; + children: ReactElement; + disabled?: boolean; + contentClassName?: string; + side?: "top" | "right" | "bottom" | "left"; + align?: "start" | "center" | "end"; + sideOffset?: number; + collisionPadding?: number; + delayDuration?: number; +} + +export function UiTooltip({ + content, + children, + disabled = false, + contentClassName, + side = "top", + align = "center", + sideOffset = 6, + collisionPadding = 16, + delayDuration, +}: UiTooltipProps) { + if (disabled || content === null || content === undefined || content === "") { + return children; + } + + const hasProvider = useContext(UiTooltipContext); + const tooltipClassName = contentClassName + ? `ui-popup ui-popup--tooltip ${contentClassName}` + : "ui-popup ui-popup--tooltip"; + + const tooltip = ( + + {children} + + + {content} + + + + + ); + + if (hasProvider) { + return tooltip; + } + + return ( + + {tooltip} + + ); +} diff --git a/frontend/src/components/ui/UiTooltipProvider.tsx b/frontend/src/components/ui/UiTooltipProvider.tsx new file mode 100644 index 0000000..7f3051c --- /dev/null +++ b/frontend/src/components/ui/UiTooltipProvider.tsx @@ -0,0 +1,29 @@ +import { createContext, type ReactNode } from "react"; +import * as Tooltip from "@radix-ui/react-tooltip"; + +export const DEFAULT_TOOLTIP_DELAY_DURATION = 200; +export const DEFAULT_TOOLTIP_SKIP_DELAY_DURATION = 120; +export const UiTooltipContext = createContext(false); + +interface UiTooltipProviderProps { + children: ReactNode; + delayDuration?: number; + skipDelayDuration?: number; +} + +export function UiTooltipProvider({ + children, + delayDuration = DEFAULT_TOOLTIP_DELAY_DURATION, + skipDelayDuration = DEFAULT_TOOLTIP_SKIP_DELAY_DURATION, +}: UiTooltipProviderProps) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/components/ui/UiTooltipTriggerBoundary.test.tsx b/frontend/src/components/ui/UiTooltipTriggerBoundary.test.tsx new file mode 100644 index 0000000..cec5a04 --- /dev/null +++ b/frontend/src/components/ui/UiTooltipTriggerBoundary.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { UiTooltipProvider } from "./UiTooltipProvider"; +import { UiTooltipTriggerBoundary } from "./UiTooltipTriggerBoundary"; + +describe("UiTooltipTriggerBoundary", () => { + it("keeps disabled controls focusable for tooltip triggers", async () => { + render( + + + + + , + ); + + const trigger = screen.getByText("Remove from Skill Manager").closest(".ui-tooltip-trigger"); + expect(trigger).not.toBeNull(); + + fireEvent.focus(trigger!); + + await waitFor(() => { + const bubble = document.querySelector(".ui-popup--tooltip"); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent("Blocked for testing"); + }); + }); + + it("blocks activation from bubbling to parent rows or cards", () => { + const onParentActivate = vi.fn(); + + render( +
{ + if (event.key === "Enter" || event.key === " ") { + onParentActivate(); + } + }} + > + + + +
, + ); + + const trigger = screen.getByText("Install").closest(".ui-tooltip-trigger"); + expect(trigger).not.toBeNull(); + + fireEvent.click(trigger!); + fireEvent.keyDown(trigger!, { key: "Enter" }); + fireEvent.keyDown(trigger!, { key: " " }); + + expect(onParentActivate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ui/UiTooltipTriggerBoundary.tsx b/frontend/src/components/ui/UiTooltipTriggerBoundary.tsx new file mode 100644 index 0000000..36c5aea --- /dev/null +++ b/frontend/src/components/ui/UiTooltipTriggerBoundary.tsx @@ -0,0 +1,60 @@ +import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; + +import { UiTooltip, type UiTooltipProps } from "./UiTooltip"; + +interface UiTooltipTriggerBoundaryProps extends Omit { + children: ReactNode; + className?: string; +} + +export function UiTooltipTriggerBoundary({ + children, + className, + content, + disabled, + contentClassName, + side, + align, + sideOffset, + collisionPadding, + delayDuration, +}: UiTooltipTriggerBoundaryProps) { + const classes = className + ? `ui-tooltip-trigger ${className}` + : "ui-tooltip-trigger"; + + function handleClick(event: MouseEvent) { + event.stopPropagation(); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + event.stopPropagation(); + } + + return ( + + + {children} + + + ); +} diff --git a/frontend/src/components/ui/useHoverSurfaceState.ts b/frontend/src/components/ui/useHoverSurfaceState.ts deleted file mode 100644 index b87380a..0000000 --- a/frontend/src/components/ui/useHoverSurfaceState.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -const HOVER_CLOSE_DELAY_MS = 100; - -export function useHoverSurfaceState() { - const [open, setOpen] = useState(false); - const closeTimerRef = useRef(null); - - useEffect(() => () => { - if (closeTimerRef.current !== null) { - window.clearTimeout(closeTimerRef.current); - } - }, []); - - function cancelClose(): void { - if (closeTimerRef.current !== null) { - window.clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; - } - } - - function scheduleClose(): void { - cancelClose(); - closeTimerRef.current = window.setTimeout(() => { - setOpen(false); - closeTimerRef.current = null; - }, HOVER_CLOSE_DELAY_MS); - } - - return { - open, - setOpen, - triggerProps: { - onMouseEnter: () => { - cancelClose(); - setOpen(true); - }, - onMouseLeave: scheduleClose, - onFocus: () => { - cancelClose(); - setOpen(true); - }, - onBlur: scheduleClose, - }, - contentProps: { - onMouseEnter: cancelClose, - onMouseLeave: scheduleClose, - }, - }; -} diff --git a/frontend/src/features/marketplace/api/cli-client.ts b/frontend/src/features/marketplace/api/cli-client.ts new file mode 100644 index 0000000..9a36bce --- /dev/null +++ b/frontend/src/features/marketplace/api/cli-client.ts @@ -0,0 +1,64 @@ +import { fetchJson } from "../../../api/http"; + +import type { + CliMarketplaceDetailDto, + CliMarketplacePageResultDto, +} from "./cli-types"; + +interface CliPageParams { + limit?: number; + offset?: number; +} + +export interface CliSearchParams extends CliPageParams { + query?: string; +} + +export async function fetchCliMarketplacePopular( + params: CliPageParams = {}, +): Promise { + return fetchJson( + withQuery("/marketplace/clis/popular", { + limit: params.limit, + offset: params.offset, + }), + ); +} + +export async function searchCliMarketplace( + params: CliSearchParams = {}, +): Promise { + return fetchJson( + withQuery("/marketplace/clis/search", { + q: params.query?.trim(), + limit: params.limit, + offset: params.offset, + }), + ); +} + +export async function fetchCliMarketplaceDetail( + idOrSlug: string, +): Promise { + const slug = idOrSlug.startsWith("clisdev:") + ? idOrSlug.slice("clisdev:".length) + : idOrSlug; + return fetchJson( + `/marketplace/clis/items/${encodeURIComponent(slug)}`, + ); +} + +function withQuery( + path: string, + params: Record, +): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") { + continue; + } + search.set(key, String(value)); + } + const query = search.toString(); + return query ? `${path}?${query}` : path; +} diff --git a/frontend/src/features/marketplace/api/cli-queries.ts b/frontend/src/features/marketplace/api/cli-queries.ts new file mode 100644 index 0000000..784d34b --- /dev/null +++ b/frontend/src/features/marketplace/api/cli-queries.ts @@ -0,0 +1,53 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; + +import { flattenUniquePageItems, queryPolicy } from "../../../lib/query"; +import { + fetchCliMarketplaceDetail, + fetchCliMarketplacePopular, + searchCliMarketplace, +} from "./cli-client"; +import type { + CliMarketplaceItemDto, + CliMarketplacePageResultDto, +} from "./cli-types"; + +const CLI_MARKETPLACE_STALE_TIME_MS = 60_000; +const CLI_MARKETPLACE_GC_TIME_MS = 15 * 60_000; +const PAGE_SIZE = 30; + +export const cliMarketplaceKeys = { + all: ["marketplace", "clis"] as const, + feed: (query: string) => ["marketplace", "clis", "feed", query] as const, + detail: (idOrSlug: string) => ["marketplace", "clis", "detail", idOrSlug] as const, +}; + +export function useCliMarketplaceFeedQuery(query: string) { + const trimmed = query.trim(); + + return useInfiniteQuery({ + queryKey: cliMarketplaceKeys.feed(trimmed || "__popular__"), + initialPageParam: 0, + queryFn: ({ pageParam }) => + trimmed + ? searchCliMarketplace({ query: trimmed, limit: PAGE_SIZE, offset: pageParam }) + : fetchCliMarketplacePopular({ limit: PAGE_SIZE, offset: pageParam }), + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.nextOffset ?? undefined : undefined, + ...queryPolicy(CLI_MARKETPLACE_STALE_TIME_MS, CLI_MARKETPLACE_GC_TIME_MS), + }); +} + +export function useCliMarketplaceDetailQuery(idOrSlug: string | null) { + return useQuery({ + queryKey: cliMarketplaceKeys.detail(idOrSlug ?? "__none__"), + queryFn: () => fetchCliMarketplaceDetail(idOrSlug!), + enabled: Boolean(idOrSlug), + ...queryPolicy(CLI_MARKETPLACE_STALE_TIME_MS, CLI_MARKETPLACE_GC_TIME_MS), + }); +} + +export function flattenCliMarketplaceItems( + data: { pages: CliMarketplacePageResultDto[] } | undefined, +): CliMarketplaceItemDto[] { + return flattenUniquePageItems(data, (item) => item.id); +} diff --git a/frontend/src/features/marketplace/api/cli-types.ts b/frontend/src/features/marketplace/api/cli-types.ts new file mode 100644 index 0000000..8c081d8 --- /dev/null +++ b/frontend/src/features/marketplace/api/cli-types.ts @@ -0,0 +1,5 @@ +import type { components } from "../../../api/generated"; + +export type CliMarketplaceItemDto = components["schemas"]["CliMarketplaceItemResponse"]; +export type CliMarketplacePageResultDto = components["schemas"]["CliMarketplacePageResponse"]; +export type CliMarketplaceDetailDto = components["schemas"]["CliMarketplaceDetailResponse"]; diff --git a/frontend/src/features/marketplace/api/mcp-client.ts b/frontend/src/features/marketplace/api/mcp-client.ts new file mode 100644 index 0000000..0d0853d --- /dev/null +++ b/frontend/src/features/marketplace/api/mcp-client.ts @@ -0,0 +1,76 @@ +import { fetchJson, postJson } from "../../../api/http"; + +import type { + AddMcpServerRequestDto, + AddMcpServerResponseDto, + McpInstallTargetsDto, + McpMarketplaceDetailDto, + McpMarketplaceFilter, + McpMarketplacePageResultDto, +} from "./mcp-types"; + +interface McpPageParams { + limit?: number; + offset?: number; +} + +export interface McpSearchParams extends McpPageParams { + query?: string; + filter?: McpMarketplaceFilter; +} + +export async function fetchMcpMarketplacePopular( + params: McpPageParams = {}, +): Promise { + return fetchJson( + withQuery("/marketplace/mcp/popular", { limit: params.limit, offset: params.offset }), + ); +} + +export async function searchMcpMarketplace( + params: McpSearchParams = {}, +): Promise { + const filter = params.filter ?? "all"; + const query = (params.query ?? "").trim(); + return fetchJson( + withQuery("/marketplace/mcp/search", { + q: query || undefined, + limit: params.limit, + offset: params.offset, + remote: filter === "remote" ? "true" : filter === "local" ? "false" : undefined, + verified: filter === "verified" ? "true" : undefined, + }), + ); +} + +export async function fetchMcpMarketplaceDetail( + qualifiedName: string, +): Promise { + const encoded = qualifiedName.split("/").map(encodeURIComponent).join("/"); + return fetchJson(`/marketplace/mcp/items/${encoded}`); +} + +export async function fetchMcpInstallTargets(): Promise { + return fetchJson("/marketplace/mcp/install-targets"); +} + +export async function addMcpServer( + body: AddMcpServerRequestDto, +): Promise { + return postJson("/mcp/servers", body); +} + +function withQuery( + path: string, + params: Record, +): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") { + continue; + } + search.set(key, String(value)); + } + const query = search.toString(); + return query ? `${path}?${query}` : path; +} diff --git a/frontend/src/features/marketplace/api/mcp-queries.ts b/frontend/src/features/marketplace/api/mcp-queries.ts new file mode 100644 index 0000000..113acc9 --- /dev/null +++ b/frontend/src/features/marketplace/api/mcp-queries.ts @@ -0,0 +1,110 @@ +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useToast } from "../../../components/Toast"; +import { flattenUniquePageItems, queryPolicy } from "../../../lib/query"; +import { invalidateMcpQueries } from "../../mcp/public"; +import { useInstallingState } from "../model/installing-context"; +import { + fetchMcpInstallTargets, + fetchMcpMarketplaceDetail, + fetchMcpMarketplacePopular, + addMcpServer, + searchMcpMarketplace, +} from "./mcp-client"; +import type { + AddMcpServerResponseDto, + McpMarketplaceFilter, + McpMarketplaceItemDto, + McpMarketplacePageResultDto, +} from "./mcp-types"; + +const MCP_MARKETPLACE_STALE_TIME_MS = 60_000; +const MCP_MARKETPLACE_GC_TIME_MS = 15 * 60_000; +const PAGE_SIZE = 30; + +export const mcpMarketplaceKeys = { + all: ["marketplace", "mcp"] as const, + feed: (query: string, filter: McpMarketplaceFilter) => + ["marketplace", "mcp", "feed", query, filter] as const, + detail: (qualifiedName: string) => + ["marketplace", "mcp", "detail", qualifiedName] as const, + installTargets: () => ["marketplace", "mcp", "install-targets"] as const, +}; + +export function useMcpMarketplaceFeedQuery(query: string, filter: McpMarketplaceFilter) { + const trimmed = query.trim(); + const usePopular = !trimmed && filter === "all"; + + return useInfiniteQuery({ + queryKey: mcpMarketplaceKeys.feed(trimmed || "__popular__", filter), + initialPageParam: 0, + queryFn: ({ pageParam }) => + usePopular + ? fetchMcpMarketplacePopular({ limit: PAGE_SIZE, offset: pageParam }) + : searchMcpMarketplace({ + query: trimmed, + filter, + limit: PAGE_SIZE, + offset: pageParam, + }), + getNextPageParam: (lastPage) => + lastPage.hasMore ? lastPage.nextOffset ?? undefined : undefined, + ...queryPolicy(MCP_MARKETPLACE_STALE_TIME_MS, MCP_MARKETPLACE_GC_TIME_MS), + }); +} + +export function useMcpMarketplaceDetailQuery(qualifiedName: string | null) { + return useQuery({ + queryKey: mcpMarketplaceKeys.detail(qualifiedName ?? "__none__"), + queryFn: () => fetchMcpMarketplaceDetail(qualifiedName!), + enabled: Boolean(qualifiedName), + ...queryPolicy(MCP_MARKETPLACE_STALE_TIME_MS, MCP_MARKETPLACE_GC_TIME_MS), + }); +} + +export function useMcpInstallTargetsQuery() { + return useQuery({ + queryKey: mcpMarketplaceKeys.installTargets(), + queryFn: fetchMcpInstallTargets, + ...queryPolicy(MCP_MARKETPLACE_GC_TIME_MS, MCP_MARKETPLACE_GC_TIME_MS), + }); +} + +/** + * Shared marketplace install mutation used by the detail view. + * Handles: pending-state publication, inventory invalidation, and success/error toasts. + */ +export function useAddMcpServerMutation() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + const { begin, finish } = useInstallingState(); + + return useMutation< + AddMcpServerResponseDto, + Error, + { qualifiedName: string; sourceHarness: string; displayName?: string } + >({ + mutationFn: ({ qualifiedName, sourceHarness }) => addMcpServer({ qualifiedName, sourceHarness }), + onMutate: ({ qualifiedName }) => { + begin(qualifiedName); + }, + onSuccess: (response, { displayName }) => { + // Invalidate the central inventory so the card button flips to + // "Open in MCPs" in place. User stays on the marketplace. + void invalidateMcpQueries(queryClient); + toast(`${displayName ?? response.server.name} added to your MCP servers`); + }, + onError: (error) => { + toast(error instanceof Error ? error.message : "Install failed"); + }, + onSettled: (_data, _err, { qualifiedName }) => { + finish(qualifiedName); + }, + }); +} + +export function flattenMcpMarketplaceItems( + data: { pages: McpMarketplacePageResultDto[] } | undefined, +): McpMarketplaceItemDto[] { + return flattenUniquePageItems(data, (item) => item.qualifiedName); +} diff --git a/frontend/src/features/marketplace/api/mcp-types.ts b/frontend/src/features/marketplace/api/mcp-types.ts new file mode 100644 index 0000000..10eed61 --- /dev/null +++ b/frontend/src/features/marketplace/api/mcp-types.ts @@ -0,0 +1,18 @@ +import type { components } from "../../../api/generated"; + +export type McpMarketplaceItemDto = components["schemas"]["McpMarketplaceItemResponse"]; +export type McpMarketplacePageResultDto = components["schemas"]["McpMarketplacePageResponse"]; +export type McpConnectionKind = components["schemas"]["McpMarketplaceConnectionResponse"]["kind"]; +export type McpConnectionDto = components["schemas"]["McpMarketplaceConnectionResponse"]; +export type McpParameterDto = components["schemas"]["McpMarketplaceParameterResponse"]; +export type McpToolDto = components["schemas"]["McpMarketplaceToolResponse"]; +export type McpResourceDto = components["schemas"]["McpMarketplaceResourceResponse"]; +export type McpPromptArgumentDto = components["schemas"]["McpMarketplacePromptArgumentResponse"]; +export type McpPromptDto = components["schemas"]["McpMarketplacePromptResponse"]; +export type McpCapabilityCountsDto = components["schemas"]["McpMarketplaceCapabilityCountsResponse"]; +export type McpMarketplaceDetailDto = components["schemas"]["McpMarketplaceDetailResponse"]; +export type McpInstallTargetDto = components["schemas"]["McpInstallTargetResponse"]; +export type McpInstallTargetsDto = components["schemas"]["McpInstallTargetsResponse"]; +export type McpMarketplaceFilter = "all" | "remote" | "local" | "verified"; +export type AddMcpServerRequestDto = components["schemas"]["AddMcpServerRequest"]; +export type AddMcpServerResponseDto = components["schemas"]["McpServerMutationResponse"]; diff --git a/frontend/src/features/marketplace/api/queries.ts b/frontend/src/features/marketplace/api/queries.ts index df1839b..5a6a8fd 100644 --- a/frontend/src/features/marketplace/api/queries.ts +++ b/frontend/src/features/marketplace/api/queries.ts @@ -1,7 +1,8 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { invalidateSettingsQueries } from "../../settings/queries"; -import { invalidateSkillsQueries } from "../../skills/api/queries"; +import { flattenUniquePageItems, queryPolicy } from "../../../lib/query"; +import { invalidateSettingsQueries } from "../../settings/public"; +import { invalidateSkillsQueries } from "../../skills/public"; import { fetchMarketplaceDetail, fetchMarketplaceDocument, @@ -32,9 +33,7 @@ export function useMarketplaceFeedQuery(query: string) { ? searchMarketplace(trimmed, { limit: 20, offset: pageParam }) : fetchMarketplacePopular({ limit: 20, offset: pageParam }), getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextOffset ?? undefined : undefined), - staleTime: MARKETPLACE_STALE_TIME_MS, - gcTime: MARKETPLACE_GC_TIME_MS, - refetchOnWindowFocus: false, + ...queryPolicy(MARKETPLACE_STALE_TIME_MS, MARKETPLACE_GC_TIME_MS), }); } @@ -43,9 +42,7 @@ export function useMarketplaceDetailQuery(itemId: string | null) { queryKey: marketplaceKeys.detail(itemId ?? "__none__"), queryFn: () => fetchMarketplaceDetail(itemId!), enabled: Boolean(itemId), - staleTime: MARKETPLACE_STALE_TIME_MS, - gcTime: MARKETPLACE_GC_TIME_MS, - refetchOnWindowFocus: false, + ...queryPolicy(MARKETPLACE_STALE_TIME_MS, MARKETPLACE_GC_TIME_MS), }); } @@ -54,9 +51,7 @@ export function useMarketplaceDocumentQuery(itemId: string | null) { queryKey: marketplaceKeys.document(itemId ?? "__none__"), queryFn: () => fetchMarketplaceDocument(itemId!), enabled: Boolean(itemId), - staleTime: MARKETPLACE_STALE_TIME_MS, - gcTime: MARKETPLACE_GC_TIME_MS, - refetchOnWindowFocus: false, + ...queryPolicy(MARKETPLACE_STALE_TIME_MS, MARKETPLACE_GC_TIME_MS), }); } @@ -65,22 +60,7 @@ export async function invalidateMarketplaceQueries(queryClient: import("@tanstac } export function flattenMarketplaceItems(data: { pages: MarketplacePageResultDto[] } | undefined): MarketplaceItemDto[] { - if (!data) { - return []; - } - - const seen = new Set(); - const items: MarketplaceItemDto[] = []; - for (const page of data.pages) { - for (const item of page.items) { - if (seen.has(item.id)) { - continue; - } - seen.add(item.id); - items.push(item); - } - } - return items; + return flattenUniquePageItems(data, (item) => item.id); } export function useInstallMarketplaceSkillMutation() { diff --git a/frontend/src/features/marketplace/components/CliMarketplaceCard.tsx b/frontend/src/features/marketplace/components/CliMarketplaceCard.tsx new file mode 100644 index 0000000..5256f29 --- /dev/null +++ b/frontend/src/features/marketplace/components/CliMarketplaceCard.tsx @@ -0,0 +1,102 @@ +import { type KeyboardEvent, useState } from "react"; +import { CheckCircle2, Star, TerminalSquare } from "lucide-react"; + +import type { CliMarketplaceItemDto } from "../api/cli-types"; +import { formatMarketplaceStars } from "../model/formatters"; + +interface CliMarketplaceCardProps { + item: CliMarketplaceItemDto; + selected: boolean; + onOpenDetail: () => void; +} + +function avatarFallbackLabel(item: CliMarketplaceItemDto): string { + return (item.name || item.slug).slice(0, 2).toUpperCase(); +} + +function sourceLine(item: CliMarketplaceItemDto): string { + if (item.githubUrl) { + try { + return new URL(item.githubUrl).pathname.replace(/^\//, ""); + } catch { + return item.slug; + } + } + return item.vendorName || item.sourceType || `clis.dev/${item.slug}`; +} + +export function CliMarketplaceCard({ + item, + selected, + onOpenDetail, +}: CliMarketplaceCardProps) { + const [avatarFailed, setAvatarFailed] = useState(false); + const avatarSrc = item.iconUrl && !avatarFailed ? item.iconUrl : null; + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + onOpenDetail(); + } + + return ( +
+
+
+ {avatarSrc ? ( + {`Avatar setAvatarFailed(true)} + /> + ) : ( + <> +
+
+

{item.name}

+

{sourceLine(item)}

+
+
+ +

+ {item.description || "No description provided."} +

+ +
+
+ {item.category ? {item.category} : null} + {item.language ? {item.language} : null} + {item.isOfficial ? ( + + + ) : null} + {item.isTui ? TUI : null} + {item.hasMcp ? MCP : null} + {item.hasSkill ? Skill : null} +
+ {item.stars != null ? ( + + + ) : null} +
+
+ ); +} diff --git a/frontend/src/features/marketplace/components/CliMarketplaceDetailSheet.tsx b/frontend/src/features/marketplace/components/CliMarketplaceDetailSheet.tsx new file mode 100644 index 0000000..050d35a --- /dev/null +++ b/frontend/src/features/marketplace/components/CliMarketplaceDetailSheet.tsx @@ -0,0 +1,48 @@ +import * as Dialog from "@radix-ui/react-dialog"; + +import type { CliMarketplaceItemDto } from "../api/cli-types"; +import { CliMarketplaceDetailView } from "./CliMarketplaceDetailView"; + +interface CliMarketplaceDetailSheetProps { + itemId: string | null; + initialItem: CliMarketplaceItemDto | null; + onClose: () => void; +} + +export function CliMarketplaceDetailSheet({ + itemId, + initialItem, + onClose, +}: CliMarketplaceDetailSheetProps) { + if (!itemId) { + return null; + } + + return ( + { + if (!open) { + onClose(); + } + }} + > + + + + CLI details + + Preview CLI marketplace metadata and links. Skill Manager does not install or + manage CLIs. + + + + + + ); +} diff --git a/frontend/src/features/marketplace/components/CliMarketplaceDetailView.tsx b/frontend/src/features/marketplace/components/CliMarketplaceDetailView.tsx new file mode 100644 index 0000000..47a150b --- /dev/null +++ b/frontend/src/features/marketplace/components/CliMarketplaceDetailView.tsx @@ -0,0 +1,288 @@ +import { Fragment, lazy, Suspense, useId, useState } from "react"; +import { CheckCircle2, Copy, TerminalSquare } from "lucide-react"; + +import { DetailHeader } from "../../../components/detail/DetailHeader"; +import { DetailSection } from "../../../components/detail/DetailSection"; +import { DetailSourceLinks, type DetailSourceLink } from "../../../components/detail/DetailSourceLinks"; +import { ErrorBanner } from "../../../components/ErrorBanner"; +import { LoadingSpinner } from "../../../components/LoadingSpinner"; +import { useToast } from "../../../components/Toast"; +import { useCliMarketplaceDetailQuery } from "../api/cli-queries"; +import type { CliMarketplaceItemDto } from "../api/cli-types"; +import { formatMarketplaceStars } from "../model/formatters"; + +const MarkdownDocument = lazy(() => import("../../../components/MarkdownDocument")); + +interface CliMarketplaceDetailViewProps { + itemId: string; + initialItem: CliMarketplaceItemDto | null; + onClose: () => void; +} + +export function CliMarketplaceDetailView({ + itemId, + initialItem, + onClose, +}: CliMarketplaceDetailViewProps) { + const headingId = useId(); + const detailQuery = useCliMarketplaceDetailQuery(itemId); + const detail = detailQuery.data ?? null; + const { toast } = useToast(); + const queryErrorMessage = + detailQuery.error instanceof Error ? detailQuery.error.message : ""; + + const headerName = detail?.name ?? initialItem?.name ?? itemId; + const headerSlug = detail?.slug ?? initialItem?.slug ?? itemId.replace(/^clisdev:/, ""); + const headerMarketplaceUrl = + detail?.marketplaceUrl ?? initialItem?.marketplaceUrl ?? `https://clis.dev/cli/${headerSlug}`; + const headerGithubUrl = detail?.githubUrl ?? initialItem?.githubUrl ?? null; + const headerWebsiteUrl = detail?.websiteUrl ?? initialItem?.websiteUrl ?? null; + const headerIconUrl = detail?.iconUrl ?? initialItem?.iconUrl ?? null; + const stars = detail?.stars ?? initialItem?.stars ?? null; + const [avatarFailed, setAvatarFailed] = useState(false); + const avatarSrc = headerIconUrl && !avatarFailed ? headerIconUrl : null; + + function handleCopy(value: string): void { + if (!navigator.clipboard?.writeText) { + toast("Command copied"); + return; + } + void navigator.clipboard + .writeText(value) + .then(() => toast("Command copied")) + .catch(() => toast("Copy failed")); + } + + if (!detail && detailQuery.isPending) { + return ( + <> +
+ {headerName}} + meta={

clis.dev/{headerSlug}

} + closeLabel="Close CLI preview" + onClose={onClose} + /> +
+
+
+ +
+
+ + ); + } + + if (!detail) { + return ( + <> +
+ Unable to load CLI} + closeLabel="Close CLI preview" + onClose={onClose} + /> + +
+
+

Try reopening the CLI from the marketplace grid.

+
+ + ); + } + + const installCommand = detail.installCommand ?? null; + const hasSourceMetadata = Boolean(detail.sourceType || detail.vendorName); + const headerFacts = cliHeaderFacts(detail, stars); + const sourceLinks = cliSourceLinks({ + marketplaceUrl: headerMarketplaceUrl, + githubUrl: headerGithubUrl, + websiteUrl: headerWebsiteUrl, + }); + + return ( + <> +
+ + + +

{headerName}

+ clis.dev/{headerSlug} +
+
+ } + meta={ +
+ {headerFacts.length > 0 ? ( +
+ {headerFacts.map((fact, index) => ( + + {index > 0 ? ( + + ) : null} + + {fact.accent ? + + ))} +
+ ) : null} + +
+ } + closeLabel="Close CLI preview" + onClose={onClose} + /> + {queryErrorMessage ? : null} +
+ +
+ {installCommand ? ( + +
+ {installCommand} + +
+
+ ) : null} + + +

+ {detail.description || "No description provided."} +

+ {detail.longDescription ? ( + }> + + + ) : null} +
+ + {hasSourceMetadata ? ( + +
+ + +
+
+ ) : null} +
+ + ); +} + +function avatarFallbackLabel(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +interface CliHeaderFact { + label: string; + accent?: boolean; +} + +function cliHeaderFacts( + detail: CliMarketplaceItemDto, + stars: number | null, +): CliHeaderFact[] { + const facts: CliHeaderFact[] = []; + if (detail.category) { + facts.push({ label: detail.category }); + } + if (detail.language) { + facts.push({ label: detail.language }); + } + if (detail.isOfficial) { + facts.push({ label: "Official", accent: true }); + } + if (detail.isTui) { + facts.push({ label: "TUI" }); + } + if (detail.hasMcp) { + facts.push({ label: "MCP" }); + } + if (detail.hasSkill) { + facts.push({ label: "Skill" }); + } + if (stars != null) { + facts.push({ label: `${formatMarketplaceStars(stars)} stars` }); + } + return facts; +} + +function cliSourceLinks({ + marketplaceUrl, + githubUrl, + websiteUrl, +}: { + marketplaceUrl: string; + githubUrl: string | null; + websiteUrl: string | null; +}): DetailSourceLink[] { + const links: DetailSourceLink[] = []; + if (githubUrl) { + links.push({ + href: githubUrl, + label: "Repo", + kind: "repo", + }); + } + if (websiteUrl) { + links.push({ + href: websiteUrl, + label: "Website", + kind: "website", + }); + } + if (links.length === 0) { + links.push({ + href: marketplaceUrl, + label: "CLIs.dev", + kind: "marketplace", + }); + } + return links; +} + +function MetaRow({ label, value }: { label: string; value: string | null }) { + if (!value) { + return null; + } + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/features/marketplace/components/MarketplaceCard.test.tsx b/frontend/src/features/marketplace/components/MarketplaceCard.test.tsx deleted file mode 100644 index 511ce82..0000000 --- a/frontend/src/features/marketplace/components/MarketplaceCard.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { createMarketplaceItem } from "../test-fixtures"; -import { MarketplaceCard } from "./MarketplaceCard"; - -const baseItem = createMarketplaceItem(); - -describe("MarketplaceCard", () => { - it("renders repo identity, installs, and stars", () => { - render( - {}} - onInstall={() => {}} - onOpenInstalledSkill={() => {}} - />, - ); - - expect(screen.getByAltText("Avatar for mode-io/skills")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "mode-io/skills" })).toHaveAttribute("href", baseItem.repoUrl); - expect(screen.getByText("512")).toBeInTheDocument(); - expect(screen.getByText("128 installs")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "View on skills.sh" })).toHaveAttribute("href", baseItem.skillsDetailUrl); - }); - - it("opens marketplace detail from the preview surface", () => { - const onOpenDetail = vi.fn(); - - render( - {}} - onOpenInstalledSkill={() => {}} - />, - ); - - fireEvent.click(screen.getByRole("button", { name: /open marketplace detail for mode switch/i })); - - expect(onOpenDetail).toHaveBeenCalledTimes(1); - }); - - it("always keeps the repo and skills.sh links stable", () => { - render( - {}} - onInstall={() => {}} - onOpenInstalledSkill={() => {}} - />, - ); - - expect(screen.getByRole("link", { name: "mode-io/skills" })).toHaveAttribute("href", baseItem.repoUrl); - expect(screen.getByRole("link", { name: "View on skills.sh" })).toHaveAttribute("href", baseItem.skillsDetailUrl); - }); - - it("switches the action to Open in Skills when the item is already installed", () => { - const onOpenInstalledSkill = vi.fn(); - - render( - {}} - onInstall={() => {}} - onOpenInstalledSkill={onOpenInstalledSkill} - />, - ); - - fireEvent.click(screen.getByRole("button", { name: "Open in Skills" })); - - expect(onOpenInstalledSkill).toHaveBeenCalledWith("shared:mode-switch"); - }); -}); diff --git a/frontend/src/features/marketplace/components/MarketplaceCard.tsx b/frontend/src/features/marketplace/components/MarketplaceCard.tsx index e39b874..b08a3ea 100644 --- a/frontend/src/features/marketplace/components/MarketplaceCard.tsx +++ b/frontend/src/features/marketplace/components/MarketplaceCard.tsx @@ -1,5 +1,5 @@ import { type KeyboardEvent, useState } from "react"; -import { Star } from "lucide-react"; +import { ArrowUpRight, Plus, Star } from "lucide-react"; import { LoadingSpinner } from "../../../components/LoadingSpinner"; import type { MarketplaceItemDto } from "../api/types"; @@ -24,7 +24,6 @@ function avatarFallbackLabel(item: MarketplaceItemDto): string { export function MarketplaceCard({ item, - selected, installing, onOpenDetail, onInstall, @@ -51,80 +50,71 @@ export function MarketplaceCard({ } return ( -
-
-
-
-
- {avatarSrc ? ( - {`Avatar setAvatarFailed(true)} - /> - ) : ( - {avatarFallbackLabel(item)} - )} -
- -
- {stars > 0 ? ( - - - {formatMarketplaceStars(stars)} - - ) : null} +
+
+
+ {avatarSrc ? ( + {`Avatar setAvatarFailed(true)} + /> + ) : ( + avatarFallbackLabel(item) + )}
- -

{item.description || "No summary available on skills.sh."}

- -
- {installs} installs - event.stopPropagation()} - > - View on skills.sh - +
+

{item.name}

+

{item.repoLabel}

+ {stars > 0 ? ( + + + {formatMarketplaceStars(stars)} + + ) : null}
-
+

{item.description || "No summary available on skills.sh."}

+ +
+ {installs} installs {item.installation.status === "installed" && item.installation.installedSkillRef ? ( ) : ( - )} diff --git a/frontend/src/features/marketplace/components/MarketplaceDetailSheet.tsx b/frontend/src/features/marketplace/components/MarketplaceDetailSheet.tsx index 6e4f58f..da67531 100644 --- a/frontend/src/features/marketplace/components/MarketplaceDetailSheet.tsx +++ b/frontend/src/features/marketplace/components/MarketplaceDetailSheet.tsx @@ -36,8 +36,8 @@ export function MarketplaceDetailSheet({ }} > - - + + Marketplace skill details Preview a marketplace skill before opening it in Skills or installing it. diff --git a/frontend/src/features/marketplace/components/MarketplaceDetailSkeleton.tsx b/frontend/src/features/marketplace/components/MarketplaceDetailSkeleton.tsx index 83be204..1f979af 100644 --- a/frontend/src/features/marketplace/components/MarketplaceDetailSkeleton.tsx +++ b/frontend/src/features/marketplace/components/MarketplaceDetailSkeleton.tsx @@ -13,13 +13,12 @@ export function MarketplaceDetailSkeleton({ onClose }: MarketplaceDetailSkeleton title={
- ); } -interface HarnessCellControlProps { +interface HarnessCellActionProps { skillName: string; cell: HarnessCell; - pendingToggleHarnesses: ReadonlySet; - structuralLocked: boolean; + pending: boolean; + disabled: boolean; onToggleCell: (cell: HarnessCell) => void; } -function HarnessCellControl({ +function HarnessCellAction({ skillName, cell, - pendingToggleHarnesses, - structuralLocked, + pending, + disabled, onToggleCell, -}: HarnessCellControlProps) { - const pending = pendingToggleHarnesses.has(cell.harness); +}: HarnessCellActionProps) { + if (!cell.interactive) { + if (cell.state === "found") { + return ( + + Adopt this skill to manage it + + ); + } + return null; + } - return ( - onToggleCell(cell)} - /> - ); + if (cell.state === "enabled") { + return ( + + ); + } + + if (cell.state === "disabled") { + return ( + + ); + } + + return null; } diff --git a/frontend/src/features/skills/components/detail/SkillDetailModal.tsx b/frontend/src/features/skills/components/detail/SkillDetailModal.tsx new file mode 100644 index 0000000..bd25e47 --- /dev/null +++ b/frontend/src/features/skills/components/detail/SkillDetailModal.tsx @@ -0,0 +1,58 @@ +import * as Dialog from "@radix-ui/react-dialog"; + +import type { HarnessCellState } from "../../model/types"; +import type { StructuralSkillAction } from "../../model/pending"; +import { SkillDetailView } from "./SkillDetailView"; + +interface SkillDetailModalProps { + open: boolean; + skillRef: string | null; + pendingToggleHarnesses: ReadonlySet; + pendingStructuralAction: StructuralSkillAction | null; + onClose: () => void; + onManageSkill: (skillRef: string) => Promise; + onToggleSkill: (skillRef: string, harness: string, currentState: HarnessCellState) => Promise; + onUpdateSkill: (skillRef: string) => Promise; + onRemoveSkill: (skillRef: string) => Promise; + onDeleteSkill: (skillRef: string) => Promise; +} + +export function SkillDetailModal({ + open, + skillRef, + pendingToggleHarnesses, + pendingStructuralAction, + onClose, + onManageSkill, + onToggleSkill, + onUpdateSkill, + onRemoveSkill, + onDeleteSkill, +}: SkillDetailModalProps) { + return ( + (next ? null : onClose())}> + + + + Skill details + + Inspect and manage this skill across harnesses. + + {skillRef ? ( + + ) : null} + + + + ); +} diff --git a/frontend/src/features/skills/components/detail/SkillDetailPanel.tsx b/frontend/src/features/skills/components/detail/SkillDetailPanel.tsx deleted file mode 100644 index 2893f41..0000000 --- a/frontend/src/features/skills/components/detail/SkillDetailPanel.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { HarnessCellState } from "../../model/types"; -import { useEffect, useRef, useState } from "react"; - -import type { StructuralSkillAction } from "../../model/pending"; -import { SkillDetailSkeleton } from "./SkillDetailSkeleton"; -import { SkillDetailView } from "./SkillDetailView"; - -interface SkillDetailPanelProps { - isOpen: boolean; - skillRef: string | null; - pendingToggleHarnesses: ReadonlySet; - pendingStructuralAction: StructuralSkillAction | null; - onClose: () => void; - onManageSkill: (skillRef: string) => Promise; - onToggleSkill: (skillRef: string, harness: string, currentState: HarnessCellState) => Promise; - onUpdateSkill: (skillRef: string) => Promise; - onUnmanageSkill: (skillRef: string) => Promise; - onDeleteSkill: (skillRef: string) => Promise; -} - -export function SkillDetailPanel({ - isOpen, - skillRef, - pendingToggleHarnesses, - pendingStructuralAction, - onClose, - onManageSkill, - onToggleSkill, - onUpdateSkill, - onUnmanageSkill, - onDeleteSkill, -}: SkillDetailPanelProps) { - const [panelPhase, setPanelPhase] = useState<"closed" | "opening" | "open" | "closing">(isOpen ? "open" : "closed"); - const [displayedSkillRef, setDisplayedSkillRef] = useState(skillRef); - const detailScrollRef = useRef(null); - const previousSkillRef = useRef(null); - const transitionTimerRef = useRef(null); - - useEffect(() => { - return () => { - if (transitionTimerRef.current !== null) { - window.clearTimeout(transitionTimerRef.current); - } - }; - }, []); - - useEffect(() => { - if (skillRef) { - setDisplayedSkillRef(skillRef); - } - }, [skillRef]); - - useEffect(() => { - if (transitionTimerRef.current !== null) { - window.clearTimeout(transitionTimerRef.current); - } - - if (isOpen) { - setPanelPhase((current) => { - if (current === "closed") { - transitionTimerRef.current = window.setTimeout(() => { - setPanelPhase("open"); - transitionTimerRef.current = null; - }, 220); - return "opening"; - } - return "open"; - }); - return; - } - - setPanelPhase((current) => { - if (current === "closed") { - setDisplayedSkillRef(null); - return current; - } - transitionTimerRef.current = window.setTimeout(() => { - setPanelPhase("closed"); - setDisplayedSkillRef(null); - transitionTimerRef.current = null; - }, 140); - return "closing"; - }); - }, [isOpen]); - - useEffect(() => { - if (!isOpen) { - previousSkillRef.current = displayedSkillRef; - return; - } - - if (displayedSkillRef !== previousSkillRef.current && detailScrollRef.current) { - detailScrollRef.current.scrollTop = 0; - } - - previousSkillRef.current = displayedSkillRef; - }, [displayedSkillRef, isOpen]); - - const isPanelMounted = panelPhase !== "closed"; - const isContentVisible = panelPhase === "open"; - - return ( - - ); -} diff --git a/frontend/src/features/skills/components/detail/SkillDetailRemoveAction.test.tsx b/frontend/src/features/skills/components/detail/SkillDetailRemoveAction.test.tsx new file mode 100644 index 0000000..ed76fe0 --- /dev/null +++ b/frontend/src/features/skills/components/detail/SkillDetailRemoveAction.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { UiTooltipProvider } from "../../../../components/ui/UiTooltipProvider"; +import { SkillDetailRemoveAction } from "./SkillDetailRemoveAction"; + +describe("SkillDetailRemoveAction", () => { + it("shows help copy for an available remove action and forwards clicks", async () => { + const onRequestRemove = vi.fn(); + + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Remove from Skill Manager" }); + fireEvent.focus(button); + + await waitFor(() => { + const bubble = document.querySelector(".ui-popup--tooltip"); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent("Removes this skill from the Skill Manager store and restores local copies only for the harnesses that are currently enabled."); + }); + + fireEvent.click(button); + expect(onRequestRemove).toHaveBeenCalledTimes(1); + }); + + it("shows disabled guidance when no harnesses are enabled", async () => { + render( + + undefined} + /> + , + ); + + const trigger = screen.getByRole("button", { name: "Remove from Skill Manager" }).closest(".ui-tooltip-trigger"); + expect(trigger).not.toBeNull(); + + fireEvent.focus(trigger!); + + await waitFor(() => { + const bubble = document.querySelector(".ui-popup--tooltip"); + expect(bubble).not.toBeNull(); + expect(bubble).toHaveTextContent("Enable at least one harness before removing this skill from Skill Manager."); + }); + + expect(screen.getByRole("button", { name: "Remove from Skill Manager" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/features/skills/components/detail/SkillDetailRemoveAction.tsx b/frontend/src/features/skills/components/detail/SkillDetailRemoveAction.tsx new file mode 100644 index 0000000..76b74a3 --- /dev/null +++ b/frontend/src/features/skills/components/detail/SkillDetailRemoveAction.tsx @@ -0,0 +1,52 @@ +import { UiTooltip } from "../../../../components/ui/UiTooltip"; +import { UiTooltipTriggerBoundary } from "../../../../components/ui/UiTooltipTriggerBoundary"; +import type { SkillRemoveStatus } from "../../model/types"; + +interface SkillDetailRemoveActionProps { + status: SkillRemoveStatus; + disabled: boolean; + onRequestRemove: () => void; +} + +export function SkillDetailRemoveAction({ + status, + disabled, + onRequestRemove, +}: SkillDetailRemoveActionProps) { + const isBlocked = disabled || status === "disabled_no_enabled"; + + const copy = status === "disabled_no_enabled" + ? "Enable at least one harness before removing this skill from Skill Manager." + : "Removes this skill from the Skill Manager store and restores local copies only for the harnesses that are currently enabled."; + + const button = ( + + ); + + if (isBlocked) { + return ( + + {button} + + ); + } + + return ( + + {button} + + ); +} diff --git a/frontend/src/features/skills/components/detail/SkillDetailShell.tsx b/frontend/src/features/skills/components/detail/SkillDetailShell.tsx new file mode 100644 index 0000000..a1c083c --- /dev/null +++ b/frontend/src/features/skills/components/detail/SkillDetailShell.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +interface SkillDetailShellProps { + chrome: ReactNode; + body: ReactNode; + footer?: ReactNode; + bodyAriaLabelledBy?: string; + bodyAriaHidden?: boolean; +} + +export function SkillDetailShell({ + chrome, + body, + footer, + bodyAriaLabelledBy, + bodyAriaHidden = false, +}: SkillDetailShellProps) { + return ( + <> +
{chrome}
+
+
{body}
+
+ {footer ? ( +
+ {footer} +
+ ) : null} + + ); +} diff --git a/frontend/src/features/skills/components/detail/SkillDetailSkeleton.test.tsx b/frontend/src/features/skills/components/detail/SkillDetailSkeleton.test.tsx deleted file mode 100644 index aae3ecb..0000000 --- a/frontend/src/features/skills/components/detail/SkillDetailSkeleton.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { SkillDetailSkeleton } from "./SkillDetailSkeleton"; - -describe("SkillDetailSkeleton", () => { - it("renders the shared detail header shell while loading", () => { - render(); - - expect(document.querySelector(".skill-detail__header-top")).not.toBeNull(); - expect(document.querySelector(".skill-detail__utility-rail")).not.toBeNull(); - expect(screen.getByRole("button", { name: "Close skill details" })).toHaveClass("skill-detail__close-button"); - expect(screen.getByText("Loading")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/features/skills/components/detail/SkillDetailSkeleton.tsx b/frontend/src/features/skills/components/detail/SkillDetailSkeleton.tsx index 214071e..ca9cb87 100644 --- a/frontend/src/features/skills/components/detail/SkillDetailSkeleton.tsx +++ b/frontend/src/features/skills/components/detail/SkillDetailSkeleton.tsx @@ -1,5 +1,7 @@ import { DetailHeader } from "../../../../components/detail/DetailHeader"; import { DetailLoadingChip } from "../../../../components/detail/DetailLoadingChip"; +import { DetailSection } from "../../../../components/detail/DetailSection"; +import { SkillDetailShell } from "./SkillDetailShell"; interface SkillDetailSkeletonProps { onClose: () => void; @@ -7,49 +9,35 @@ interface SkillDetailSkeletonProps { export function SkillDetailSkeleton({ onClose }: SkillDetailSkeletonProps) { return ( - <> -
-
- - - + + + )} + bodyAriaHidden + /> ); } diff --git a/frontend/src/features/skills/components/detail/SkillDetailStopManagingAction.test.tsx b/frontend/src/features/skills/components/detail/SkillDetailStopManagingAction.test.tsx deleted file mode 100644 index a39dced..0000000 --- a/frontend/src/features/skills/components/detail/SkillDetailStopManagingAction.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { SkillDetailStopManagingAction } from "./SkillDetailStopManagingAction"; - -describe("SkillDetailStopManagingAction", () => { - it("shows help copy for an available stop-managing action and forwards clicks", async () => { - const onRequestStopManaging = vi.fn(); - - render( - , - ); - - const button = screen.getByRole("button", { name: "Stop Managing" }); - fireEvent.mouseEnter(button); - - await waitFor(() => - expect(screen.getByText("Moves this skill out of the shared managed store and restores local copies only for the harnesses that are currently enabled.")).toBeInTheDocument(), - ); - - fireEvent.click(button); - expect(onRequestStopManaging).toHaveBeenCalledTimes(1); - }); - - it("shows disabled guidance when no harnesses are enabled", async () => { - render( - undefined} - />, - ); - - const trigger = screen.getByText("Stop Managing").closest(".skill-detail__action-trigger"); - expect(trigger).not.toBeNull(); - - fireEvent.focus(trigger!); - - await waitFor(() => - expect(screen.getByText("Turn on at least one harness before moving this skill back to unmanaged.")).toBeInTheDocument(), - ); - - expect(screen.getByRole("button", { name: "Stop Managing" })).toBeDisabled(); - }); -}); diff --git a/frontend/src/features/skills/components/detail/SkillDetailStopManagingAction.tsx b/frontend/src/features/skills/components/detail/SkillDetailStopManagingAction.tsx deleted file mode 100644 index d707288..0000000 --- a/frontend/src/features/skills/components/detail/SkillDetailStopManagingAction.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { HelpPopover } from "../../../../components/ui/HelpPopover"; -import type { SkillStopManagingStatus } from "../../model/types"; - -interface SkillDetailStopManagingActionProps { - status: SkillStopManagingStatus; - disabled: boolean; - onRequestStopManaging: () => void; -} - -export function SkillDetailStopManagingAction({ - status, - disabled, - onRequestStopManaging, -}: SkillDetailStopManagingActionProps) { - const isBlocked = disabled || status === "disabled_no_enabled"; - - const copy = status === "disabled_no_enabled" - ? "Turn on at least one harness before moving this skill back to unmanaged." - : "Moves this skill out of the shared managed store and restores local copies only for the harnesses that are currently enabled."; - - return ( - - - - - - ); -} diff --git a/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.test.tsx b/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.test.tsx index bd21ab6..d056e39 100644 --- a/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.test.tsx +++ b/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.test.tsx @@ -16,7 +16,10 @@ describe("SkillDetailUpdateControl", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: "Update From Source" })); + const button = screen.getByRole("button", { name: "Update From Source" }); + expect(button).toHaveClass("action-pill--md"); + + fireEvent.click(button); expect(onUpdate).toHaveBeenCalledTimes(1); }); @@ -30,7 +33,9 @@ describe("SkillDetailUpdateControl", () => { />, ); - expect(screen.getByText("No Update Available")).toBeInTheDocument(); + const noUpdate = screen.getByText("No Update Available"); + expect(noUpdate).toBeInTheDocument(); + expect(noUpdate).toHaveClass("card-status-pill--md"); expect(screen.queryByRole("button", { name: "No Update Available" })).not.toBeInTheDocument(); rerender( @@ -42,7 +47,22 @@ describe("SkillDetailUpdateControl", () => { />, ); - expect(screen.getByText("No Source Available")).toBeInTheDocument(); + const noSource = screen.getByText("No Source Available"); + expect(noSource).toBeInTheDocument(); + expect(noSource).toHaveClass("card-status-pill--md"); expect(screen.queryByRole("button", { name: "No Source Available" })).not.toBeInTheDocument(); }); + + it("renders nothing for local-changes-disabled state", () => { + const { container } = render( + undefined} + />, + ); + + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.tsx b/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.tsx index 86d6e71..2ead359 100644 --- a/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.tsx +++ b/frontend/src/features/skills/components/detail/SkillDetailUpdateControl.tsx @@ -8,7 +8,7 @@ interface SkillDetailUpdateControlProps { onUpdate: () => void; } -const UPDATE_STATUS_LABELS: Record, string> = { +const UPDATE_STATUS_LABELS: Record, string> = { no_update_available: "No Update Available", no_source_available: "No Source Available", }; @@ -23,7 +23,7 @@ export function SkillDetailUpdateControl({ return (
- + )} + bodyAriaLabelledBy={fallbackHeadingId} + /> ); } @@ -93,25 +103,27 @@ export function SkillDetailView({ onManage={onManage} onToggleHarness={(cell) => onToggleHarness(cell.harness, cell.state)} onUpdate={onUpdate} - onRequestStopManaging={requestStopManaging} + onRequestRemove={requestRemove} onRequestDelete={requestDelete} /> {detail.actions.stopManagingStatus !== null ? ( - ) : null} {detail.actions.canDelete ? ( - diff --git a/frontend/src/features/skills/components/dialogs/SkillActionConfirmDialog.tsx b/frontend/src/features/skills/components/dialogs/SkillActionConfirmDialog.tsx new file mode 100644 index 0000000..d5d22b2 --- /dev/null +++ b/frontend/src/features/skills/components/dialogs/SkillActionConfirmDialog.tsx @@ -0,0 +1,76 @@ +import { ConfirmActionDialog } from "../../../../components/ConfirmActionDialog"; + +type SkillActionConfirmKind = "unmanage" | "delete"; + +interface SkillActionConfirmDialogProps { + open: boolean; + action: SkillActionConfirmKind; + skillName: string; + harnessLabels: readonly string[]; + isPending: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; +} + +export function SkillActionConfirmDialog({ + open, + action, + skillName, + harnessLabels, + isPending, + onOpenChange, + onConfirm, +}: SkillActionConfirmDialogProps) { + const content = action === "unmanage" + ? { + title: "Remove skill from Skill Manager?", + description: ( + <> + This removes {skillName} from the Skill Manager store and restores local copies only + for the harnesses that are currently enabled. + + ), + note: + harnessLabels.length > 0 ? ( +

Will restore to: {harnessLabels.join(", ")}

+ ) : undefined, + confirmLabel: "Remove", + pendingLabel: "Removing", + confirmTone: "primary" as const, + } + : { + title: "Delete skill from Skill Manager?", + description: ( + <> + This will remove {skillName} from the shared store and delete its + links from all harnesses. + + ), + note: ( + <> +

This action cannot be undone.

+ {harnessLabels.length > 0 ? ( +

Affected harnesses: {harnessLabels.join(", ")}

+ ) : null} + + ), + confirmLabel: "Delete", + pendingLabel: "Deleting skill", + confirmTone: "danger" as const, + }; + + return ( + + ); +} diff --git a/frontend/src/features/skills/components/dialogs/SkillDeleteDialog.tsx b/frontend/src/features/skills/components/dialogs/SkillDeleteDialog.tsx deleted file mode 100644 index 5775961..0000000 --- a/frontend/src/features/skills/components/dialogs/SkillDeleteDialog.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { SkillActionDialog } from "./SkillActionDialog"; - -interface SkillDeleteDialogProps { - open: boolean; - skillName: string; - harnessLabels: string[]; - isDeleting: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void | Promise; -} - -export function SkillDeleteDialog({ - open, - skillName, - harnessLabels, - isDeleting, - onOpenChange, - onConfirm, -}: SkillDeleteDialogProps) { - return ( - - This will remove {skillName} from the shared store and delete its links from all harnesses. - This action cannot be undone. - - )} - note={harnessLabels.length > 0 ? `Affected harnesses: ${harnessLabels.join(", ")}` : undefined} - tone="danger" - confirmLabel="Still Delete" - confirmClassName="btn btn-danger" - pendingLabel="Deleting skill" - isPending={isDeleting} - onOpenChange={onOpenChange} - onConfirm={onConfirm} - /> - ); -} diff --git a/frontend/src/features/skills/components/dialogs/SkillStopManagingDialog.tsx b/frontend/src/features/skills/components/dialogs/SkillStopManagingDialog.tsx deleted file mode 100644 index 46522f0..0000000 --- a/frontend/src/features/skills/components/dialogs/SkillStopManagingDialog.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { SkillActionDialog } from "./SkillActionDialog"; - -interface SkillStopManagingDialogProps { - open: boolean; - skillName: string; - harnessLabels: string[]; - isPending: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void | Promise; -} - -export function SkillStopManagingDialog({ - open, - skillName, - harnessLabels, - isPending, - onOpenChange, - onConfirm, -}: SkillStopManagingDialogProps) { - return ( - - This removes {skillName} from the shared managed store and restores local copies only for - the harnesses that are currently enabled. - - )} - note={harnessLabels.length > 0 ? `Will restore to: ${harnessLabels.join(", ")}` : undefined} - tone="neutral" - confirmLabel="Stop Managing" - confirmClassName="btn btn-primary" - pendingLabel="Stopping management" - isPending={isPending} - onOpenChange={onOpenChange} - onConfirm={onConfirm} - /> - ); -} diff --git a/frontend/src/features/skills/components/harness/BulkManageHelp.tsx b/frontend/src/features/skills/components/harness/BulkManageHelp.tsx deleted file mode 100644 index d61131e..0000000 --- a/frontend/src/features/skills/components/harness/BulkManageHelp.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { HelpPopover } from "../../../../components/ui/HelpPopover"; - -const BULK_MANAGE_COPY = - "Moves local copies into the Shared Store, then replaces tool-folder copies with managed links."; - -export function BulkManageHelp() { - return ( - - - - ); -} diff --git a/frontend/src/features/skills/components/harness/HarnessMark.tsx b/frontend/src/features/skills/components/harness/HarnessMark.tsx deleted file mode 100644 index e60b0ba..0000000 --- a/frontend/src/features/skills/components/harness/HarnessMark.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { getHarnessPresentation } from "./harnessPresentation"; - -interface HarnessMarkProps { - harness: string; - label: string; - logoKey?: string | null; - className?: string; -} - -export function HarnessMark({ harness, label, logoKey, className }: HarnessMarkProps) { - const presentation = getHarnessPresentation(logoKey ?? harness); - const classes = ["skill-harness-mark", className].filter(Boolean).join(" "); - - if (!presentation) { - return {label}; - } - - return ( - - - {label} - - ); -} diff --git a/frontend/src/features/skills/components/harness/HarnessStateChip.test.tsx b/frontend/src/features/skills/components/harness/HarnessStateChip.test.tsx deleted file mode 100644 index 3281672..0000000 --- a/frontend/src/features/skills/components/harness/HarnessStateChip.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { HarnessStateChip } from "./HarnessStateChip"; - -describe("HarnessStateChip", () => { - it("renders interactive on-off switches for enabled and disabled states", () => { - const onCheckedChange = vi.fn(); - - const { rerender } = render( - , - ); - - expect(screen.getByRole("switch", { name: "Enable Shared Audit for Codex" })).toBeInTheDocument(); - expect(screen.getByText("Off")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("switch", { name: "Enable Shared Audit for Codex" })); - expect(onCheckedChange).toHaveBeenCalled(); - - rerender( - , - ); - - expect(screen.getByRole("switch", { name: "Disable Shared Audit for Codex" })).toBeInTheDocument(); - expect(screen.getByText("On")).toBeInTheDocument(); - }); - - it("renders passive found, not found, and built-in chips", () => { - const { container, rerender } = render(); - - expect(screen.getByText("Found")).toBeInTheDocument(); - expect(container.querySelector(".harness-state-chip--found")).not.toBeNull(); - - rerender(); - expect(screen.getByText("Not Found")).toBeInTheDocument(); - expect(container.querySelector(".harness-state-chip--empty")).not.toBeNull(); - - rerender(); - expect(screen.getByText("Built-in")).toBeInTheDocument(); - expect(container.querySelector(".harness-state-chip--builtin")).not.toBeNull(); - }); - - it("renders pending feedback inside the interactive chip without changing passive variants", () => { - render( - , - ); - - const toggle = screen.getByRole("switch", { name: "Disable Shared Audit for Codex" }); - expect(toggle).toBeDisabled(); - expect(screen.getByText("Saving")).toBeInTheDocument(); - expect(screen.getByLabelText("Saving harness state")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/features/skills/components/harness/HarnessStateChip.tsx b/frontend/src/features/skills/components/harness/HarnessStateChip.tsx deleted file mode 100644 index c6af9ca..0000000 --- a/frontend/src/features/skills/components/harness/HarnessStateChip.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import * as SwitchPrimitive from "@radix-ui/react-switch"; - -import { LoadingSpinner } from "../../../../components/LoadingSpinner"; -import type { HarnessCellState } from "../../model/types"; - -interface HarnessStateChipProps { - state: HarnessCellState; - interactive: boolean; - disabled?: boolean; - pending?: boolean; - ariaLabel?: string; - onCheckedChange?: (checked: boolean) => void; -} - -export function HarnessStateChip({ - state, - interactive, - disabled = false, - pending = false, - ariaLabel, - onCheckedChange, -}: HarnessStateChipProps) { - if (interactive && (state === "enabled" || state === "disabled")) { - const checked = state === "enabled"; - return ( - - {pending ? : null} - - - ); - } - - const passiveState = passiveChipPresentation(state); - return ( - - {passiveState.label} - - ); -} - -function passiveChipPresentation( - state: HarnessCellState, -): { label: string; variant: "found" | "empty" | "builtin" } { - switch (state) { - case "found": - return { label: "Found", variant: "found" }; - case "builtin": - return { label: "Built-in", variant: "builtin" }; - case "empty": - default: - return { label: "Not Found", variant: "empty" }; - } -} diff --git a/frontend/src/features/skills/components/harness/ManagedSkillHarnessCluster.test.tsx b/frontend/src/features/skills/components/harness/ManagedSkillHarnessCluster.test.tsx deleted file mode 100644 index ee5c018..0000000 --- a/frontend/src/features/skills/components/harness/ManagedSkillHarnessCluster.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { cellActionKey } from "../../model/pending"; -import type { HarnessColumn, SkillListRow } from "../../model/types"; -import { ManagedSkillHarnessCluster } from "./ManagedSkillHarnessCluster"; - -const columns: HarnessColumn[] = [ - { harness: "codex", label: "Codex" }, - { harness: "cursor", label: "Cursor" }, - { harness: "openclaw", label: "OpenClaw" }, - { harness: "opencode", label: "OpenCode" }, -]; - -const row: SkillListRow = { - skillRef: "shared:trace-lens", - name: "Trace Lens", - description: "Trace review workflow", - displayStatus: "Managed", - attentionMessage: null, - canManage: false, - cells: [ - { harness: "codex", label: "Codex", state: "disabled", interactive: true }, - { harness: "cursor", label: "Cursor", state: "builtin", interactive: false }, - { harness: "openclaw", label: "OpenClaw", state: "enabled", interactive: true }, - { harness: "opencode", label: "OpenCode", state: "disabled", interactive: true }, - ], -}; - -describe("ManagedSkillHarnessCluster", () => { - it("renders grouped harness controls and forwards toggle events", () => { - const onToggleCell = vi.fn(); - - const { container } = render( - , - ); - - expect(screen.getByText("Built-in")).toBeInTheDocument(); - expect(container.querySelectorAll(".skill-harness-mark__logo")).toHaveLength(4); - expect(container.querySelector(".skill-harness-mark--codex .skill-harness-mark__logo")).not.toBeNull(); - expect(container.querySelector(".skill-harness-mark--openclaw .skill-harness-mark__logo")).not.toBeNull(); - expect(container.querySelectorAll(".harness-state-chip")).toHaveLength(4); - expect(screen.getAllByText("Off")).toHaveLength(2); - - fireEvent.click(screen.getByRole("switch", { name: "Enable Trace Lens for Codex" })); - expect(onToggleCell).toHaveBeenCalledWith(row, row.cells[0]); - }); - - it("only disables the active harness switch while leaving sibling toggles usable", () => { - render( - , - ); - - expect(screen.getByRole("switch", { name: "Enable Trace Lens for Codex" })).toBeDisabled(); - expect(screen.getByRole("switch", { name: "Disable Trace Lens for OpenClaw" })).toBeEnabled(); - expect(screen.getByRole("switch", { name: "Enable Trace Lens for OpenCode" })).toBeEnabled(); - }); -}); diff --git a/frontend/src/features/skills/components/harness/ManagedSkillHarnessCluster.tsx b/frontend/src/features/skills/components/harness/ManagedSkillHarnessCluster.tsx deleted file mode 100644 index d6869e0..0000000 --- a/frontend/src/features/skills/components/harness/ManagedSkillHarnessCluster.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { hasPendingToggleForCell, type CellActionKey } from "../../model/pending"; -import type { HarnessCell, HarnessColumn, SkillListRow } from "../../model/types"; -import { alignHarnessCells } from "../../model/selectors"; -import { HarnessMark } from "./HarnessMark"; -import { HarnessStateChip } from "./HarnessStateChip"; - -interface ManagedSkillHarnessClusterProps { - row: SkillListRow; - columns: HarnessColumn[]; - pendingToggleKeys: ReadonlySet; - structuralLocked: boolean; - onToggleCell: (row: SkillListRow, cell: HarnessCell) => void; -} - -export function ManagedSkillHarnessCluster({ - row, - columns, - pendingToggleKeys, - structuralLocked, - onToggleCell, -}: ManagedSkillHarnessClusterProps) { - const items = alignHarnessCells(row, columns); - - return ( -
event.stopPropagation()} - > -
- {items.map(({ column, cell }) => ( -
- -
- -
-
- ))} -
-
- ); -} - -interface ManagedHarnessClusterControlProps { - row: SkillListRow; - cell: HarnessCell; - pendingToggleKeys: ReadonlySet; - structuralLocked: boolean; - onToggleCell: (row: SkillListRow, cell: HarnessCell) => void; -} - -function ManagedHarnessClusterControl({ - row, - cell, - pendingToggleKeys, - structuralLocked, - onToggleCell, -}: ManagedHarnessClusterControlProps) { - const pending = hasPendingToggleForCell(pendingToggleKeys, row.skillRef, cell.harness); - - return ( - onToggleCell(row, cell)} - /> - ); -} diff --git a/frontend/src/features/skills/components/matrix/MatrixRow.tsx b/frontend/src/features/skills/components/matrix/MatrixRow.tsx new file mode 100644 index 0000000..4c164db --- /dev/null +++ b/frontend/src/features/skills/components/matrix/MatrixRow.tsx @@ -0,0 +1,114 @@ +import { CardSelectCheckbox } from "../../../../components/cards/CardSelectCheckbox"; +import { OverflowTooltipText } from "../../../../components/ui/OverflowTooltipText"; +import { HarnessChipStack } from "../cards/HarnessChipStack"; +import { cellActionKey } from "../../model/pending"; +import type { CellActionKey } from "../../model/pending"; +import type { + HarnessCell as HarnessCellType, + HarnessColumn, + SkillListRow, +} from "../../model/types"; +import { SkillMatrixHarnessCell } from "./SkillMatrixHarnessCell"; + +interface MatrixRowProps { + row: SkillListRow; + harnessColumns: HarnessColumn[]; + checked: boolean; + selected: boolean; + pendingToggleKeys: ReadonlySet; + onOpenSkill: (skillRef: string) => void; + onToggleChecked: (skillRef: string) => void; + onToggleCell: (row: SkillListRow, cell: HarnessCellType) => void; +} + +function findCell(row: SkillListRow, harness: string): HarnessCellType { + return ( + row.cells.find((cell) => cell.harness === harness) ?? { + harness, + label: harness, + state: "empty", + interactive: false, + } + ); +} + +function countEnabled(row: SkillListRow): number { + let count = 0; + for (const cell of row.cells) if (cell.state === "enabled") count += 1; + return count; +} + +export function MatrixRow({ + row, + harnessColumns, + checked, + selected, + pendingToggleKeys, + onOpenSkill, + onToggleChecked, + onToggleCell, +}: MatrixRowProps) { + const enabledCount = countEnabled(row); + const totalCount = harnessColumns.length; + + return ( + + + onToggleChecked(row.skillRef)} + /> + + + onOpenSkill(row.skillRef)} + > +
+ + {row.name} + +
+ {row.description ? ( + + {row.description} + + ) : null} + + + {harnessColumns.map((column) => { + const cell = findCell(row, column.harness); + const pending = pendingToggleKeys.has(cellActionKey(row.skillRef, cell.harness)); + return ( + + onToggleCell(row, next)} + /> + + ); + })} + + + + + + + + {enabledCount} + + + + + ); +} diff --git a/frontend/src/features/skills/components/matrix/MatrixView.test.tsx b/frontend/src/features/skills/components/matrix/MatrixView.test.tsx new file mode 100644 index 0000000..ad7986e --- /dev/null +++ b/frontend/src/features/skills/components/matrix/MatrixView.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { HarnessColumn, SkillListRow } from "../../model/types"; +import { MatrixView } from "./MatrixView"; + +const harnessColumns: HarnessColumn[] = [ + { harness: "codex", label: "Codex", logoKey: "codex", installed: true }, + { harness: "cursor", label: "Cursor", logoKey: "cursor", installed: true }, +]; + +const rows: SkillListRow[] = [ + { + skillRef: "shared:alpha", + name: "Alpha", + description: "First skill", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: true }, + cells: [ + { harness: "codex", label: "Codex", logoKey: "codex", state: "enabled", interactive: true }, + { harness: "cursor", label: "Cursor", logoKey: "cursor", state: "disabled", interactive: true }, + ], + }, + { + skillRef: "shared:zeta", + name: "Zeta", + description: "Last skill", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: true }, + cells: [ + { harness: "codex", label: "Codex", logoKey: "codex", state: "disabled", interactive: true }, + { harness: "cursor", label: "Cursor", logoKey: "cursor", state: "disabled", interactive: true }, + ], + }, +]; + +function renderMatrix() { + const props = { + rows, + harnessColumns, + checkedRefs: new Set(), + selectedSkillRef: null, + pendingToggleKeys: new Set(), + onOpenSkill: vi.fn(), + onToggleChecked: vi.fn(), + onToggleCell: vi.fn(), + }; + render(); + return props; +} + +describe("Skills MatrixView", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders a harness matrix with sortable rows", () => { + renderMatrix(); + + const table = screen.getByRole("table", { name: "Skills harness matrix" }); + expect(table.querySelectorAll("col")).toHaveLength(harnessColumns.length + 4); + expect(rowNames()).toEqual(["Alpha", "Zeta"]); + + fireEvent.click(screen.getByRole("button", { name: "Sort by Name" })); + expect(rowNames()).toEqual(["Zeta", "Alpha"]); + }); + + it("toggles harness cells", () => { + const { onToggleCell } = renderMatrix(); + + fireEvent.click(screen.getByRole("button", { name: "Disable Alpha on Codex" })); + + expect(onToggleCell).toHaveBeenCalledWith(rows[0], rows[0].cells[0]); + }); +}); + +function rowNames(): string[] { + const table = screen.getByRole("table", { name: "Skills harness matrix" }); + return within(table) + .getAllByRole("row") + .slice(1) + .map((row) => within(row).getAllByText(/Alpha|Zeta/)[0].textContent ?? ""); +} diff --git a/frontend/src/features/skills/components/matrix/MatrixView.tsx b/frontend/src/features/skills/components/matrix/MatrixView.tsx new file mode 100644 index 0000000..4d92a9b --- /dev/null +++ b/frontend/src/features/skills/components/matrix/MatrixView.tsx @@ -0,0 +1,118 @@ +import { useMemo, useState } from "react"; + +import { + MatrixHarnessIcon, + MatrixSortableHeader, + MatrixTable, +} from "../../../../components/matrix"; +import { MatrixRow } from "./MatrixRow"; +import { sortRows, sortKeysEqual, type SortKey, type SortState } from "../../model/sortRows"; +import type { CellActionKey } from "../../model/pending"; +import type { HarnessCell, HarnessColumn, SkillListRow } from "../../model/types"; + +interface MatrixViewProps { + rows: SkillListRow[]; + harnessColumns: HarnessColumn[]; + checkedRefs: ReadonlySet; + selectedSkillRef: string | null; + pendingToggleKeys: ReadonlySet; + onOpenSkill: (skillRef: string) => void; + onToggleChecked: (skillRef: string) => void; + onToggleCell: (row: SkillListRow, cell: HarnessCell) => void; +} + +const INITIAL_SORT: SortState = { key: "name", direction: "asc" }; + +export function MatrixView({ + rows, + harnessColumns, + checkedRefs, + selectedSkillRef, + pendingToggleKeys, + onOpenSkill, + onToggleChecked, + onToggleCell, +}: MatrixViewProps) { + const [sort, setSort] = useState(INITIAL_SORT); + + const sortedRows = useMemo(() => sortRows(rows, sort), [rows, sort]); + + const requestSort = (key: SortKey) => { + setSort((current) => { + if (sortKeysEqual(current.key, key)) { + return { key, direction: current.direction === "asc" ? "desc" : "asc" }; + } + return { key, direction: "asc" }; + }); + }; + + return ( + + + + + requestSort("name")} + /> + {harnessColumns.map((column) => { + const key: SortKey = { harness: column.harness }; + return ( + + } + srLabel={`Sort by ${column.label}`} + onClick={() => requestSort(key)} + /> + ); + })} + + Harnesses + + requestSort("coverage")} + /> + + + + {sortedRows.map((row) => ( + + ))} + + + ); +} diff --git a/frontend/src/features/skills/components/matrix/SkillMatrixHarnessCell.tsx b/frontend/src/features/skills/components/matrix/SkillMatrixHarnessCell.tsx new file mode 100644 index 0000000..62f4b47 --- /dev/null +++ b/frontend/src/features/skills/components/matrix/SkillMatrixHarnessCell.tsx @@ -0,0 +1,57 @@ +import { + MatrixHarnessCellTarget, + MatrixHarnessIcon, +} from "../../../../components/matrix"; +import { UiTooltip } from "../../../../components/ui/UiTooltip"; +import type { HarnessCell as HarnessCellType } from "../../model/types"; + +interface SkillMatrixHarnessCellProps { + cell: HarnessCellType; + skillName: string; + pending?: boolean; + onToggle: (cell: HarnessCellType) => void; +} + +export function SkillMatrixHarnessCell({ + cell, + skillName, + pending = false, + onToggle, +}: SkillMatrixHarnessCellProps) { + if (cell.state === "empty" || cell.state === "found") { + return ( + + ); + } + + const isEnabled = cell.state === "enabled"; + const action = isEnabled ? "Disable" : "Enable"; + + const button = ( + { + event.stopPropagation(); + onToggle(cell); + }} + > + + + ); + + return ( + + {button} + + ); +} diff --git a/frontend/src/features/skills/components/pane/SkillsEmptyState.tsx b/frontend/src/features/skills/components/pane/SkillsEmptyState.tsx index 1e00fe2..ebb8d37 100644 --- a/frontend/src/features/skills/components/pane/SkillsEmptyState.tsx +++ b/frontend/src/features/skills/components/pane/SkillsEmptyState.tsx @@ -6,11 +6,14 @@ export function SkillsEmptyState({ onResetFilters }: SkillsEmptyStateProps) { return (
-

No matching skills

No skills match the current filters.

Adjust the search or filter controls to bring skills back into view.

-
diff --git a/frontend/src/features/skills/components/pane/SkillsPaneChrome.test.tsx b/frontend/src/features/skills/components/pane/SkillsPaneChrome.test.tsx deleted file mode 100644 index 7897956..0000000 --- a/frontend/src/features/skills/components/pane/SkillsPaneChrome.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { SkillsPaneChrome } from "./SkillsPaneChrome"; - -describe("SkillsPaneChrome", () => { - it("updates search and shows a trailing reset action only when active", () => { - const onSearchChange = vi.fn(); - const onReset = vi.fn(); - - const { rerender } = render( - , - ); - - fireEvent.change(screen.getByRole("textbox", { name: "Search managed skills" }), { - target: { value: "trace" }, - }); - - expect(onSearchChange).toHaveBeenCalledWith("trace"); - expect(screen.queryByRole("button", { name: /Reset/i })).not.toBeInTheDocument(); - - rerender( - , - ); - - fireEvent.click(screen.getByRole("button", { name: /Reset/i })); - - expect(onReset).toHaveBeenCalled(); - }); - - it("renders optional header actions beside the title", () => { - render( - - - - - } - searchValue="" - hasActiveFilters={false} - onSearchChange={() => {}} - onReset={() => {}} - searchLabel="Unmanaged skills filters" - searchInputLabel="Search unmanaged skills" - searchPlaceholder="Search unmanaged skills by name, description, or tool" - />, - ); - - expect(screen.getByRole("heading", { name: "Unmanaged skills" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Bring All Eligible Skills Under Management" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "What Happens?" })).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/features/skills/components/pane/SkillsPaneChrome.tsx b/frontend/src/features/skills/components/pane/SkillsPaneChrome.tsx deleted file mode 100644 index 2d1e156..0000000 --- a/frontend/src/features/skills/components/pane/SkillsPaneChrome.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ReactNode } from "react"; - -import { RotateCcw, Search } from "lucide-react"; - -interface SkillsPaneChromeProps { - title: string; - actions?: ReactNode; - searchValue: string; - hasActiveFilters: boolean; - onSearchChange: (value: string) => void; - onReset: () => void; - searchLabel: string; - searchInputLabel: string; - searchPlaceholder: string; -} - -export function SkillsPaneChrome({ - title, - actions, - searchValue, - hasActiveFilters, - onSearchChange, - onReset, - searchLabel, - searchInputLabel, - searchPlaceholder, -}: SkillsPaneChromeProps) { - return ( -
-
-
-

{title}

-
- {actions ?
{actions}
: null} -
- -
-
- - onSearchChange(event.target.value)} - placeholder={searchPlaceholder} - aria-label={searchInputLabel} - /> - {hasActiveFilters ? ( - - ) : null} -
-
-
- ); -} diff --git a/frontend/src/features/skills/components/pane/SkillsPaneScaffold.test.tsx b/frontend/src/features/skills/components/pane/SkillsPaneScaffold.test.tsx deleted file mode 100644 index 05507a9..0000000 --- a/frontend/src/features/skills/components/pane/SkillsPaneScaffold.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { createRef } from "react"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { SkillsPaneScaffold } from "./SkillsPaneScaffold"; - -describe("SkillsPaneScaffold", () => { - it("renders fixed chrome and a dedicated list scroller when ready", () => { - const scrollRef = createRef(); - - const { container } = render( - {}} - onReset={() => {}} - searchLabel="Managed skills filters" - searchInputLabel="Search managed skills" - searchPlaceholder="Search managed skills by name, description, or state" - scrollRef={scrollRef} - isReady={true} - isInitialLoading={false} - hasError={false} - loadingLabel="Loading managed skills" - errorMessage="Unable to load managed skills." - > -
List body
-
, - ); - - expect(screen.getByRole("heading", { name: "Managed skills" })).toBeInTheDocument(); - expect(screen.getByRole("search", { name: "Managed skills filters" })).toBeInTheDocument(); - expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument(); - expect(container.querySelector(".skills-pane__scroll")).toBe(scrollRef.current); - }); - - it("renders loading and error states outside the ready pane content", () => { - render( - {}} - onReset={() => {}} - searchLabel="Unmanaged skills filters" - searchInputLabel="Search unmanaged skills" - searchPlaceholder="Search unmanaged skills by name, description, or tool" - scrollRef={createRef()} - isReady={false} - isInitialLoading={true} - hasError={true} - loadingLabel="Loading unmanaged skills" - errorMessage="Unable to load unmanaged skills." - > -
Unused
-
, - ); - - expect(screen.getByRole("status", { name: "Loading unmanaged skills" })).toBeInTheDocument(); - expect(screen.getByText("Unable to load unmanaged skills.")).toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Unmanaged skills" })).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/features/skills/components/pane/SkillsPaneScaffold.tsx b/frontend/src/features/skills/components/pane/SkillsPaneScaffold.tsx deleted file mode 100644 index ae6c06b..0000000 --- a/frontend/src/features/skills/components/pane/SkillsPaneScaffold.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { ReactNode, RefObject } from "react"; - -import { LoadingSpinner } from "../../../../components/LoadingSpinner"; -import { SkillsPaneChrome } from "./SkillsPaneChrome"; - -interface SkillsPaneScaffoldProps { - title: string; - actions?: ReactNode; - searchValue: string; - hasActiveFilters: boolean; - onSearchChange: (value: string) => void; - onReset: () => void; - searchLabel: string; - searchInputLabel: string; - searchPlaceholder: string; - scrollRef: RefObject; - isReady: boolean; - isInitialLoading: boolean; - hasError: boolean; - loadingLabel: string; - errorMessage: string; - children: ReactNode; -} - -export function SkillsPaneScaffold({ - title, - actions, - searchValue, - hasActiveFilters, - onSearchChange, - onReset, - searchLabel, - searchInputLabel, - searchPlaceholder, - scrollRef, - isReady, - isInitialLoading, - hasError, - loadingLabel, - errorMessage, - children, -}: SkillsPaneScaffoldProps) { - return ( -
- {isReady ? ( - <> - - -
-
{children}
-
- - ) : null} - - {isInitialLoading ? ( -
- -
- ) : null} - - {hasError ? ( -
-

{errorMessage}

-
- ) : null} -
- ); -} diff --git a/frontend/src/features/skills/components/pane/SkillsPaneTransition.tsx b/frontend/src/features/skills/components/pane/SkillsPaneTransition.tsx deleted file mode 100644 index 9656b44..0000000 --- a/frontend/src/features/skills/components/pane/SkillsPaneTransition.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { ReactNode } from "react"; - -export type SkillsPaneView = "managed" | "unmanaged"; -export type SkillsPaneDirection = "forward" | "backward"; - -interface SkillsPaneTransitionProps { - view: SkillsPaneView; - direction: SkillsPaneDirection; - animate?: boolean; - children: ReactNode; -} - -export function SkillsPaneTransition({ view, direction, animate = true, children }: SkillsPaneTransitionProps) { - return ( -
- {children} -
- ); -} diff --git a/frontend/src/features/skills/components/pane/SkillsWorkspaceTabs.test.tsx b/frontend/src/features/skills/components/pane/SkillsWorkspaceTabs.test.tsx deleted file mode 100644 index 3d17afd..0000000 --- a/frontend/src/features/skills/components/pane/SkillsWorkspaceTabs.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { MemoryRouter } from "react-router-dom"; -import { render, screen, within } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { SkillsWorkspaceTabs } from "./SkillsWorkspaceTabs"; - -describe("SkillsWorkspaceTabs", () => { - it("renders managed and unmanaged tabs with counts", () => { - render( - - - , - ); - - const managedTab = screen.getByRole("link", { name: /^Managed/i }); - const unmanagedTab = screen.getByRole("link", { name: /^Unmanaged/i }); - - expect(managedTab).toBeInTheDocument(); - expect(unmanagedTab).toBeInTheDocument(); - expect(within(managedTab).getByText("3")).toBeInTheDocument(); - expect(within(unmanagedTab).getByText("3")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/features/skills/components/pane/SkillsWorkspaceTabs.tsx b/frontend/src/features/skills/components/pane/SkillsWorkspaceTabs.tsx deleted file mode 100644 index 5913e08..0000000 --- a/frontend/src/features/skills/components/pane/SkillsWorkspaceTabs.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { NavLink } from "react-router-dom"; - -import type { SkillsSummary } from "../../model/types"; - -interface SkillsWorkspaceTabsProps { - summary: SkillsSummary | null; -} - -export function SkillsWorkspaceTabs({ summary }: SkillsWorkspaceTabsProps) { - const managedCount = summary ? summary.managed + summary.custom : 0; - const unmanagedCount = summary?.unmanaged ?? 0; - - return ( - - ); -} diff --git a/frontend/src/features/skills/model/bucketForRow.test.ts b/frontend/src/features/skills/model/bucketForRow.test.ts new file mode 100644 index 0000000..a22f3e2 --- /dev/null +++ b/frontend/src/features/skills/model/bucketForRow.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { bucketForRow, bucketRows } from "./bucketForRow"; +import type { HarnessCell, SkillListRow } from "./types"; + +function row(cells: HarnessCell[], skillRef = "test:row"): SkillListRow { + return { + skillRef, + name: "Test skill", + description: "", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: false }, + cells, + } as unknown as SkillListRow; +} + +const enabled: HarnessCell = { harness: "codex", label: "Codex", state: "enabled", interactive: true }; +const disabled: HarnessCell = { harness: "cursor", label: "Cursor", state: "disabled", interactive: true }; +const found: HarnessCell = { harness: "claude", label: "Claude", state: "found", interactive: false }; +const empty: HarnessCell = { harness: "other", label: "Other", state: "empty", interactive: false }; + +describe("bucketForRow", () => { + it("classifies all-enabled as 'enabled'", () => { + expect(bucketForRow(row([enabled, { ...enabled, harness: "cursor", label: "Cursor" }]))).toBe("enabled"); + }); + + it("classifies all-disabled as 'disabled'", () => { + expect(bucketForRow(row([disabled, { ...disabled, harness: "codex", label: "Codex" }]))).toBe("disabled"); + }); + + it("classifies mixed interactive states as 'selective'", () => { + expect(bucketForRow(row([enabled, disabled]))).toBe("selective"); + }); + + it("ignores non-interactive cells when classifying", () => { + expect(bucketForRow(row([enabled, found, empty]))).toBe("enabled"); + expect(bucketForRow(row([disabled, found, empty]))).toBe("disabled"); + expect(bucketForRow(row([enabled, disabled, found]))).toBe("selective"); + }); + + it("treats rows with no interactive cells as 'enabled'", () => { + expect(bucketForRow(row([found, empty]))).toBe("enabled"); + expect(bucketForRow(row([]))).toBe("enabled"); + }); +}); + +describe("bucketRows", () => { + it("partitions rows into three buckets preserving order", () => { + const a = row([disabled], "a"); + const b = row([enabled, disabled], "b"); + const c = row([enabled], "c"); + const d = row([disabled, disabled], "d"); + const result = bucketRows([a, b, c, d]); + expect(result.disabled.map((r) => r.skillRef)).toEqual(["a", "d"]); + expect(result.selective.map((r) => r.skillRef)).toEqual(["b"]); + expect(result.enabled.map((r) => r.skillRef)).toEqual(["c"]); + }); +}); diff --git a/frontend/src/features/skills/model/bucketForRow.ts b/frontend/src/features/skills/model/bucketForRow.ts new file mode 100644 index 0000000..b6fd5ad --- /dev/null +++ b/frontend/src/features/skills/model/bucketForRow.ts @@ -0,0 +1,29 @@ +import type { SkillListRow } from "./types"; + +export type SkillBucket = "disabled" | "selective" | "enabled"; + +export function bucketForRow(row: SkillListRow): SkillBucket { + const interactive = row.cells.filter((cell) => cell.interactive); + if (interactive.length === 0) { + return "enabled"; + } + const allEnabled = interactive.every((cell) => cell.state === "enabled"); + if (allEnabled) return "enabled"; + const allDisabled = interactive.every((cell) => cell.state === "disabled"); + if (allDisabled) return "disabled"; + return "selective"; +} + +export interface BucketedRows { + disabled: SkillListRow[]; + selective: SkillListRow[]; + enabled: SkillListRow[]; +} + +export function bucketRows(rows: SkillListRow[]): BucketedRows { + const result: BucketedRows = { disabled: [], selective: [], enabled: [] }; + for (const row of rows) { + result[bucketForRow(row)].push(row); + } + return result; +} diff --git a/frontend/src/features/skills/model/selectors.test.ts b/frontend/src/features/skills/model/selectors.test.ts index acf1131..1da1217 100644 --- a/frontend/src/features/skills/model/selectors.test.ts +++ b/frontend/src/features/skills/model/selectors.test.ts @@ -2,35 +2,32 @@ import { describe, expect, it } from "vitest"; import type { SkillsWorkspaceData } from "./types"; import { - countManageableUnmanagedRows, - countUnmanagedRows, - filterBuiltInRows, - filterManagedRows, - filterUnmanagedRows, - resetManagedSkillsFilters, - resetUnmanagedSkillsFilters, + countAdoptableLocalSkillRows, + countNeedsReviewRows, + filterNeedsReviewRows, + filterSkillsInUseRows, + resetSkillsNeedsReviewFilters, + resetSkillsInUseFilters, } from "./selectors"; const data: SkillsWorkspaceData = { - summary: { managed: 1, unmanaged: 1, custom: 1, builtIn: 1 }, - harnessColumns: [{ harness: "codex", label: "Codex" }], + summary: { managed: 2, unmanaged: 1 }, + harnessColumns: [{ harness: "codex", label: "Codex", installed: true }], rows: [ { skillRef: "shared:shared-audit", name: "Shared Audit", description: "Shared audit workflow", displayStatus: "Managed", - attentionMessage: null, - canManage: false, + actions: { canManage: false, canStopManaging: true, canDelete: false }, cells: [{ harness: "codex", label: "Codex", state: "disabled", interactive: true }], }, { skillRef: "shared:audit-skill", name: "Audit Skill", - description: "Custom audit workflow", - displayStatus: "Custom", - attentionMessage: "Modified locally; source updates are disabled.", - canManage: false, + description: "Locally modified audit workflow", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: true }, cells: [{ harness: "codex", label: "Codex", state: "enabled", interactive: true }], }, { @@ -38,42 +35,39 @@ const data: SkillsWorkspaceData = { name: "Trace Lens", description: "Trace review workflow", displayStatus: "Unmanaged", - attentionMessage: null, - canManage: true, + actions: { canManage: true, canStopManaging: false, canDelete: false }, cells: [{ harness: "codex", label: "Codex", state: "found", interactive: false }], }, - { - skillRef: "builtin:review-helper", - name: "Review Helper", - description: "Bundled with OpenCode", - displayStatus: "Built-in", - attentionMessage: null, - canManage: false, - cells: [{ harness: "codex", label: "Codex", state: "builtin", interactive: false }], - }, ], -}; +} as unknown as SkillsWorkspaceData; describe("skills workspace model", () => { - it("partitions managed and unmanaged rows correctly", () => { - const managedRows = filterManagedRows(data, resetManagedSkillsFilters()); - const builtInRows = filterBuiltInRows(data); - const unmanagedRows = filterUnmanagedRows(data, resetUnmanagedSkillsFilters()); + it("partitions in-use and needs-review rows correctly", () => { + const inUseRows = filterSkillsInUseRows(data, resetSkillsInUseFilters()); + const needsReviewRows = filterNeedsReviewRows(data, resetSkillsNeedsReviewFilters()); + + expect(inUseRows.map((row) => row.name)).toEqual(["Shared Audit", "Audit Skill"]); + expect(needsReviewRows.map((row) => row.name)).toEqual(["Trace Lens"]); + }); - expect(managedRows.map((row) => row.name)).toEqual(["Shared Audit", "Audit Skill"]); - expect(builtInRows.map((row) => row.name)).toEqual(["Review Helper"]); - expect(unmanagedRows.map((row) => row.name)).toEqual(["Trace Lens"]); + it("treats locally modified shared-store entries as in-use rows", () => { + expect(filterSkillsInUseRows(data, resetSkillsInUseFilters()).map((row) => row.name)).toEqual([ + "Shared Audit", + "Audit Skill", + ]); }); - it("filters managed rows by display status only", () => { - expect(filterManagedRows(data, resetManagedSkillsFilters()).map((row) => row.name)).toEqual([ + it("searches only user-visible row content and harness labels", () => { + expect(filterSkillsInUseRows(data, { search: "codex" }).map((row) => row.name)).toEqual([ "Shared Audit", "Audit Skill", ]); + expect(filterSkillsInUseRows(data, { search: "managed" })).toEqual([]); + expect(filterSkillsInUseRows(data, { search: "local changes" })).toEqual([]); }); - it("counts unmanaged rows and manageable actions without the deleted overview strip", () => { - expect(countUnmanagedRows(data)).toBe(1); - expect(countManageableUnmanagedRows(data)).toBe(1); + it("counts needs-review rows and adoptable actions", () => { + expect(countNeedsReviewRows(data)).toBe(1); + expect(countAdoptableLocalSkillRows(data)).toBe(1); }); }); diff --git a/frontend/src/features/skills/model/selectors.ts b/frontend/src/features/skills/model/selectors.ts index 6c788c5..386ec73 100644 --- a/frontend/src/features/skills/model/selectors.ts +++ b/frontend/src/features/skills/model/selectors.ts @@ -1,10 +1,11 @@ -import type { HarnessColumn, SkillListRow, SkillsWorkspaceData } from "./types"; +import { skillStatusConcept } from "../../../lib/product-language"; +import type { HarnessCellState, HarnessColumn, SkillListRow, SkillsWorkspaceData } from "./types"; -export interface ManagedSkillsFilterState { +export interface SkillsInUseFilterState { search: string; } -export interface UnmanagedSkillsFilterState { +export interface SkillsNeedsReviewFilterState { search: string; } @@ -13,44 +14,40 @@ export interface AlignedHarnessCell { cell: SkillListRow["cells"][number] | null; } -export function hasActiveManagedSkillsFilters(filters: ManagedSkillsFilterState): boolean { +export function hasActiveSkillsInUseFilters(filters: SkillsInUseFilterState): boolean { return filters.search.trim() !== ""; } -export function hasActiveUnmanagedFilters(filters: UnmanagedSkillsFilterState): boolean { +export function hasActiveNeedsReviewFilters(filters: SkillsNeedsReviewFilterState): boolean { return filters.search.trim() !== ""; } -export function resetManagedSkillsFilters(): ManagedSkillsFilterState { +export function resetSkillsInUseFilters(): SkillsInUseFilterState { return { search: "", }; } -export function resetUnmanagedSkillsFilters(): UnmanagedSkillsFilterState { +export function resetSkillsNeedsReviewFilters(): SkillsNeedsReviewFilterState { return { search: "", }; } -export function filterManagedRows(data: SkillsWorkspaceData | null, filters: ManagedSkillsFilterState): SkillListRow[] { - return selectManagedRows(data).filter((row) => matchesSearch(row, filters.search, ["enabled", "disabled"])); +export function filterSkillsInUseRows(data: SkillsWorkspaceData | null, filters: SkillsInUseFilterState): SkillListRow[] { + return selectSkillsInUseRows(data).filter((row) => matchesSearch(row, filters.search, ["enabled", "disabled"])); } -export function filterBuiltInRows(data: SkillsWorkspaceData | null): SkillListRow[] { - return selectBuiltInRows(data); +export function filterNeedsReviewRows(data: SkillsWorkspaceData | null, filters: SkillsNeedsReviewFilterState): SkillListRow[] { + return selectNeedsReviewRows(data).filter((row) => matchesSearch(row, filters.search, ["found"])); } -export function filterUnmanagedRows(data: SkillsWorkspaceData | null, filters: UnmanagedSkillsFilterState): SkillListRow[] { - return selectUnmanagedRows(data).filter((row) => matchesSearch(row, filters.search, ["found"])); +export function countNeedsReviewRows(data: SkillsWorkspaceData | null): number { + return selectNeedsReviewRows(data).length; } -export function countUnmanagedRows(data: SkillsWorkspaceData | null): number { - return selectUnmanagedRows(data).length; -} - -export function countManageableUnmanagedRows(data: SkillsWorkspaceData | null): number { - return selectUnmanagedRows(data).filter((row) => row.canManage).length; +export function countAdoptableLocalSkillRows(data: SkillsWorkspaceData | null): number { + return selectNeedsReviewRows(data).filter((row) => row.actions.canManage).length; } export function alignHarnessCells(row: SkillListRow, columns: HarnessColumn[]): AlignedHarnessCell[] { @@ -60,28 +57,25 @@ export function alignHarnessCells(row: SkillListRow, columns: HarnessColumn[]): })); } -function selectManagedRows(data: SkillsWorkspaceData | null): SkillListRow[] { - if (!data) { - return []; - } - return data.rows.filter((row) => row.displayStatus === "Managed" || row.displayStatus === "Custom"); -} - -function selectBuiltInRows(data: SkillsWorkspaceData | null): SkillListRow[] { +function selectSkillsInUseRows(data: SkillsWorkspaceData | null): SkillListRow[] { if (!data) { return []; } - return data.rows.filter((row) => row.displayStatus === "Built-in"); + return data.rows.filter((row) => skillStatusConcept(row.displayStatus) === "inUse"); } -function selectUnmanagedRows(data: SkillsWorkspaceData | null): SkillListRow[] { +function selectNeedsReviewRows(data: SkillsWorkspaceData | null): SkillListRow[] { if (!data) { return []; } - return data.rows.filter((row) => row.displayStatus === "Unmanaged"); + return data.rows.filter((row) => skillStatusConcept(row.displayStatus) === "needsReview"); } -function matchesSearch(row: SkillListRow, search: string, searchableCellStates: string[]): boolean { +function matchesSearch( + row: SkillListRow, + search: string, + searchableCellStates: readonly HarnessCellState[], +): boolean { const normalizedSearch = search.trim().toLowerCase(); if (!normalizedSearch) { return true; @@ -94,8 +88,6 @@ function matchesSearch(row: SkillListRow, search: string, searchableCellStates: const searchHaystack = [ row.name, row.description, - row.displayStatus, - row.attentionMessage ?? "", ...harnessLabels, ].join(" ").toLowerCase(); diff --git a/frontend/src/features/skills/model/session.test.tsx b/frontend/src/features/skills/model/session.test.tsx index a0363bd..bad358b 100644 --- a/frontend/src/features/skills/model/session.test.tsx +++ b/frontend/src/features/skills/model/session.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { SkillsWorkspaceSessionProvider, useSkillsTabScroll } from "./session"; -function ScrollProbe({ tab }: { tab: "managed" | "unmanaged" }) { +function ScrollProbe({ tab }: { tab: "inUse" | "needsReview" }) { const elementRef = useRef(null); useSkillsTabScroll(tab, true, elementRef); @@ -16,7 +16,7 @@ function ScrollProbe({ tab }: { tab: "managed" | "unmanaged" }) { ); } -function SessionHarness({ tab }: { tab: "managed" | "unmanaged" }) { +function SessionHarness({ tab }: { tab: "inUse" | "needsReview" }) { return ( @@ -44,19 +44,19 @@ describe("useSkillsTabScroll", () => { }); it("stores and restores per-tab pane scroll positions without using window scroll", () => { - const { rerender } = render(); + const { rerender } = render(); - const managedScroll = screen.getByTestId("managed-scroll") as HTMLDivElement; - managedScroll.scrollTop = 180; + const inUseScroll = screen.getByTestId("inUse-scroll") as HTMLDivElement; + inUseScroll.scrollTop = 180; - rerender(); + rerender(); - const unmanagedScroll = screen.getByTestId("unmanaged-scroll") as HTMLDivElement; - unmanagedScroll.scrollTop = 48; + const needsReviewScroll = screen.getByTestId("needsReview-scroll") as HTMLDivElement; + needsReviewScroll.scrollTop = 48; - rerender(); + rerender(); - expect((screen.getByTestId("managed-scroll") as HTMLDivElement).scrollTop).toBe(180); + expect((screen.getByTestId("inUse-scroll") as HTMLDivElement).scrollTop).toBe(180); expect(window.scrollTo).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/features/skills/model/session.tsx b/frontend/src/features/skills/model/session.tsx index c750f61..95fa477 100644 --- a/frontend/src/features/skills/model/session.tsx +++ b/frontend/src/features/skills/model/session.tsx @@ -1,78 +1,78 @@ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { - resetUnmanagedSkillsFilters, - resetManagedSkillsFilters, - type UnmanagedSkillsFilterState, - type ManagedSkillsFilterState, + resetSkillsNeedsReviewFilters, + resetSkillsInUseFilters, + type SkillsNeedsReviewFilterState, + type SkillsInUseFilterState, } from "./selectors"; -type SkillsWorkspaceTab = "managed" | "unmanaged"; +type SkillsWorkspaceTab = "inUse" | "needsReview"; interface SkillsWorkspaceSessionContextValue { - managedFilters: ManagedSkillsFilterState; - unmanagedFilters: UnmanagedSkillsFilterState; - managedScrollTop: number | null; - unmanagedScrollTop: number | null; - updateManagedFilters: (partial: Partial) => void; - updateUnmanagedFilters: (partial: Partial) => void; - resetManagedFilters: () => void; - resetUnmanagedFilters: () => void; + inUseFilters: SkillsInUseFilterState; + needsReviewFilters: SkillsNeedsReviewFilterState; + inUseScrollTop: number | null; + needsReviewScrollTop: number | null; + updateInUseFilters: (partial: Partial) => void; + updateNeedsReviewFilters: (partial: Partial) => void; + resetInUseFilters: () => void; + resetNeedsReviewFilters: () => void; setScrollPosition: (tab: SkillsWorkspaceTab, scrollTop: number) => void; } const SkillsWorkspaceSessionContext = createContext(null); export function SkillsWorkspaceSessionProvider({ children }: { children: ReactNode }) { - const [managedFilters, setManagedFilters] = useState(() => resetManagedSkillsFilters()); - const [unmanagedFilters, setUnmanagedFilters] = useState(() => resetUnmanagedSkillsFilters()); - const [managedScrollTop, setManagedScrollTop] = useState(null); - const [unmanagedScrollTop, setUnmanagedScrollTop] = useState(null); + const [inUseFilters, setInUseFilters] = useState(() => resetSkillsInUseFilters()); + const [needsReviewFilters, setNeedsReviewFilters] = useState(() => resetSkillsNeedsReviewFilters()); + const [inUseScrollTop, setInUseScrollTop] = useState(null); + const [needsReviewScrollTop, setNeedsReviewScrollTop] = useState(null); - const updateManagedFilters = useCallback((partial: Partial) => { - setManagedFilters((current) => ({ ...current, ...partial })); + const updateInUseFilters = useCallback((partial: Partial) => { + setInUseFilters((current) => ({ ...current, ...partial })); }, []); - const updateUnmanagedFilters = useCallback((partial: Partial) => { - setUnmanagedFilters((current) => ({ ...current, ...partial })); + const updateNeedsReviewFilters = useCallback((partial: Partial) => { + setNeedsReviewFilters((current) => ({ ...current, ...partial })); }, []); - const resetManaged = useCallback(() => { - setManagedFilters(resetManagedSkillsFilters()); + const resetInUse = useCallback(() => { + setInUseFilters(resetSkillsInUseFilters()); }, []); - const resetUnmanaged = useCallback(() => { - setUnmanagedFilters(resetUnmanagedSkillsFilters()); + const resetNeedsReview = useCallback(() => { + setNeedsReviewFilters(resetSkillsNeedsReviewFilters()); }, []); const setScrollPosition = useCallback((tab: SkillsWorkspaceTab, scrollTop: number) => { - if (tab === "managed") { - setManagedScrollTop(scrollTop); + if (tab === "inUse") { + setInUseScrollTop(scrollTop); return; } - setUnmanagedScrollTop(scrollTop); + setNeedsReviewScrollTop(scrollTop); }, []); const value = useMemo(() => ({ - managedFilters, - unmanagedFilters, - managedScrollTop, - unmanagedScrollTop, - updateManagedFilters, - updateUnmanagedFilters, - resetManagedFilters: resetManaged, - resetUnmanagedFilters: resetUnmanaged, + inUseFilters, + needsReviewFilters, + inUseScrollTop, + needsReviewScrollTop, + updateInUseFilters, + updateNeedsReviewFilters, + resetInUseFilters: resetInUse, + resetNeedsReviewFilters: resetNeedsReview, setScrollPosition, }), [ - unmanagedFilters, - unmanagedScrollTop, - managedFilters, - managedScrollTop, - resetUnmanaged, - resetManaged, + needsReviewFilters, + needsReviewScrollTop, + inUseFilters, + inUseScrollTop, + resetNeedsReview, + resetInUse, setScrollPosition, - updateUnmanagedFilters, - updateManagedFilters, + updateNeedsReviewFilters, + updateInUseFilters, ]); return ( @@ -82,21 +82,21 @@ export function SkillsWorkspaceSessionProvider({ children }: { children: ReactNo ); } -export function useManagedSkillsSession() { +export function useSkillsInUseSession() { const context = useSkillsWorkspaceSession(); return { - filters: context.managedFilters, - updateFilters: context.updateManagedFilters, - resetFilters: context.resetManagedFilters, + filters: context.inUseFilters, + updateFilters: context.updateInUseFilters, + resetFilters: context.resetInUseFilters, }; } -export function useUnmanagedSkillsSession() { +export function useSkillsNeedsReviewSession() { const context = useSkillsWorkspaceSession(); return { - filters: context.unmanagedFilters, - updateFilters: context.updateUnmanagedFilters, - resetFilters: context.resetUnmanagedFilters, + filters: context.needsReviewFilters, + updateFilters: context.updateNeedsReviewFilters, + resetFilters: context.resetNeedsReviewFilters, }; } @@ -107,7 +107,7 @@ export function useSkillsTabScroll( ) { const context = useSkillsWorkspaceSession(); const restoredRef = useRef(false); - const targetScrollTop = tab === "managed" ? context.managedScrollTop : context.unmanagedScrollTop; + const targetScrollTop = tab === "inUse" ? context.inUseScrollTop : context.needsReviewScrollTop; useLayoutEffect(() => { if (!ready || restoredRef.current || targetScrollTop === null) { diff --git a/frontend/src/features/skills/model/sortRows.test.ts b/frontend/src/features/skills/model/sortRows.test.ts new file mode 100644 index 0000000..1b68d93 --- /dev/null +++ b/frontend/src/features/skills/model/sortRows.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { sortRows, type SortState } from "./sortRows"; +import type { HarnessCell, SkillListRow } from "./types"; + +function makeRow(name: string, cells: HarnessCell[]): SkillListRow { + return { + skillRef: `shared:${name.toLowerCase().replace(/\s+/g, "-")}`, + name, + description: "", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: false }, + cells, + } as unknown as SkillListRow; +} + +const enabled = (harness: string): HarnessCell => ({ harness, label: harness, state: "enabled", interactive: true }); +const disabled = (harness: string): HarnessCell => ({ harness, label: harness, state: "disabled", interactive: true }); +const empty = (harness: string): HarnessCell => ({ harness, label: harness, state: "empty", interactive: false }); + +describe("sortRows", () => { + const rows: SkillListRow[] = [ + makeRow("charlie", [enabled("codex"), disabled("cursor"), disabled("claude")]), + makeRow("alpha", [disabled("codex"), disabled("cursor"), disabled("claude")]), + makeRow("Bravo", [enabled("codex"), enabled("cursor"), empty("claude")]), + ]; + + it("sorts by name ascending (case-insensitive)", () => { + const sorted = sortRows(rows, { key: "name", direction: "asc" }); + expect(sorted.map((r) => r.name)).toEqual(["alpha", "Bravo", "charlie"]); + }); + + it("sorts by name descending", () => { + const sorted = sortRows(rows, { key: "name", direction: "desc" }); + expect(sorted.map((r) => r.name)).toEqual(["charlie", "Bravo", "alpha"]); + }); + + it("sorts by coverage ascending with name as secondary", () => { + const sorted = sortRows(rows, { key: "coverage", direction: "asc" }); + // alpha=0, charlie=1, Bravo=2 + expect(sorted.map((r) => r.name)).toEqual(["alpha", "charlie", "Bravo"]); + }); + + it("sorts by coverage descending", () => { + const sorted = sortRows(rows, { key: "coverage", direction: "desc" }); + expect(sorted.map((r) => r.name)).toEqual(["Bravo", "charlie", "alpha"]); + }); + + it("sorts by a harness column: enabled first, then disabled, then empty", () => { + const sorted = sortRows(rows, { key: { harness: "claude" }, direction: "asc" }); + // alpha + charlie are disabled on claude, while Bravo is not present there. + // Priority: disabled beats empty, with name as the tie-breaker. + expect(sorted.map((r) => r.name)).toEqual(["alpha", "charlie", "Bravo"]); + }); + + it("does not mutate the original array", () => { + const original = rows.slice(); + sortRows(rows, { key: "coverage", direction: "desc" }); + expect(rows).toEqual(original); + }); + + it("returns a new array reference", () => { + const result = sortRows(rows, { key: "name", direction: "asc" } satisfies SortState); + expect(result).not.toBe(rows); + }); +}); diff --git a/frontend/src/features/skills/model/sortRows.ts b/frontend/src/features/skills/model/sortRows.ts new file mode 100644 index 0000000..f089c98 --- /dev/null +++ b/frontend/src/features/skills/model/sortRows.ts @@ -0,0 +1,74 @@ +import type { HarnessCell, HarnessCellState, SkillListRow } from "./types"; + +export type SortDirection = "asc" | "desc"; + +export type SortKey = "name" | "coverage" | { harness: string }; + +export interface SortState { + key: SortKey; + direction: SortDirection; +} + +const HARNESS_STATE_PRIORITY: Record = { + enabled: 0, + disabled: 1, + found: 2, + empty: 3, +}; + +function countEnabled(row: SkillListRow): number { + let count = 0; + for (const cell of row.cells) { + if (cell.state === "enabled") count += 1; + } + return count; +} + +function findCell(row: SkillListRow, harness: string): HarnessCell | undefined { + return row.cells.find((cell) => cell.harness === harness); +} + +function compareByName(a: SkillListRow, b: SkillListRow): number { + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); +} + +export function sortRows(rows: SkillListRow[], sort: SortState): SkillListRow[] { + const directionMultiplier = sort.direction === "asc" ? 1 : -1; + const next = rows.slice(); + + if (sort.key === "name") { + next.sort((a, b) => compareByName(a, b) * directionMultiplier); + return next; + } + + if (sort.key === "coverage") { + next.sort((a, b) => { + const diff = countEnabled(a) - countEnabled(b); + if (diff !== 0) return diff * directionMultiplier; + return compareByName(a, b); + }); + return next; + } + + const harness = sort.key.harness; + next.sort((a, b) => { + const aCell = findCell(a, harness); + const bCell = findCell(b, harness); + const aPriority = aCell ? HARNESS_STATE_PRIORITY[aCell.state] : HARNESS_STATE_PRIORITY.empty; + const bPriority = bCell ? HARNESS_STATE_PRIORITY[bCell.state] : HARNESS_STATE_PRIORITY.empty; + const diff = aPriority - bPriority; + if (diff !== 0) return diff * directionMultiplier; + return compareByName(a, b); + }); + return next; +} + +export function isHarnessSortKey(key: SortKey): key is { harness: string } { + return typeof key === "object" && key !== null && "harness" in key; +} + +export function sortKeysEqual(a: SortKey, b: SortKey): boolean { + if (typeof a === "string" && typeof b === "string") return a === b; + if (isHarnessSortKey(a) && isHarnessSortKey(b)) return a.harness === b.harness; + return false; +} diff --git a/frontend/src/features/skills/model/status-mappings.ts b/frontend/src/features/skills/model/status-mappings.ts deleted file mode 100644 index 5dbb686..0000000 --- a/frontend/src/features/skills/model/status-mappings.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StatusBadgeTone } from "../../../components/ui/StatusBadge"; -import type { SkillStatus } from "./types"; - -export function skillStatusTone(status: SkillStatus): StatusBadgeTone { - switch (status) { - case "Managed": - return "success"; - case "Custom": - return "warning"; - case "Unmanaged": - return "neutral"; - case "Built-in": - return "muted"; - default: - return "neutral"; - } -} diff --git a/frontend/src/features/skills/model/types.ts b/frontend/src/features/skills/model/types.ts index 51a5b93..3c8b36f 100644 --- a/frontend/src/features/skills/model/types.ts +++ b/frontend/src/features/skills/model/types.ts @@ -3,13 +3,13 @@ import type { HarnessCell as HarnessCellDto, HarnessCellState as HarnessCellStateDto, HarnessColumn as HarnessColumnDto, - SkillDetailDto, + SkillRowActionsDto, SkillLocation as SkillLocationDto, SkillSourceLinks as SkillSourceLinksDto, SkillStatus as SkillStatusDto, SkillsSummary as SkillsSummaryDto, SkillDetailActionsDto, - SkillStopManagingStatus as SkillStopManagingStatusDto, + SkillRemoveStatus as SkillRemoveStatusDto, SkillSourceStatusDto, SkillUpdateStatus as SkillUpdateStatusDto, } from "../api/types"; @@ -17,10 +17,11 @@ import type { export type SkillStatus = SkillStatusDto; export type HarnessCellState = HarnessCellStateDto; export type SkillUpdateStatus = SkillUpdateStatusDto; -export type SkillStopManagingStatus = SkillStopManagingStatusDto; +export type SkillRemoveStatus = SkillRemoveStatusDto; export type SkillsSummary = SkillsSummaryDto; export type HarnessColumn = HarnessColumnDto; export type HarnessCell = HarnessCellDto; +export type SkillRowActions = SkillRowActionsDto; export type SkillLocation = SkillLocationDto; export type SkillSourceLinks = SkillSourceLinksDto; export type BulkManageResult = BulkManageResultDto; @@ -30,8 +31,7 @@ export interface SkillListRow { name: string; description: string; displayStatus: SkillStatus; - attentionMessage: string | null; - canManage: boolean; + actions: SkillRowActions; cells: HarnessCell[]; } diff --git a/frontend/src/features/skills/model/use-skill-detail-controller.ts b/frontend/src/features/skills/model/use-skill-detail-controller.ts index cf593e8..5243edd 100644 --- a/frontend/src/features/skills/model/use-skill-detail-controller.ts +++ b/frontend/src/features/skills/model/use-skill-detail-controller.ts @@ -7,7 +7,7 @@ interface SkillDetailMutationHandlers { onManageSkill: (skillRef: string) => Promise; onToggleSkill: (skillRef: string, harness: string, currentState: HarnessCellState) => Promise; onUpdateSkill: (skillRef: string) => Promise; - onUnmanageSkill: (skillRef: string) => Promise; + onRemoveSkill: (skillRef: string) => Promise; onDeleteSkill: (skillRef: string) => Promise; } @@ -18,7 +18,7 @@ export function useSkillDetailController( const detailQuery = useSkillDetailQuery(skillRef); const sourceStatusQuery = useSkillSourceStatusQuery(skillRef); const [actionErrorMessage, setActionErrorMessage] = useState(""); - const [isStopManagingDialogOpen, setStopManagingDialogOpen] = useState(false); + const [isRemoveDialogOpen, setRemoveDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const isMountedRef = useRef(true); @@ -44,7 +44,7 @@ export function useSkillDetailController( useEffect(() => { setActionErrorMessage(""); - setStopManagingDialogOpen(false); + setRemoveDialogOpen(false); setDeleteDialogOpen(false); }, [skillRef]); @@ -73,13 +73,13 @@ export function useSkillDetailController( } } - async function handleConfirmStopManaging(): Promise { + async function handleConfirmRemove(): Promise { if (!detail) { return; } - const didSucceed = await runAction(() => handlers.onUnmanageSkill(detail.skillRef)); + const didSucceed = await runAction(() => handlers.onRemoveSkill(detail.skillRef)); if (didSucceed && isMountedRef.current) { - setStopManagingDialogOpen(false); + setRemoveDialogOpen(false); } } @@ -88,24 +88,24 @@ export function useSkillDetailController( isInitialLoading, queryErrorMessage, actionErrorMessage, - isStopManagingDialogOpen, + isRemoveDialogOpen, isDeleteDialogOpen, dismissActionError: () => setActionErrorMessage(""), onManage: () => detail && void runAction(() => handlers.onManageSkill(detail.skillRef)), onToggleHarness: (harness: string, currentState: HarnessCellState) => detail && void runAction(() => handlers.onToggleSkill(detail.skillRef, harness, currentState)), onUpdate: () => detail && void runAction(() => handlers.onUpdateSkill(detail.skillRef)), - requestStopManaging: () => { + requestRemove: () => { setActionErrorMessage(""); - setStopManagingDialogOpen(true); + setRemoveDialogOpen(true); }, requestDelete: () => { setActionErrorMessage(""); setDeleteDialogOpen(true); }, - setStopManagingDialogOpen, + setRemoveDialogOpen, setDeleteDialogOpen, handleConfirmDelete, - handleConfirmStopManaging, + handleConfirmRemove, }; } diff --git a/frontend/src/features/skills/model/use-skill-workspace-selection.ts b/frontend/src/features/skills/model/use-skill-workspace-selection.ts new file mode 100644 index 0000000..9d2dec5 --- /dev/null +++ b/frontend/src/features/skills/model/use-skill-workspace-selection.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; + +import { skillStatusConcept } from "../../../lib/product-language"; +import type { SkillListRow, SkillsWorkspaceData } from "./types"; + +export type SkillsWorkspaceTab = "inUse" | "needsReview"; + +export function useSkillWorkspaceSelection(data: SkillsWorkspaceData | null) { + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const isMobileDetail = useCompactDetailLayout(); + const activeTab: SkillsWorkspaceTab = location.pathname.endsWith("/review") || location.pathname.endsWith("/unmanaged") + ? "needsReview" + : "inUse"; + const selectedSkillRef = searchParams.get("skill"); + + const updateSelectedSkillRef = useCallback((skillRef: string | null, replace = false) => { + const nextParams = new URLSearchParams(searchParams); + if (skillRef) { + nextParams.set("skill", skillRef); + } else { + nextParams.delete("skill"); + } + setSearchParams(nextParams, { replace }); + }, [searchParams, setSearchParams]); + + useEffect(() => { + if (!selectedSkillRef || !data) { + return; + } + const stillVisibleInTab = data.rows.some((row) => + row.skillRef === selectedSkillRef && rowVisibleOnTab(row, activeTab), + ); + if (!stillVisibleInTab) { + updateSelectedSkillRef(null, true); + } + }, [activeTab, data, selectedSkillRef, updateSelectedSkillRef]); + + const handleOpenSkill = useCallback((skillRef: string) => { + updateSelectedSkillRef(selectedSkillRef === skillRef ? null : skillRef); + }, [selectedSkillRef, updateSelectedSkillRef]); + + return { + activeTab, + selectedSkillRef, + isDesktopDetailOpen: Boolean(selectedSkillRef) && !isMobileDetail, + closeSelectedSkill: () => updateSelectedSkillRef(null), + handleOpenSkill, + updateSelectedSkillRef, + }; +} + +function rowVisibleOnTab(row: SkillListRow, tab: SkillsWorkspaceTab): boolean { + if (tab === "needsReview") { + return skillStatusConcept(row.displayStatus) === "needsReview"; + } + return skillStatusConcept(row.displayStatus) === "inUse"; +} + +function useCompactDetailLayout(breakpointPx = 900): boolean { + const [matches, setMatches] = useState(() => getCompactDetailLayoutMatch(breakpointPx)); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + setMatches(getCompactDetailLayoutMatch(breakpointPx)); + return undefined; + } + + const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx}px)`); + const update = () => setMatches(mediaQuery.matches); + update(); + + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", update); + return () => mediaQuery.removeEventListener("change", update); + } + + mediaQuery.addListener(update); + return () => mediaQuery.removeListener(update); + }, [breakpointPx]); + + return matches; +} + +function getCompactDetailLayoutMatch(breakpointPx: number): boolean { + if (typeof window === "undefined") { + return false; + } + if (typeof window.matchMedia === "function") { + return window.matchMedia(`(max-width: ${breakpointPx}px)`).matches; + } + return window.innerWidth <= breakpointPx; +} diff --git a/frontend/src/features/skills/model/use-skills-workspace-controller.test.tsx b/frontend/src/features/skills/model/use-skills-workspace-controller.test.tsx new file mode 100644 index 0000000..348e9c3 --- /dev/null +++ b/frontend/src/features/skills/model/use-skills-workspace-controller.test.tsx @@ -0,0 +1,149 @@ +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SkillsWorkspaceData } from "./types"; + +const hoisted = vi.hoisted(() => { + const setHarnessesCalls: Array<{ skillRef: string; target: "enabled" | "disabled" }> = []; + const failFor = new Set(); + let nextResponse: { succeeded: string[]; failed: Array<{ harness: string; error: string }> } | null = null; + return { + setHarnessesCalls, + failFor, + setNextResponse(value: typeof nextResponse) { + nextResponse = value; + }, + takeNextResponse() { + const value = nextResponse; + nextResponse = null; + return value; + }, + }; +}); + +const testData: SkillsWorkspaceData = { + summary: { managed: 1, unmanaged: 0 }, + harnessColumns: [ + { harness: "codex", label: "Codex", installed: true }, + { harness: "cursor", label: "Cursor", installed: true }, + { harness: "claude", label: "Claude", installed: true }, + ], + rows: [ + { + skillRef: "shared:test-skill", + name: "Test Skill", + description: "", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: false }, + cells: [ + { harness: "codex", label: "Codex", state: "enabled", interactive: true }, + { harness: "cursor", label: "Cursor", state: "disabled", interactive: true }, + { harness: "claude", label: "Claude", state: "empty", interactive: false }, + ], + }, + ], +} as unknown as SkillsWorkspaceData; + +vi.mock("../api/queries", () => ({ + useSkillsListQuery: () => ({ + data: testData, + isPending: false, + error: null, + }), + useToggleSkillMutation: () => ({ + mutateAsync: vi.fn(), + }), + useSetSkillHarnessesMutation: () => ({ + mutateAsync: async (vars: { skillRef: string; target: "enabled" | "disabled" }) => { + hoisted.setHarnessesCalls.push(vars); + const override = hoisted.takeNextResponse(); + if (override) { + return { ok: override.failed.length === 0, ...override }; + } + // Default behavior: mirror the current row's cells to derive who would flip. + const row = testData.rows.find((r) => r.skillRef === vars.skillRef)!; + const succeeded: string[] = []; + const failed: Array<{ harness: string; error: string }> = []; + for (const cell of row.cells) { + if (!cell.interactive || cell.state === vars.target) continue; + if (hoisted.failFor.has(cell.harness)) { + failed.push({ harness: cell.harness, error: `${cell.harness} toggle failed` }); + } else { + succeeded.push(cell.harness); + } + } + return { ok: failed.length === 0, succeeded, failed }; + }, + }), + useManageSkillMutation: () => ({ mutateAsync: vi.fn() }), + useManageAllSkillsMutation: () => ({ mutateAsync: vi.fn() }), + useUpdateSkillMutation: () => ({ mutateAsync: vi.fn() }), + useUnmanageSkillMutation: () => ({ mutateAsync: vi.fn() }), + useDeleteSkillMutation: () => ({ mutateAsync: vi.fn() }), +})); + +import { useSkillsWorkspaceController } from "./use-skills-workspace-controller"; + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient(); + return ( + + {children} + + ); +} + +describe("useSkillsWorkspaceController > onSetSkillAllHarnesses", () => { + beforeEach(() => { + hoisted.setHarnessesCalls.length = 0; + hoisted.failFor.clear(); + hoisted.setNextResponse(null); + }); + + it("dispatches a single bulk request with the target and returns the server's succeeded list", async () => { + const { result } = renderHook(() => useSkillsWorkspaceController(), { wrapper }); + + let outcome: Awaited> | undefined; + await act(async () => { + outcome = await result.current.context.onSetSkillAllHarnesses("shared:test-skill", "enabled"); + }); + + expect(hoisted.setHarnessesCalls).toEqual([ + { skillRef: "shared:test-skill", target: "enabled" }, + ]); + expect(outcome?.succeeded).toEqual(["cursor"]); + expect(outcome?.failed).toEqual([]); + expect(result.current.actionErrorMessage).toBe(""); + }); + + it("surfaces partial failures from the server and sets an error message", async () => { + hoisted.failFor.add("cursor"); + const { result } = renderHook(() => useSkillsWorkspaceController(), { wrapper }); + + let outcome: Awaited> | undefined; + await act(async () => { + outcome = await result.current.context.onSetSkillAllHarnesses("shared:test-skill", "enabled"); + }); + + expect(outcome?.succeeded).toEqual([]); + expect(outcome?.failed).toHaveLength(1); + expect(outcome?.failed[0]?.harness).toBe("cursor"); + expect(result.current.actionErrorMessage).toContain("cursor"); + }); + + it("issues the bulk call for the opposite direction too", async () => { + const { result } = renderHook(() => useSkillsWorkspaceController(), { wrapper }); + + let outcome: Awaited> | undefined; + await act(async () => { + outcome = await result.current.context.onSetSkillAllHarnesses("shared:test-skill", "disabled"); + }); + + expect(hoisted.setHarnessesCalls).toEqual([ + { skillRef: "shared:test-skill", target: "disabled" }, + ]); + expect(outcome?.succeeded).toEqual(["codex"]); + }); +}); diff --git a/frontend/src/features/skills/model/use-skills-workspace-controller.ts b/frontend/src/features/skills/model/use-skills-workspace-controller.ts index 7523e4d..12d251e 100644 --- a/frontend/src/features/skills/model/use-skills-workspace-controller.ts +++ b/frontend/src/features/skills/model/use-skills-workspace-controller.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocation, useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; +import { usePendingRegistry } from "../../../lib/async/pending-registry"; import { cellActionKey, type BulkSkillsAction, @@ -8,55 +8,59 @@ import { type StructuralSkillAction, } from "./pending"; import type { HarnessCell, HarnessCellState, SkillListRow } from "./types"; -import type { SkillsWorkspaceContextValue } from "./workspace-context"; +import type { + MultiSelectAction, + SetAllHarnessesFailure, + SetAllHarnessesResult, + SetAllHarnessesTarget, + SkillsWorkspaceContextValue, +} from "./workspace-context"; import { useDeleteSkillMutation, useManageAllSkillsMutation, useManageSkillMutation, + useSetSkillHarnessesMutation, useSkillsListQuery, useToggleSkillMutation, useUnmanageSkillMutation, useUpdateSkillMutation, } from "../api/queries"; -import type { SkillsPaneDirection, SkillsPaneView } from "../components/pane/SkillsPaneTransition"; +import { useSkillWorkspaceSelection, type SkillsWorkspaceTab } from "./use-skill-workspace-selection"; export interface SkillsWorkspaceController { context: SkillsWorkspaceContextValue; - activeTab: SkillsPaneView; + activeTab: SkillsWorkspaceTab; selectedSkillRef: string | null; - isMobileDetail: boolean; isDesktopDetailOpen: boolean; - shouldAnimatePaneTransition: boolean; - transitionDirection: SkillsPaneDirection; actionErrorMessage: string; queryErrorMessage: string; closeSelectedSkill: () => void; handleManageSkill: (skillRef: string) => Promise; handleToggleSkill: (skillRef: string, harness: string, currentState: HarnessCellState) => Promise; handleUpdateSkill: (skillRef: string) => Promise; - handleUnmanageSkill: (skillRef: string) => Promise; + handleRemoveSkill: (skillRef: string) => Promise; handleDeleteSkill: (skillRef: string) => Promise; dismissActionError: () => void; } export function useSkillsWorkspaceController(): SkillsWorkspaceController { - const location = useLocation(); - const [searchParams, setSearchParams] = useSearchParams(); const listQuery = useSkillsListQuery(); const toggleMutation = useToggleSkillMutation(); + const setHarnessesMutation = useSetSkillHarnessesMutation(); const manageMutation = useManageSkillMutation(); const manageAllMutation = useManageAllSkillsMutation(); const updateMutation = useUpdateSkillMutation(); - const unmanageMutation = useUnmanageSkillMutation(); + const removeMutation = useUnmanageSkillMutation(); const deleteMutation = useDeleteSkillMutation(); - const isMobileDetail = useCompactDetailLayout(); const [actionErrorMessage, setActionErrorMessage] = useState(""); - const [pendingToggleKeys, setPendingToggleKeys] = useState>(() => new Set()); + const toggleRegistry = usePendingRegistry(); const [pendingStructuralActions, setPendingStructuralActions] = useState>( () => new Map(), ); const [pendingBulkAction, setPendingBulkAction] = useState(null); + const [multiSelectedRefs, setMultiSelectedRefs] = useState>(() => new Set()); + const [multiSelectPending, setMultiSelectPending] = useState(null); const data = listQuery.data ?? null; const hasData = data !== null; @@ -69,57 +73,14 @@ export function useSkillsWorkspaceController(): SkillsWorkspaceController { : queryErrorMessage ? "error" : "loading"; - const activeTab = location.pathname.endsWith("/unmanaged") ? "unmanaged" : "managed"; - const selectedSkillRef = searchParams.get("skill"); - const { direction: transitionDirection, shouldAnimate: shouldAnimatePaneTransition } = usePaneTransition(activeTab); - - const updateSelectedSkillRef = useCallback((skillRef: string | null, replace = false) => { - const nextParams = new URLSearchParams(searchParams); - if (skillRef) { - nextParams.set("skill", skillRef); - } else { - nextParams.delete("skill"); - } - setSearchParams(nextParams, { replace }); - }, [searchParams, setSearchParams]); - - useEffect(() => { - if (!selectedSkillRef || !data) { - return; - } - const stillVisibleInTab = data.rows.some((row) => - row.skillRef === selectedSkillRef && rowVisibleOnTab(row, activeTab), - ); - if (!stillVisibleInTab) { - updateSelectedSkillRef(null, true); - } - }, [activeTab, data, selectedSkillRef, updateSelectedSkillRef]); - - const handleOpenSkill = useCallback((skillRef: string) => { - updateSelectedSkillRef(selectedSkillRef === skillRef ? null : skillRef); - }, [selectedSkillRef, updateSelectedSkillRef]); - - function addPendingToggle(key: CellActionKey): void { - setPendingToggleKeys((current) => { - if (current.has(key)) { - return current; - } - const next = new Set(current); - next.add(key); - return next; - }); - } - - function removePendingToggle(key: CellActionKey): void { - setPendingToggleKeys((current) => { - if (!current.has(key)) { - return current; - } - const next = new Set(current); - next.delete(key); - return next; - }); - } + const { + activeTab, + selectedSkillRef, + isDesktopDetailOpen, + closeSelectedSkill, + handleOpenSkill, + updateSelectedSkillRef, + } = useSkillWorkspaceSelection(data); function setPendingStructuralAction(skillRef: string, action: StructuralSkillAction): void { setPendingStructuralActions((current) => { @@ -151,19 +112,18 @@ export function useSkillsWorkspaceController(): SkillsWorkspaceController { ): Promise { const nextState: HarnessCellState = currentState === "enabled" ? "disabled" : "enabled"; const key = cellActionKey(skillRef, harness); - addPendingToggle(key); if (reportError) { setActionErrorMessage(""); } try { - await toggleMutation.mutateAsync({ skillRef, harness, nextState }); + await toggleRegistry.run(key, () => + toggleMutation.mutateAsync({ skillRef, harness, nextState }), + ); } catch (error) { if (reportError) { setActionErrorMessage(error instanceof Error ? error.message : "Unable to toggle the skill."); } throw error; - } finally { - removePendingToggle(key); } } @@ -254,129 +214,273 @@ export function useSkillsWorkspaceController(): SkillsWorkspaceController { ); } - async function handleUnmanageSkill(skillRef: string): Promise { + async function handleDeleteSkillFromList(skillRef: string): Promise { + await runStructuralAction( + skillRef, + "delete", + () => deleteMutation.mutateAsync({ skillRef }), + true, + () => updateSelectedSkillRef(null, true), + ); + } + + async function handleRemoveSkill(skillRef: string): Promise { await runStructuralAction( skillRef, "unmanage", - () => unmanageMutation.mutateAsync({ skillRef }), + () => removeMutation.mutateAsync({ skillRef }), false, () => updateSelectedSkillRef(null, true), ); } + async function handleRemoveSkillFromList(skillRef: string): Promise { + await runStructuralAction( + skillRef, + "unmanage", + () => removeMutation.mutateAsync({ skillRef }), + true, + () => updateSelectedSkillRef(null, true), + ); + } + function handleToggleCell(row: SkillListRow, cell: HarnessCell): void { void handleToggleSkillFromList(row.skillRef, cell.harness, cell.state); } + const toggleMultiSelect = useCallback((skillRef: string) => { + setMultiSelectedRefs((current) => { + const next = new Set(current); + if (next.has(skillRef)) { + next.delete(skillRef); + } else { + next.add(skillRef); + } + return next; + }); + }, []); + + const clearMultiSelect = useCallback(() => { + setMultiSelectedRefs((current) => (current.size === 0 ? current : new Set())); + }, []); + + // Drop selection when a previously selected row leaves the dataset. + useEffect(() => { + if (!data || multiSelectedRefs.size === 0) { + return; + } + const available = new Set(data.rows.map((row) => row.skillRef)); + let changed = false; + const next = new Set(); + for (const ref of multiSelectedRefs) { + if (available.has(ref)) { + next.add(ref); + } else { + changed = true; + } + } + if (changed) { + setMultiSelectedRefs(next); + } + }, [data, multiSelectedRefs]); + + async function runMultiSelect( + action: MultiSelectAction, + task: (rows: SkillListRow[]) => Promise, + ): Promise { + if (multiSelectedRefs.size === 0 || !data) { + return; + } + const rows = data.rows.filter((row) => multiSelectedRefs.has(row.skillRef)); + if (rows.length === 0) { + return; + } + setMultiSelectPending(action); + setActionErrorMessage(""); + try { + await task(rows); + setMultiSelectedRefs(new Set()); + } catch (error) { + setActionErrorMessage(error instanceof Error ? error.message : "Unable to complete the bulk action."); + throw error; + } finally { + setMultiSelectPending(null); + } + } + + async function handleMultiSelectEnableAll(): Promise { + await runMultiSelect("enable-all", async (rows) => { + const tasks: Promise[] = []; + for (const row of rows) { + for (const cell of row.cells) { + if (cell.state === "disabled") { + tasks.push(toggleMutation.mutateAsync({ skillRef: row.skillRef, harness: cell.harness, nextState: "enabled" })); + } + } + } + await Promise.all(tasks); + }); + } + + async function handleMultiSelectDisableAll(): Promise { + await runMultiSelect("disable-all", async (rows) => { + const tasks: Promise[] = []; + for (const row of rows) { + for (const cell of row.cells) { + if (cell.state === "enabled") { + tasks.push(toggleMutation.mutateAsync({ skillRef: row.skillRef, harness: cell.harness, nextState: "disabled" })); + } + } + } + await Promise.all(tasks); + }); + } + + async function handleMultiSelectDelete(): Promise { + await runMultiSelect("delete", async (rows) => { + await Promise.all(rows.map((row) => deleteMutation.mutateAsync({ skillRef: row.skillRef }))); + }); + } + + async function setSkillAllHarnesses( + row: SkillListRow, + target: SetAllHarnessesTarget, + ): Promise { + const targets = row.cells.filter((cell) => cell.interactive && cell.state !== target); + if (targets.length === 0) { + return { succeeded: [], failed: [] }; + } + // Mark every cell this drop would flip as pending so per-cell affordances + // (dim overlays on the matrix + board) match reality while the single bulk + // request is in flight. + const pendingKeys = targets.map((cell) => cellActionKey(row.skillRef, cell.harness)); + pendingKeys.forEach((key) => toggleRegistry.begin(key)); + try { + const outcome = await setHarnessesMutation.mutateAsync({ skillRef: row.skillRef, target }); + const failed: SetAllHarnessesFailure[] = outcome.failed.map((failure) => ({ + harness: failure.harness, + error: new Error(failure.error), + })); + return { succeeded: outcome.succeeded, failed }; + } catch (error) { + const reason = error instanceof Error ? error : new Error(String(error ?? "Unknown error")); + return { + succeeded: [], + failed: targets.map((cell) => ({ harness: cell.harness, error: reason })), + }; + } finally { + pendingKeys.forEach((key) => toggleRegistry.finish(key)); + } + } + + async function handleSetSkillAllHarnesses( + skillRef: string, + target: SetAllHarnessesTarget, + ): Promise { + setActionErrorMessage(""); + const row = data?.rows.find((candidate) => candidate.skillRef === skillRef); + if (!row) { + return { succeeded: [], failed: [] }; + } + const result = await setSkillAllHarnesses(row, target); + if (result.failed.length > 0) { + setActionErrorMessage(formatSingleSkillFailureMessage(row.name, target, result.failed)); + } + return result; + } + + async function handleSetManySkillsAllHarnesses( + skillRefs: string[], + target: SetAllHarnessesTarget, + ): Promise> { + setActionErrorMessage(""); + const refSet = new Set(skillRefs); + const rows = data?.rows.filter((row) => refSet.has(row.skillRef)) ?? []; + if (rows.length === 0) { + return new Map(); + } + const entries = await Promise.all( + rows.map(async (row): Promise<[string, SetAllHarnessesResult]> => { + const result = await setSkillAllHarnesses(row, target); + return [row.skillRef, result]; + }), + ); + const byRef = new Map(entries); + const failingRows = rows + .map((row) => ({ row, result: byRef.get(row.skillRef) })) + .filter((entry): entry is { row: SkillListRow; result: SetAllHarnessesResult } => + Boolean(entry.result && entry.result.failed.length > 0), + ); + if (failingRows.length > 0) { + setActionErrorMessage(formatMultiSkillFailureMessage(failingRows, target)); + } + return byRef; + } + const context: SkillsWorkspaceContextValue = { data, hasData, isInitialLoading, status, errorMessage: actionErrorMessage || (hasData ? queryErrorMessage : ""), - pendingToggleKeys, + pendingToggleKeys: toggleRegistry.pendingKeys, pendingStructuralActions, pendingBulkAction, selectedSkillRef, + multiSelectedRefs, + multiSelectPending, onManageAll: () => void handleManageAll(), onManageSkill: handleManageSkillFromList, onOpenSkill: handleOpenSkill, onToggleCell: handleToggleCell, + onToggleMultiSelect: toggleMultiSelect, + onClearMultiSelect: clearMultiSelect, + onMultiSelectEnableAll: handleMultiSelectEnableAll, + onMultiSelectDisableAll: handleMultiSelectDisableAll, + onMultiSelectDelete: handleMultiSelectDelete, + onSetSkillAllHarnesses: handleSetSkillAllHarnesses, + onSetManySkillsAllHarnesses: handleSetManySkillsAllHarnesses, + onUpdateSkill: handleUpdateSkill, + onRemoveSkill: handleRemoveSkillFromList, + onDeleteSkill: handleDeleteSkillFromList, }; return { context, activeTab, selectedSkillRef, - isMobileDetail, - isDesktopDetailOpen: Boolean(selectedSkillRef) && !isMobileDetail, - shouldAnimatePaneTransition, - transitionDirection, + isDesktopDetailOpen, actionErrorMessage, queryErrorMessage, - closeSelectedSkill: () => updateSelectedSkillRef(null), + closeSelectedSkill, handleManageSkill, handleToggleSkill, handleUpdateSkill, - handleUnmanageSkill, + handleRemoveSkill, handleDeleteSkill, dismissActionError: () => setActionErrorMessage(""), }; } -type SkillsWorkspaceTab = SkillsPaneView; - -function rowVisibleOnTab(row: SkillListRow, tab: SkillsWorkspaceTab): boolean { - if (tab === "unmanaged") { - return row.displayStatus === "Unmanaged"; - } - return row.displayStatus === "Managed" || row.displayStatus === "Custom" || row.displayStatus === "Built-in"; -} - -function usePaneTransition(activeTab: SkillsPaneView): { direction: SkillsPaneDirection; shouldAnimate: boolean } { - const previousTabRef = useRef(null); - const [transitionState, setTransitionState] = useState<{ - direction: SkillsPaneDirection; - shouldAnimate: boolean; - }>({ - direction: "forward", - shouldAnimate: false, - }); - - useEffect(() => { - const previousTab = previousTabRef.current; - if (previousTab === null) { - previousTabRef.current = activeTab; - return; - } - - if (previousTab !== activeTab) { - setTransitionState({ - direction: getPaneDirection(previousTab, activeTab), - shouldAnimate: true, - }); - previousTabRef.current = activeTab; - } - }, [activeTab]); - - return transitionState; -} - -function getPaneDirection(previousTab: SkillsPaneView, activeTab: SkillsPaneView): SkillsPaneDirection { - return previousTab === "managed" && activeTab === "unmanaged" ? "forward" : "backward"; +function formatSingleSkillFailureMessage( + name: string, + target: SetAllHarnessesTarget, + failures: SetAllHarnessesFailure[], +): string { + const verb = target === "enabled" ? "enable" : "disable"; + const harnesses = failures.map((failure) => failure.harness).join(", "); + return `Unable to ${verb} ${name} on ${harnesses}.`; } -function useCompactDetailLayout(breakpointPx = 1180): boolean { - const [matches, setMatches] = useState(() => getCompactDetailLayoutMatch(breakpointPx)); - - useEffect(() => { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - setMatches(getCompactDetailLayoutMatch(breakpointPx)); - return undefined; - } - - const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx}px)`); - const update = () => setMatches(mediaQuery.matches); - update(); - - if (typeof mediaQuery.addEventListener === "function") { - mediaQuery.addEventListener("change", update); - return () => mediaQuery.removeEventListener("change", update); - } - - mediaQuery.addListener(update); - return () => mediaQuery.removeListener(update); - }, [breakpointPx]); - - return matches; -} - -function getCompactDetailLayoutMatch(breakpointPx: number): boolean { - if (typeof window === "undefined") { - return false; - } - if (typeof window.matchMedia === "function") { - return window.matchMedia(`(max-width: ${breakpointPx}px)`).matches; +function formatMultiSkillFailureMessage( + failingRows: Array<{ row: SkillListRow; result: SetAllHarnessesResult }>, + target: SetAllHarnessesTarget, +): string { + const verb = target === "enabled" ? "enable" : "disable"; + if (failingRows.length === 1) { + const { row, result } = failingRows[0]; + return formatSingleSkillFailureMessage(row.name, target, result.failed); } - return window.innerWidth <= breakpointPx; + const names = failingRows.map((entry) => entry.row.name).join(", "); + return `Unable to ${verb} every harness for ${failingRows.length} skills: ${names}.`; } diff --git a/frontend/src/features/skills/model/useInUseViewMode.test.tsx b/frontend/src/features/skills/model/useInUseViewMode.test.tsx new file mode 100644 index 0000000..a83acb5 --- /dev/null +++ b/frontend/src/features/skills/model/useInUseViewMode.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useInUseViewMode } from "./useInUseViewMode"; + +function Probe() { + const [mode] = useInUseViewMode(); + const location = useLocation(); + return ( + <> +
{mode}
+
{location.search}
+ + ); +} + +function renderProbe(route: string) { + render( + + + , + ); +} + +describe("useInUseViewMode", () => { + let storage: Map; + + beforeEach(() => { + storage = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + storage.set(key, value); + }), + }, + }); + }); + + it("uses matrix as the canonical skills coverage view", () => { + renderProbe("/skills/use?view=matrix"); + + expect(screen.getByTestId("mode")).toHaveTextContent("matrix"); + }); + + it("canonicalizes the legacy table URL value to matrix", async () => { + renderProbe("/skills/use?view=table"); + + expect(screen.getByTestId("mode")).toHaveTextContent("matrix"); + await waitFor(() => expect(screen.getByTestId("search")).toHaveTextContent("?view=matrix")); + }); + + it("canonicalizes the legacy stored table preference to matrix", async () => { + window.localStorage.setItem("skillmgr.inUse.view", "table"); + + renderProbe("/skills/use"); + + expect(screen.getByTestId("mode")).toHaveTextContent("matrix"); + await waitFor(() => expect(window.localStorage.getItem("skillmgr.inUse.view")).toBe("matrix")); + }); +}); diff --git a/frontend/src/features/skills/model/useInUseViewMode.ts b/frontend/src/features/skills/model/useInUseViewMode.ts new file mode 100644 index 0000000..04d5430 --- /dev/null +++ b/frontend/src/features/skills/model/useInUseViewMode.ts @@ -0,0 +1,32 @@ +import { usePersistentViewMode } from "../../../lib/usePersistentViewMode"; + +export type InUseViewMode = "grid" | "board" | "matrix"; + +const STORAGE_KEY = "skillmgr.inUse.view"; + +function isValidMode(value: unknown): value is InUseViewMode { + return value === "grid" || value === "board" || value === "matrix"; +} + +function normalizeLegacyMode(value: unknown): InUseViewMode | null { + return value === "table" ? "matrix" : null; +} + +/** + * Resolution order on first render: + * 1. `?view=` in the URL (shareable link) + * 2. localStorage (persisted user choice) + * 3. "grid" (default) + * + * User toggles write BOTH localStorage AND the URL param. + * A URL override alone never writes to localStorage (so share links don't + * permanently flip someone else's preference). + */ +export function useInUseViewMode(): [InUseViewMode, (next: InUseViewMode) => void] { + return usePersistentViewMode({ + storageKey: STORAGE_KEY, + defaultMode: "grid", + isValidMode, + normalizeMode: normalizeLegacyMode, + }); +} diff --git a/frontend/src/features/skills/model/workspace-context.ts b/frontend/src/features/skills/model/workspace-context.ts index 2b16166..403de9c 100644 --- a/frontend/src/features/skills/model/workspace-context.ts +++ b/frontend/src/features/skills/model/workspace-context.ts @@ -1,8 +1,23 @@ import { useOutletContext } from "react-router-dom"; +import type { MultiSelectAction } from "../../../components/BulkActionBar"; import type { BulkSkillsAction, CellActionKey, StructuralSkillAction } from "./pending"; import type { HarnessCell, SkillListRow, SkillsWorkspaceData } from "./types"; +export type { MultiSelectAction }; + +export type SetAllHarnessesTarget = "enabled" | "disabled"; + +export interface SetAllHarnessesFailure { + harness: string; + error: Error; +} + +export interface SetAllHarnessesResult { + succeeded: string[]; + failed: SetAllHarnessesFailure[]; +} + export interface SkillsWorkspaceContextValue { data: SkillsWorkspaceData | null; hasData: boolean; @@ -13,10 +28,25 @@ export interface SkillsWorkspaceContextValue { pendingStructuralActions: ReadonlyMap; pendingBulkAction: BulkSkillsAction | null; selectedSkillRef: string | null; + multiSelectedRefs: ReadonlySet; + multiSelectPending: MultiSelectAction | null; onManageAll: () => void; onManageSkill: (skillRef: string) => Promise; onOpenSkill: (skillRef: string) => void; onToggleCell: (row: SkillListRow, cell: HarnessCell) => void; + onToggleMultiSelect: (skillRef: string) => void; + onClearMultiSelect: () => void; + onMultiSelectEnableAll: () => Promise; + onMultiSelectDisableAll: () => Promise; + onMultiSelectDelete: () => Promise; + onSetSkillAllHarnesses: (skillRef: string, target: SetAllHarnessesTarget) => Promise; + onSetManySkillsAllHarnesses: ( + skillRefs: string[], + target: SetAllHarnessesTarget, + ) => Promise>; + onUpdateSkill: (skillRef: string) => Promise; + onRemoveSkill: (skillRef: string) => Promise; + onDeleteSkill: (skillRef: string) => Promise; } export function useSkillsWorkspace(): SkillsWorkspaceContextValue { diff --git a/frontend/src/features/skills/public.ts b/frontend/src/features/skills/public.ts new file mode 100644 index 0000000..8714a93 --- /dev/null +++ b/frontend/src/features/skills/public.ts @@ -0,0 +1,26 @@ +export { + useDeleteSkillMutation, + useManageAllSkillsMutation, + useManageSkillMutation, + useSetSkillHarnessesMutation, + useSkillDetailQuery, + useSkillsListQuery, + useSkillSourceStatusQuery, + useToggleSkillMutation, + useUnmanageSkillMutation, + useUpdateSkillMutation, +} from "./api/queries"; +export { invalidateSkillsQueries } from "./api/invalidation"; +export { skillsKeys } from "./api/keys"; +export type { + HarnessCell, + HarnessColumn, + SkillListRow, + SkillsWorkspaceData, +} from "./model/types"; + +export const skillsRoutes = { + inUse: "/skills/use", + needsReview: "/skills/review", + marketplace: "/marketplace/skills", +} as const; diff --git a/frontend/src/features/skills/screens/ManagedSkillsPage.tsx b/frontend/src/features/skills/screens/ManagedSkillsPage.tsx deleted file mode 100644 index 38ed1d4..0000000 --- a/frontend/src/features/skills/screens/ManagedSkillsPage.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useMemo, useRef } from "react"; -import { Link } from "react-router-dom"; - -import { ManagedSkillsList } from "../components/cards/ManagedSkillsList"; -import { SkillsEmptyState } from "../components/pane/SkillsEmptyState"; -import { SkillsPaneScaffold } from "../components/pane/SkillsPaneScaffold"; -import { useManagedSkillsSession, useSkillsTabScroll } from "../model/session"; -import { - filterBuiltInRows, - filterManagedRows, - hasActiveManagedSkillsFilters, -} from "../model/selectors"; -import { useSkillsWorkspace } from "../model/workspace-context"; - -export default function ManagedSkillsPage() { - const { - data, - status, - pendingToggleKeys, - pendingStructuralActions, - selectedSkillRef, - onOpenSkill, - onToggleCell, - isInitialLoading, - } = useSkillsWorkspace(); - const { filters, updateFilters, resetFilters } = useManagedSkillsSession(); - const scrollRef = useRef(null); - - useSkillsTabScroll("managed", status === "ready", scrollRef); - - const rows = useMemo(() => filterManagedRows(data, filters), [data, filters]); - const builtInRows = useMemo(() => filterBuiltInRows(data), [data]); - const hasActiveFilters = useMemo(() => hasActiveManagedSkillsFilters(filters), [filters]); - const hasManagedInventory = (data?.summary.managed ?? 0) + (data?.summary.custom ?? 0) > 0; - const isReady = status === "ready" && Boolean(data); - - return ( - - Review Unmanaged - - ) : null - } - searchValue={filters.search} - hasActiveFilters={hasActiveFilters} - onSearchChange={(search) => updateFilters({ search })} - onReset={resetFilters} - searchLabel="Managed skills filters" - searchInputLabel="Search managed skills" - searchPlaceholder="Search managed skills by name, description, or state" - scrollRef={scrollRef} - isReady={isReady} - isInitialLoading={isInitialLoading} - hasError={status === "error"} - loadingLabel="Loading managed skills" - errorMessage="Unable to load managed skills." - > - {isReady && data ? ( - <> - {rows.length > 0 ? ( - - ) : hasManagedInventory ? ( - - ) : ( -
-
-

No managed skills yet

-

Your shared inventory is empty.

-

Review unmanaged skills found in supported global roots or install something from the marketplace to start managing coverage here.

-
-
- - Review Unmanaged - - - Open Marketplace - -
-
- )} - - {builtInRows.length > 0 ? ( -
-
-
-

Reference only

-

Built-in skills

-

These come from harnesses directly and stay outside the shared managed flow.

-
-
- -
- ) : null} - - ) : null} -
- ); -} diff --git a/frontend/src/features/skills/screens/SkillsInUsePage.test.tsx b/frontend/src/features/skills/screens/SkillsInUsePage.test.tsx new file mode 100644 index 0000000..192fcb8 --- /dev/null +++ b/frontend/src/features/skills/screens/SkillsInUsePage.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import SkillsInUsePage from "./SkillsInUsePage"; + +const hooks = vi.hoisted(() => { + return { + onRemoveSkill: vi.fn(async () => undefined), + onDeleteSkill: vi.fn(async () => undefined), + updateFilters: vi.fn(), + resetFilters: vi.fn(), + toast: vi.fn(), + }; +}); + +vi.mock("../model/workspace-context", () => ({ + useSkillsWorkspace: () => ({ + data: { + summary: { managed: 1, unmanaged: 0 }, + harnessColumns: [ + { harness: "codex", label: "Codex", installed: true }, + { harness: "cursor", label: "Cursor", installed: true }, + ], + rows: [ + { + skillRef: "shared:trace-lens", + name: "Trace Lens", + description: "Trace review workflow", + displayStatus: "Managed", + actions: { canManage: false, canStopManaging: true, canDelete: true }, + cells: [ + { harness: "codex", label: "Codex", state: "enabled", interactive: true }, + { harness: "cursor", label: "Cursor", state: "disabled", interactive: true }, + ], + }, + ], + }, + status: "ready", + pendingToggleKeys: new Set(), + pendingStructuralActions: new Map(), + selectedSkillRef: null, + multiSelectedRefs: new Set(), + onOpenSkill: vi.fn(), + onToggleCell: vi.fn(), + onToggleMultiSelect: vi.fn(), + onClearMultiSelect: vi.fn(), + onSetSkillAllHarnesses: vi.fn(), + onSetManySkillsAllHarnesses: vi.fn(), + onRemoveSkill: hooks.onRemoveSkill, + onDeleteSkill: hooks.onDeleteSkill, + isInitialLoading: false, + }), +})); + +vi.mock("../model/session", () => ({ + useSkillsInUseSession: () => ({ + filters: { search: "" }, + updateFilters: hooks.updateFilters, + resetFilters: hooks.resetFilters, + }), +})); + +vi.mock("../model/useInUseViewMode", () => ({ + useInUseViewMode: () => ["grid", vi.fn()] as const, +})); + +vi.mock("../../../components/Toast", async () => { + const actual = await vi.importActual( + "../../../components/Toast", + ); + return { + ...actual, + useToast: () => ({ toast: hooks.toast }), + }; +}); + +describe("SkillsInUsePage", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + hooks.onRemoveSkill.mockClear(); + hooks.onDeleteSkill.mockClear(); + hooks.updateFilters.mockClear(); + hooks.resetFilters.mockClear(); + hooks.toast.mockClear(); + }); + + it("opens a remove confirm popup from the skill card menu", async () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "More actions for Trace Lens" })); + fireEvent.click(screen.getByRole("button", { name: "Remove from Skill Manager" })); + + await waitFor(() => + expect(screen.getByRole("heading", { name: /remove skill from skill manager/i })).toBeInTheDocument(), + ); + expect(screen.getByText(/will restore to: codex/i)).toBeInTheDocument(); + expect(hooks.onRemoveSkill).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + await waitFor(() => + expect(hooks.onRemoveSkill).toHaveBeenCalledWith("shared:trace-lens"), + ); + }); + + it("labels the harness coverage view as Matrix", () => { + render( + + + + + , + ); + + expect(screen.getByRole("button", { name: "Matrix" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Table" })).not.toBeInTheDocument(); + }); + + it("opens a delete confirm popup from the skill card menu", async () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "More actions for Trace Lens" })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => + expect(screen.getByRole("heading", { name: /delete skill from skill manager/i })).toBeInTheDocument(), + ); + expect(screen.getByText(/affected harnesses: codex/i)).toBeInTheDocument(); + expect(hooks.onDeleteSkill).not.toHaveBeenCalled(); + + fireEvent.click(screen.getAllByRole("button", { name: "Delete" }).at(-1)!); + await waitFor(() => + expect(hooks.onDeleteSkill).toHaveBeenCalledWith("shared:trace-lens"), + ); + }); +}); diff --git a/frontend/src/features/skills/screens/SkillsInUsePage.tsx b/frontend/src/features/skills/screens/SkillsInUsePage.tsx new file mode 100644 index 0000000..b93eee7 --- /dev/null +++ b/frontend/src/features/skills/screens/SkillsInUsePage.tsx @@ -0,0 +1,290 @@ +import { useMemo, useState } from "react"; +import { Columns3, FolderPlus, LayoutGrid, Rows3 } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { SkillActionConfirmDialog } from "../components/dialogs/SkillActionConfirmDialog"; +import { FilterBar } from "../../../components/FilterBar"; +import { LoadingSpinner } from "../../../components/LoadingSpinner"; +import { PageHeader } from "../../../components/PageHeader"; +import { useToast } from "../../../components/Toast"; +import { SelectionMenu } from "../../../components/ui/SelectionMenu"; +import { ViewModeToggle, type ViewModeOption } from "../../../components/ViewModeToggle"; +import { BoardView } from "../components/board/BoardView"; +import { SkillsInUseList } from "../components/cards/SkillsInUseList"; +import { MatrixView } from "../components/matrix/MatrixView"; +import { SkillsEmptyState } from "../components/pane/SkillsEmptyState"; +import { useSkillsInUseSession } from "../model/session"; +import { + filterSkillsInUseRows, + hasActiveSkillsInUseFilters, +} from "../model/selectors"; +import { useInUseViewMode, type InUseViewMode } from "../model/useInUseViewMode"; +import { useSkillsWorkspace } from "../model/workspace-context"; +import type { SkillListRow } from "../model/types"; + +type InUsePillValue = "all" | "enabled" | "all-harnesses" | "off"; + +const PILL_LABELS: Record = { + all: "All", + enabled: "Enabled", + "all-harnesses": "Enabled on all", + off: "Off", +}; + +const VIEW_MODE_OPTIONS: readonly ViewModeOption[] = [ + { value: "grid", label: "Grid", icon: LayoutGrid }, + { value: "board", label: "Board", icon: Columns3 }, + { value: "matrix", label: "Matrix", icon: Rows3 }, +]; + +function countEnabledCells(row: SkillListRow): number { + return row.cells.filter((cell) => cell.state === "enabled").length; +} + +function applyPillFilter(rows: SkillListRow[], pill: InUsePillValue, harnessCount: number): SkillListRow[] { + if (pill === "all") return rows; + if (pill === "enabled") return rows.filter((row) => countEnabledCells(row) > 0); + if (pill === "all-harnesses") return rows.filter((row) => countEnabledCells(row) === harnessCount && harnessCount > 0); + if (pill === "off") return rows.filter((row) => countEnabledCells(row) === 0); + return rows; +} + +export default function SkillsInUsePage() { + const { + data, + status, + pendingToggleKeys, + pendingStructuralActions, + selectedSkillRef, + multiSelectedRefs, + onOpenSkill, + onToggleCell, + onToggleMultiSelect, + onClearMultiSelect, + onSetSkillAllHarnesses, + onSetManySkillsAllHarnesses, + onRemoveSkill, + onDeleteSkill, + isInitialLoading, + } = useSkillsWorkspace(); + const { filters, updateFilters, resetFilters } = useSkillsInUseSession(); + const { toast } = useToast(); + const [pill, setPill] = useState("all"); + const [viewMode, setViewMode] = useInUseViewMode(); + const [pendingConfirm, setPendingConfirm] = useState<{ + action: "unmanage" | "delete"; + skillRef: string; + skillName: string; + harnessLabels: string[]; + } | null>(null); + + const baseRows = useMemo(() => filterSkillsInUseRows(data, filters), [data, filters]); + + const harnessCount = data?.harnessColumns.length ?? 0; + // The pill filter only applies in Grid view. Board view already answers the + // "coverage" question visually via its columns, so re-applying the pill would + // collapse the board to a single column and invite confusion. We preserve the + // pill state so a user flipping back to Grid keeps their prior filter. + const rows = useMemo( + () => (viewMode === "grid" ? applyPillFilter(baseRows, pill, harnessCount) : baseRows), + [baseRows, pill, harnessCount, viewMode], + ); + + const pillCounts: Record = useMemo(() => { + return { + all: baseRows.length, + enabled: baseRows.filter((r) => countEnabledCells(r) > 0).length, + "all-harnesses": baseRows.filter((r) => countEnabledCells(r) === harnessCount && harnessCount > 0).length, + off: baseRows.filter((r) => countEnabledCells(r) === 0).length, + }; + }, [baseRows, harnessCount]); + const pillOptions = useMemo( + () => + (["all", "enabled", "all-harnesses", "off"] as const).map((value) => ({ + value, + label: PILL_LABELS[value], + meta: pillCounts[value], + })), + [pillCounts], + ); + + const hasActiveFilters = + hasActiveSkillsInUseFilters(filters) || (viewMode === "grid" && pill !== "all"); + const hasInUseInventory = (data?.summary.managed ?? 0) > 0; + const isReady = status === "ready" && Boolean(data); + const pendingConfirmAction = + pendingConfirm === null + ? null + : pendingStructuralActions.get(pendingConfirm.skillRef) ?? null; + + function enabledHarnessLabels(row: SkillListRow): string[] { + return row.cells + .filter((cell) => cell.state === "enabled") + .map((cell) => cell.label); + } + + function requestSkillConfirm(action: "unmanage" | "delete", row: SkillListRow): void { + setPendingConfirm({ + action, + skillRef: row.skillRef, + skillName: row.name, + harnessLabels: enabledHarnessLabels(row), + }); + } + + async function handleConfirmAction(): Promise { + if (!pendingConfirm) { + return; + } + try { + if (pendingConfirm.action === "unmanage") { + await onRemoveSkill(pendingConfirm.skillRef); + } else { + await onDeleteSkill(pendingConfirm.skillRef); + } + setPendingConfirm(null); + } catch { + // The workspace controller already routes list-surface failures into the + // shared action error banner; keep the dialog open so the user can retry. + } + } + + return ( + <> +
+ + + + + } + /> + + updateFilters({ search })} + searchPlaceholder="Search by name, tag, description..." + searchLabel="Search skills in use" + trailing={ + viewMode === "grid" ? ( + + ) : undefined + } + /> +
+ + {isInitialLoading ? ( +
+ +
+ ) : status === "error" ? ( +
Unable to load skills in use.
+ ) : isReady && data ? ( + <> + {rows.length > 0 ? ( + viewMode === "board" ? ( + + ) : viewMode === "matrix" ? ( + + ) : ( + requestSkillConfirm("unmanage", row)} + onRequestDelete={(row) => requestSkillConfirm("delete", row)} + /> + ) + ) : hasInUseInventory || hasActiveFilters ? ( + { + resetFilters(); + setPill("all"); + }} /> + ) : ( +
+

No skills in use yet

+

+ Review local skill folders or install something from the marketplace to start controlling harness + coverage here. +

+
+ + Review items + + + Open Marketplace + +
+
+ )} + + + ) : null} + + {pendingConfirm ? ( + { + if (!open) { + setPendingConfirm(null); + } + }} + onConfirm={handleConfirmAction} + /> + ) : null} + + ); +} diff --git a/frontend/src/features/skills/screens/SkillsNeedsReviewPage.tsx b/frontend/src/features/skills/screens/SkillsNeedsReviewPage.tsx new file mode 100644 index 0000000..1b490c9 --- /dev/null +++ b/frontend/src/features/skills/screens/SkillsNeedsReviewPage.tsx @@ -0,0 +1,113 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; + +import { FilterBar } from "../../../components/FilterBar"; +import { LoadingSpinner } from "../../../components/LoadingSpinner"; +import { PageHeader } from "../../../components/PageHeader"; +import { SkillsNeedsReviewList } from "../components/cards/SkillsNeedsReviewList"; +import { SkillsEmptyState } from "../components/pane/SkillsEmptyState"; +import { useSkillsWorkspace } from "../model/workspace-context"; +import { + countAdoptableLocalSkillRows, + countNeedsReviewRows, + filterNeedsReviewRows, + hasActiveNeedsReviewFilters, +} from "../model/selectors"; +import { useSkillsNeedsReviewSession } from "../model/session"; + +export default function SkillsNeedsReviewPage() { + const { + data, + status, + pendingStructuralActions, + pendingBulkAction, + selectedSkillRef, + onManageAll, + onManageSkill, + onOpenSkill, + isInitialLoading, + } = useSkillsWorkspace(); + const { filters, updateFilters, resetFilters } = useSkillsNeedsReviewSession(); + + const rows = useMemo(() => filterNeedsReviewRows(data, filters), [data, filters]); + const hasActiveFilters = useMemo(() => hasActiveNeedsReviewFilters(filters), [filters]); + const needsReviewCount = useMemo(() => countNeedsReviewRows(data), [data]); + const adoptableCount = useMemo(() => countAdoptableLocalSkillRows(data), [data]); + const isReady = status === "ready" && Boolean(data); + + return ( + <> +
+ 0 + ? `${needsReviewCount} skill${needsReviewCount === 1 ? "" : "s"} need${needsReviewCount === 1 ? "s" : ""} a review decision.` + : "No local skill folders need review across your harnesses." + } + actions={ + + } + /> + + {needsReviewCount > 0 ? ( + updateFilters({ search })} + searchPlaceholder="Search skills to review..." + searchLabel="Search skills to review" + /> + ) : null} +
+ + {isInitialLoading ? ( +
+ +
+ ) : status === "error" ? ( +
Unable to load skills to review.
+ ) : isReady && data ? ( + rows.length > 0 ? ( + + ) : needsReviewCount > 0 ? ( + + ) : ( +
+

Nothing needs review

+

+ Your local harness folders are either already in use through Skill Manager or currently empty. Install + from the marketplace to add new skills. +

+
+ + Open Marketplace + +
+
+ ) + ) : null} + + {hasActiveFilters && rows.length === 0 ? null : null} + + ); +} diff --git a/frontend/src/features/skills/screens/SkillsWorkspacePage.tsx b/frontend/src/features/skills/screens/SkillsWorkspacePage.tsx index 84bb40b..f21d429 100644 --- a/frontend/src/features/skills/screens/SkillsWorkspacePage.tsx +++ b/frontend/src/features/skills/screens/SkillsWorkspacePage.tsx @@ -1,10 +1,8 @@ import { Outlet } from "react-router-dom"; +import { BulkActionBar } from "../../../components/BulkActionBar"; import { ErrorBanner } from "../../../components/ErrorBanner"; -import { SkillDetailDrawer } from "../components/detail/SkillDetailDrawer"; -import { SkillDetailPanel } from "../components/detail/SkillDetailPanel"; -import { SkillsPaneTransition } from "../components/pane/SkillsPaneTransition"; -import { SkillsWorkspaceTabs } from "../components/pane/SkillsWorkspaceTabs"; +import { SkillDetailModal } from "../components/detail/SkillDetailModal"; import { pendingToggleHarnessesForSkill } from "../model/pending"; import { useSkillsWorkspaceController } from "../model/use-skills-workspace-controller"; @@ -13,22 +11,18 @@ export default function SkillsWorkspacePage() { context, activeTab, selectedSkillRef, - isMobileDetail, isDesktopDetailOpen, - shouldAnimatePaneTransition, - transitionDirection, actionErrorMessage, queryErrorMessage, closeSelectedSkill, handleManageSkill, handleToggleSkill, handleUpdateSkill, - handleUnmanageSkill, + handleRemoveSkill, handleDeleteSkill, dismissActionError, } = useSkillsWorkspaceController(); - const data = context.data; const hasData = context.hasData; const selectedPendingToggleHarnesses = selectedSkillRef ? pendingToggleHarnessesForSkill(context.pendingToggleKeys, selectedSkillRef) @@ -39,69 +33,46 @@ export default function SkillsWorkspacePage() { return ( <> -
-
-
-
-
-
-
-
-

Skills

- -
-
-
- - {actionErrorMessage ? ( - - ) : null} - {!actionErrorMessage && hasData && queryErrorMessage ? ( - - ) : null} -
- -
- - - -
-
-
- {!isMobileDetail ? ( - - ) : null} -
-
+ {actionErrorMessage ? ( + + ) : null} + {!actionErrorMessage && hasData && queryErrorMessage ? ( + + ) : null} + - {isMobileDetail ? ( - ) : null} + + ); } diff --git a/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx b/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx deleted file mode 100644 index dddb6a3..0000000 --- a/frontend/src/features/skills/screens/UnmanagedSkillsPage.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useMemo, useRef } from "react"; -import { Link } from "react-router-dom"; - -import { LoadingSpinner } from "../../../components/LoadingSpinner"; -import { UnmanagedSkillsList } from "../components/cards/UnmanagedSkillsList"; -import { BulkManageHelp } from "../components/harness/BulkManageHelp"; -import { SkillsEmptyState } from "../components/pane/SkillsEmptyState"; -import { SkillsPaneScaffold } from "../components/pane/SkillsPaneScaffold"; -import { useSkillsWorkspace } from "../model/workspace-context"; -import { countManageableUnmanagedRows, countUnmanagedRows, filterUnmanagedRows, hasActiveUnmanagedFilters } from "../model/selectors"; -import { useSkillsTabScroll, useUnmanagedSkillsSession } from "../model/session"; - -export default function UnmanagedSkillsPage() { - const { - data, - status, - pendingStructuralActions, - pendingBulkAction, - selectedSkillRef, - onManageAll, - onManageSkill, - onOpenSkill, - isInitialLoading, - } = useSkillsWorkspace(); - const { filters, updateFilters, resetFilters } = useUnmanagedSkillsSession(); - const scrollRef = useRef(null); - - useSkillsTabScroll("unmanaged", status === "ready", scrollRef); - - const rows = useMemo(() => filterUnmanagedRows(data, filters), [data, filters]); - const hasActiveFilters = useMemo(() => hasActiveUnmanagedFilters(filters), [filters]); - const unmanagedCount = useMemo(() => countUnmanagedRows(data), [data]); - const manageableCount = useMemo(() => countManageableUnmanagedRows(data), [data]); - const isReady = status === "ready" && Boolean(data); - - return ( - - - - - } - searchValue={filters.search} - hasActiveFilters={hasActiveFilters} - onSearchChange={(search) => updateFilters({ search })} - onReset={resetFilters} - searchLabel="Unmanaged skills filters" - searchInputLabel="Search unmanaged skills" - searchPlaceholder="Search unmanaged skills by name, description, or tool" - scrollRef={scrollRef} - isReady={isReady} - isInitialLoading={isInitialLoading} - hasError={status === "error"} - loadingLabel="Loading unmanaged skills" - errorMessage="Unable to load unmanaged skills." - > - {isReady && data ? ( - <> - {rows.length > 0 ? ( - - ) : unmanagedCount > 0 ? ( - - ) : ( -
-
-

Nothing waiting for management

-

No local discoveries need action right now.

-

Your local tool folders are either already managed or currently empty.

-
-
- - Open Marketplace - -
-
- )} - - ) : null} -
- ); -} diff --git a/frontend/src/features/skills/styles/board.css b/frontend/src/features/skills/styles/board.css new file mode 100644 index 0000000..e0a3873 --- /dev/null +++ b/frontend/src/features/skills/styles/board.css @@ -0,0 +1,180 @@ +@layer features { + +/* -------------------------------------------------------------------------- */ +/* Skill board (Kanban view) */ +/* -------------------------------------------------------------------------- */ + +.skill-board { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-5); +} + +@media (max-width: 900px) { + .skill-board { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Outer slot: carries the droppable ref, stretches to match the tallest + column's height so a drag from a deep row still has a valid target + everywhere across the lane. Inner `.board-column` stays natural height. */ +.board-column-slot { + display: flex; + flex-direction: column; +} + +.board-column-slot > .board-column { + flex: 0 0 auto; +} + +.board-column { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-4); + border-radius: var(--radius-md); + background: var(--color-surface-sunken); + min-height: 220px; +} + +.board-column--selective { + background: transparent; + border: 1px dashed var(--color-border-strong); +} + +.board-column__head { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: 0 var(--space-1); +} + +.board-column__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.board-column__title { + margin: 0; + font-size: var(--font-size-md); + font-weight: 600; + color: var(--color-text); +} + +.board-column__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 var(--space-2); + border-radius: var(--radius-pill); + background: var(--color-surface-raised); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: 600; +} + +.board-column__description { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + line-height: 1.4; +} + +.board-column__body { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.board-column__empty { + margin: 0; + padding: var(--space-5) var(--space-3); + font-size: var(--font-size-sm); + color: var(--color-text-muted); + text-align: center; + line-height: 1.5; +} + +.skill-card--board { + min-height: 0; + padding: var(--space-4); + gap: var(--space-2); + background: var(--color-surface); + touch-action: none; + user-select: none; + transition: background 120ms ease, box-shadow 120ms ease, transform 120ms ease, opacity 120ms ease; +} + +.skill-card--board[data-dragging="true"] { + cursor: grabbing; + box-shadow: var(--shadow-lift); + background: var(--color-surface-raised); + z-index: 2; +} + +.skill-card--board[data-pending="true"] { + opacity: 0.65; + pointer-events: none; +} + +.skill-card__description--compact { + -webkit-line-clamp: 2; +} + +/* Column drop feedback — attributes live on the slot so the full lane is + the droppable, but the visual treatment applies to the inner column card. */ +.board-column { + transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +.board-column-slot[data-drop-active="true"] > .board-column { + background: var(--color-surface-raised); + box-shadow: inset 0 0 0 1px var(--color-accent); +} + +.board-column-slot[data-drag-global="true"][data-drop-target="false"] > .board-column { + opacity: 0.55; +} + +/* Multi-drag badge on the dragged card */ +.skill-card__multi-badge { + position: absolute; + top: -8px; + right: -8px; + min-width: 22px; + height: 22px; + padding: 0 var(--space-2); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--color-text-inverted); + font-size: var(--font-size-xs); + font-weight: 600; + box-shadow: var(--shadow-sm); + pointer-events: none; +} + +.skill-card--board[data-multi-drag="true"] { + position: relative; +} + +/* Stack effect: small offset duplicate peeking behind the dragged card */ +.skill-card--board[data-multi-drag="true"]::before { + content: ""; + position: absolute; + inset: 6px -6px -6px 6px; + border-radius: var(--radius-md); + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); + z-index: -1; + pointer-events: none; +} + +} diff --git a/frontend/src/features/skills/styles/cards.css b/frontend/src/features/skills/styles/cards.css deleted file mode 100644 index b73435c..0000000 --- a/frontend/src/features/skills/styles/cards.css +++ /dev/null @@ -1,217 +0,0 @@ -.skill-card { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 18px; - align-items: start; - padding: 16px; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: rgba(15, 17, 22, 0.86); - cursor: pointer; - transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; -} - -.skill-card__header { - display: block; -} - -.skill-card__identity { - display: grid; - gap: 4px; -} - -.skill-card__content, -.skill-card__aside, -.skill-card__action { - display: grid; - gap: 14px; -} - -.skill-card__content { - min-width: 0; -} - -.skill-card__aside { - align-content: start; - justify-items: end; -} - -.skill-card__title-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.skill-card__name { - padding: 0; - border: none; - background: transparent; - color: var(--color-text); - font-size: 1.28rem; - font-weight: 600; - letter-spacing: -0.03em; - text-align: left; - transition: color 120ms ease; -} - -.skill-card__name:hover { - color: var(--color-accent); -} - -.skill-card:hover { - border-color: rgba(240, 163, 107, 0.28); - background: rgba(18, 22, 29, 0.92); -} - -.skill-card.is-selected { - border-color: rgba(240, 163, 107, 0.42); - box-shadow: 0 0 0 1px rgba(240, 163, 107, 0.16); - background: rgba(19, 24, 31, 0.94); -} - -.skill-status-indicator__trigger { - cursor: help; - transition: box-shadow 120ms ease, transform 120ms ease; -} - -.skill-status-indicator__trigger:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(243, 201, 105, 0.18); -} - -.skill-status-popover { - z-index: 50; - width: min(260px, calc(100vw - 32px)); - padding: 12px 14px; - border: 1px solid rgba(243, 201, 105, 0.28); - border-radius: var(--radius-sm); - background: rgba(14, 17, 22, 0.98); - box-shadow: var(--shadow-panel); -} - -.skill-status-popover__title, -.skill-status-popover__copy { - margin: 0; -} - -.skill-status-popover__title { - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-warning); -} - -.skill-status-popover__copy { - margin-top: 8px; - color: var(--color-text-muted); - line-height: 1.5; -} - -.skills-help-popover { - z-index: 50; - width: min(320px, calc(100vw - 32px)); - padding: 12px 14px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: var(--radius-sm); - background: rgba(14, 17, 22, 0.98); - box-shadow: var(--shadow-panel); -} - -.skills-help-popover__title, -.skills-help-popover__copy { - margin: 0; -} - -.skills-help-popover__title { - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-accent); -} - -.skills-help-popover__copy { - margin-top: 8px; - color: var(--color-text-muted); - line-height: 1.5; -} - - -.skill-card__action .btn { - min-width: 176px; -} - -.skill-card--unmanaged .skill-card__action--compact .btn { - min-width: 0; - padding-inline: 12px; -} - -.skill-card--unmanaged .skill-card__manage-button { - border: 1px solid rgba(240, 163, 107, 0.22); - background: rgba(240, 163, 107, 0.08); - color: var(--color-accent); -} - -.skill-card--unmanaged .skill-card__manage-button:hover { - border-color: rgba(240, 163, 107, 0.34); - background: rgba(240, 163, 107, 0.14); -} - -.skill-card__body { - display: grid; - gap: 12px; -} - -.skill-card__description { - margin: 0; -} - -.skill-card__description { - color: var(--color-text-muted); - line-height: 1.5; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; -} - -.unmanaged-skills-list { - display: grid; - gap: 14px; -} - -.skills-empty-state__actions { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; -} - -.skills-empty-state { - display: grid; - gap: 14px; - padding: 24px; - border: 1px dashed rgba(255, 255, 255, 0.14); - border-radius: var(--radius); - background: rgba(20, 24, 32, 0.84); -} - -.skills-empty-state__eyebrow { - margin: 0 0 6px; - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.skills-empty-state h3, -.skills-empty-state p { - margin: 0; -} - -.skills-empty-state p { - color: var(--color-text-muted); -} diff --git a/frontend/src/features/skills/styles/detail.css b/frontend/src/features/skills/styles/detail.css index dc26aed..88b2217 100644 --- a/frontend/src/features/skills/styles/detail.css +++ b/frontend/src/features/skills/styles/detail.css @@ -1,3 +1,5 @@ +@layer features { + .skill-detail__context-heading { display: flex; align-items: flex-start; @@ -5,10 +7,37 @@ gap: 16px; } +.detail-sheet.skill-detail-modal { + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.skill-detail-shell__chrome { + min-width: 0; +} + +.skill-detail-shell__body { + min-width: 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; +} + +.skill-detail-shell__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + .skill-detail__chrome { display: grid; - gap: 18px; - margin-bottom: 18px; + gap: 12px; } .skill-detail__header { @@ -107,189 +136,10 @@ box-shadow: 0 0 0 3px rgba(240, 163, 107, 0.18); } -.skill-detail__eyebrow { - margin: 0; - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-accent); -} - -.skill-detail__source-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px 14px; - min-width: 0; -} - -.skill-detail__source-label { - display: inline-flex; - align-items: center; - gap: 8px; - font-family: var(--font-mono); - font-size: 0.7rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.skill-detail__source-links { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - min-width: 0; -} - -.skill-detail__source-link { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 0 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 999px; - background: rgba(18, 22, 30, 0.62); - color: var(--color-text-muted); - transition: border-color 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease; -} - -.skill-detail__source-link:hover { - border-color: rgba(240, 163, 107, 0.28); - background: rgba(240, 163, 107, 0.09); - color: var(--color-text); - transform: translateY(-1px); -} - -.skill-detail__source-link--repo { - color: var(--color-accent); -} - -.skill-detail__source-link--repo:hover { - color: var(--color-accent); -} - -.skill-detail__harness-section { - box-sizing: border-box; - display: grid; - gap: 12px; - padding: 14px 16px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - background: - linear-gradient(180deg, rgba(24, 29, 39, 0.96), rgba(18, 22, 30, 0.94)), - rgba(18, 22, 30, 0.92); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - width: 100%; -} - -.skill-detail__harness-eyebrow { - margin: 0; - font-family: var(--font-mono); - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.skill-detail__harness-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); - gap: 10px; -} - -.skill-detail__harness-card { - display: grid; - gap: 10px; - align-content: start; - justify-items: center; - min-width: 0; - padding: 12px 10px; - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 12px; - background: rgba(12, 15, 21, 0.58); -} - -.skill-detail__harness-label { - margin: 0; - font-family: var(--font-mono); - font-size: 0.68rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-text-muted); - text-align: center; -} - -.skill-detail__harness-mark { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; -} - -.skill-detail__harness-control { - display: grid; - gap: 6px; - justify-items: center; - min-height: 26px; -} - -.skill-detail__body { - display: grid; - gap: 20px; -} - -.skill-detail__status, -.skill-detail__primary-actions, -.skill-detail__badge-row { - display: grid; - gap: 14px; -} - -.skill-detail__body--skeleton { - gap: 24px; -} - -.skill-detail__actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; -} - .skill-detail__update-control { min-width: 170px; } -.skill-detail__action-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.skill-detail__action-trailing { - display: flex; - align-items: flex-start; - gap: 12px; - justify-content: flex-end; -} - -.skill-detail__action-trailing .btn, -.skill-detail__action-trigger .btn { - min-width: 132px; -} - -.skill-detail__action-trigger { - display: inline-flex; -} - -.skill-detail__action-trigger:focus-visible { - outline: none; -} - .skill-detail__fallback { display: grid; gap: 14px; @@ -312,15 +162,6 @@ color: var(--color-text-muted); } -.skill-detail__alert { - margin-top: 12px; - padding: 12px 14px; - border: 1px solid rgba(240, 141, 121, 0.42); - border-radius: var(--radius); - background: var(--color-danger-soft); - color: var(--color-danger); -} - .skill-detail__context, .skill-detail__disclosure { padding-top: 16px; @@ -382,6 +223,10 @@ gap: 0; } +.skill-detail-disclosure__header { + margin: 0; +} + .skill-detail-disclosure__trigger { display: flex; align-items: center; @@ -401,14 +246,6 @@ gap: 6px; } -.skill-detail-disclosure__eyebrow { - font-family: var(--font-mono); - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-text-muted); -} - .skill-detail-disclosure__title { color: var(--color-text); font-size: 1rem; @@ -444,16 +281,13 @@ overflow: hidden; } -.skill-detail__disclosure--document .skill-detail-disclosure__eyebrow { - color: var(--color-accent); -} - .skill-detail__disclosure--document .skill-detail-disclosure__title { font-size: 1.08rem; } .skill-detail__document-surface { min-width: 0; + max-width: 100%; padding: 20px 22px; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 12px; @@ -461,6 +295,7 @@ linear-gradient(180deg, rgba(24, 28, 36, 0.94), rgba(18, 22, 30, 0.92)), rgba(18, 22, 30, 0.92); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + overflow: hidden; } .skill-detail__markdown { @@ -537,7 +372,7 @@ overflow-x: auto; padding: 14px 16px; border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--radius); + border-radius: var(--radius-md); background: rgba(10, 13, 18, 0.94); } @@ -549,6 +384,10 @@ .skill-detail__markdown table { width: 100%; border-collapse: collapse; + /* Wide tables scroll inside the surface, not the modal. */ + display: block; + overflow-x: auto; + max-width: 100%; } .skill-detail__markdown th, @@ -563,3 +402,5 @@ color: var(--color-text); background: rgba(255, 255, 255, 0.04); } + +} diff --git a/frontend/src/features/skills/styles/harness.css b/frontend/src/features/skills/styles/harness.css deleted file mode 100644 index af7a173..0000000 --- a/frontend/src/features/skills/styles/harness.css +++ /dev/null @@ -1,161 +0,0 @@ -.skill-card__harnesses { - min-width: 0; -} - -.skill-harness-cluster { - display: flex; - align-items: center; - gap: 14px; -} - -.skill-harness-cluster__items { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.skill-harness-cluster__item { - display: grid; - gap: 8px; - justify-items: center; - min-width: 46px; -} - -.skill-harness-cluster__tool { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; -} - -.skill-harness-mark { - min-width: 0; -} - -.skill-harness-mark--text { - font-family: var(--font-mono); - font-size: 0.7rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.skill-harness-mark--logo { - width: auto; -} - -.skill-harness-mark__logo { - display: block; - width: auto; - height: 42px; - max-width: 96px; -} - -.skill-harness-cluster__control { - display: grid; - gap: 6px; - justify-items: center; -} - -.harness-state-chip { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 54px; - height: 26px; - padding: 0 12px; - box-sizing: border-box; - appearance: none; - -webkit-appearance: none; - border: none; - border-radius: 999px; - background: rgba(28, 33, 40, 0.98); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); - color: #bcc4ce; - cursor: pointer; - transition: - background 140ms ease, - color 140ms ease, - box-shadow 140ms ease, - transform 140ms ease; -} - -.harness-state-chip:hover { - background: rgba(32, 38, 46, 0.98); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); - color: #d4dbe4; -} - -.harness-state-chip:focus-visible { - outline: none; - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.08), - 0 0 0 3px rgba(107, 194, 164, 0.16); -} - -.harness-state-chip[data-state="checked"] { - background: rgba(34, 68, 58, 0.98); - box-shadow: inset 0 0 0 1px rgba(189, 241, 221, 0.08); - color: #e3fbf2; -} - -.harness-state-chip[data-state="checked"]:hover { - background: rgba(39, 75, 64, 0.98); - box-shadow: inset 0 0 0 1px rgba(198, 246, 227, 0.1); - color: #effff8; -} - -.harness-state-chip[data-disabled] { - cursor: not-allowed; - opacity: 0.55; -} - -.harness-state-chip[data-pending] { - gap: 6px; -} - -.harness-state-chip .spinner { - flex-shrink: 0; -} - -.harness-state-chip--static { - cursor: default; -} - -.harness-state-chip--static:hover { - transform: none; -} - -.harness-state-chip--found { - background: rgba(34, 68, 58, 0.98); - box-shadow: inset 0 0 0 1px rgba(189, 241, 221, 0.08); - color: #e3fbf2; -} - -.harness-state-chip--found:hover { - background: rgba(34, 68, 58, 0.98); - box-shadow: inset 0 0 0 1px rgba(189, 241, 221, 0.08); - color: #e3fbf2; -} - -.harness-state-chip--empty, -.harness-state-chip--builtin { - background: rgba(28, 33, 40, 0.98); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); - color: #a5b0bc; -} - -.harness-state-chip--empty:hover, -.harness-state-chip--builtin:hover { - background: rgba(28, 33, 40, 0.98); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); - color: #a5b0bc; -} - -.harness-state-chip__label { - font-family: var(--font-sans); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.005em; - line-height: 1; -} diff --git a/frontend/src/features/skills/styles/index.css b/frontend/src/features/skills/styles/index.css deleted file mode 100644 index 46ea706..0000000 --- a/frontend/src/features/skills/styles/index.css +++ /dev/null @@ -1,6 +0,0 @@ -@import "./workspace.css"; -@import "./pane.css"; -@import "./cards.css"; -@import "./detail.css"; -@import "./harness.css"; -@import "./responsive.css"; diff --git a/frontend/src/features/skills/styles/pane.css b/frontend/src/features/skills/styles/pane.css deleted file mode 100644 index 7278ee8..0000000 --- a/frontend/src/features/skills/styles/pane.css +++ /dev/null @@ -1,194 +0,0 @@ -.skills-pane { - flex: 1 1 auto; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 16px; - height: 100%; - min-height: 0; - overflow: hidden; -} - -.skills-pane__chrome { - --skills-pane-header-row-height: 38px; - --skills-pane-control-height: 38px; - --skills-pane-row-gap: 16px; - --skills-pane-action-gap: 10px; - display: grid; - gap: var(--skills-pane-row-gap); - padding-right: 6px; - container-type: inline-size; -} - -.skills-pane__scroll { - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - padding-right: 6px; -} - -.skills-pane__content { - display: grid; - gap: 18px; - padding-bottom: 18px; -} - -.skills-pane__state { - min-height: 100%; -} - -.skills-pane__header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 20px; - align-items: center; - min-height: var(--skills-pane-header-row-height); -} - -.skills-pane__header-copy { - min-width: 0; - min-height: var(--skills-pane-header-row-height); - display: flex; - align-items: center; -} - -.skills-secondary-section__header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 20px; - align-items: start; -} - -.skills-pane__header h3 { - margin: 0; - font-size: 1.18rem; - letter-spacing: -0.03em; -} - -.skills-secondary-section__header h4 { - margin: 0; - font-size: 1.18rem; - letter-spacing: -0.03em; -} - -.skills-pane__header-actions { - display: inline-flex; - align-items: center; - gap: var(--skills-pane-action-gap); - flex-wrap: nowrap; - justify-content: flex-end; - min-height: var(--skills-pane-header-row-height); -} - -.skills-pane__header-actions > * { - flex: 0 0 auto; -} - -.skills-secondary-section__eyebrow { - margin: 0 0 8px; - font-size: 0.72rem; - color: var(--color-text-muted); -} - -.skills-secondary-section__header p { - margin: 10px 0 0; - color: var(--color-text-muted); -} - -.skills-pane__help-trigger { - padding: 0 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); - color: var(--color-text-muted); - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - white-space: nowrap; - transition: border-color 120ms ease, color 120ms ease, background 120ms ease; -} - -.skills-pane__header-actions .btn, -.skills-pane__help-trigger { - min-height: var(--skills-pane-control-height); - white-space: nowrap; -} - -.skills-pane__help-trigger:hover { - border-color: rgba(240, 163, 107, 0.22); - background: rgba(240, 163, 107, 0.08); - color: var(--color-text); -} - -.skills-pane__help-trigger:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(240, 163, 107, 0.18); -} - -.skills-pane__search { - width: 100%; -} - -.skills-pane__search-field { - position: relative; -} - -.skills-pane__search-field input { - width: 100%; - min-height: var(--skills-pane-control-height); - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: rgba(20, 24, 32, 0.88); - padding: 0 14px 0 38px; - color: var(--color-text); -} - -.skills-pane__search-field input:focus, -.skills-pane__search-field input:focus-visible { - outline: none; - border-color: rgba(240, 163, 107, 0.34); - box-shadow: 0 0 0 1px rgba(240, 163, 107, 0.2); -} - -.skills-pane__search-field.is-reset-visible input { - padding-right: 88px; -} - -.skills-pane__search-icon { - position: absolute; - top: 50%; - left: 12px; - transform: translateY(-50%); - color: var(--color-text-muted); -} - -.skills-pane__search-reset { - position: absolute; - top: 50%; - right: 8px; - transform: translateY(-50%); - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 32px; - padding: 0 10px; - border: 1px solid transparent; - border-radius: var(--radius); - background: rgba(255, 255, 255, 0.04); - color: var(--color-text-muted); -} - -.skills-pane__search-reset:hover { - color: var(--color-text); -} - -.skills-list { - display: grid; - gap: 14px; -} - -.skills-secondary-section { - display: grid; - gap: 14px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} diff --git a/frontend/src/features/skills/styles/responsive.css b/frontend/src/features/skills/styles/responsive.css deleted file mode 100644 index 54c33e9..0000000 --- a/frontend/src/features/skills/styles/responsive.css +++ /dev/null @@ -1,156 +0,0 @@ - -@container (max-width: 960px) { - .skills-pane__header { - grid-template-columns: 1fr; - align-items: start; - } - - .skills-pane__header-copy, - .skills-pane__header-actions { - min-height: 0; - } - - .skills-pane__header-actions { - justify-content: flex-start; - flex-wrap: wrap; - } -} - -@media (max-width: 1180px) { - .skills-workspace-shell { - grid-template-columns: 1fr; - } - - .skills-pane__scroll { - padding-right: 0; - } - - .skills-pane__chrome { - padding-right: 0; - } - - .skill-detail__harness-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .skill-detail__source-row, - .skill-detail__source-links { - align-items: flex-start; - } - - .skill-detail__title-row { - flex-direction: column; - align-items: stretch; - } - - .skill-detail__title-action { - width: 100%; - } - - .skill-detail__action-row { - flex-direction: column; - } - - .skill-detail__action-trailing, - .skill-detail__action-trigger, - .skill-detail__action-trailing .btn, - .skill-detail__action-trigger .btn { - width: 100%; - } -} - -@media (max-width: 900px) { - .skill-detail__header { - gap: 12px; - } - - .skill-detail__header-top { - gap: 12px; - } - - .skill-detail__close-button { - width: 36px; - height: 36px; - } - - .skills-pane__search-field.is-reset-visible input { - padding-right: 94px; - } - - .skills-pane__search-reset { - min-height: 40px; - } - - .skills-workspace__header, - .skills-secondary-section__header { - grid-template-columns: 1fr; - } - - .skill-card { - grid-template-columns: 1fr; - } - - .skill-card { - padding: 16px; - } - - .skill-card__action { - justify-items: stretch; - } - - .skill-card__action .btn { - width: 100%; - } - - .skill-harness-cluster { - flex-direction: column; - align-items: stretch; - } - - .skill-harness-cluster__items { - justify-content: space-between; - } - - .skill-card__aside, - .skill-card__action { - justify-items: stretch; - } - - .skill-card__action .btn, - .skills-empty-state__actions .btn { - width: 100%; - } - - .skill-card--unmanaged .skill-card__action--compact .btn { - width: auto; - justify-self: stretch; - } -} - -@keyframes skills-pane-enter-forward { - from { - opacity: 0; - transform: translateX(12px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes skills-pane-enter-backward { - from { - opacity: 0; - transform: translateX(-12px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@media (prefers-reduced-motion: reduce) { - .skills-pane-transition { - animation: none; - } -} diff --git a/frontend/src/features/skills/styles/workspace.css b/frontend/src/features/skills/styles/workspace.css deleted file mode 100644 index 84a3ce0..0000000 --- a/frontend/src/features/skills/styles/workspace.css +++ /dev/null @@ -1,232 +0,0 @@ -.skills-workspace-page { - display: flex; - flex: 1 1 auto; - height: 100%; - min-height: 0; - overflow: hidden; -} - -.skills-workspace-shell { - --skills-detail-column: 0px; - display: grid; - flex: 1 1 auto; - grid-template-columns: minmax(0, 1fr) var(--skills-detail-column); - gap: 0; - height: 100%; - min-height: 0; - overflow: hidden; - transition: grid-template-columns 220ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.skills-workspace-shell.is-detail-open { - --skills-detail-column: clamp(540px, 41vw, 720px); -} - -.skills-workspace-shell__main { - display: block; - height: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; - padding-right: 0; - transition: padding-right 220ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.skills-workspace-shell.is-detail-open .skills-workspace-shell__main { - padding-right: 34px; -} - -.skills-workspace { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 18px; - height: 100%; - min-height: 0; - overflow: hidden; -} - -.skills-workspace__top { - display: grid; - gap: 18px; -} - -.skills-workspace__content { - display: flex; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; -} - -.skills-pane-transition { - display: flex; - flex: 1 1 auto; - height: 100%; - min-height: 0; - overflow: hidden; - animation-duration: 160ms; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - animation-fill-mode: both; -} - -.skills-pane-transition--forward { - animation-name: skills-pane-enter-forward; -} - -.skills-pane-transition--backward { - animation-name: skills-pane-enter-backward; -} - -.skills-workspace__header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 20px; - align-items: start; -} - -.skills-workspace__header-main { - min-width: 0; -} - -.skills-workspace__title-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px 18px; -} - -.skills-detail-panel { - position: relative; - align-self: start; - height: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; - opacity: 0; - pointer-events: none; - transform: translateX(26px); - transition: - opacity 140ms ease, - transform 220ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.skills-detail-panel::before { - content: ""; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; - width: 1px; - background: linear-gradient( - to bottom, - rgba(255, 255, 255, 0), - rgba(255, 255, 255, 0.14) 12%, - rgba(255, 255, 255, 0.14) 88%, - rgba(255, 255, 255, 0) - ); - opacity: 0; - transition: opacity 180ms ease; -} - -.skills-detail-panel.is-open { - opacity: 1; - pointer-events: auto; - transform: translateX(0); -} - -.skills-detail-panel.is-open::before { - opacity: 1; -} - -.skills-detail-panel__inner { - position: relative; - height: 100%; - min-height: 0; - padding: 8px 0 12px 32px; - overflow-y: auto; - overscroll-behavior: contain; -} - -.skills-detail-panel__content, -.skills-detail-panel__placeholder { - transition: - opacity 140ms ease, - transform 140ms ease, - visibility 0s linear 140ms; -} - -.skills-detail-panel__content { - opacity: 0; - visibility: hidden; - transform: translateY(10px); -} - -.skills-detail-panel__content.is-visible { - opacity: 1; - visibility: visible; - transform: translateY(0); - transition-delay: 20ms, 20ms, 0s; -} - -.skills-detail-panel__placeholder { - opacity: 1; - transform: translateY(0); -} - -.skills-workspace__header h2 { - margin: 0; - font-size: 1.72rem; - font-weight: 600; - letter-spacing: -0.04em; -} - -.skills-workspace__tabs { - display: inline-flex; - flex-wrap: wrap; - gap: 8px; - padding: 4px; - width: fit-content; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: rgba(20, 24, 32, 0.88); -} - -.skills-workspace__tab { - display: inline-flex; - align-items: center; - gap: 10px; - min-height: 40px; - padding: 0 14px; - border: 1px solid transparent; - border-radius: var(--radius); - color: var(--color-text-muted); - transition: border-color 120ms ease, background 120ms ease, color 120ms ease; -} - -.skills-workspace__tab:hover { - color: var(--color-text); -} - -.skills-workspace__tab.is-active { - border-color: rgba(240, 163, 107, 0.42); - background: var(--color-accent-soft); - color: var(--color-accent); -} - -.skills-workspace__tab-label, -.skills-workspace__tab-count, -.skills-secondary-section__eyebrow { - font-family: var(--font-mono); - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.skills-workspace__tab-label { - font-size: 0.78rem; -} - -.skills-workspace__tab-count { - display: inline-block; - color: inherit; - font-size: 0.72rem; - font-variant-numeric: tabular-nums; -} diff --git a/frontend/src/lib/product-language.ts b/frontend/src/lib/product-language.ts new file mode 100644 index 0000000..ae26c5d --- /dev/null +++ b/frontend/src/lib/product-language.ts @@ -0,0 +1,20 @@ +export const productLanguage = { + inUse: "In use", + needsReview: "Needs review", + review: "Review", + discover: "Discover", +} as const; + +export type ProductInventoryConcept = "inUse" | "needsReview"; + +export function skillStatusConcept(displayStatus: string): ProductInventoryConcept | null { + if (displayStatus === "Managed") return "inUse"; + if (displayStatus === "Unmanaged") return "needsReview"; + return null; +} + +export function mcpKindConcept(kind: string): ProductInventoryConcept | null { + if (kind === "managed") return "inUse"; + if (kind === "unmanaged") return "needsReview"; + return null; +} diff --git a/frontend/src/lib/query/flattenPages.ts b/frontend/src/lib/query/flattenPages.ts new file mode 100644 index 0000000..aa25ae7 --- /dev/null +++ b/frontend/src/lib/query/flattenPages.ts @@ -0,0 +1,22 @@ +export function flattenUniquePageItems( + data: { pages: readonly Page[] } | undefined, + keyOf: (item: T) => string, +): T[] { + if (!data) { + return []; + } + + const seen = new Set(); + const items: T[] = []; + for (const page of data.pages) { + for (const item of page.items) { + const key = keyOf(item); + if (seen.has(key)) { + continue; + } + seen.add(key); + items.push(item); + } + } + return items; +} diff --git a/frontend/src/lib/query/index.ts b/frontend/src/lib/query/index.ts new file mode 100644 index 0000000..2851469 --- /dev/null +++ b/frontend/src/lib/query/index.ts @@ -0,0 +1,3 @@ +export { flattenUniquePageItems } from "./flattenPages"; +export { queryPolicy } from "./options"; +export { useInfiniteScrollSentinel } from "./useInfiniteScrollSentinel"; diff --git a/frontend/src/lib/query/options.ts b/frontend/src/lib/query/options.ts new file mode 100644 index 0000000..424480e --- /dev/null +++ b/frontend/src/lib/query/options.ts @@ -0,0 +1,7 @@ +export function queryPolicy(staleTime: number, gcTime: number) { + return { + staleTime, + gcTime, + refetchOnWindowFocus: false, + } as const; +} diff --git a/frontend/src/lib/query/useInfiniteScrollSentinel.ts b/frontend/src/lib/query/useInfiniteScrollSentinel.ts new file mode 100644 index 0000000..0dc90e9 --- /dev/null +++ b/frontend/src/lib/query/useInfiniteScrollSentinel.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from "react"; + +interface InfiniteScrollSentinelOptions { + enabled: boolean; + hasMore: boolean; + onLoadMore: () => Promise; + rootMargin?: string; +} + +export function useInfiniteScrollSentinel({ + enabled, + hasMore, + onLoadMore, + rootMargin = "240px", +}: InfiniteScrollSentinelOptions) { + const sentinelRef = useRef(null); + const pagingRef = useRef(false); + + useEffect(() => { + if (!enabled || !hasMore) { + return; + } + const node = sentinelRef.current; + if (!node) { + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting) || pagingRef.current) { + return; + } + pagingRef.current = true; + void onLoadMore().finally(() => { + pagingRef.current = false; + }); + }, + { rootMargin }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [enabled, hasMore, onLoadMore, rootMargin]); + + return sentinelRef; +} diff --git a/frontend/src/lib/usePersistentViewMode.ts b/frontend/src/lib/usePersistentViewMode.ts new file mode 100644 index 0000000..830f0a6 --- /dev/null +++ b/frontend/src/lib/usePersistentViewMode.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +interface PersistentViewModeOptions { + storageKey: string; + defaultMode: T; + isValidMode: (value: unknown) => value is T; + normalizeMode?: (value: unknown) => T | null; + urlParam?: string; +} + +function resolveMode( + value: unknown, + isValidMode: (value: unknown) => value is T, + normalizeMode?: (value: unknown) => T | null, +): T | null { + if (isValidMode(value)) return value; + return normalizeMode?.(value) ?? null; +} + +function readStoredMode( + storageKey: string, + isValidMode: (value: unknown) => value is T, + normalizeMode?: (value: unknown) => T | null, +): { mode: T | null; raw: string | null } { + try { + const raw = window.localStorage.getItem(storageKey); + return { mode: resolveMode(raw, isValidMode, normalizeMode), raw }; + } catch { + return { mode: null, raw: null }; + } +} + +function writeStoredMode(storageKey: string, mode: T): void { + try { + window.localStorage.setItem(storageKey, mode); + } catch { + /* noop - storage may be unavailable */ + } +} + +export function usePersistentViewMode({ + storageKey, + defaultMode, + isValidMode, + normalizeMode, + urlParam = "view", +}: PersistentViewModeOptions): [T, (next: T) => void] { + const [searchParams, setSearchParams] = useSearchParams(); + + const initial = useRef(null); + const pendingCanonicalization = useRef<{ urlMode?: T; storageMode?: T } | null>(null); + if (initial.current === null) { + const fromUrl = searchParams.get(urlParam); + const urlMode = resolveMode(fromUrl, isValidMode, normalizeMode); + if (urlMode) { + initial.current = urlMode; + if (fromUrl !== urlMode) { + pendingCanonicalization.current = { urlMode }; + } + } else { + const stored = readStoredMode(storageKey, isValidMode, normalizeMode); + initial.current = stored.mode ?? defaultMode; + if (stored.raw && stored.mode && stored.raw !== stored.mode) { + pendingCanonicalization.current = { storageMode: stored.mode }; + } + } + } + + const [mode, setMode] = useState(initial.current as T); + + useEffect(() => { + const pending = pendingCanonicalization.current; + if (!pending) return; + pendingCanonicalization.current = null; + + if (pending.storageMode) { + writeStoredMode(storageKey, pending.storageMode); + } + if (pending.urlMode) { + const params = new URLSearchParams(searchParams); + if (pending.urlMode === defaultMode) { + params.delete(urlParam); + } else { + params.set(urlParam, pending.urlMode); + } + setSearchParams(params, { replace: true }); + } + }, [defaultMode, searchParams, setSearchParams, storageKey, urlParam]); + + const setModeExplicit = useCallback( + (next: T) => { + setMode(next); + writeStoredMode(storageKey, next); + const params = new URLSearchParams(searchParams); + if (next === defaultMode) { + params.delete(urlParam); + } else { + params.set(urlParam, next); + } + setSearchParams(params, { replace: true }); + }, + [defaultMode, searchParams, setSearchParams, storageKey, urlParam], + ); + + return [mode, setModeExplicit]; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 156edd3..30d5e2b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,17 +3,24 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; -import "./styles/variables.css"; -import "./styles/reset.css"; -import "./styles/scrollbars.css"; -import "./styles/app.css"; -import "./styles/ui.css"; +import "./styles/index.css"; + +/* Feature-local CSS. + * Order preserves the original app.css feature-section sequence so in-layer + * ties resolve identically to pre-split behavior. Each file wraps its + * contents in @layer features { … }. */ import "./components/detail/index.css"; -import "./features/marketplace/styles/index.css"; -import "./features/settings/styles/index.css"; -import "./features/skills/styles/index.css"; -import "./styles/dialogs.css"; -import "./styles/drawers.css"; +import "./features/overview/styles/overview.css"; +import "./features/marketplace/styles/cards.css"; +import "./features/settings/styles/settings.css"; +import "./features/skills/styles/detail.css"; +import "./features/skills/styles/board.css"; +import "./components/matrix/matrix.css"; +import "./features/marketplace/styles/panes.css"; +import "./features/marketplace/styles/mcp-detail.css"; +import "./features/mcp/styles/pages.css"; +import "./features/mcp/styles/detail-sheet.css"; +import "./features/mcp/styles/edit-dialogs.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/styles/README.md b/frontend/src/styles/README.md new file mode 100644 index 0000000..193b9df --- /dev/null +++ b/frontend/src/styles/README.md @@ -0,0 +1,106 @@ +# Styles + +CSS is organized around **cascade layers** and **feature colocation**. The entry +point is `styles/index.css`; everything else is discovered through it or via +feature-local imports in `main.tsx`. + +## Layer order + +Declared once in `styles/index.css`, low → high priority: + +``` +reset → tokens → base → components → features → utilities → overrides +``` + +Lower layers lose to higher layers regardless of source order. Source order +only resolves ties *within* a layer. Cross-layer cascade is locked — moving a +file in the import list cannot silently change which rule wins. + +## Where new rules belong + +| New rule styles… | Goes in… | Layer | +|---|---|---| +| an element reset | `styles/reset.css` | `reset` | +| a design token (custom property) | `styles/tokens.css` | `tokens` | +| html / body / scrollbars / selection | `styles/scrollbars.css` or new file in `styles/` | `base` | +| a shared primitive used in ≥ 2 features (e.g. buttons, cards, dialogs) | `styles/components/.css` | `components` | +| a screen or widget inside one feature | `features//styles/.css` | `features` | +| a cross-cutting helper class (e.g. `.muted-text`) | `styles/utilities.css` | `utilities` | +| an emergency override | `styles/overrides.css` (create if missing) | `overrides` | + +## File conventions + +- Wrap each file's contents in `@layer { … }`. +- Kebab-case filenames, one topic per file (`sidebar.css`, not `layout.css`). +- Class names: BEM-ish (`block__elem--mod`). No IDs. +- No `!important` outside the `overrides` layer. +- A feature's CSS never imports another feature's CSS. Share primitives + through `components/` instead. + +## Adding files + +- **Shared primitive**: create `styles/components/.css`, wrap in + `@layer components { … }`, then add an `@import "./components/.css";` + to `styles/index.css`. +- **Feature-specific style**: create `features//styles/.css`, + wrap in `@layer features { … }`, then add + `import "./features//styles/.css";` to `main.tsx`. + +## Current layout + +``` +frontend/src/styles/ + index.css # layer declaration + @import wiring + reset.css # reset layer + tokens.css # tokens layer (design tokens / custom properties) + scrollbars.css # base layer + utilities.css # utilities layer + dialogs.css # components layer (Radix dialog styles) + components/ # components layer — one file per primitive + buttons.css + bulk-bar.css + cards.css # generic .skill-card + grid (both skills + MCP cards extend it) + chips.css # chips + status badges + empty-panel.css # shared empty-state panel + harness.css # shared single-harness avatar primitive + note.css # shared in-body highlight/note surface + error-banner.css + filter.css # filter bar, pill group, filter trigger + page.css # app shell, page header, page chrome + popup.css # tooltip, hovercard, and popup-menu surfaces + sidebar.css # sidebar + nav magic-bar + spinner.css + toast.css + view-mode-toggle.css + +frontend/src/features/ + skills/styles/ # all in features layer + board.css + detail.css # also contains the shared skill-detail modal shell + list.css # needs-review skill rows + mcp/styles/ + pages.css # in-use / needs-review page-level rules + drift overlay + detail-sheet.css # MCP detail sheet (in-use + needs-review) + edit-dialogs.css # edit-config + reconcile dialogs + marketplace/styles/ + cards.css # .market-card + mcp-card variants + mcp-detail.css # MCP marketplace detail modal + panes.css # marketplace keep-mounted panes + settings/styles/ + settings.css + +frontend/src/components/ + detail/index.css # shared detail-view skeleton styles (components layer) + matrix/matrix.css # shared extension × harness matrix styles +``` + +## Debugging cascade + +If a rule isn't applying as expected: + +1. Open DevTools → Styles → look at the "Cascade Layers" section for the + element. The highest-priority layer wins, then source order within it. +2. If a `features` rule is being overridden by a `components` rule, the + layers are in the wrong order in `styles/index.css` — fix it there. +3. If within a layer, swap `@import` order in `index.css` (for components) + or `main.tsx` (for feature CSS). diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css deleted file mode 100644 index 2305922..0000000 --- a/frontend/src/styles/app.css +++ /dev/null @@ -1,302 +0,0 @@ -.app-shell { - position: relative; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - min-height: 100dvh; - height: 100dvh; - overflow: hidden; - isolation: isolate; - background: - radial-gradient(circle at top left, var(--color-bg-overlay), transparent 30%), - linear-gradient(to bottom, rgba(255, 255, 255, 0.02), transparent 28%), - var(--color-bg); -} - -.app-shell::before { - content: ""; - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - background: - repeating-linear-gradient(to right, transparent 0 39px, var(--color-grid) 39px 40px), - repeating-linear-gradient(to bottom, transparent 0 39px, var(--color-grid) 39px 40px); - mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.84), transparent 92%); - opacity: 0.56; -} - -.app-header { - position: sticky; - top: 0; - z-index: 20; - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 20px; - padding: 18px 28px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(9, 10, 13, 0.84); - backdrop-filter: blur(16px); -} - -.app-header__brand { - display: grid; - gap: 4px; -} - -.app-header__title { - margin: 0; - font-size: 1.18rem; - font-weight: 600; - letter-spacing: -0.03em; -} - -.app-header__eyebrow { - margin: 0; - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.app-header__nav { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.app-header__actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; -} - -.app-header__refresh { - appearance: none; - -webkit-appearance: none; - white-space: nowrap; - font: inherit; -} - -.app-header__refresh:disabled { - cursor: not-allowed; - opacity: 0.72; -} - -.app-header__refresh[aria-busy="true"] { - border-color: var(--color-border-strong); - color: var(--color-text); -} - -.app-header__settings.is-active { - border-color: rgba(240, 163, 107, 0.44); - background: var(--color-accent-soft); - color: var(--color-accent); -} - -.nav-link { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 36px; - padding: 0 12px; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: rgba(20, 24, 32, 0.88); - color: var(--color-text-muted); -} - -.nav-link:hover { - border-color: var(--color-border-strong); - color: var(--color-text); -} - -.nav-link.is-active { - border-color: rgba(240, 163, 107, 0.44); - background: var(--color-accent-soft); - color: var(--color-accent); -} - -.nav-link__prompt { - font-family: var(--font-mono); - font-size: 0.76rem; -} - -.nav-link__label { - font-family: var(--font-mono); - font-size: 0.8rem; - letter-spacing: 0.04em; -} - -.app-main { - position: relative; - width: min(1280px, calc(100vw - 48px)); - margin: 0 auto; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - padding: 28px 0 42px; -} - -.app-main--skills { - width: min(1480px, calc(100vw - 48px)); - display: flex; - overflow: hidden; - padding-bottom: 28px; -} - -.page-panel { - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-panel); - box-shadow: var(--shadow-panel); - padding: 24px; -} - -.page-header { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 24px; - align-items: start; -} - -.page-header h2 { - margin: 0; - font-size: 1.72rem; - font-weight: 600; - letter-spacing: -0.04em; -} - -.page-header__eyebrow { - margin: 0 0 8px; - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-accent); -} - -.page-header__copy, -.muted-text { - margin: 10px 0 0; - color: var(--color-text-muted); -} - -.definition-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px 16px; - margin-top: 14px; -} - -.definition-grid dt { - margin: 0; - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.definition-grid dd { - margin: 6px 0 0; - word-break: break-word; -} - -.panel-state { - display: flex; - align-items: center; - justify-content: center; - min-height: 180px; - color: var(--color-text-muted); -} - -.toggle-switch { - display: inline-flex; - align-items: center; - gap: 10px; - color: var(--color-text); -} - -.toggle-switch__label { - font-family: var(--font-mono); - font-size: 0.78rem; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--color-text-muted); -} - -.toggle-switch__root { - position: relative; - display: inline-flex; - align-items: center; - width: 52px; - height: 30px; - padding: 2px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 999px; - background: rgba(20, 24, 32, 0.94); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); - transition: - background 140ms ease, - border-color 140ms ease, - box-shadow 140ms ease; -} - -.toggle-switch__root:hover { - border-color: rgba(255, 255, 255, 0.14); -} - -.toggle-switch__root:focus-visible { - outline: none; - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.08), - 0 0 0 3px rgba(107, 194, 164, 0.18); -} - -.toggle-switch__root[data-state="checked"] { - background: rgba(34, 68, 58, 0.98); - border-color: rgba(189, 241, 221, 0.18); -} - -.toggle-switch__root[data-disabled] { - cursor: wait; - opacity: 0.72; -} - -.toggle-switch__thumb { - display: block; - width: 24px; - height: 24px; - border-radius: 999px; - background: #f6f8fb; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28); - transform: translateX(0); - transition: transform 140ms ease; -} - -.toggle-switch__thumb[data-state="checked"] { - transform: translateX(22px); -} - -@media (max-width: 900px) { - .app-header, - .page-header { - grid-template-columns: 1fr; - } - - .app-header__actions { - justify-content: flex-start; - } - - .app-main { - width: min(100vw - 24px, 100%); - padding: 20px 0 28px; - } - - .page-panel { - padding: 18px; - } -} diff --git a/frontend/src/styles/components/action-pill.css b/frontend/src/styles/components/action-pill.css new file mode 100644 index 0000000..98bddcd --- /dev/null +++ b/frontend/src/styles/components/action-pill.css @@ -0,0 +1,152 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Action pill — the universal interactive button language. */ +/* -------------------------------------------------------------------------- */ +/* + * Used across cards, rows, page-header CTAs, empty-state CTAs, detail-sheet + * actions, and lightweight chooser dialogs. Solid .btn-* family in + * styles/components/buttons.css is reserved for heavier confirmation + * footers where binary commitment (Cancel | Confirm) should carry more + * visual weight. + * + * Default size matches the chip-row height so card footers sit on the same + * rhythm. Modifiers --md and --lg scale up for page chrome. + * Modifier --accent tints the resting state for the page's primary action. + */ + +.action-pill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 3px var(--space-3); + border-radius: var(--radius-pill); + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: 1.5; + font-weight: 500; + cursor: pointer; + transition: color 140ms ease, border-color 140ms ease, background 140ms ease; + white-space: nowrap; +} + +.action-pill:hover:not(:disabled), +.action-pill:focus-visible:not(:disabled) { + color: var(--color-accent); + border-color: var(--color-accent); + background: var(--color-accent-softer); +} + +.action-pill:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-accent-soft); +} + +.action-pill:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.action-pill[data-pending="true"] { + color: var(--color-text); + border-color: var(--color-border-strong); + cursor: progress; +} + +/* Size modifiers ---------------------------------------------------------- */ + +.action-pill--md { + padding: 6px var(--space-4); + gap: var(--space-2); + font-size: var(--font-size-sm); +} + +.action-pill--lg { + padding: 8px var(--space-5); + gap: var(--space-2); + font-size: var(--font-size-md); +} + +/* Emphasis modifier ------------------------------------------------------- */ + +/* The page's primary action — accent-tinted at rest so it stands apart from + * the surrounding plain pills, but stays in the same visual family. */ +.action-pill--accent { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 38%, transparent); +} + +.action-pill--accent:hover:not(:disabled), +.action-pill--accent:focus-visible:not(:disabled) { + color: var(--color-accent-strong); + border-color: var(--color-accent); + background: var(--color-accent-soft); +} + +/* Destructive trigger — used outside dialogs (e.g. "Delete Skill" in the + * detail sheet). The actual confirm step uses the shared confirm-dialog + * footer styling so the binary commitment carries more visual weight. */ +.action-pill--danger { + color: var(--color-danger); + border-color: color-mix(in srgb, var(--color-danger) 38%, transparent); +} + +.action-pill--danger:hover:not(:disabled), +.action-pill--danger:focus-visible:not(:disabled) { + color: var(--color-danger); + border-color: var(--color-danger); + background: var(--color-danger-soft); +} + +.action-pill--danger:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 20%, transparent); +} + +/* -------------------------------------------------------------------------- */ +/* Status pill — non-interactive cousin of .action-pill. */ +/* -------------------------------------------------------------------------- */ +/* + * Surfaces status (Identical, Differs, Match in marketplace) using the same + * ghost-pill shape so action buttons + status indicators read as one design + * language rather than two. + */ + +.card-status-pill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-3); + border-radius: var(--radius-pill); + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: 1.5; + font-weight: 500; + white-space: nowrap; +} + +.card-status-pill--md { + padding: 6px var(--space-4); + gap: var(--space-2); + font-size: var(--font-size-sm); +} + +.card-status-pill--success { + color: var(--color-success); + border-color: color-mix(in srgb, var(--color-success) 32%, transparent); +} + +.card-status-pill--warning { + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 32%, transparent); +} + +.card-status-pill--accent { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 32%, transparent); +} + +} diff --git a/frontend/src/styles/components/bulk-bar.css b/frontend/src/styles/components/bulk-bar.css new file mode 100644 index 0000000..db7ed77 --- /dev/null +++ b/frontend/src/styles/components/bulk-bar.css @@ -0,0 +1,174 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Bulk action bar (floating pill) */ +/* -------------------------------------------------------------------------- */ + +.bulk-dock { + position: fixed; + left: var(--sidebar-width); + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: flex-end; + padding-bottom: var(--space-7); + pointer-events: none; + z-index: 40; +} + +.bulk-dock__fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 160px; + background: linear-gradient( + to top, + var(--color-bg) 0%, + rgba(11, 12, 15, 0.96) 32%, + rgba(11, 12, 15, 0.7) 60%, + rgba(11, 12, 15, 0) 100% + ); + pointer-events: none; +} + +@media (max-width: 900px) { + .bulk-dock { + left: 0; + } +} + +.bulk-bar { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-pill); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lift), inset 0 1px 0 rgba(255, 255, 255, 0.04); + backdrop-filter: blur(10px); + pointer-events: auto; + transform-origin: bottom center; + animation: bulk-bar-in 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.bulk-bar[data-state="closed"] { + animation: bulk-bar-out 180ms ease forwards; +} + +@keyframes bulk-bar-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes bulk-bar-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.98); + } +} + +.bulk-bar__group { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.bulk-bar__count { + padding: 0 var(--space-3) 0 var(--space-4); + color: var(--color-text-muted); + font-size: 0.86rem; + white-space: nowrap; +} + +.bulk-bar__count strong { + color: var(--color-text); + font-weight: 600; +} + +.bulk-bar__clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.bulk-bar__clear:hover:not(:disabled) { + background: var(--color-surface); + color: var(--color-text); +} + +.bulk-bar__divider { + width: 1px; + height: 20px; + background: var(--color-border); +} + +.bulk-bar__action { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 var(--space-4); + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-text); + font: inherit; + font-size: 0.86rem; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.bulk-bar__action:hover:not(:disabled) { + background: var(--color-surface); +} + +.bulk-bar__action:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.bulk-bar__danger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--color-danger); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.bulk-bar__danger:hover:not(:disabled) { + background: var(--color-danger-soft); +} + +.bulk-bar__danger:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +} diff --git a/frontend/src/styles/components/buttons.css b/frontend/src/styles/components/buttons.css new file mode 100644 index 0000000..7b4940b --- /dev/null +++ b/frontend/src/styles/components/buttons.css @@ -0,0 +1,118 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Buttons + icon buttons */ +/* -------------------------------------------------------------------------- */ +/* + * Reserved for dialog footers, where binary commitment (Cancel | Confirm) + * benefits from heavy visual weight + clear win/lose hierarchy. Everywhere + * else (cards, rows, page chrome, empty-state CTAs) uses the lighter + * .action-pill family — see styles/components/action-pill.css. + */ + +.btn, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + height: 34px; + padding: 0 var(--space-4); + border: none; + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.88rem; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.btn:hover, +.icon-button:hover { + background: var(--color-surface-raised); +} + +.btn:disabled, +.icon-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.btn:focus-visible, +.icon-button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-softer); +} + +.btn.btn-primary { + background: var(--color-accent); + color: var(--color-text-inverted); + font-weight: 500; +} + +.btn.btn-primary:hover { + background: var(--color-accent-strong); +} + +.btn.btn-secondary { + background: var(--color-surface); +} + +.btn.btn-ghost { + background: transparent; + color: var(--color-text-muted); +} + +.btn.btn-ghost:hover { + color: var(--color-text); + background: var(--color-surface-raised); +} + +.btn.btn-static { + cursor: default; + color: var(--color-text-muted); +} + +.btn.btn-static:hover { + background: var(--color-surface); +} + +.icon-button { + width: 34px; + padding: 0; +} + +.card-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 24px; + padding: 0; + border: none; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.card-icon-button:hover:not(:disabled) { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.card-icon-button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-softer); +} + +.card-icon-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* -------------------------------------------------------------------------- */ + +} diff --git a/frontend/src/styles/components/cards.css b/frontend/src/styles/components/cards.css new file mode 100644 index 0000000..f466f27 --- /dev/null +++ b/frontend/src/styles/components/cards.css @@ -0,0 +1,181 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Skill cards grid */ +/* -------------------------------------------------------------------------- */ + +.skill-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-6); +} + +@media (max-width: 1100px) { + .skill-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .skill-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.skill-card { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-height: 160px; + padding: var(--space-5) var(--space-5) var(--space-4); + border-radius: var(--radius-md); + background: var(--color-surface); + cursor: pointer; + transition: background 120ms ease, box-shadow 120ms ease; +} + +.skill-card:hover { + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); +} + +.skill-card__head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + align-items: center; + gap: var(--space-2); +} + +.skill-card__footer { + display: flex; + align-items: center; + gap: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + margin-top: auto; +} + +.skill-card__footer .skill-card__harness-row { + flex: 1; + border-top: none; + padding-top: 0; + margin-top: 0; +} + +.card-select-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 3px; + background: transparent; + border: 1px solid var(--color-border-strong); + opacity: 0.55; + cursor: pointer; + transition: opacity 120ms ease, border-color 120ms ease; +} + +.card-select-checkbox:hover { + opacity: 0.85; + border-color: var(--color-text-muted); +} + +.card-select-checkbox[data-state="checked"] { + background: var(--color-accent); + border-color: var(--color-accent); + opacity: 1; + color: var(--color-text-inverted); +} + +.skill-card__name { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--color-text); + letter-spacing: -0.005em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skill-card__description { + margin: 0; + color: var(--color-text-muted); + font-size: 0.88rem; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 3px var(--space-2); + border-radius: 4px; + background: var(--color-surface-raised); + color: var(--color-text-muted); + font-size: 0.72rem; + letter-spacing: 0.01em; +} + +.skill-card__harness-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + min-height: 32px; + margin-top: auto; +} + +.harness-stack { + display: inline-flex; + align-items: center; + position: relative; + isolation: isolate; +} + +.harness-stack__item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; +} + +.harness-stack__item + .harness-stack__item { + margin-left: -6px; +} + +.harness-stack__item img { + width: 22px; + height: 22px; + display: block; +} + +.harness-stack__fallback { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-surface-raised); + color: var(--color-text-muted); + font-size: 0.68rem; + font-weight: 700; + border-radius: 4px; +} + +.skill-card__harness-count { + margin-left: auto; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +} diff --git a/frontend/src/styles/components/chips.css b/frontend/src/styles/components/chips.css new file mode 100644 index 0000000..fcde424 --- /dev/null +++ b/frontend/src/styles/components/chips.css @@ -0,0 +1,42 @@ +@layer components { + +/* Status badges */ +/* -------------------------------------------------------------------------- */ + +.ui-status-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 var(--space-2); + border: none; + border-radius: 4px; + background: var(--color-surface-raised); + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text); +} + +.ui-status-badge--success { + background: var(--color-success-soft); + color: var(--color-success); +} + +.ui-status-badge--warning { + background: var(--color-warning-soft); + color: var(--color-warning); +} + +.ui-status-badge--neutral { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.ui-status-badge--muted { + color: var(--color-text-muted); +} + +/* -------------------------------------------------------------------------- */ + +} diff --git a/frontend/src/styles/components/detail-sheet.css b/frontend/src/styles/components/detail-sheet.css new file mode 100644 index 0000000..26c0f60 --- /dev/null +++ b/frontend/src/styles/components/detail-sheet.css @@ -0,0 +1,194 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Detail sheet — shared modal container for skill + MCP + marketplace views. */ +/* -------------------------------------------------------------------------- */ +/* + * This primitive owns the modal frame only: sizing, padding, elevation, and + * base section/body/meta rhythm. Feature-local shells (for example the skill + * and MCP detail shells) are responsible for splitting chrome/body/footer + * regions when a view needs fixed actions or specialized scrolling behavior. + */ + +.detail-sheet { + position: fixed; + top: 50%; + left: 50%; + z-index: 80; + display: grid; + gap: var(--space-4); + width: min(960px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + padding: var(--space-6); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-lift); + /* hidden horizontally so wide content (long URLs, code blocks, JSON + * payloads) gets its own scroll instead of pushing the whole modal. */ + overflow: hidden auto; + transform: translate(-50%, -50%); +} + +.detail-sheet:focus-visible { + outline: none; +} + +@media (max-width: 680px) { + .detail-sheet { + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + padding: var(--space-4); + border-radius: var(--radius-md); + } +} + +/* Body — scrollable area between chrome and footer. Consumers put + * children inside. min-width: 0 so flex children opt + * into shrinking below their content width (otherwise wide code blocks + * push the modal wider than its declared width). */ +.detail-sheet__body { + display: flex; + flex-direction: column; + gap: var(--space-5); + min-height: 0; + min-width: 0; +} + +/* Section primitive — used via the component. */ +.detail-sheet__section { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} + +.detail-sheet__section-heading { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text); + letter-spacing: 0.02em; + text-transform: uppercase; +} + +/* Meta cluster — single horizontal strip under the title that carries + * identity + status + source links. */ +.detail-sheet__meta { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.detail-sheet__divider { + color: var(--color-text-muted); +} + +/* Footer — single canonical action surface. Sticky bottom, right-aligned + * actions, wraps gracefully when there are several. */ +.detail-sheet__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); + flex-wrap: wrap; +} + +/* Per-harness binding row — shared between MCP server bindings and skill + * harness access. Status is visual; the action is textual. */ +.detail-sheet__bindings { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.detail-sheet__binding-row { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + background: var(--color-surface-raised); + border-radius: var(--radius-sm); +} + +.detail-sheet__binding-row[data-pending="true"] { + opacity: 0.7; +} + +.detail-sheet__binding-identity { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + flex-wrap: wrap; +} + +.detail-sheet__binding-label { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: var(--font-size-sm); + color: var(--color-text); +} + +.detail-sheet__binding-label-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-sheet__binding-dot { + flex: 0 0 auto; + width: 7px; + height: 7px; + border-radius: 999px; + border: 1px solid var(--color-border-strong); + background: transparent; +} + +.detail-sheet__binding-dot[data-tone="enabled"] { + border-color: color-mix(in srgb, var(--color-accent) 70%, transparent); + background: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-softer); +} + +.detail-sheet__binding-dot[data-tone="warning"] { + border-color: color-mix(in srgb, var(--color-warning) 72%, transparent); + background: var(--color-warning); + box-shadow: 0 0 0 3px var(--color-warning-soft); +} + +.detail-sheet__binding-state { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.detail-sheet__binding-state[data-tone="warning"] { + color: var(--color-warning); +} + +.detail-sheet__binding-detail { + font-family: var(--font-mono); + font-size: var(--font-size-xs); +} + +.detail-sheet__binding-actions { + display: flex; + gap: var(--space-1); + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +.detail-sheet__binding-hint { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + font-style: italic; +} + +} diff --git a/frontend/src/styles/components/empty-panel.css b/frontend/src/styles/components/empty-panel.css new file mode 100644 index 0000000..7aa4c03 --- /dev/null +++ b/frontend/src/styles/components/empty-panel.css @@ -0,0 +1,81 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Empty-state panel */ +/* -------------------------------------------------------------------------- */ + +.empty-panel { + max-width: 760px; + padding: var(--space-8) var(--space-7); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.empty-panel__header { + display: flex; + flex-direction: column; + align-items: center; +} + +.empty-panel__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + margin-bottom: var(--space-5); + border: 1.5px solid var(--color-accent); + border-radius: var(--radius-md); + color: var(--color-accent); +} + +.empty-panel__title { + margin: 0; + font-size: 1.32rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--color-text); + text-align: center; +} + +.empty-panel__body { + margin: var(--space-4) 0 var(--space-6); + color: var(--color-text-muted); + line-height: 1.55; + text-align: center; +} + +.empty-panel__steps { + display: grid; + gap: var(--space-3); + max-width: 520px; + margin: 0 auto var(--space-6); +} + +.empty-panel__step { + display: grid; + grid-template-columns: 60px 1fr; + gap: var(--space-4); + align-items: baseline; +} + +.empty-panel__step-label { + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.empty-panel__step-copy { + color: var(--color-text); + font-size: 0.92rem; +} + +.empty-panel__actions { + display: flex; + justify-content: center; + gap: var(--space-3); +} + +} diff --git a/frontend/src/styles/components/error-banner.css b/frontend/src/styles/components/error-banner.css new file mode 100644 index 0000000..e34a49c --- /dev/null +++ b/frontend/src/styles/components/error-banner.css @@ -0,0 +1,36 @@ +@layer components { + +/* Error banner */ +/* -------------------------------------------------------------------------- */ + +.error-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--color-danger-soft); + color: var(--color-danger); +} + +.error-banner__message { + min-width: 0; +} + +.error-banner__dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + cursor: pointer; +} + +/* -------------------------------------------------------------------------- */ + +} diff --git a/frontend/src/styles/components/filter.css b/frontend/src/styles/components/filter.css new file mode 100644 index 0000000..cfc6b9f --- /dev/null +++ b/frontend/src/styles/components/filter.css @@ -0,0 +1,143 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Filter bar */ +/* -------------------------------------------------------------------------- */ + +.filter-bar { + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} + +.filter-bar__search { + position: relative; + flex: 1 1 260px; + min-width: 220px; +} + +.filter-bar__search input { + width: 100%; + height: 38px; + padding: 0 var(--space-4) 0 38px; + border: none; + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.9rem; +} + +.filter-bar__search input:focus-visible { + outline: none; + background: var(--color-surface-raised); + box-shadow: 0 0 0 2px var(--color-accent-softer); +} + +.filter-bar__search input::placeholder { + color: var(--color-text-subtle); +} + +.filter-bar__search svg { + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + color: var(--color-text-muted); + pointer-events: none; +} + +.pill-group { + display: inline-flex; + align-items: center; + padding: 3px; + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.pill-group__pill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 5px var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + font: inherit; + font-size: var(--font-size-sm); + text-decoration: none; + cursor: pointer; +} + +.pill-group__pill:hover { + color: var(--color-text); +} + +.pill-group__pill[data-active="true"], +.pill-group__pill.is-active { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.pill-group__count { + color: var(--color-text-subtle); + font-size: 0.76rem; + font-variant-numeric: tabular-nums; +} + +.pill-group__pill[data-active="true"] .pill-group__count { + color: var(--color-text-muted); +} + +.filter-trigger { + position: relative; + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: 38px; + padding: 0 var(--space-3); + min-width: 38px; + justify-content: center; + border: none; + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-muted); + font: inherit; + font-size: 0.86rem; + cursor: pointer; + transition: color 120ms ease, background 120ms ease; +} + +.filter-trigger:hover { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.filter-trigger[data-active="true"] { + color: var(--color-accent); + background: var(--color-accent-softer); + padding: 0 var(--space-4); +} + +.filter-trigger__label { + color: inherit; +} + +.filter-trigger__dot { + position: absolute; + top: 6px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-accent); +} + +.filter-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-softer); +} + +} diff --git a/frontend/src/styles/components/harness.css b/frontend/src/styles/components/harness.css new file mode 100644 index 0000000..6715c25 --- /dev/null +++ b/frontend/src/styles/components/harness.css @@ -0,0 +1,32 @@ +@layer components { + +.harness-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 22px; +} + +.harness-avatar__logo { + width: 100%; + height: 100%; + display: block; +} + +.harness-avatar__fallback { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + background: var(--color-surface-raised); + color: var(--color-text-muted); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; +} + +} diff --git a/frontend/src/styles/components/needs-review-row.css b/frontend/src/styles/components/needs-review-row.css new file mode 100644 index 0000000..9f0a0a7 --- /dev/null +++ b/frontend/src/styles/components/needs-review-row.css @@ -0,0 +1,99 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Needs-review row — shared layout for local skill and MCP review surfaces */ +/* -------------------------------------------------------------------------- */ + +.needs-review-rows { + display: grid; + gap: 1px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-border); +} + +.needs-review-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--space-5); + padding: var(--space-5); + background: var(--color-surface); + cursor: pointer; + transition: background 120ms ease; +} + +.needs-review-row:hover { + background: var(--color-surface-raised); +} + +.needs-review-row:focus-visible { + outline: none; + background: var(--color-surface-raised); + box-shadow: inset 0 0 0 2px var(--color-accent-softer); +} + +.needs-review-row__body { + display: grid; + gap: 6px; + min-width: 0; +} + +.needs-review-row__title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + min-width: 0; +} + +.needs-review-row__name { + margin: 0; + font-size: 0.98rem; + font-weight: 600; + color: var(--color-text); + line-height: 1.3; + word-break: break-word; +} + +/* Logo stack sits flush against the name. Reuses .harness-stack__item + * sizing from styles/components/cards.css. */ +.needs-review-row__logos { + display: inline-flex; + align-items: center; + position: relative; + isolation: isolate; +} + +.needs-review-row__meta { + margin: 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.45; +} + +.needs-review-row__description { + margin: 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Trailing column: status chip(s) + action button on the same line. + * Pinned to the row's first text line so multi-line descriptions don't + * push it down. Chips sit to the left of the action button. */ +.needs-review-row__trailing { + display: inline-flex; + align-items: center; + gap: var(--space-2); + align-self: start; + margin-top: 2px; + flex-wrap: wrap; + justify-content: flex-end; +} + +} diff --git a/frontend/src/styles/components/note.css b/frontend/src/styles/components/note.css new file mode 100644 index 0000000..de0cc14 --- /dev/null +++ b/frontend/src/styles/components/note.css @@ -0,0 +1,18 @@ +@layer components { + +.detail-note { + margin: 0; + padding: var(--space-3) var(--space-4); + border: 1px solid color-mix(in srgb, var(--color-warning) 18%, var(--color-border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-warning-soft) 72%, transparent); + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.45; +} + +.detail-note strong { + color: var(--color-text); +} + +} diff --git a/frontend/src/styles/components/page.css b/frontend/src/styles/components/page.css new file mode 100644 index 0000000..f7bf338 --- /dev/null +++ b/frontend/src/styles/components/page.css @@ -0,0 +1,119 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* App shell — sidebar + main pane */ +/* -------------------------------------------------------------------------- */ + +.app-shell { + position: relative; + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 100dvh; + height: 100dvh; + overflow: hidden; + isolation: isolate; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-sans); +} + +.app-main { + position: relative; + min-height: 0; + overflow-y: auto; + padding: var(--space-8) var(--space-10); +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + .app-main { + padding: var(--space-6) var(--space-6); + } +} + +/* -------------------------------------------------------------------------- */ +/* Page header */ +/* -------------------------------------------------------------------------- */ + +.page-shell { + display: grid; + gap: var(--space-8); + max-width: 1200px; + margin: 0 auto; +} + +.page-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-6); + align-items: start; +} + +.page-header__breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--color-text-muted); + font-size: 0.84rem; +} + +.page-header__breadcrumb span[data-current="true"] { + color: var(--color-text); +} + +.page-header__breadcrumb span[aria-hidden="true"] { + opacity: 0.55; +} + +.page-header__title { + margin: var(--space-3) 0 0; + font-size: 2rem; + font-weight: 600; + letter-spacing: -0.03em; + color: var(--color-text); +} + +.page-header__subtitle { + margin: var(--space-3) 0 0; + max-width: 56ch; + color: var(--color-text-muted); + line-height: 1.5; +} + +.page-header__actions { + display: flex; + align-items: center; + gap: var(--space-3); + padding-top: var(--space-6); +} + +/* -------------------------------------------------------------------------- */ +/* Page chrome (sticky header + filter bar with fade-out below) */ +/* -------------------------------------------------------------------------- */ + +.page-chrome { + position: sticky; + top: calc(-1 * var(--space-8)); + z-index: 20; + display: grid; + gap: var(--space-7); + padding-top: var(--space-8); + padding-bottom: var(--space-7); + margin-top: calc(-1 * var(--space-8)); + background: var(--color-bg); +} + +.page-chrome::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 16px; + background: linear-gradient(to bottom, var(--color-bg), rgba(11, 12, 15, 0)); + pointer-events: none; +} + +} diff --git a/frontend/src/styles/components/popup.css b/frontend/src/styles/components/popup.css new file mode 100644 index 0000000..7a6d972 --- /dev/null +++ b/frontend/src/styles/components/popup.css @@ -0,0 +1,208 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Popup surfaces */ +/* -------------------------------------------------------------------------- */ + +.ui-popup { + --ui-popup-surface: color-mix(in srgb, var(--color-surface-raised) 92%, black 8%); + position: relative; + color: var(--color-text); + border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent); + background: var(--ui-popup-surface); + border-radius: var(--radius-md); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.34), 0 2px 10px rgba(0, 0, 0, 0.18); + /* Radix copies this z-index onto its fixed popper wrapper. Keep popups + * above detail/dialog surfaces (70/80/90) but below toasts (100). */ + z-index: 95; + transform-origin: var( + --radix-popover-content-transform-origin, + var(--radix-tooltip-content-transform-origin, center) + ); + animation: ui-popup-in 140ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.ui-popup[data-state="closed"] { + animation: ui-popup-out 100ms ease forwards; +} + +.ui-popup[data-side="top"] { + --ui-popup-enter-x: 0px; + --ui-popup-enter-y: 4px; +} + +.ui-popup[data-side="right"] { + --ui-popup-enter-x: -4px; + --ui-popup-enter-y: 0px; +} + +.ui-popup[data-side="bottom"] { + --ui-popup-enter-x: 0px; + --ui-popup-enter-y: -4px; +} + +.ui-popup[data-side="left"] { + --ui-popup-enter-x: 4px; + --ui-popup-enter-y: 0px; +} + +.ui-popup__arrow { + fill: var(--ui-popup-surface); +} + +.ui-popup--tooltip { + max-width: min(34ch, calc(100vw - 24px)); + padding: 5px 10px; + color: color-mix(in srgb, var(--color-text) 72%, var(--color-text-muted)); + font-size: var(--font-size-sm); + line-height: 1.22; + text-wrap: pretty; + user-select: none; +} + +.ui-popup--tooltip--hint { + max-width: min(40ch, calc(100vw - 24px)); + color: var(--color-text-muted); +} + +.ui-tooltip-trigger { + display: inline-flex; + min-width: 0; +} + +.ui-tooltip-trigger:focus-visible { + outline: none; +} + +.ui-tooltip-trigger:focus-visible > * { + box-shadow: 0 0 0 2px var(--color-accent-soft); +} + +.ui-tooltip-trigger > :disabled { + pointer-events: none; +} + +.ui-popup--menu { + min-width: 220px; + padding: var(--space-2); +} + +.ui-menu { + display: grid; + gap: var(--space-1); +} + +.ui-menu__list { + display: grid; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.ui-menu__section-label { + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.04em; + padding: var(--space-1) var(--space-3); + text-transform: uppercase; +} + +.ui-menu__item { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font: inherit; + font-size: 0.88rem; + text-align: left; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.ui-menu__item:hover:not(:disabled), +.ui-menu__item:focus-visible:not(:disabled) { + background: color-mix(in srgb, white 4%, var(--color-surface)); +} + +.ui-menu__item:focus-visible { + outline: none; +} + +.ui-menu__item:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ui-menu__item[data-selected="true"] { + color: var(--color-accent); +} + +.ui-menu__item[data-destructive="true"] { + color: var(--color-danger); +} + +.ui-menu__item[data-destructive="true"]:hover:not(:disabled), +.ui-menu__item[data-destructive="true"]:focus-visible:not(:disabled) { + background: color-mix(in srgb, var(--color-danger-soft) 88%, transparent); +} + +.ui-menu__icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-accent); +} + +.ui-menu__item[data-destructive="true"] .ui-menu__icon { + color: inherit; +} + +.ui-menu__label { + min-width: 0; +} + +.ui-menu__meta { + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.ui-menu__item[data-selected="true"] .ui-menu__meta { + color: var(--color-accent); + opacity: 0.8; +} + +@keyframes ui-popup-in { + from { + opacity: 0; + transform: translate3d( + var(--ui-popup-enter-x, 0px), + var(--ui-popup-enter-y, 0px), + 0 + ) scale(0.985); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes ui-popup-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +} diff --git a/frontend/src/styles/components/sidebar.css b/frontend/src/styles/components/sidebar.css new file mode 100644 index 0000000..46ae380 --- /dev/null +++ b/frontend/src/styles/components/sidebar.css @@ -0,0 +1,238 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Sidebar */ +/* -------------------------------------------------------------------------- */ + +.sidebar { + display: grid; + grid-template-rows: auto 1fr auto; + gap: var(--space-5); + padding: var(--space-6) var(--space-5); + background: var(--color-sidebar-bg); + overflow-y: auto; +} + +.sidebar__brand { + padding: 0 var(--space-2); +} + +.sidebar__brand-name { + display: inline-flex; + margin: 0; + font-size: 1.02rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--color-text); + text-decoration: none; + transition: color 120ms ease; +} + +.sidebar__brand-name:hover, +.sidebar__brand-name:focus-visible { + color: var(--color-accent); + outline: none; +} + +.sidebar__nav { + display: grid; + gap: var(--space-2); + align-content: start; +} + +.sidebar-group { + display: grid; + gap: 2px; +} + +.sidebar-top-link { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: 0.92rem; + font-weight: 600; + text-decoration: none; + transition: background 120ms ease, color 120ms ease; +} + +.sidebar-top-link:hover, +.sidebar-top-link:focus-visible, +.sidebar-top-link.is-active { + background: var(--color-surface-raised); + color: var(--color-text); + outline: none; +} + +.sidebar-group__header { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto 14px; + align-items: center; + gap: var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + background: transparent; + color: var(--color-text); + font-size: 0.92rem; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.sidebar-group__chevron { + grid-column: 4; + justify-self: end; + color: var(--color-text-muted); + transition: transform 120ms ease; +} + +.sidebar-group[data-collapsed="true"] .sidebar-group__chevron { + transform: rotate(-90deg); +} + +.sidebar-group__items { + position: relative; + display: grid; + gap: 2px; + padding-left: var(--space-3); +} + +.sidebar-link { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 4px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: 0.88rem; + text-decoration: none; + transition: color 160ms ease; +} + +.sidebar-link:hover, +.sidebar-link:focus-visible { + color: var(--color-text); + outline: none; +} + +.sidebar-link.is-active { + color: var(--color-text); +} + +/* Magic-bar — single absolutely-positioned pill per NavGroup that tracks the + active (or hovered/focused) link. Provides the highlight that used to live + on .sidebar-link directly. */ +.sidebar-indicator { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + opacity: 0; + pointer-events: none; + z-index: 0; + transition: + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + width 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + height 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + opacity 160ms ease; +} + +.sidebar-indicator[data-visible="true"] { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-indicator { + transition: opacity 160ms ease; + } +} + +.sidebar-link__dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.55; +} + +.sidebar-group__count, +.sidebar-link__count { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.sidebar-group__count { + min-width: 2ch; + color: var(--color-text-muted); + font-size: 0.8rem; + font-weight: 600; + line-height: 1; +} + +.sidebar-link__count { + min-width: 2ch; + color: var(--color-text-muted); + font-size: 0.74rem; + line-height: 1; + opacity: 0.76; +} + +.sidebar-link.is-active .sidebar-link__count { + color: var(--color-text); + opacity: 0.9; +} + +.sidebar__footer { + display: grid; + gap: 2px; + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +.sidebar-footer-btn { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + font-size: 0.88rem; + text-align: left; + text-decoration: none; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.sidebar-footer-btn:hover { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.sidebar-footer-btn.is-active { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.sidebar-footer-btn[aria-busy="true"] { + color: var(--color-text); +} + +.sidebar-footer-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +} diff --git a/frontend/src/styles/components/spinner.css b/frontend/src/styles/components/spinner.css new file mode 100644 index 0000000..be18e67 --- /dev/null +++ b/frontend/src/styles/components/spinner.css @@ -0,0 +1,47 @@ +@layer components { + +/* Spinner */ +/* -------------------------------------------------------------------------- */ + +.spinner { + display: inline-block; + border: 2px solid var(--color-border); + border-top-color: var(--color-accent); + border-radius: 999px; + animation: spin 0.8s linear infinite; +} + +.spinner-sm { + width: 14px; + height: 14px; +} + +.spinner-md { + width: 20px; + height: 20px; +} + +.spinner-lg { + width: 28px; + height: 28px; +} + +.card-action-spinner { + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .card-action-spinner { + animation: none; + } +} + +/* -------------------------------------------------------------------------- */ + +} diff --git a/frontend/src/styles/components/toast.css b/frontend/src/styles/components/toast.css new file mode 100644 index 0000000..69773b1 --- /dev/null +++ b/frontend/src/styles/components/toast.css @@ -0,0 +1,45 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* Toast */ +/* -------------------------------------------------------------------------- */ + +.toast-viewport { + position: fixed; + bottom: var(--space-7); + right: var(--space-7); + display: flex; + flex-direction: column; + gap: var(--space-3); + z-index: 100; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 240px; + max-width: 360px; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: 0.88rem; + box-shadow: var(--shadow-md); + pointer-events: auto; + animation: toast-in 180ms ease; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +} diff --git a/frontend/src/styles/components/view-mode-toggle.css b/frontend/src/styles/components/view-mode-toggle.css new file mode 100644 index 0000000..1f384a7 --- /dev/null +++ b/frontend/src/styles/components/view-mode-toggle.css @@ -0,0 +1,38 @@ +@layer components { + +/* -------------------------------------------------------------------------- */ +/* View mode toggle */ +/* -------------------------------------------------------------------------- */ + +.view-mode-toggle { + display: inline-flex; + padding: 2px; + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.view-mode-toggle__btn { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + border: none; + border-radius: calc(var(--radius-sm) - 2px); + background: transparent; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-family: inherit; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.view-mode-toggle__btn:hover { + color: var(--color-text); +} + +.view-mode-toggle__btn[data-active="true"] { + background: var(--color-surface-raised); + color: var(--color-text); +} + +} diff --git a/frontend/src/styles/dialogs.css b/frontend/src/styles/dialogs.css index 0c34188..54bea72 100644 --- a/frontend/src/styles/dialogs.css +++ b/frontend/src/styles/dialogs.css @@ -1,9 +1,11 @@ +@layer components { + .dialog-overlay { position: fixed; inset: 0; z-index: 70; - background: rgba(5, 7, 10, 0.78); - backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(3px); } .dialog-content { @@ -13,12 +15,9 @@ z-index: 80; width: min(480px, calc(100vw - 32px)); padding: 20px; - border: 1px solid var(--color-border-strong); - border-radius: 18px; - background: - linear-gradient(180deg, rgba(22, 27, 36, 0.98), rgba(14, 18, 24, 0.98)), - var(--color-panel); - box-shadow: 0 28px 60px rgba(0, 0, 0, 0.42); + border-radius: 14px; + background: var(--color-surface-raised); + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.48); transform: translate(-50%, -50%); } @@ -26,71 +25,130 @@ outline: none; } -.dialog-content--danger { - border-color: rgba(240, 141, 121, 0.24); -} - -.dialog-content--neutral { - border-color: rgba(240, 163, 107, 0.2); -} - .dialog-header { display: grid; gap: 8px; } -.dialog-eyebrow { +.dialog-title { margin: 0; - font-family: var(--font-mono); - font-size: 0.7rem; - letter-spacing: 0.08em; - text-transform: uppercase; + font-size: 1.18rem; + letter-spacing: -0.02em; +} + +.dialog-description, +.dialog-meta { + margin: 14px 0 0; + color: var(--color-text-muted); + line-height: 1.6; } -.dialog-eyebrow--danger { +.dialog-error { + margin: 0; color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.5; } -.dialog-eyebrow--neutral { - color: var(--color-accent); +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 20px; } -.dialog-title { +.confirm-dialog { + width: min(500px, calc(100vw - 32px)); + padding: 24px; + border: 1px solid color-mix(in srgb, var(--color-border-strong) 72%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-surface) 96%, black); + box-shadow: var(--shadow-lift); +} + +.confirm-dialog__header { + gap: 0; +} + +.confirm-dialog__title { + font-size: 1.28rem; + letter-spacing: -0.025em; +} + +.confirm-dialog__description { + margin-top: 12px; + font-size: var(--font-size-md); + line-height: 1.55; +} + +.confirm-dialog__note { + display: grid; + gap: var(--space-2); + margin-top: 12px; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.55; +} + +.confirm-dialog__note > * { margin: 0; - font-size: 1.18rem; - letter-spacing: -0.02em; } -.dialog-description, -.dialog-note { - margin: 14px 0 0; +.confirm-dialog__actions { + margin-top: 24px; +} + +.confirm-dialog__button { + min-width: 110px; + height: 38px; + padding: 0 var(--space-4); + border: 1px solid transparent; + border-radius: var(--radius-md); + font-size: var(--font-size-md); + font-weight: 500; +} + +.confirm-dialog__button--cancel { + border-color: var(--color-border); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); color: var(--color-text-muted); - line-height: 1.6; } -.dialog-note { - padding: 10px 12px; - border-radius: 12px; +.confirm-dialog__button--cancel:hover:not(:disabled) { + border-color: var(--color-border-strong); + background: var(--color-surface-raised); + color: var(--color-text); } -.dialog-note--danger { - border: 1px solid rgba(240, 141, 121, 0.16); - background: rgba(240, 141, 121, 0.06); +.confirm-dialog__button--primary { + border-color: color-mix(in srgb, var(--color-accent) 42%, transparent); + background: var(--color-accent-soft); + color: var(--color-accent); } -.dialog-note--neutral { - border: 1px solid rgba(240, 163, 107, 0.14); - background: rgba(240, 163, 107, 0.06); +.confirm-dialog__button--primary:hover:not(:disabled) { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent-soft) 70%, transparent); + color: var(--color-accent-strong); } -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 20px; +.confirm-dialog__button--danger { + border-color: color-mix(in srgb, var(--color-danger) 34%, transparent); + background: color-mix(in srgb, var(--color-danger-soft) 78%, transparent); + color: var(--color-danger); +} + +.confirm-dialog__button--danger:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-danger) 58%, transparent); + background: color-mix(in srgb, var(--color-danger-soft) 96%, transparent); + color: var(--color-danger); +} + +.text-input[aria-invalid="true"] { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-danger) 58%, transparent); } -@media (max-width: 640px) { +@media (max-width: 680px) { .dialog-content { width: min(100vw - 20px, 480px); padding: 18px; @@ -103,4 +161,123 @@ .dialog-actions .btn { width: 100%; } + + .confirm-dialog__button { + width: 100%; + } +} + +/* Shared MCP dialog scaffolding (edit, etc.) ------------------------------ */ + +.mcp-dialog { + position: fixed; + top: 50%; + left: 50%; + z-index: 90; + display: grid; + gap: var(--space-4); + width: min(480px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + padding: var(--space-5); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-lift); + overflow-y: auto; + transform: translate(-50%, -50%); +} + +.mcp-dialog__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.mcp-dialog__title { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-text); +} + +.mcp-dialog__subtitle { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.mcp-dialog__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; +} + +.mcp-dialog__close:hover { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.mcp-dialog__form { + display: grid; + gap: var(--space-4); +} + +.mcp-dialog__field { + display: grid; + gap: var(--space-1); +} + +.mcp-dialog__field-label { + display: inline-flex; + align-items: baseline; + gap: var(--space-1); + font-size: var(--font-size-sm); + color: var(--color-text); +} + +.mcp-dialog__field-label code { + font-size: var(--font-size-sm); +} + +.mcp-dialog__field-hint { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.mcp-dialog__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); +} + +.mcp-dialog__submit { + min-width: 96px; + justify-content: center; +} + +.mcp-dialog__spinner { + animation: mcp-dialog-spinner 0.8s linear infinite; +} + +@keyframes mcp-dialog-spinner { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .mcp-dialog__spinner { + animation: none; + } +} + } diff --git a/frontend/src/styles/drawers.css b/frontend/src/styles/drawers.css deleted file mode 100644 index 2dfae90..0000000 --- a/frontend/src/styles/drawers.css +++ /dev/null @@ -1,31 +0,0 @@ -.drawer-backdrop { - position: fixed; - inset: 0; - z-index: 40; - background: rgba(4, 5, 8, 0.56); -} - -.drawer { - position: fixed; - top: 16px; - right: 16px; - z-index: 50; - width: min(560px, calc(100vw - 32px)); - height: calc(100vh - 32px); - padding: 24px; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: rgba(14, 17, 22, 0.96); - box-shadow: var(--shadow-panel); - overflow-y: auto; -} - -@media (max-width: 900px) { - .drawer { - top: 0; - right: 0; - width: 100vw; - height: 100vh; - border-radius: 0; - } -} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..7205f6d --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,49 @@ +/* Cascade layer order — declared once, enforced everywhere. + * + * reset → element resets + * tokens → custom properties (design tokens) + * base → element-level rules (scrollbars; add html/body here if needed) + * components → shared, app-wide primitives (buttons, cards, dialogs, …) + * features → feature-specific styles colocated under features//styles/ + * utilities → helper classes + * overrides → escape hatch (empty by default) + * + * Lower-numbered layers lose to higher-numbered ones regardless of source + * order. Source order within a layer wins ties. Each file below already + * wraps its contents in the appropriate @layer { … } block, so @import + * order here only affects in-layer ties — cross-layer cascade is locked. + * + * Adding a new shared primitive: create a file in styles/components/ + * (wrap contents in @layer components { … }) and @import it below. + * See frontend/src/styles/README.md for more. + */ +@layer reset, tokens, base, components, features, utilities, overrides; + +@import "./reset.css" layer(reset); +@import "./tokens.css" layer(tokens); +@import "./scrollbars.css" layer(base); + +/* Shared component primitives. + * Order preserves the original app.css → ui.css → dialogs.css sequence so + * in-layer source-order ties resolve identically to pre-split behavior. */ +@import "./components/page.css"; +@import "./components/sidebar.css"; +@import "./components/filter.css"; +@import "./components/bulk-bar.css"; +@import "./components/toast.css"; +@import "./components/empty-panel.css"; +@import "./components/cards.css"; +@import "./components/action-pill.css"; +@import "./components/needs-review-row.css"; +@import "./components/view-mode-toggle.css"; +@import "./components/buttons.css"; +@import "./components/chips.css"; +@import "./components/harness.css"; +@import "./components/note.css"; +@import "./components/error-banner.css"; +@import "./components/spinner.css"; +@import "./components/popup.css"; +@import "./components/detail-sheet.css"; +@import "./dialogs.css"; + +@import "./utilities.css"; diff --git a/frontend/src/styles/scrollbars.css b/frontend/src/styles/scrollbars.css index e9a730a..9836b68 100644 --- a/frontend/src/styles/scrollbars.css +++ b/frontend/src/styles/scrollbars.css @@ -1,25 +1,39 @@ -html { - scrollbar-gutter: stable; - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); -} +/* Notion-style overlay scrollbars. + - Gutter is reserved for layout stability, but rendered transparent. + - Thumb is invisible at rest and fades in when the user hovers or + actively scrolls within the container. + - Thin pill shape, no border, no track background. +*/ -.ui-scrollbar { +html, +.ui-scrollbar, +.ui-scrollbar--thin, +.skill-detail__markdown pre { scrollbar-gutter: stable; scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); + scrollbar-color: transparent transparent; + transition: scrollbar-color 180ms ease; } -.ui-scrollbar--thin, -.skill-detail__markdown pre { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +html:hover, +.ui-scrollbar:hover, +.ui-scrollbar--thin:hover, +.skill-detail__markdown pre:hover, +html:focus-within, +.ui-scrollbar:focus-within, +.ui-scrollbar--thin:focus-within, +.skill-detail__markdown pre:focus-within { + scrollbar-color: var(--scrollbar-thumb) transparent; } +/* Webkit */ html::-webkit-scrollbar, -.ui-scrollbar::-webkit-scrollbar { +.ui-scrollbar::-webkit-scrollbar, +.ui-scrollbar--thin::-webkit-scrollbar, +.skill-detail__markdown pre::-webkit-scrollbar { width: var(--scrollbar-size); height: var(--scrollbar-size); + background: transparent; } .ui-scrollbar--thin::-webkit-scrollbar, @@ -32,20 +46,34 @@ html::-webkit-scrollbar-track, .ui-scrollbar::-webkit-scrollbar-track, .ui-scrollbar--thin::-webkit-scrollbar-track, .skill-detail__markdown pre::-webkit-scrollbar-track { - background: var(--scrollbar-track); - border-radius: 999px; + background: transparent; } html::-webkit-scrollbar-thumb, .ui-scrollbar::-webkit-scrollbar-thumb, .ui-scrollbar--thin::-webkit-scrollbar-thumb, .skill-detail__markdown pre::-webkit-scrollbar-thumb { - background-color: var(--scrollbar-thumb); - border: 2px solid transparent; + background-color: transparent; + border: 1px solid transparent; border-radius: 999px; background-clip: padding-box; + min-height: 32px; + transition: background-color 180ms ease; +} + +/* Reveal thumb when the user hovers or is interacting with the scroll region. */ +html:hover::-webkit-scrollbar-thumb, +.ui-scrollbar:hover::-webkit-scrollbar-thumb, +.ui-scrollbar--thin:hover::-webkit-scrollbar-thumb, +.skill-detail__markdown pre:hover::-webkit-scrollbar-thumb, +html:focus-within::-webkit-scrollbar-thumb, +.ui-scrollbar:focus-within::-webkit-scrollbar-thumb, +.ui-scrollbar--thin:focus-within::-webkit-scrollbar-thumb, +.skill-detail__markdown pre:focus-within::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); } +/* Directly hovering the thumb — pronounced. */ html::-webkit-scrollbar-thumb:hover, .ui-scrollbar::-webkit-scrollbar-thumb:hover, .ui-scrollbar--thin::-webkit-scrollbar-thumb:hover, @@ -64,5 +92,5 @@ html::-webkit-scrollbar-corner, .ui-scrollbar::-webkit-scrollbar-corner, .ui-scrollbar--thin::-webkit-scrollbar-corner, .skill-detail__markdown pre::-webkit-scrollbar-corner { - background: var(--scrollbar-corner); + background: transparent; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..946bdd0 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,84 @@ +:root { + --font-sans: "Inter", "SF Pro Text", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-mono: "SF Mono", "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace; + + /* Typography scale */ + --font-size-xs: 0.72rem; + --font-size-sm: 0.84rem; + --font-size-md: 0.92rem; + --font-size-lg: 1.05rem; + --font-size-xl: 1.32rem; + --font-size-2xl: 2rem; + + /* Surfaces */ + --color-bg: #0b0c0f; + --color-surface: #1c1d21; + --color-surface-raised: #24252a; + --color-surface-sunken: #15161a; + --color-sidebar-bg: #1a1b1f; + + /* Borders */ + --color-border: #2a2b2f; + --color-border-strong: #3a3b40; + + /* Text */ + --color-text: #e8e6e1; + --color-text-muted: #8a8680; + --color-text-subtle: #65625d; + --color-text-inverted: #ffffff; + + /* Accent (Notion-ish dark-mode blue) */ + --color-accent: #529cca; + --color-accent-strong: #4184b0; + --color-accent-soft: rgba(82, 156, 202, 0.14); + --color-accent-softer: rgba(82, 156, 202, 0.08); + + /* Status */ + --color-success: #6bc2a4; + --color-success-soft: rgba(107, 194, 164, 0.12); + --color-danger: #f08d79; + --color-danger-soft: rgba(240, 141, 121, 0.14); + --color-warning: #f3c969; + --color-warning-soft: rgba(243, 201, 105, 0.16); + + /* Radii */ + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 20px; + --radius-pill: 999px; + + /* Spacing scale */ + --space-1: 4px; + --space-2: 6px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; + --space-6: 20px; + --space-7: 24px; + --space-8: 32px; + --space-9: 40px; + --space-10: 56px; + + /* Breakpoints (for reference — @media queries use these px values inline) */ + --breakpoint-sm: 680px; + --breakpoint-md: 900px; + --breakpoint-lg: 1100px; + + /* Scrollbars */ + --scrollbar-size: 6px; + --scrollbar-size-thin: 4px; + --scrollbar-track: rgba(255, 255, 255, 0.02); + --scrollbar-thumb: rgba(154, 164, 178, 0.28); + --scrollbar-thumb-hover: rgba(178, 188, 200, 0.44); + --scrollbar-thumb-active: rgba(208, 216, 226, 0.58); + --scrollbar-corner: rgba(9, 10, 13, 0.01); + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25); + --shadow-panel: 0 12px 28px rgba(0, 0, 0, 0.28); + --shadow-lift: 0 10px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.32); + + /* Layout */ + --sidebar-width: 256px; +} diff --git a/frontend/src/styles/ui.css b/frontend/src/styles/ui.css deleted file mode 100644 index 419149f..0000000 --- a/frontend/src/styles/ui.css +++ /dev/null @@ -1,197 +0,0 @@ -.btn, -.icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 38px; - border-radius: var(--radius); - transition: box-shadow 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease; -} - -.u-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.btn { - padding: 0 14px; - border: none; - background: var(--color-panel-strong); - color: var(--color-text); -} - -.btn:hover { - background: var(--color-panel-elevated); -} - -.btn:disabled, -.icon-button:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.btn:focus-visible, -.icon-button:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(240, 163, 107, 0.18); -} - -.btn.btn-primary { - background: var(--color-accent-soft); - color: var(--color-accent); -} - -.btn.btn-primary:hover { - background: rgba(240, 163, 107, 0.18); -} - -.btn.btn-secondary { - background: var(--color-panel-strong); -} - -.btn.btn-static { - cursor: default; - color: var(--color-text-muted); -} - -.btn.btn-static:hover { - background: var(--color-panel-strong); -} - -.btn.btn-danger { - border: 1px solid rgba(240, 141, 121, 0.34); - background: rgba(240, 141, 121, 0.08); - color: var(--color-danger); -} - -.btn.btn-danger:hover { - background: rgba(240, 141, 121, 0.14); -} - -.icon-button { - width: 38px; - padding: 0; - border: 1px solid var(--color-border); - background: var(--color-panel-strong); - color: var(--color-text); -} - -.icon-button:hover { - border-color: var(--color-border-strong); - background: var(--color-panel-elevated); -} - -.ui-status-badge { - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 0 8px; - border: none; - border-radius: var(--radius); - background: var(--color-panel-elevated); - font-family: var(--font-mono); - font-size: 0.72rem; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.ui-status-badge--success { - background: var(--color-success-soft); - color: var(--color-success); -} - -.ui-status-badge--warning { - background: var(--color-warning-soft); - color: var(--color-warning); -} - -.ui-status-badge--neutral { - background: var(--color-panel-elevated); - color: var(--color-text); -} - -.ui-status-badge--muted { - color: var(--color-text-muted); -} - -.ui-hover-tooltip { - z-index: 50; - width: min(280px, calc(100vw - 32px)); - padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--radius-sm); - background: rgba(12, 15, 20, 0.98); - box-shadow: var(--shadow-panel); -} - -.ui-hover-tooltip__copy { - margin: 0; - color: var(--color-text); - font-size: 0.92rem; - line-height: 1.45; -} - -.error-banner { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 14px; - border: 1px solid rgba(240, 141, 121, 0.42); - border-radius: var(--radius); - background: var(--color-danger-soft); - color: var(--color-danger); -} - -.error-banner__message { - min-width: 0; -} - -.error-banner__dismiss { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: var(--radius); - background: transparent; - color: inherit; -} - -.spinner { - display: inline-block; - border: 2px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 999px; - animation: spin 0.8s linear infinite; -} - -.spinner-sm { - width: 14px; - height: 14px; -} - -.spinner-md { - width: 20px; - height: 20px; -} - -.spinner-lg { - width: 28px; - height: 28px; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} diff --git a/frontend/src/styles/utilities.css b/frontend/src/styles/utilities.css new file mode 100644 index 0000000..ce4da02 --- /dev/null +++ b/frontend/src/styles/utilities.css @@ -0,0 +1,114 @@ +@layer utilities { + +/* -------------------------------------------------------------------------- */ +/* Misc page patterns */ +/* -------------------------------------------------------------------------- */ + +.panel-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; + color: var(--color-text-muted); +} + +.muted-text { + margin: 0; + color: var(--color-text-muted); +} + +.toggle-switch { + display: inline-flex; + align-items: center; + gap: var(--space-3); + color: var(--color-text); +} + +.toggle-switch__label { + font-size: 0.88rem; + color: var(--color-text); +} + +.toggle-switch__root { + position: relative; + display: inline-flex; + align-items: center; + width: 40px; + height: 22px; + padding: 2px; + border-radius: var(--radius-pill); + background: var(--color-surface-raised); + cursor: pointer; + transition: background 140ms ease; +} + +.toggle-switch__root[data-state="checked"] { + background: var(--color-accent); +} + +.toggle-switch__thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 999px; + background: #f6f8fb; + transform: translateX(0); + transition: transform 140ms ease; +} + +.toggle-switch__thumb[data-state="checked"] { + transform: translateX(18px); +} + +/* Utilities */ +/* -------------------------------------------------------------------------- */ + +.u-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.section-stack { + display: grid; + gap: var(--space-4); +} + +.dialog-section { + display: grid; + gap: var(--space-3); + margin: var(--space-4) 0; +} + +.settings-row__controls { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.text-input { + height: 38px; + padding: 0 var(--space-4); + border: none; + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.9rem; +} + +.text-input:focus-visible { + outline: none; + background: var(--color-surface-raised); + box-shadow: 0 0 0 2px var(--color-accent-softer); +} + +/* -------------------------------------------------------------------------- */ + +} diff --git a/frontend/src/styles/variables.css b/frontend/src/styles/variables.css deleted file mode 100644 index 4250d9c..0000000 --- a/frontend/src/styles/variables.css +++ /dev/null @@ -1,31 +0,0 @@ -:root { - --font-sans: "IBM Plex Sans", "Segoe UI", sans-serif; - --font-mono: "IBM Plex Mono", "SFMono-Regular", monospace; - --color-bg: #090a0d; - --color-bg-overlay: rgba(243, 166, 102, 0.05); - --color-grid: rgba(255, 255, 255, 0.035); - --color-panel: rgba(15, 17, 22, 0.92); - --color-panel-strong: #141820; - --color-panel-elevated: #181d27; - --color-border: #282e39; - --color-border-strong: #3a4351; - --color-text: #f2f4f7; - --color-text-muted: #9aa4b2; - --color-accent: #f0a36b; - --color-accent-soft: rgba(240, 163, 107, 0.12); - --color-success: #6bc2a4; - --color-success-soft: rgba(107, 194, 164, 0.12); - --color-danger: #f08d79; - --color-danger-soft: rgba(240, 141, 121, 0.14); - --color-warning: #f3c969; - --color-warning-soft: rgba(243, 201, 105, 0.16); - --scrollbar-size: 10px; - --scrollbar-size-thin: 8px; - --scrollbar-track: rgba(255, 255, 255, 0.02); - --scrollbar-thumb: rgba(154, 164, 178, 0.34); - --scrollbar-thumb-hover: rgba(178, 188, 200, 0.5); - --scrollbar-thumb-active: rgba(208, 216, 226, 0.62); - --scrollbar-corner: rgba(9, 10, 13, 0.01); - --shadow-panel: 0 12px 28px rgba(0, 0, 0, 0.22); - --radius: 4px; -} diff --git a/frontend/src/test/fetch.ts b/frontend/src/test/fetch.ts new file mode 100644 index 0000000..a358e53 --- /dev/null +++ b/frontend/src/test/fetch.ts @@ -0,0 +1,87 @@ +type FetchInput = RequestInfo | URL; + +export function okJson(payload: unknown, init: Partial = {}): Response { + return { + ok: true, + status: 200, + statusText: "OK", + json: async () => payload, + ...init, + } as Response; +} + +export function errorJson( + message: string, + { + status = 500, + statusText = "Server Error", + field = "detail", + }: { + status?: number; + statusText?: string; + field?: "detail" | "error"; + } = {}, +): Response { + return { + ok: false, + status, + statusText, + json: async () => ({ [field]: message }), + } as Response; +} + +export interface FetchRoute { + match: string | RegExp | ((url: string, input: FetchInput, init?: RequestInit) => boolean); + response: + | Response + | unknown + | ((url: string, input: FetchInput, init?: RequestInit) => Response | Promise | unknown | Promise); +} + +export function createRouteFetchMock( + routes: FetchRoute[], + fallback?: (url: string, input: FetchInput, init?: RequestInit) => Response | Promise, +) { + return async (input: FetchInput, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input.toString(); + for (const route of routes) { + if (!routeMatches(route.match, url, input, init)) { + continue; + } + const response = + typeof route.response === "function" + ? await route.response(url, input, init) + : route.response; + return isResponseLike(response) ? response : okJson(response); + } + if (fallback) { + return fallback(url, input, init); + } + throw new Error(`Unhandled URL ${url}`); + }; +} + +function routeMatches( + match: FetchRoute["match"], + url: string, + input: FetchInput, + init?: RequestInit, +): boolean { + if (typeof match === "string") { + return url === match || url.includes(match); + } + if (match instanceof RegExp) { + return match.test(url); + } + return match(url, input, init); +} + +function isResponseLike(value: unknown): value is Response { + return Boolean( + value && + typeof value === "object" && + "ok" in value && + "json" in value && + typeof (value as { json?: unknown }).json === "function", + ); +} diff --git a/frontend/src/test/fixtures/marketplace.ts b/frontend/src/test/fixtures/marketplace.ts new file mode 100644 index 0000000..8284b94 --- /dev/null +++ b/frontend/src/test/fixtures/marketplace.ts @@ -0,0 +1,12 @@ +export function marketplacePage( + items: T[] = [], + { + nextOffset = null, + hasMore = false, + }: { + nextOffset?: number | null; + hasMore?: boolean; + } = {}, +) { + return { items, nextOffset, hasMore }; +} diff --git a/frontend/src/test/fixtures/mcp.ts b/frontend/src/test/fixtures/mcp.ts new file mode 100644 index 0000000..0256756 --- /dev/null +++ b/frontend/src/test/fixtures/mcp.ts @@ -0,0 +1,34 @@ +import type { + McpInventoryDto, + McpInventoryEntryDto, +} from "../../features/mcp/api/management-types"; + +export function mcpInventoryPayload( + entries: McpInventoryEntryDto[] = [], + overrides: Partial = {}, +): McpInventoryDto { + return { + columns: [], + entries, + issues: [], + ...overrides, + }; +} + +export function mcpInventoryEntry({ + name, + kind, + displayName = name, + sightings = [], + canEnable = kind === "managed", + spec = null, +}: Pick & Partial): McpInventoryEntryDto { + return { + name, + displayName, + kind, + canEnable, + spec, + sightings, + }; +} diff --git a/frontend/src/test/fixtures/skills.ts b/frontend/src/test/fixtures/skills.ts new file mode 100644 index 0000000..45e3951 --- /dev/null +++ b/frontend/src/test/fixtures/skills.ts @@ -0,0 +1,17 @@ +import type { SkillsPageDto } from "../../features/skills/api/types"; + +export function skillsPayload({ + managed = 0, + unmanaged = 0, + harnessColumns = [], + rows = [], +}: Partial & { + managed?: number; + unmanaged?: number; +} = {}): SkillsPageDto { + return { + summary: { managed, unmanaged }, + harnessColumns, + rows, + }; +} diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx new file mode 100644 index 0000000..d488fa9 --- /dev/null +++ b/frontend/src/test/render.tsx @@ -0,0 +1,77 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, type RenderOptions } from "@testing-library/react"; +import { type ReactElement, type ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { ToastProvider } from "../components/Toast"; +import { UiTooltipProvider } from "../components/ui/UiTooltipProvider"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +export function renderWithAppProviders( + ui: ReactElement, + { + route = "/", + queryClient = createTestQueryClient(), + ...renderOptions + }: RenderOptions & { + route?: string; + queryClient?: QueryClient; + } = {}, +) { + const result = render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + + + {children} + + + + ), + ...renderOptions, + }); + + return { ...result, queryClient }; +} + +export function renderWithRouter( + ui: ReactElement, + { + route = "/", + ...renderOptions + }: RenderOptions & { + route?: string; + } = {}, +) { + return render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + ...renderOptions, + }); +} + +export function stubDesktopMatchMedia(): void { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }), + }); +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index f149f27..9c45122 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1 +1,12 @@ import "@testing-library/jest-dom/vitest"; +import { vi } from "vitest"; + +if (typeof ResizeObserver === "undefined") { + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserver); +} diff --git a/package-lock.json b/package-lock.json index f7ab8a1..ccf2171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "skill-manager", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "skill-manager", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.95.2", "lucide-react": "^1.0.1", "react": "^19.1.1", @@ -20,9 +22,9 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { - "@playwright/test": "^1.54.2", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@types/node": "^25.6.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", @@ -468,6 +470,45 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -998,22 +1039,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1381,6 +1406,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1517,6 +1576,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", @@ -2186,6 +2268,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4348,38 +4440,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -5098,6 +5158,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index 84401a8..b8fe99f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skill-manager", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "VITE_API_BASE=/api vite --host 127.0.0.1 --port 5173", @@ -14,14 +14,15 @@ "build": "VITE_API_BASE=/api vite build", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:e2e": "playwright test", "codegen:openapi": "./.venv/bin/python scripts/dump_openapi.py && openapi-typescript frontend/src/api/openapi.json -o frontend/src/api/generated.ts", "codegen:check": "npm run codegen:openapi && git diff --exit-code frontend/src/api/openapi.json frontend/src/api/generated.ts" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.95.2", "lucide-react": "^1.0.1", "react": "^19.1.1", @@ -31,9 +32,9 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { - "@playwright/test": "^1.54.2", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@types/node": "^25.6.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", diff --git a/packaging/npm/package.json b/packaging/npm/package.json index b2ca11c..32e6a1f 100644 --- a/packaging/npm/package.json +++ b/packaging/npm/package.json @@ -1,6 +1,6 @@ { "name": "@mode-io/skill-manager", - "version": "0.1.0", + "version": "0.2.0", "description": "Public macOS installer wrapper for the Mode IO skill-manager native release artifact.", "license": "MIT", "private": false, diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index f6262d6..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "./frontend/e2e", - timeout: 30000, - use: { - baseURL: "http://127.0.0.1:4173", - trace: "retain-on-failure", - }, - webServer: { - command: "./.venv/bin/python scripts/serve_e2e_fixture.py", - url: "http://127.0.0.1:4173/api/health", - reuseExistingServer: false, - timeout: 30000, - }, -}); diff --git a/pyproject.toml b/pyproject.toml index 24f6c1f..25ccec7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ dependencies = [ "certifi>=2024.0.0", "fastapi>=0.135,<0.136", + "tomli-w>=1.2,<2", "uvicorn>=0.44,<0.45", "certifi>=2024.8.30,<2026", ] diff --git a/requirements.txt b/requirements.txt index 4601f1b..10623e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ certifi>=2024.0.0 fastapi>=0.135,<0.136 +tomli-w>=1.2,<2 uvicorn>=0.44,<0.45 certifi>=2024.8.30,<2026 diff --git a/scripts/ci_validate.sh b/scripts/ci_validate.sh index d76681c..7595c3c 100755 --- a/scripts/ci_validate.sh +++ b/scripts/ci_validate.sh @@ -7,4 +7,3 @@ cd "$ROOT_DIR" bash "$ROOT_DIR/scripts/test_backend.sh" npm run test npm run build -npm run test:e2e diff --git a/scripts/dump_openapi.py b/scripts/dump_openapi.py index a6f60fd..f8770f1 100644 --- a/scripts/dump_openapi.py +++ b/scripts/dump_openapi.py @@ -11,7 +11,7 @@ from skill_manager.api.app import create_app # noqa: E402 from skill_manager.application import build_backend_container # noqa: E402 -from skill_manager.application.marketplace import MarketplaceCatalog # noqa: E402 +from skill_manager.application.skills.marketplace import MarketplaceCatalog # noqa: E402 def main() -> int: diff --git a/scripts/validate_npm_wrapper.sh b/scripts/validate_npm_wrapper.sh index 7aa8c5e..90f5781 100755 --- a/scripts/validate_npm_wrapper.sh +++ b/scripts/validate_npm_wrapper.sh @@ -13,6 +13,11 @@ TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/skill-manager-npm-XXXXXX")" FIXTURE_MANIFEST="$TMP_DIR/marketplace-fixture.json" FIXTURE_LOG="$TMP_DIR/marketplace-fixture.log" FIXTURE_PID="" +export HOME="$TMP_DIR/home" +export XDG_CONFIG_HOME="$TMP_DIR/xdg-config" +export XDG_DATA_HOME="$TMP_DIR/xdg-data" +export XDG_STATE_HOME="$TMP_DIR/xdg-state" +mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" resolve_python_bin() { if [[ -n "${PYTHON_BIN:-}" ]]; then diff --git a/scripts/validate_release_artifact.py b/scripts/validate_release_artifact.py index 01115d0..968498d 100755 --- a/scripts/validate_release_artifact.py +++ b/scripts/validate_release_artifact.py @@ -94,9 +94,23 @@ def main(argv: list[str] | None = None) -> int: raise RuntimeError(f"unexpected version output: expected {expected_version!r}, got {version_output!r}") runtime_dir = tmp_path / "runtime" + home_dir = tmp_path / "home" + xdg_config_dir = tmp_path / "xdg-config" + xdg_data_dir = tmp_path / "xdg-data" + xdg_state_dir = tmp_path / "xdg-state" + for path in (home_dir, xdg_config_dir, xdg_data_dir, xdg_state_dir): + path.mkdir(parents=True, exist_ok=True) with MarketplaceFixtureServer() as fixture: runtime_env = dict(os.environ) runtime_env.update(fixture.env()) + runtime_env.update( + { + "HOME": str(home_dir), + "XDG_CONFIG_HOME": str(xdg_config_dir), + "XDG_DATA_HOME": str(xdg_data_dir), + "XDG_STATE_HOME": str(xdg_state_dir), + } + ) try: start_output = run( [ diff --git a/skill_manager/VERSION b/skill_manager/VERSION index 6e8bf73..0ea3a94 100644 --- a/skill_manager/VERSION +++ b/skill_manager/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/skill_manager/api/app.py b/skill_manager/api/app.py index c9b0916..9f812a0 100644 --- a/skill_manager/api/app.py +++ b/skill_manager/api/app.py @@ -8,7 +8,7 @@ from skill_manager.application import BackendContainer from .errors import install_error_handlers -from .routers import health, marketplace, settings, skills +from .routers import health, marketplace, mcp, settings, skills def create_app( @@ -24,6 +24,7 @@ def create_app( app.include_router(settings.router) app.include_router(skills.router) app.include_router(marketplace.router) + app.include_router(mcp.router) @app.get("/{full_path:path}", include_in_schema=False, response_model=None) def serve_frontend(full_path: str): diff --git a/skill_manager/api/routers/__init__.py b/skill_manager/api/routers/__init__.py index 048e861..489dc10 100644 --- a/skill_manager/api/routers/__init__.py +++ b/skill_manager/api/routers/__init__.py @@ -1,3 +1,3 @@ -from . import health, marketplace, settings, skills +from . import health, marketplace, mcp, settings, skills -__all__ = ["health", "marketplace", "settings", "skills"] +__all__ = ["health", "marketplace", "mcp", "settings", "skills"] diff --git a/skill_manager/api/routers/marketplace.py b/skill_manager/api/routers/marketplace.py index 7fd66c9..3dc5423 100644 --- a/skill_manager/api/routers/marketplace.py +++ b/skill_manager/api/routers/marketplace.py @@ -1,55 +1,10 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter -from skill_manager.application import BackendContainer -from skill_manager.api.deps import get_container -from skill_manager.api.schemas import InstallMarketplaceSkillRequest +from . import marketplace_clis, marketplace_mcp, marketplace_skills -router = APIRouter(prefix="/api/marketplace") - - -@router.get("/popular") -def popular_marketplace( - limit: int | None = Query(default=None), - offset: int = Query(default=0), - container: BackendContainer = Depends(get_container), -) -> dict[str, object]: - return container.marketplace_queries.popular_page(limit=limit, offset=offset) - - -@router.get("/search") -def search_marketplace( - q: str = Query(...), - limit: int | None = Query(default=None), - offset: int = Query(default=0), - container: BackendContainer = Depends(get_container), -) -> dict[str, object]: - try: - return container.marketplace_queries.search_page(q, limit=limit, offset=offset) - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) from error - - -@router.get("/items/{item_id:path}/document") -def get_marketplace_document(item_id: str, container: BackendContainer = Depends(get_container)) -> dict[str, object]: - payload = container.marketplace_queries.get_item_document(item_id) - if payload is None: - raise HTTPException(status_code=404, detail=f"unknown marketplace item: {item_id}") - return payload - - -@router.get("/items/{item_id:path}") -def get_marketplace_detail(item_id: str, container: BackendContainer = Depends(get_container)) -> dict[str, object]: - payload = container.marketplace_queries.get_item_detail(item_id) - if payload is None: - raise HTTPException(status_code=404, detail=f"unknown marketplace item: {item_id}") - return payload - - -@router.post("/install") -def install_marketplace_skill( - body: InstallMarketplaceSkillRequest, - container: BackendContainer = Depends(get_container), -) -> dict[str, bool]: - return container.marketplace_installs.install_skill(body.install_token) +router = APIRouter() +router.include_router(marketplace_skills.router) +router.include_router(marketplace_mcp.router) +router.include_router(marketplace_clis.router) diff --git a/skill_manager/api/routers/marketplace_clis.py b/skill_manager/api/routers/marketplace_clis.py new file mode 100644 index 0000000..ea05741 --- /dev/null +++ b/skill_manager/api/routers/marketplace_clis.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from skill_manager.api.deps import get_container +from skill_manager.api.schemas import ( + CliMarketplaceDetailResponse, + CliMarketplacePageResponse, +) +from skill_manager.application import BackendContainer + +router = APIRouter(prefix="/api/marketplace/clis") + + +@router.get("/popular", response_model=CliMarketplacePageResponse) +def popular_cli_marketplace( + limit: int | None = Query(default=None), + offset: int = Query(default=0), + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.cli_marketplace_catalog.popular_page(limit=limit, offset=offset) + + +@router.get("/search", response_model=CliMarketplacePageResponse) +def search_cli_marketplace( + q: str = Query(default=""), + limit: int | None = Query(default=None), + offset: int = Query(default=0), + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + try: + return container.cli_marketplace_catalog.search_page(q, limit=limit, offset=offset) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@router.get("/items/{slug:path}", response_model=CliMarketplaceDetailResponse) +def get_cli_marketplace_detail( + slug: str, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + payload = container.cli_marketplace_catalog.detail(slug) + if payload is None: + raise HTTPException(status_code=404, detail=f"unknown CLI: {slug}") + return payload diff --git a/skill_manager/api/routers/marketplace_mcp.py b/skill_manager/api/routers/marketplace_mcp.py new file mode 100644 index 0000000..c7f7c1c --- /dev/null +++ b/skill_manager/api/routers/marketplace_mcp.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from skill_manager.api.deps import get_container +from skill_manager.api.schemas import ( + McpInstallTargetsResponse, + McpMarketplaceDetailResponse, + McpMarketplacePageResponse, +) +from skill_manager.application import BackendContainer + +router = APIRouter(prefix="/api/marketplace/mcp") + + +@router.get("/popular", response_model=McpMarketplacePageResponse) +def popular_mcp_marketplace( + limit: int | None = Query(default=None), + offset: int = Query(default=0), + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_marketplace_catalog.popular_page(limit=limit, offset=offset) + + +@router.get("/search", response_model=McpMarketplacePageResponse) +def search_mcp_marketplace( + q: str = Query(default=""), + limit: int | None = Query(default=None), + offset: int = Query(default=0), + remote: bool | None = Query(default=None), + verified: bool | None = Query(default=None), + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + try: + return container.mcp_marketplace_catalog.search_page( + q, + limit=limit, + offset=offset, + remote=remote, + verified=verified, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@router.get("/install-targets", response_model=McpInstallTargetsResponse) +def get_mcp_install_targets( + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_mutations.install_targets() + + +@router.get("/items/{qualified_name:path}", response_model=McpMarketplaceDetailResponse) +def get_mcp_marketplace_detail( + qualified_name: str, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + payload = container.mcp_marketplace_catalog.detail(qualified_name) + if payload is None: + raise HTTPException(status_code=404, detail=f"unknown MCP server: {qualified_name}") + return payload diff --git a/skill_manager/api/routers/marketplace_skills.py b/skill_manager/api/routers/marketplace_skills.py new file mode 100644 index 0000000..9d2080e --- /dev/null +++ b/skill_manager/api/routers/marketplace_skills.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query + +from skill_manager.api.deps import get_container +from skill_manager.api.schemas import InstallMarketplaceSkillRequest +from skill_manager.application import BackendContainer + +router = APIRouter(prefix="/api/marketplace") + + +@router.get("/popular") +def popular_marketplace( + limit: int | None = Query(default=None), + offset: int = Query(default=0), + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.skills_marketplace_queries.popular_page(limit=limit, offset=offset) + + +@router.get("/search") +def search_marketplace( + q: str = Query(...), + limit: int | None = Query(default=None), + offset: int = Query(default=0), + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + try: + return container.skills_marketplace_queries.search_page(q, limit=limit, offset=offset) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@router.get("/items/{item_id:path}/document") +def get_marketplace_document(item_id: str, container: BackendContainer = Depends(get_container)) -> dict[str, object]: + payload = container.skills_marketplace_queries.get_item_document(item_id) + if payload is None: + raise HTTPException(status_code=404, detail=f"unknown marketplace item: {item_id}") + return payload + + +@router.get("/items/{item_id:path}") +def get_marketplace_detail(item_id: str, container: BackendContainer = Depends(get_container)) -> dict[str, object]: + payload = container.skills_marketplace_queries.get_item_detail(item_id) + if payload is None: + raise HTTPException(status_code=404, detail=f"unknown marketplace item: {item_id}") + return payload + + +@router.post("/install") +def install_marketplace_skill( + body: InstallMarketplaceSkillRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, bool]: + return container.skills_marketplace_installs.install_skill(body.install_token) diff --git a/skill_manager/api/routers/mcp.py b/skill_manager/api/routers/mcp.py new file mode 100644 index 0000000..098f6dc --- /dev/null +++ b/skill_manager/api/routers/mcp.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from skill_manager.api.deps import get_container +from skill_manager.api.schemas import ( + AddMcpServerRequest, + AdoptMcpRequest, + DisableMcpServerRequest, + EnableMcpServerRequest, + McpApplyConfigResponse, + McpInventoryResponse, + McpServerDetailResponse, + McpServerMutationResponse, + McpSetHarnessesResultResponse, + McpUnmanagedByServerResponse, + OkResponse, + ReconcileMcpServerRequest, + SetMcpServerHarnessesRequest, +) +from skill_manager.application import BackendContainer + +router = APIRouter(prefix="/api/mcp") + + +@router.get("/servers", response_model=McpInventoryResponse) +def list_mcp_servers(container: BackendContainer = Depends(get_container)) -> dict[str, object]: + return container.mcp_queries.list_servers() + + +@router.get("/servers/{name}", response_model=McpServerDetailResponse) +def get_mcp_server( + name: str, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_queries.get_server(name) + + +@router.post("/servers", response_model=McpServerMutationResponse) +def install_mcp_server( + body: AddMcpServerRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_mutations.install_from_marketplace( + body.qualified_name, + source_harness=body.source_harness, + ) + + +@router.delete("/servers/{name}", response_model=McpSetHarnessesResultResponse) +def uninstall_mcp_server( + name: str, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_mutations.uninstall_server(name) + + +@router.post("/servers/{name}/enable", response_model=OkResponse) +def enable_mcp_server( + name: str, + body: EnableMcpServerRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, bool]: + return container.mcp_mutations.enable_server(name, body.harness) + + +@router.post("/servers/{name}/disable", response_model=OkResponse) +def disable_mcp_server( + name: str, + body: DisableMcpServerRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, bool]: + return container.mcp_mutations.disable_server(name, body.harness) + + +@router.post("/servers/{name}/reconcile", response_model=McpApplyConfigResponse) +def reconcile_mcp_server( + name: str, + body: ReconcileMcpServerRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_mutations.reconcile_server( + name, + source_kind=body.source_kind, + source_harness=body.source_harness, + harnesses=body.harnesses, + ) + + +@router.post("/servers/{name}/set-harnesses", response_model=McpSetHarnessesResultResponse) +def set_mcp_server_harnesses( + name: str, + body: SetMcpServerHarnessesRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_mutations.set_server_all_harnesses(name, body.target) + + +@router.get("/unmanaged/by-server", response_model=McpUnmanagedByServerResponse) +def list_unmanaged_by_server( + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_queries.list_unmanaged_by_server() + + +@router.post("/unmanaged/adopt", response_model=McpApplyConfigResponse) +def adopt_mcp_server( + body: AdoptMcpRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.mcp_mutations.adopt( + body.name, + source_harness=body.source_harness, + harnesses=body.harnesses, + ) diff --git a/skill_manager/api/routers/skills.py b/skill_manager/api/routers/skills.py index 554792b..558c4b9 100644 --- a/skill_manager/api/routers/skills.py +++ b/skill_manager/api/routers/skills.py @@ -4,17 +4,27 @@ from skill_manager.application import BackendContainer from skill_manager.api.deps import get_container -from skill_manager.api.schemas import DisableSkillRequest, EnableSkillRequest +from skill_manager.api.schemas import ( + BulkManageResultResponse, + DisableSkillRequest, + EnableSkillRequest, + OkResponse, + SetSkillHarnessesRequest, + SetSkillHarnessesResultResponse, + SkillDetailResponse, + SkillsPageResponse, + SkillSourceStatusResponse, +) router = APIRouter(prefix="/api/skills") -@router.get("") +@router.get("", response_model=SkillsPageResponse) def list_skills(container: BackendContainer = Depends(get_container)) -> dict[str, object]: return container.skills_queries.list_skills() -@router.get("/{skill_ref:path}/source-status") +@router.get("/{skill_ref:path}/source-status", response_model=SkillSourceStatusResponse) def get_skill_source_status(skill_ref: str, container: BackendContainer = Depends(get_container)) -> dict[str, object]: payload = container.skills_queries.get_skill_source_status(skill_ref) if payload is None: @@ -22,7 +32,7 @@ def get_skill_source_status(skill_ref: str, container: BackendContainer = Depend return payload -@router.get("/{skill_ref:path}") +@router.get("/{skill_ref:path}", response_model=SkillDetailResponse) def get_skill_detail(skill_ref: str, container: BackendContainer = Depends(get_container)) -> dict[str, object]: payload = container.skills_queries.get_skill_detail(skill_ref) if payload is None: @@ -30,7 +40,7 @@ def get_skill_detail(skill_ref: str, container: BackendContainer = Depends(get_c return payload -@router.post("/{skill_ref:path}/enable") +@router.post("/{skill_ref:path}/enable", response_model=OkResponse) def enable_skill( skill_ref: str, body: EnableSkillRequest, @@ -39,7 +49,7 @@ def enable_skill( return container.skills_mutations.enable_skill(skill_ref, body.harness) -@router.post("/{skill_ref:path}/disable") +@router.post("/{skill_ref:path}/disable", response_model=OkResponse) def disable_skill( skill_ref: str, body: DisableSkillRequest, @@ -48,26 +58,35 @@ def disable_skill( return container.skills_mutations.disable_skill(skill_ref, body.harness) -@router.post("/{skill_ref:path}/manage") +@router.post("/{skill_ref:path}/set-harnesses", response_model=SetSkillHarnessesResultResponse) +def set_skill_harnesses( + skill_ref: str, + body: SetSkillHarnessesRequest, + container: BackendContainer = Depends(get_container), +) -> dict[str, object]: + return container.skills_mutations.set_skill_all_harnesses(skill_ref, body.target) + + +@router.post("/{skill_ref:path}/manage", response_model=OkResponse) def manage_skill(skill_ref: str, container: BackendContainer = Depends(get_container)) -> dict[str, bool]: return container.skills_mutations.manage_skill(skill_ref) -@router.post("/manage-all") +@router.post("/manage-all", response_model=BulkManageResultResponse) def manage_all_skills(container: BackendContainer = Depends(get_container)) -> dict[str, object]: return container.skills_mutations.manage_all_skills() -@router.post("/{skill_ref:path}/update") +@router.post("/{skill_ref:path}/update", response_model=OkResponse) def update_skill(skill_ref: str, container: BackendContainer = Depends(get_container)) -> dict[str, bool]: return container.skills_mutations.update_skill(skill_ref) -@router.post("/{skill_ref:path}/unmanage") +@router.post("/{skill_ref:path}/unmanage", response_model=OkResponse) def unmanage_skill(skill_ref: str, container: BackendContainer = Depends(get_container)) -> dict[str, bool]: return container.skills_mutations.unmanage_skill(skill_ref) -@router.post("/{skill_ref:path}/delete") +@router.post("/{skill_ref:path}/delete", response_model=OkResponse) def delete_skill(skill_ref: str, container: BackendContainer = Depends(get_container)) -> dict[str, bool]: return container.skills_mutations.delete_skill(skill_ref) diff --git a/skill_manager/api/schemas.py b/skill_manager/api/schemas.py deleted file mode 100644 index 3c3d063..0000000 --- a/skill_manager/api/schemas.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, ConfigDict, Field - - -class HarnessTarget(BaseModel): - harness: str = Field(..., min_length=1, description="Harness identifier") - - -class EnableSkillRequest(HarnessTarget): - pass - - -class DisableSkillRequest(HarnessTarget): - pass - - -class InstallMarketplaceSkillRequest(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - install_token: str = Field(..., alias="installToken", min_length=1) - - -class SetHarnessSupportRequest(BaseModel): - enabled: bool diff --git a/skill_manager/api/schemas/__init__.py b/skill_manager/api/schemas/__init__.py new file mode 100644 index 0000000..82dd323 --- /dev/null +++ b/skill_manager/api/schemas/__init__.py @@ -0,0 +1,141 @@ +from .common import HarnessTarget, OkResponse, SetHarnessSupportRequest +from .cli_marketplace import ( + CliMarketplaceDetailResponse, + CliMarketplaceItemResponse, + CliMarketplacePageResponse, +) +from .mcp import ( + AddMcpServerRequest, + AdoptMcpRequest, + DisableMcpServerRequest, + EnableMcpServerRequest, + McpApplyConfigResponse, + McpAdoptionIssueResponse, + McpBindingResponse, + McpConfigChoiceResponse, + McpEnvEntryResponse, + McpIdentityGroupResponse, + McpIdentitySightingResponse, + McpInstallTargetResponse, + McpInstallTargetsResponse, + McpInventoryColumnResponse, + McpInventoryEntryResponse, + McpInventoryIssueResponse, + McpInventoryResponse, + McpMarketplaceCapabilityCountsResponse, + McpMarketplaceConnectionResponse, + McpMarketplaceDetailResponse, + McpMarketplaceItemResponse, + McpMarketplaceLinkResponse, + McpMarketplacePageResponse, + McpMarketplaceParameterResponse, + McpMarketplacePromptArgumentResponse, + McpMarketplacePromptResponse, + McpMarketplaceResourceResponse, + McpMarketplaceToolResponse, + McpMutationFailureResponse, + McpServerDetailResponse, + McpServerMutationResponse, + McpServerSpecResponse, + McpSetHarnessesResultResponse, + McpSourceResponse, + McpUnmanagedByServerResponse, + McpUnmanagedHarnessResponse, + ReconcileMcpServerRequest, + SetMcpServerHarnessesRequest, +) +from .skills import ( + BulkManageFailureResponse, + BulkManageResultResponse, + DisableSkillRequest, + EnableSkillRequest, + HarnessCellResponse, + HarnessCellState, + HarnessColumnResponse, + InstallMarketplaceSkillRequest, + SetSkillHarnessesFailureResponse, + SetSkillHarnessesRequest, + SetSkillHarnessesResultResponse, + SkillDetailActionsResponse, + SkillDetailResponse, + SkillLocationResponse, + SkillRowActionsResponse, + SkillSourceLinksResponse, + SkillSourceStatusResponse, + SkillStatus, + SkillStopManagingStatus, + SkillTableRowResponse, + SkillUpdateStatus, + SkillsPageResponse, + SkillsSummaryResponse, +) + +__all__ = [ + "AdoptMcpRequest", + "BulkManageFailureResponse", + "BulkManageResultResponse", + "CliMarketplaceDetailResponse", + "CliMarketplaceItemResponse", + "CliMarketplacePageResponse", + "DisableMcpServerRequest", + "DisableSkillRequest", + "EnableMcpServerRequest", + "EnableSkillRequest", + "HarnessCellResponse", + "HarnessCellState", + "HarnessColumnResponse", + "HarnessTarget", + "InstallMarketplaceSkillRequest", + "AddMcpServerRequest", + "McpApplyConfigResponse", + "McpAdoptionIssueResponse", + "McpBindingResponse", + "McpConfigChoiceResponse", + "McpEnvEntryResponse", + "McpIdentityGroupResponse", + "McpIdentitySightingResponse", + "McpInstallTargetResponse", + "McpInstallTargetsResponse", + "McpInventoryColumnResponse", + "McpInventoryEntryResponse", + "McpInventoryIssueResponse", + "McpInventoryResponse", + "McpMarketplaceCapabilityCountsResponse", + "McpMarketplaceConnectionResponse", + "McpMarketplaceDetailResponse", + "McpMarketplaceItemResponse", + "McpMarketplaceLinkResponse", + "McpMarketplacePageResponse", + "McpMarketplaceParameterResponse", + "McpMarketplacePromptArgumentResponse", + "McpMarketplacePromptResponse", + "McpMarketplaceResourceResponse", + "McpMarketplaceToolResponse", + "McpMutationFailureResponse", + "McpServerDetailResponse", + "McpServerMutationResponse", + "McpServerSpecResponse", + "McpSetHarnessesResultResponse", + "McpSourceResponse", + "McpUnmanagedByServerResponse", + "McpUnmanagedHarnessResponse", + "OkResponse", + "ReconcileMcpServerRequest", + "SetHarnessSupportRequest", + "SetMcpServerHarnessesRequest", + "SetSkillHarnessesFailureResponse", + "SetSkillHarnessesRequest", + "SetSkillHarnessesResultResponse", + "SkillDetailActionsResponse", + "SkillDetailResponse", + "SkillLocationResponse", + "SkillRowActionsResponse", + "SkillSourceLinksResponse", + "SkillSourceStatusResponse", + "SkillStatus", + "SkillStopManagingStatus", + "SkillTableRowResponse", + "SkillUpdateStatus", + "SkillsPageResponse", + "SkillsSummaryResponse", +] diff --git a/skill_manager/api/schemas/cli_marketplace.py b/skill_manager/api/schemas/cli_marketplace.py new file mode 100644 index 0000000..b0b7bb6 --- /dev/null +++ b/skill_manager/api/schemas/cli_marketplace.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class CliMarketplaceItemResponse(BaseModel): + id: str + slug: str + name: str + description: str + marketplaceUrl: str + iconUrl: str | None = None + githubUrl: str | None = None + websiteUrl: str | None = None + stars: int | None = None + language: str | None = None + category: str | None = None + hasMcp: bool + hasSkill: bool + isOfficial: bool + isTui: bool + sourceType: str | None = None + vendorName: str | None = None + + +class CliMarketplacePageResponse(BaseModel): + items: list[CliMarketplaceItemResponse] + nextOffset: int | None = None + hasMore: bool + + +class CliMarketplaceDetailResponse(CliMarketplaceItemResponse): + longDescription: str | None = None + installCommand: str | None = None + + +__all__ = [ + "CliMarketplaceDetailResponse", + "CliMarketplaceItemResponse", + "CliMarketplacePageResponse", +] diff --git a/skill_manager/api/schemas/common.py b/skill_manager/api/schemas/common.py new file mode 100644 index 0000000..a7cc70a --- /dev/null +++ b/skill_manager/api/schemas/common.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class HarnessTarget(BaseModel): + harness: str = Field(..., min_length=1, description="Harness identifier") + + +class SetHarnessSupportRequest(BaseModel): + enabled: bool + + +class OkResponse(BaseModel): + ok: bool + + +__all__ = ["HarnessTarget", "OkResponse", "SetHarnessSupportRequest"] diff --git a/skill_manager/api/schemas/mcp.py b/skill_manager/api/schemas/mcp.py new file mode 100644 index 0000000..ae8ebc9 --- /dev/null +++ b/skill_manager/api/schemas/mcp.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from .common import HarnessTarget + + +class AddMcpServerRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + qualified_name: str = Field(..., alias="qualifiedName", min_length=1) + source_harness: str = Field(..., alias="sourceHarness", min_length=1) + + +class EnableMcpServerRequest(HarnessTarget): + pass + + +class DisableMcpServerRequest(HarnessTarget): + pass + + +class SetMcpServerHarnessesRequest(BaseModel): + target: Literal["enabled", "disabled"] + + +class AdoptMcpRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + name: str = Field(..., min_length=1) + source_harness: str | None = Field(default=None, alias="sourceHarness") + harnesses: list[str] | None = None + + +class ReconcileMcpServerRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + source_kind: Literal["managed", "harness"] = Field(..., alias="sourceKind") + source_harness: str | None = Field(default=None, alias="sourceHarness") + harnesses: list[str] | None = None + + +class McpSourceResponse(BaseModel): + kind: Literal["marketplace", "adopted", "manual"] + locator: str + + +class McpServerSpecResponse(BaseModel): + name: str + displayName: str + source: McpSourceResponse + transport: Literal["stdio", "http", "sse"] + command: str | None = None + args: list[str] | None = None + env: dict[str, str] | None = None + url: str | None = None + headers: dict[str, str] | None = None + installedAt: str + revision: str + + +class McpInventoryColumnResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + installed: bool + configPresent: bool + mcpWritable: bool = True + mcpUnavailableReason: str | None = None + + +class McpInventoryIssueResponse(BaseModel): + name: str + reason: str + + +class McpBindingResponse(BaseModel): + harness: str + state: Literal["managed", "drifted", "unmanaged", "missing"] + driftDetail: str | None = None + + +class McpInventoryEntryResponse(BaseModel): + name: str + displayName: str + kind: Literal["managed", "unmanaged"] + spec: McpServerSpecResponse | None = None + canEnable: bool + sightings: list[McpBindingResponse] + + +class McpInventoryResponse(BaseModel): + columns: list[McpInventoryColumnResponse] + entries: list[McpInventoryEntryResponse] + issues: list[McpInventoryIssueResponse] = Field(default_factory=list) + + +class McpMutationFailureResponse(BaseModel): + harness: str + error: str + + +class McpSetHarnessesResultResponse(BaseModel): + ok: bool + succeeded: list[str] + failed: list[McpMutationFailureResponse] + + +class McpServerMutationResponse(BaseModel): + ok: bool + server: McpServerSpecResponse + + +class McpApplyConfigResponse(BaseModel): + ok: bool + server: McpServerSpecResponse + succeeded: list[str] + failed: list[McpMutationFailureResponse] + + +class McpEnvEntryResponse(BaseModel): + key: str + value: str | None = None + isEnvRef: bool + + +class McpConfigChoiceResponse(BaseModel): + sourceKind: Literal["managed", "harness"] + sourceHarness: str | None = None + label: str + logoKey: str | None = None + configPath: str | None = None + payloadPreview: dict[str, object] + spec: McpServerSpecResponse + env: list[McpEnvEntryResponse] = Field(default_factory=list) + + +class McpMarketplaceLinkResponse(BaseModel): + qualifiedName: str + displayName: str + iconUrl: str | None = None + externalUrl: str + description: str + isRemote: bool + isVerified: bool + + +class McpServerDetailResponse(McpInventoryEntryResponse): + env: list[McpEnvEntryResponse] = Field(default_factory=list) + configChoices: list[McpConfigChoiceResponse] = Field(default_factory=list) + marketplaceLink: McpMarketplaceLinkResponse | None = None + + +class McpUnmanagedHarnessResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + installed: bool + configPresent: bool + mcpWritable: bool = True + mcpUnavailableReason: str | None = None + configPath: str | None = None + + +class McpIdentitySightingResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + configPath: str | None = None + payloadPreview: dict[str, object] + spec: McpServerSpecResponse + env: list[McpEnvEntryResponse] = Field(default_factory=list) + + +class McpAdoptionIssueResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + name: str + configPath: str | None = None + payloadPreview: dict[str, object] | None = None + reason: str + + +class McpIdentityGroupResponse(BaseModel): + name: str + identical: bool + canonicalSpec: McpServerSpecResponse | None = None + sightings: list[McpIdentitySightingResponse] + marketplaceLink: McpMarketplaceLinkResponse | None = None + + +class McpUnmanagedByServerResponse(BaseModel): + harnesses: list[McpUnmanagedHarnessResponse] + servers: list[McpIdentityGroupResponse] + issues: list[McpAdoptionIssueResponse] = Field(default_factory=list) + + +class McpMarketplaceItemResponse(BaseModel): + qualifiedName: str + namespace: str + displayName: str + description: str + iconUrl: str | None = None + isVerified: bool + isRemote: bool + isDeployed: bool + useCount: int + createdAt: str | None = None + homepage: str | None = None + externalUrl: str + + +class McpMarketplacePageResponse(BaseModel): + items: list[McpMarketplaceItemResponse] + nextOffset: int | None = None + hasMore: bool + + +class McpInstallTargetResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + smitheryClient: str | None = None + supported: bool + reason: str | None = None + + +class McpInstallTargetsResponse(BaseModel): + targets: list[McpInstallTargetResponse] + + +class McpMarketplaceConnectionResponse(BaseModel): + kind: str + deploymentUrl: str | None = None + configSchema: dict[str, object] | None = None + stdioFunction: str | None = None + bundleUrl: str | None = None + runtime: str | None = None + stdioCommand: str | None = None + stdioArgs: list[str] | None = None + + +class McpMarketplaceParameterResponse(BaseModel): + name: str + type: str + description: str + required: bool + default: object | None = None + minimum: float | int | None = None + maximum: float | int | None = None + minItems: int | None = None + maxItems: int | None = None + minLength: int | None = None + maxLength: int | None = None + enum: list[object] | None = None + + +class McpMarketplaceToolResponse(BaseModel): + name: str + description: str + parameters: list[McpMarketplaceParameterResponse] + + +class McpMarketplaceResourceResponse(BaseModel): + name: str + uri: str + description: str + mimeType: str | None = None + + +class McpMarketplacePromptArgumentResponse(BaseModel): + name: str + description: str + required: bool + + +class McpMarketplacePromptResponse(BaseModel): + name: str + description: str + arguments: list[McpMarketplacePromptArgumentResponse] + + +class McpMarketplaceCapabilityCountsResponse(BaseModel): + tools: int + resources: int + prompts: int + + +class McpMarketplaceDetailResponse(BaseModel): + qualifiedName: str + managedName: str + displayName: str + description: str + iconUrl: str | None = None + isRemote: bool + deploymentUrl: str | None = None + connections: list[McpMarketplaceConnectionResponse] + tools: list[McpMarketplaceToolResponse] + resources: list[McpMarketplaceResourceResponse] + prompts: list[McpMarketplacePromptResponse] + capabilityCounts: McpMarketplaceCapabilityCountsResponse + externalUrl: str + + +__all__ = [ + "AdoptMcpRequest", + "DisableMcpServerRequest", + "EnableMcpServerRequest", + "AddMcpServerRequest", + "McpServerMutationResponse", + "McpApplyConfigResponse", + "McpAdoptionIssueResponse", + "McpBindingResponse", + "McpConfigChoiceResponse", + "McpEnvEntryResponse", + "McpIdentityGroupResponse", + "McpIdentitySightingResponse", + "McpInventoryColumnResponse", + "McpInventoryIssueResponse", + "McpInventoryEntryResponse", + "McpInventoryResponse", + "McpInstallTargetResponse", + "McpInstallTargetsResponse", + "McpMarketplaceCapabilityCountsResponse", + "McpMarketplaceConnectionResponse", + "McpMarketplaceDetailResponse", + "McpMarketplaceItemResponse", + "McpMarketplaceLinkResponse", + "McpMarketplacePageResponse", + "McpMarketplaceParameterResponse", + "McpMarketplacePromptArgumentResponse", + "McpMarketplacePromptResponse", + "McpMarketplaceResourceResponse", + "McpMarketplaceToolResponse", + "McpMutationFailureResponse", + "McpServerDetailResponse", + "McpServerSpecResponse", + "McpSetHarnessesResultResponse", + "McpSourceResponse", + "McpUnmanagedByServerResponse", + "McpUnmanagedHarnessResponse", + "ReconcileMcpServerRequest", + "SetMcpServerHarnessesRequest", +] diff --git a/skill_manager/api/schemas/skills.py b/skill_manager/api/schemas/skills.py new file mode 100644 index 0000000..9a9c85a --- /dev/null +++ b/skill_manager/api/schemas/skills.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from .common import HarnessTarget + + +class EnableSkillRequest(HarnessTarget): + pass + + +class DisableSkillRequest(HarnessTarget): + pass + + +class SetSkillHarnessesRequest(BaseModel): + target: Literal["enabled", "disabled"] = Field( + ..., + description="Target state to apply to every interactive harness cell on this skill", + ) + + +class InstallMarketplaceSkillRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + install_token: str = Field(..., alias="installToken", min_length=1) + + +SkillStatus = Literal["Managed", "Unmanaged"] +HarnessCellState = Literal["enabled", "disabled", "found", "empty"] +SkillUpdateStatus = Literal[ + "update_available", + "no_update_available", + "no_source_available", + "local_changes_detected", +] +SkillStopManagingStatus = Literal["available", "disabled_no_enabled"] + + +class SetSkillHarnessesFailureResponse(BaseModel): + harness: str + error: str + + +class SetSkillHarnessesResultResponse(BaseModel): + ok: bool + succeeded: list[str] + failed: list[SetSkillHarnessesFailureResponse] + + +class BulkManageFailureResponse(BaseModel): + skillRef: str + name: str + error: str + + +class BulkManageResultResponse(BaseModel): + ok: bool + managedCount: int + skippedCount: int + failures: list[BulkManageFailureResponse] + + +class SkillsSummaryResponse(BaseModel): + managed: int + unmanaged: int + + +class HarnessColumnResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + installed: bool + + +class SkillRowActionsResponse(BaseModel): + canManage: bool + canStopManaging: bool + canDelete: bool + + +class HarnessCellResponse(BaseModel): + harness: str + label: str + logoKey: str | None = None + state: HarnessCellState + interactive: bool + + +class SkillTableRowResponse(BaseModel): + skillRef: str + name: str + description: str + displayStatus: SkillStatus + actions: SkillRowActionsResponse + cells: list[HarnessCellResponse] + + +class SkillsPageResponse(BaseModel): + summary: SkillsSummaryResponse + harnessColumns: list[HarnessColumnResponse] + rows: list[SkillTableRowResponse] + + +class SkillDetailActionsResponse(BaseModel): + canManage: bool + stopManagingStatus: SkillStopManagingStatus | None + stopManagingHarnessLabels: list[str] + canDelete: bool + deleteHarnessLabels: list[str] + + +class SkillLocationResponse(BaseModel): + kind: Literal["shared", "harness"] + harness: str | None + label: str + scope: str | None + path: str | None + revision: str | None + sourceKind: str + sourceLocator: str + detail: str | None + + +class SkillSourceLinksResponse(BaseModel): + repoLabel: str + repoUrl: str + folderUrl: str | None + + +class SkillDetailResponse(BaseModel): + skillRef: str + name: str + description: str + displayStatus: SkillStatus + attentionMessage: str | None + actions: SkillDetailActionsResponse + harnessCells: list[HarnessCellResponse] + locations: list[SkillLocationResponse] + sourceLinks: SkillSourceLinksResponse | None + documentMarkdown: str | None + + +class SkillSourceStatusResponse(BaseModel): + updateStatus: SkillUpdateStatus | None + + +__all__ = [ + "BulkManageFailureResponse", + "BulkManageResultResponse", + "DisableSkillRequest", + "EnableSkillRequest", + "HarnessCellResponse", + "HarnessCellState", + "HarnessColumnResponse", + "InstallMarketplaceSkillRequest", + "SetSkillHarnessesFailureResponse", + "SetSkillHarnessesRequest", + "SetSkillHarnessesResultResponse", + "SkillDetailActionsResponse", + "SkillDetailResponse", + "SkillLocationResponse", + "SkillRowActionsResponse", + "SkillSourceLinksResponse", + "SkillSourceStatusResponse", + "SkillStatus", + "SkillStopManagingStatus", + "SkillTableRowResponse", + "SkillUpdateStatus", + "SkillsPageResponse", + "SkillsSummaryResponse", +] diff --git a/skill_manager/application/cli_marketplace/__init__.py b/skill_manager/application/cli_marketplace/__init__.py new file mode 100644 index 0000000..d3abb73 --- /dev/null +++ b/skill_manager/application/cli_marketplace/__init__.py @@ -0,0 +1,4 @@ +from .catalog import CliMarketplaceCatalog +from .client import ClisDevClient + +__all__ = ["CliMarketplaceCatalog", "ClisDevClient"] diff --git a/skill_manager/application/cli_marketplace/catalog.py b/skill_manager/application/cli_marketplace/catalog.py new file mode 100644 index 0000000..cd05cf3 --- /dev/null +++ b/skill_manager/application/cli_marketplace/catalog.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import re +from urllib.parse import quote, urlparse + +from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.errors import MarketplaceUpstreamError +from skill_manager.sources.github import github_owner_avatar_url + +from .client import ClisDevClient + +Fetcher = Callable[[str], dict[str, object]] + +_DEFAULT_PAGE_SIZE = 30 +_MAX_PAGE_SIZE = 100 +_POPULAR_TTL_SECONDS = 3600 +_SEARCH_TTL_SECONDS = 900 +_POPULAR_NAMESPACE = "clisdev-popular-v1" +_SEARCH_NAMESPACE = "clisdev-search-v1" + + +@dataclass(frozen=True) +class CliMarketplaceRecord: + slug: str + name: str + description: str + long_description: str | None + marketplace_url: str + icon_url: str | None + github_url: str | None + website_url: str | None + stars: int | None + language: str | None + category: str | None + install_command: str | None + has_mcp: bool + has_skill: bool + is_official: bool + is_tui: bool + source_type: str | None + vendor_name: str | None + + @property + def item_id(self) -> str: + return f"clisdev:{self.slug}" + + def to_item_dict(self) -> dict[str, object]: + return { + "id": self.item_id, + "slug": self.slug, + "name": self.name, + "description": self.description, + "marketplaceUrl": self.marketplace_url, + "iconUrl": self.icon_url, + "githubUrl": self.github_url, + "websiteUrl": self.website_url, + "stars": self.stars, + "language": self.language, + "category": self.category, + "hasMcp": self.has_mcp, + "hasSkill": self.has_skill, + "isOfficial": self.is_official, + "isTui": self.is_tui, + "sourceType": self.source_type, + "vendorName": self.vendor_name, + } + + def to_detail_dict(self) -> dict[str, object]: + return { + **self.to_item_dict(), + "longDescription": self.long_description, + "installCommand": self.install_command, + } + + +class CliMarketplaceCatalog: + DEFAULT_PAGE_SIZE = _DEFAULT_PAGE_SIZE + MAX_PAGE_SIZE = _MAX_PAGE_SIZE + + def __init__( + self, + *, + client: ClisDevClient | None = None, + fetcher: Fetcher | None = None, + cache: MarketplaceCache | None = None, + ) -> None: + self.client = client or ClisDevClient.from_environment() + self._fetcher = fetcher + self._cache = cache or MarketplaceCache() + + @classmethod + def from_environment( + cls, + env: dict[str, str] | None = None, + *, + cache: MarketplaceCache | None = None, + ) -> "CliMarketplaceCatalog": + return cls( + client=ClisDevClient.from_environment(env), + cache=cache or MarketplaceCache.from_environment(env), + ) + + def popular_page(self, *, limit: int | None = None, offset: int = 0) -> dict[str, object]: + return self._page(self.known_records(), limit=limit, offset=offset) + + def search_page(self, query: str, *, limit: int | None = None, offset: int = 0) -> dict[str, object]: + trimmed = query.strip() + if len(trimmed) < 2: + raise ValueError("Enter at least 2 characters to search CLIs.") + return self._page(self.search_records(trimmed), limit=limit, offset=offset) + + def detail(self, slug_or_id: str) -> dict[str, object] | None: + slug = parse_cli_slug(slug_or_id) + if slug is None: + return None + for record in self.known_records(): + if record.slug == slug: + return record.to_detail_dict() + try: + matches = self.search_records(slug) + except MarketplaceUpstreamError: + return None + for record in matches: + if record.slug == slug: + return record.to_detail_dict() + return None + + def known_records(self) -> tuple[CliMarketplaceRecord, ...]: + payload = self._cached_payload( + _POPULAR_NAMESPACE, + "all", + ttl_seconds=_POPULAR_TTL_SECONDS, + fetch=lambda: self._fetch_json("/api/clis"), + ) + return self._records_from_payload(payload) + + def search_records(self, query: str) -> tuple[CliMarketplaceRecord, ...]: + normalized = query.strip().lower() + payload = self._cached_payload( + _SEARCH_NAMESPACE, + normalized, + ttl_seconds=_SEARCH_TTL_SECONDS, + fetch=lambda: self._fetch_json(f"/api/search?q={quote(query.strip(), safe='')}"), + ) + return self._records_from_payload(payload) + + def _cached_payload( + self, + namespace: str, + key: str, + *, + ttl_seconds: int, + fetch: Callable[[], dict[str, object]], + ) -> dict[str, object]: + cached = self._cache.read(namespace, key, ttl_seconds=ttl_seconds) + if cached is not None and cached.is_fresh and isinstance(cached.payload, dict): + return cached.payload + try: + payload = fetch() + except MarketplaceUpstreamError: + if cached is not None and isinstance(cached.payload, dict): + return cached.payload + raise + self._cache.write(namespace, key, payload) + return payload + + def _fetch_json(self, path: str) -> dict[str, object]: + if self._fetcher is not None: + payload = self._fetcher(path) + if not isinstance(payload, dict): + raise MarketplaceUpstreamError("payload", path, "JSON payload must be an object") + return payload + return self.client.fetch_json(path) + + def _records_from_payload(self, payload: dict[str, object]) -> tuple[CliMarketplaceRecord, ...]: + items = _extract_items(payload) + records: list[CliMarketplaceRecord] = [] + seen: set[str] = set() + for item in items: + if not isinstance(item, dict): + continue + record = _normalize_record(item, detail_url=self.client.detail_url) + if record.slug in seen: + continue + records.append(record) + seen.add(record.slug) + return tuple(records) + + def _page( + self, + records: tuple[CliMarketplaceRecord, ...], + *, + limit: int | None, + offset: int, + ) -> dict[str, object]: + page_limit = _normalize_limit(limit) + page_offset = max(offset, 0) + page_items = records[page_offset:page_offset + page_limit] + next_offset = page_offset + len(page_items) + has_more = next_offset < len(records) + return { + "items": [record.to_item_dict() for record in page_items], + "nextOffset": next_offset if has_more else None, + "hasMore": has_more, + } + + +def parse_cli_slug(slug_or_id: str) -> str | None: + value = slug_or_id.strip() + if value.startswith("clisdev:"): + value = value.removeprefix("clisdev:") + return _slugify(value) if value else None + + +def _extract_items(payload: dict[str, object]) -> list[object]: + for key in ("clis", "items", "results", "data"): + value = payload.get(key) + if isinstance(value, list): + return value + return [] + + +def _normalize_record(payload: dict[str, object], *, detail_url: Callable[[str], str]) -> CliMarketplaceRecord: + slug = _slugify(_first_str(payload, "slug", "id", "name", fallback="cli")) + name = _first_str(payload, "name", "title", fallback=_title_from_slug(slug)) + long_description = _optional_str(payload.get("long_description")) or _optional_str(payload.get("longDescription")) + description = _first_str(payload, "description", "summary", fallback=_description_from_long(long_description)) + github_url = _valid_github_url(payload.get("github")) + website_url = _valid_http_url(payload.get("website")) or _valid_http_url(payload.get("source_url")) + return CliMarketplaceRecord( + slug=slug, + name=name, + description=description, + long_description=long_description, + marketplace_url=detail_url(slug), + icon_url=_github_icon_url(github_url), + github_url=github_url, + website_url=website_url, + stars=_optional_int(payload.get("stars")), + language=_optional_str(payload.get("language")), + category=_optional_str(payload.get("category")), + install_command=_optional_str(payload.get("install")), + has_mcp=_bool(payload.get("has_mcp")), + has_skill=_bool(payload.get("has_skill")), + is_official=_bool(payload.get("is_official")), + is_tui=_bool(payload.get("is_tui")), + source_type=_optional_str(payload.get("source_type")), + vendor_name=_optional_str(payload.get("vendor_name")), + ) + + +def _normalize_limit(limit: int | None) -> int: + if limit is None: + return _DEFAULT_PAGE_SIZE + return max(1, min(int(limit), _MAX_PAGE_SIZE)) + + +def _first_str(payload: dict[str, object], *keys: str, fallback: str) -> str: + for key in keys: + value = _optional_str(payload.get(key)) + if value is not None: + return value + return fallback + + +def _optional_str(value: object) -> str | None: + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _optional_int(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + try: + return int(value.replace(",", "").strip()) + except ValueError: + return None + return None + + +def _bool(value: object) -> bool: + return value if isinstance(value, bool) else False + + +def _valid_http_url(value: object) -> str | None: + raw = _optional_str(value) + if raw is None: + return None + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return None + return raw + + +def _valid_github_url(value: object) -> str | None: + raw = _valid_http_url(value) + if raw is None: + return None + parsed = urlparse(raw) + if parsed.netloc.lower() not in {"github.com", "www.github.com"}: + return None + parts = [part for part in parsed.path.split("/") if part] + if len(parts) < 2: + return None + owner, repo = parts[0], parts[1] + if not owner or not repo: + return None + if repo.endswith(".git"): + repo = repo[:-4] + return f"https://github.com/{owner}/{repo}" + + +def _github_icon_url(github_url: str | None) -> str | None: + if github_url is None: + return None + parsed = urlparse(github_url) + parts = [part for part in parsed.path.split("/") if part] + if len(parts) < 2: + return None + return github_owner_avatar_url(f"{parts[0]}/{parts[1]}", size=96) + + +def _slugify(value: str) -> str: + normalized = value.strip().lower() + normalized = re.sub(r"[^a-z0-9._-]+", "-", normalized) + normalized = re.sub(r"-+", "-", normalized).strip("-") + return normalized or "cli" + + +def _title_from_slug(slug: str) -> str: + return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) + + +def _description_from_long(long_description: str | None) -> str: + if not long_description: + return "No description available." + first = long_description.strip().split("\n\n", 1)[0].strip() + return first or "No description available." + + +__all__ = [ + "CliMarketplaceCatalog", + "CliMarketplaceRecord", + "parse_cli_slug", +] diff --git a/skill_manager/application/cli_marketplace/client.py b/skill_manager/application/cli_marketplace/client.py new file mode 100644 index 0000000..98ebb21 --- /dev/null +++ b/skill_manager/application/cli_marketplace/client.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import os +import socket +import ssl +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urljoin +from urllib.request import Request, urlopen + +from skill_manager.application.marketplace_http import ( + configured_marketplace_ca_file, + marketplace_ssl_context, +) +from skill_manager.errors import MarketplaceUpstreamError + +DEFAULT_CLIS_DEV_BASE_URL = "https://clis.dev" +CLIS_DEV_BASE_URL_ENV = "SKILL_MANAGER_CLIS_DEV_BASE_URL" +_TIMEOUT_SECONDS = 15 +_USER_AGENT = "skill-manager/0.1" + + +def configured_clis_dev_base_url(env: dict[str, str] | None = None) -> str: + active_env = os.environ if env is None else env + configured = active_env.get(CLIS_DEV_BASE_URL_ENV, DEFAULT_CLIS_DEV_BASE_URL).strip() + return (configured or DEFAULT_CLIS_DEV_BASE_URL).rstrip("/") + + +class ClisDevClient: + """Small CLIs.dev JSON client for preview-only marketplace reads.""" + + def __init__( + self, + *, + base_url: str = DEFAULT_CLIS_DEV_BASE_URL, + timeout_seconds: float = _TIMEOUT_SECONDS, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + self.base_url = (base_url or DEFAULT_CLIS_DEV_BASE_URL).rstrip("/") + self.timeout_seconds = timeout_seconds + self.ssl_context = ssl_context + + @classmethod + def from_environment(cls, env: dict[str, str] | None = None) -> "ClisDevClient": + return cls( + base_url=configured_clis_dev_base_url(env), + ssl_context=marketplace_ssl_context(env), + ) + + def absolute_url(self, path_or_url: str) -> str: + if path_or_url.startswith(("http://", "https://")): + return path_or_url + return urljoin(f"{self.base_url}/", path_or_url.lstrip("/")) + + def detail_url(self, slug: str) -> str: + return self.absolute_url(f"/cli/{quote(slug, safe='')}") + + def list_clis(self) -> dict[str, object]: + return self.fetch_json("/api/clis") + + def search_clis(self, query: str) -> dict[str, object]: + return self.fetch_json(f"/api/search?q={quote(query, safe='')}") + + def fetch_json(self, path_or_url: str) -> dict[str, object]: + url = self.absolute_url(path_or_url) + payload = self._request(path_or_url, accept="application/json") + try: + parsed = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise MarketplaceUpstreamError("payload", url, f"invalid JSON payload: {error}") from error + if not isinstance(parsed, dict): + raise MarketplaceUpstreamError("payload", url, "JSON payload must be an object") + return parsed + + def _request(self, path_or_url: str, *, accept: str | None = None) -> bytes: + url = self.absolute_url(path_or_url) + headers = {"User-Agent": _USER_AGENT} + if accept: + headers["Accept"] = accept + request = Request(url, headers=headers) + open_kwargs: dict[str, object] = {"timeout": self.timeout_seconds} + if self.ssl_context is not None: + open_kwargs["context"] = self.ssl_context + try: + with urlopen(request, **open_kwargs) as response: + return response.read() + except HTTPError as error: + raise MarketplaceUpstreamError( + "bad_status", + url, + f"upstream returned HTTP {error.code}", + upstream_status=error.code, + ) from error + except ssl.SSLCertVerificationError as error: + raise MarketplaceUpstreamError("tls", url, str(error)) from error + except TimeoutError as error: + raise MarketplaceUpstreamError("timeout", url, str(error)) from error + except URLError as error: + reason = error.reason + if isinstance(reason, ssl.SSLError): + kind = "tls" + elif isinstance(reason, (TimeoutError, socket.timeout)): + kind = "timeout" + else: + kind = "network" + raise MarketplaceUpstreamError(kind, url, str(reason)) from error + except OSError as error: + raise MarketplaceUpstreamError("network", url, str(error)) from error + + +__all__ = [ + "CLIS_DEV_BASE_URL_ENV", + "DEFAULT_CLIS_DEV_BASE_URL", + "ClisDevClient", + "configured_clis_dev_base_url", + "configured_marketplace_ca_file", +] diff --git a/skill_manager/application/container.py b/skill_manager/application/container.py index 4b0500f..892973d 100644 --- a/skill_manager/application/container.py +++ b/skill_manager/application/container.py @@ -3,42 +3,66 @@ import os from dataclasses import dataclass +from skill_manager.harness import HarnessKernelService, HarnessSupportStore from skill_manager.paths import AppPaths, resolve_app_paths -from skill_manager.store import HarnessSupportStore -from .marketplace import ( +from .cli_marketplace import CliMarketplaceCatalog +from .invalidation import InvalidationFanout +from .mcp.enrichment import McpEnrichmentService +from .mcp.installers import McpInstallProvider, SmitheryCliInstallProvider +from .mcp.marketplace import McpMarketplaceCatalog +from .mcp.mutations import McpMutationService +from .mcp.planner import McpAdoptionPlanner +from .mcp.query import McpQueryService +from .mcp.read_models import McpReadModelService +from .mcp.store import McpServerStore +from .settings import SettingsMutationService, SettingsQueryService +from .skills import SkillsMutationService, SkillsQueryService +from .skills.marketplace import ( MarketplaceCatalog, MarketplaceDocumentService, MarketplaceInstallService, MarketplaceQueryService, ) -from .read_model_service import ReadModelService -from .settings import SettingsMutationService, SettingsQueryService -from .skills import SkillsMutationService, SkillsQueryService -from .source_fetch_service import SourceFetchService +from .skills.read_models import SkillsReadModelService +from .skills.source_fetch import SourceFetchService +from .skills.store import SkillStore +from .marketplace_cache import MarketplaceCache @dataclass(frozen=True) class BackendContainer: paths: AppPaths - read_models: ReadModelService + harness_kernel: HarnessKernelService support_store: HarnessSupportStore - source_fetcher: SourceFetchService + invalidation: InvalidationFanout + skills_source_fetcher: SourceFetchService + skills_store: SkillStore + skills_read_models: SkillsReadModelService skills_queries: SkillsQueryService skills_mutations: SkillsMutationService settings_queries: SettingsQueryService settings_mutations: SettingsMutationService - marketplace_catalog: MarketplaceCatalog - marketplace_documents: MarketplaceDocumentService - marketplace_queries: MarketplaceQueryService - marketplace_installs: MarketplaceInstallService + skills_marketplace_catalog: MarketplaceCatalog + skills_marketplace_documents: MarketplaceDocumentService + skills_marketplace_queries: MarketplaceQueryService + skills_marketplace_installs: MarketplaceInstallService + cli_marketplace_catalog: CliMarketplaceCatalog + mcp_marketplace_catalog: McpMarketplaceCatalog + mcp_store: McpServerStore + mcp_read_models: McpReadModelService + mcp_queries: McpQueryService + mcp_mutations: McpMutationService def build_backend_container( env: dict[str, str] | None = None, *, marketplace_catalog: MarketplaceCatalog | None = None, + mcp_marketplace_catalog: McpMarketplaceCatalog | None = None, + cli_marketplace_catalog: CliMarketplaceCatalog | None = None, source_fetcher: SourceFetchService | None = None, + mcp_install_provider: McpInstallProvider | None = None, ) -> BackendContainer: active_env = dict(os.environ) if env is not None: @@ -46,27 +70,77 @@ def build_backend_container( paths = resolve_app_paths(active_env) support_store = HarnessSupportStore(paths.settings_path) - read_models = ReadModelService.from_environment(active_env, support_store=support_store) + harness_kernel = HarnessKernelService.from_environment(active_env, support_store=support_store) + invalidation = InvalidationFanout() + + skills_store = SkillStore(paths.skills_store_root, manifest_path=paths.skills_store_manifest) + skills_read_models = SkillsReadModelService.from_kernel(store=skills_store, kernel=harness_kernel) + invalidation.register(skills_read_models) + active_source_fetcher = source_fetcher or SourceFetchService() - catalog = marketplace_catalog or MarketplaceCatalog.from_environment(active_env) - skills_queries = SkillsQueryService(read_models, active_source_fetcher) - skills_mutations = SkillsMutationService(read_models, skills_queries, active_source_fetcher) - settings_queries = SettingsQueryService(read_models, support_store) - settings_mutations = SettingsMutationService(read_models, support_store) - marketplace_documents = MarketplaceDocumentService(active_source_fetcher, cache=catalog.cache) - marketplace_queries = MarketplaceQueryService(read_models, catalog, marketplace_documents) - marketplace_installs = MarketplaceInstallService(catalog, skills_mutations) + skills_queries = SkillsQueryService(skills_read_models, active_source_fetcher) + skills_mutations = SkillsMutationService(skills_read_models, skills_queries, active_source_fetcher) + settings_queries = SettingsQueryService(harness_kernel) + + cache = MarketplaceCache.from_environment(active_env) + skills_catalog = marketplace_catalog or MarketplaceCatalog.from_environment( + active_env, + cache=cache, + warm_on_init=False, + ) + skills_documents = MarketplaceDocumentService(active_source_fetcher, cache=cache) + skills_marketplace_queries = MarketplaceQueryService(skills_read_models, skills_catalog, skills_documents) + skills_marketplace_installs = MarketplaceInstallService(skills_catalog, skills_mutations) + cli_catalog = cli_marketplace_catalog or CliMarketplaceCatalog.from_environment( + active_env, + cache=cache, + ) + + mcp_store = McpServerStore(paths.mcp_store_manifest) + mcp_read_models = McpReadModelService.from_kernel(store=mcp_store, kernel=harness_kernel) + invalidation.register(mcp_read_models) + settings_mutations = SettingsMutationService(harness_kernel, support_store, invalidation) + + mcp_catalog = mcp_marketplace_catalog or McpMarketplaceCatalog.from_environment( + active_env, + cache=cache, + ) + mcp_enrichment = McpEnrichmentService(mcp_catalog) + mcp_planner = McpAdoptionPlanner(mcp_read_models) + mcp_queries = McpQueryService( + mcp_read_models, + planner=mcp_planner, + enrichment=mcp_enrichment, + ) + mcp_mutations = McpMutationService( + store=mcp_store, + read_models=mcp_read_models, + planner=mcp_planner, + marketplace_catalog=mcp_catalog, + install_provider=mcp_install_provider or SmitheryCliInstallProvider(env=active_env), + enrichment=mcp_enrichment, + ) + return BackendContainer( paths=paths, - read_models=read_models, + harness_kernel=harness_kernel, support_store=support_store, - source_fetcher=active_source_fetcher, + invalidation=invalidation, + skills_source_fetcher=active_source_fetcher, + skills_store=skills_store, + skills_read_models=skills_read_models, skills_queries=skills_queries, skills_mutations=skills_mutations, settings_queries=settings_queries, settings_mutations=settings_mutations, - marketplace_catalog=catalog, - marketplace_documents=marketplace_documents, - marketplace_queries=marketplace_queries, - marketplace_installs=marketplace_installs, + skills_marketplace_catalog=skills_catalog, + skills_marketplace_documents=skills_documents, + skills_marketplace_queries=skills_marketplace_queries, + skills_marketplace_installs=skills_marketplace_installs, + cli_marketplace_catalog=cli_catalog, + mcp_marketplace_catalog=mcp_catalog, + mcp_store=mcp_store, + mcp_read_models=mcp_read_models, + mcp_queries=mcp_queries, + mcp_mutations=mcp_mutations, ) diff --git a/skill_manager/application/invalidation.py b/skill_manager/application/invalidation.py new file mode 100644 index 0000000..1995db0 --- /dev/null +++ b/skill_manager/application/invalidation.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Protocol + + +class Invalidatable(Protocol): + def invalidate(self) -> None: ... + + +class InvalidationFanout: + def __init__(self) -> None: + self._targets: list[Invalidatable] = [] + + def register(self, target: Invalidatable) -> Invalidatable: + self._targets.append(target) + return target + + def invalidate_all(self) -> None: + for target in self._targets: + target.invalidate() + + +__all__ = ["Invalidatable", "InvalidationFanout"] diff --git a/skill_manager/application/marketplace/cache.py b/skill_manager/application/marketplace_cache.py similarity index 100% rename from skill_manager/application/marketplace/cache.py rename to skill_manager/application/marketplace_cache.py diff --git a/skill_manager/application/marketplace_http.py b/skill_manager/application/marketplace_http.py new file mode 100644 index 0000000..f483a55 --- /dev/null +++ b/skill_manager/application/marketplace_http.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os +from pathlib import Path +import ssl +import sys + +import certifi + + +def configured_marketplace_ca_file(env: dict[str, str] | None = None) -> Path | None: + active_env = os.environ if env is None else env + override = active_env.get("SSL_CERT_FILE", "").strip() + if override: + return Path(override) + if _is_packaged_runtime(): + return Path(certifi.where()) + return None + + +def marketplace_ssl_context(env: dict[str, str] | None = None) -> ssl.SSLContext | None: + cafile = configured_marketplace_ca_file(env) + if cafile is None: + return None + return ssl.create_default_context(cafile=str(cafile)) + + +def _is_packaged_runtime() -> bool: + return bool(getattr(sys, "frozen", False)) + + +__all__ = ["configured_marketplace_ca_file", "marketplace_ssl_context"] diff --git a/skill_manager/application/mcp/__init__.py b/skill_manager/application/mcp/__init__.py new file mode 100644 index 0000000..d36e2b2 --- /dev/null +++ b/skill_manager/application/mcp/__init__.py @@ -0,0 +1,62 @@ +from .adapters import FileBackedMcpAdapter, build_mcp_adapters +from .contracts import ( + BindingState, + McpBinding, + McpHarnessAdapter, + McpHarnessScan, + McpHarnessStatus, + McpInventory, + McpInventoryEntry, + McpObservedEntry, +) +from .identity import AdoptionIssue, AdoptionPlan, HarnessSighting, ServerIdentityGroup, build_identity_plan +from .installers import McpInstallProvider, McpInstallResult, SmitheryCliInstallProvider +from .names import canonical_server_name +from .inventory import build_inventory +from .mappers import ( + ClaudeCodeMapper, + CodexMapper, + CursorMapper, + OpenClawMapper, + OpenCodeMapper, + TransportMapper, + get_mapper, +) +from .planner import McpAdoptionPlanner +from .read_models import McpReadModelService, McpReadModelSnapshot +from .store import McpManagedManifest, McpServerSpec + +__all__ = [ + "AdoptionIssue", + "AdoptionPlan", + "BindingState", + "ClaudeCodeMapper", + "CodexMapper", + "CursorMapper", + "FileBackedMcpAdapter", + "HarnessSighting", + "McpAdoptionPlanner", + "McpBinding", + "McpHarnessAdapter", + "McpHarnessScan", + "McpHarnessStatus", + "McpInstallProvider", + "McpInstallResult", + "McpInventory", + "McpInventoryEntry", + "McpManagedManifest", + "McpObservedEntry", + "McpReadModelService", + "McpReadModelSnapshot", + "McpServerSpec", + "OpenClawMapper", + "OpenCodeMapper", + "ServerIdentityGroup", + "SmitheryCliInstallProvider", + "TransportMapper", + "build_identity_plan", + "build_inventory", + "build_mcp_adapters", + "canonical_server_name", + "get_mapper", +] diff --git a/skill_manager/application/mcp/adapters.py b/skill_manager/application/mcp/adapters.py new file mode 100644 index 0000000..f7e25d4 --- /dev/null +++ b/skill_manager/application/mcp/adapters.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +import tomli_w + +from skill_manager.errors import MutationError +from skill_manager.atomic_files import atomic_write_text, file_lock +from skill_manager.harness import ( + ConfigSubtreeBindingProfile, + HarnessDefinition, + HarnessKernelService, + ResolutionContext, + SubtreePath, +) + +from .contracts import McpHarnessAdapter, McpHarnessScan, McpHarnessStatus, McpObservedEntry +from .mappers import TransportMapper, get_mapper +from .store import McpServerSpec, McpSource + + +@dataclass(frozen=True) +class _RawEntry: + name: str + payload: dict[str, object] + config_path: Path + subtree_path: SubtreePath + + +class FileBackedMcpAdapter(McpHarnessAdapter): + def __init__( + self, + *, + definition: HarnessDefinition, + profile: ConfigSubtreeBindingProfile, + context: ResolutionContext, + ) -> None: + self.harness = definition.harness + self.label = definition.label + self.logo_key = definition.logo_key + self.config_path = profile.resolve_config_path(context) + self._discovery_config_paths = profile.resolve_discovery_config_paths(context) + self._install_probe = definition.install_probe + self._path_env = context.env.get("PATH") + self._file_format = profile.file_format + self._write_subtree_path = profile.subtree_path + self._read_subtree_paths = profile.resolve_discovery_subtree_paths(context) + self._mapper: TransportMapper = get_mapper(profile.codec) + self._capability_probe = profile.capability_probe + self._capability_unavailable_reason = profile.capability_unavailable_reason + + def status(self) -> McpHarnessStatus: + installed = self._is_installed() + config_present = any(path.is_file() for path in self._discovery_config_paths) + mcp_writable, unavailable_reason = self._mcp_write_capability(installed=installed) + return McpHarnessStatus( + harness=self.harness, + label=self.label, + logo_key=self.logo_key, + installed=installed, + config_path=self.config_path, + config_present=config_present, + mcp_writable=mcp_writable, + mcp_unavailable_reason=unavailable_reason, + ) + + def scan(self, specs: tuple[McpServerSpec, ...]) -> McpHarnessScan: + status = self.status() + specs_by_name = {spec.name: spec for spec in specs} + entries: list[McpObservedEntry] = [] + seen_names: set[str] = set() + scan_issue: str | None = None + + try: + raw_entries = self._read_entries() if status.config_present else () + except MutationError as error: + raw_entries = () + scan_issue = str(error) + for raw in raw_entries: + seen_names.add(raw.name) + parsed_spec: McpServerSpec | None = None + parse_issue: str | None = None + try: + parsed_spec = self._mapper.dict_to_spec( + raw.name, + raw.payload, + source=McpSource.adopted(self.harness, raw.name), + ) + except Exception as error: # noqa: BLE001 + parse_issue = str(error) + + managed_spec = specs_by_name.get(raw.name) + if managed_spec is None: + entries.append( + McpObservedEntry( + name=raw.name, + state="unmanaged", + raw_payload=dict(raw.payload), + parsed_spec=parsed_spec, + parse_issue=parse_issue, + ) + ) + continue + + if parse_issue is not None: + entries.append( + McpObservedEntry( + name=raw.name, + state="drifted", + raw_payload=dict(raw.payload), + parsed_spec=parsed_spec, + drift_detail=parse_issue, + parse_issue=parse_issue, + ) + ) + continue + + expected = _normalize_payload(self._mapper.spec_to_dict(managed_spec)) + actual = _normalize_payload(dict(raw.payload)) + if expected == actual: + entries.append( + McpObservedEntry( + name=raw.name, + state="managed", + raw_payload=dict(raw.payload), + parsed_spec=parsed_spec, + ) + ) + else: + entries.append( + McpObservedEntry( + name=raw.name, + state="drifted", + raw_payload=dict(raw.payload), + parsed_spec=parsed_spec, + drift_detail=_drift_detail(expected, actual), + ) + ) + + for spec in specs: + if spec.name in seen_names: + continue + entries.append( + McpObservedEntry( + name=spec.name, + state="missing", + parsed_spec=spec, + ) + ) + + return McpHarnessScan( + harness=self.harness, + label=self.label, + logo_key=self.logo_key, + installed=status.installed, + config_present=status.config_present, + config_path=self.config_path, + mcp_writable=status.mcp_writable, + mcp_unavailable_reason=status.mcp_unavailable_reason, + scan_issue=scan_issue, + entries=tuple(entries), + ) + + def has_binding(self, name: str) -> bool: + return any(raw.name == name for raw in self._read_entries()) + + def enable_server(self, spec: McpServerSpec) -> None: + self._require_mcp_writable() + with file_lock(self._lock_path(self.config_path)): + document = self._load_document(self.config_path) + subtree = dict(self._read_subtree(document, self._write_subtree_path)) + subtree[spec.name] = self._mapper.spec_to_dict(spec) + self._write_subtree(document, subtree, self._write_subtree_path) + for subtree_path in self._read_subtree_paths: + if subtree_path != self._write_subtree_path: + self._remove_from_subtree(document, subtree_path, spec.name) + atomic_write_text(self.config_path, self._dump_document(document)) + self._remove_from_noncanonical_config_paths(spec.name) + + def disable_server(self, name: str) -> None: + for config_path in self._discovery_config_paths: + if not config_path.is_file(): + continue + with file_lock(self._lock_path(config_path)): + document = self._load_document(config_path) + removed = False + for subtree_path in self._read_subtree_paths: + removed = self._remove_from_subtree(document, subtree_path, name) or removed + if not removed: + continue + atomic_write_text(config_path, self._dump_document(document)) + + def _remove_from_noncanonical_config_paths(self, name: str) -> None: + for config_path in self._discovery_config_paths: + if config_path == self.config_path or not config_path.is_file(): + continue + with file_lock(self._lock_path(config_path)): + document = self._load_document(config_path) + removed = False + for subtree_path in self._read_subtree_paths: + removed = self._remove_from_subtree(document, subtree_path, name) or removed + if removed: + atomic_write_text(config_path, self._dump_document(document)) + + def _require_mcp_writable(self) -> None: + status = self.status() + if status.mcp_writable: + return + reason = status.mcp_unavailable_reason or f"{self.label} MCP config is not writable" + raise MutationError(reason, status=400) + + def _mcp_write_capability(self, *, installed: bool) -> tuple[bool, str | None]: + if self._capability_probe is None: + return True, None + if self._capability_probe == "openclaw-mcp-command": + executable = shutil.which(self._install_probe, path=self._path_env) + reason = self._capability_unavailable_reason or f"{self.label} MCP support is unavailable" + if executable is None: + return False, reason + try: + result = subprocess.run( + [executable, "mcp", "--help"], + text=True, + capture_output=True, + timeout=2.0, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False, reason + return (result.returncode == 0, None if result.returncode == 0 else reason) + reason = self._capability_unavailable_reason or f"{self.label} MCP support is unavailable" + return (installed, None if installed else reason) + + @staticmethod + def _lock_path(config_path: Path) -> Path: + return config_path.with_suffix(config_path.suffix + ".lock") + + def _is_installed(self) -> bool: + return shutil.which(self._install_probe, path=self._path_env) is not None + + def _read_entries(self) -> tuple[_RawEntry, ...]: + entries: list[_RawEntry] = [] + seen_names: set[str] = set() + for config_path in self._discovery_config_paths: + if not config_path.is_file(): + continue + document = self._load_document(config_path) + for subtree_path in self._read_subtree_paths: + subtree = self._read_subtree(document, subtree_path) + for name, value in subtree.items(): + if name in seen_names or not isinstance(value, dict): + continue + seen_names.add(name) + entries.append( + _RawEntry( + name=name, + payload=dict(value), + config_path=config_path, + subtree_path=subtree_path, + ) + ) + return tuple(entries) + + def invalidate(self) -> None: + return None + + 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 self._file_format in {"json", "jsonc"}: + try: + payload = json.loads(_strip_jsonc(text) if self._file_format == "jsonc" else text) + except json.JSONDecodeError as error: + raise MutationError( + f"{self.harness} config file is not valid {self._file_format.upper()}: {error}", + status=409, + ) from error + return payload if isinstance(payload, dict) else {} + try: + payload = tomllib.loads(text) + except tomllib.TOMLDecodeError as error: + raise MutationError( + f"{self.harness} config file is not valid TOML: {error}", + status=409, + ) from error + return payload + + def _dump_document(self, document: dict[str, object]) -> str: + if self._file_format in {"json", "jsonc"}: + return json.dumps(document, ensure_ascii=False, indent=2) + "\n" + return tomli_w.dumps(document) + + def _read_subtree( + self, + document: Mapping[str, object], + subtree_path: SubtreePath, + ) -> Mapping[str, object]: + cursor: object = document + for segment in subtree_path: + if not isinstance(cursor, Mapping): + return {} + cursor = cursor.get(segment, {}) + if isinstance(cursor, Mapping): + return cursor + return {} + + def _write_subtree( + self, + document: dict[str, object], + subtree: dict[str, object], + subtree_path: SubtreePath, + ) -> None: + cursor: dict[str, object] = document + for segment in subtree_path[:-1]: + existing = cursor.get(segment) + if not isinstance(existing, dict): + existing = {} + cursor[segment] = existing + cursor = existing # type: ignore[assignment] + leaf_key = subtree_path[-1] + if subtree: + cursor[leaf_key] = subtree + else: + cursor.pop(leaf_key, None) + + def _remove_from_subtree( + self, + document: dict[str, object], + subtree_path: SubtreePath, + name: str, + ) -> bool: + cursor: dict[str, object] = document + for segment in subtree_path[:-1]: + existing = cursor.get(segment) + if not isinstance(existing, dict): + return False + cursor = existing + leaf_key = subtree_path[-1] + subtree = cursor.get(leaf_key) + if not isinstance(subtree, dict) or name not in subtree: + return False + del subtree[name] + if not subtree: + cursor.pop(leaf_key, None) + return True + + +def build_mcp_adapters( + kernel: HarnessKernelService, +) -> tuple[FileBackedMcpAdapter, ...]: + return tuple( + FileBackedMcpAdapter( + definition=binding.definition, + profile=binding.profile, + context=kernel.context, + ) + for binding in kernel.bindings_for_family("mcp") + if isinstance(binding.profile, ConfigSubtreeBindingProfile) + ) + + +def _normalize_payload(value: object) -> object: + if isinstance(value, dict): + normalized = { + key: _normalize_payload(item) + for key, item in value.items() + if not _is_semantic_default(key, item) + } + return {key: normalized[key] for key in sorted(normalized)} + if isinstance(value, list): + return [_normalize_payload(item) for item in value] + return value + + +def _is_semantic_default(key: str, value: object) -> bool: + if key == "enabled" and value is True: + return True + if key == "transport" and value == "stdio": + return True + if key in {"headers", "env", "environment", "http_headers"} and value == {}: + return True + return False + + +def _strip_jsonc(text: str) -> str: + without_block = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + without_line = re.sub(r"(^|[^:])//.*$", r"\1", without_block, flags=re.MULTILINE) + return re.sub(r",(\s*[}\]])", r"\1", without_line) + + +def _drift_detail(expected: object, actual: object) -> str: + if not isinstance(expected, dict) or not isinstance(actual, dict): + return "value mismatch" + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + changed = sorted( + key for key in set(expected) & set(actual) if expected[key] != actual[key] + ) + parts: list[str] = [] + if missing: + parts.append(f"missing={','.join(missing)}") + if extra: + parts.append(f"extra={','.join(extra)}") + if changed: + parts.append(f"changed={','.join(changed)}") + return "; ".join(parts) or "value mismatch" + + +__all__ = ["FileBackedMcpAdapter", "build_mcp_adapters"] diff --git a/skill_manager/application/mcp/contracts.py b/skill_manager/application/mcp/contracts.py new file mode 100644 index 0000000..0b47626 --- /dev/null +++ b/skill_manager/application/mcp/contracts.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Protocol + +from .store import McpServerSpec + + +BindingState = Literal["managed", "drifted", "unmanaged", "missing"] + + +@dataclass(frozen=True) +class McpHarnessStatus: + harness: str + label: str + logo_key: str | None + installed: bool + config_path: Path + config_present: bool + mcp_writable: bool = True + mcp_unavailable_reason: str | None = None + + +@dataclass(frozen=True) +class McpObservedEntry: + name: str + state: BindingState + raw_payload: dict[str, object] | None = None + parsed_spec: McpServerSpec | None = None + drift_detail: str | None = None + parse_issue: str | None = None + + +@dataclass(frozen=True) +class McpBinding: + harness: str + name: str + state: BindingState + drift_detail: str | None = None + + +@dataclass(frozen=True) +class McpHarnessScan: + harness: str + label: str + logo_key: str | None + installed: bool + config_present: bool + config_path: Path + mcp_writable: bool = True + mcp_unavailable_reason: str | None = None + scan_issue: str | None = None + entries: tuple[McpObservedEntry, ...] = () + + +@dataclass(frozen=True) +class McpInventoryEntry: + name: str + display_name: str + spec: McpServerSpec | None + sightings: tuple[McpBinding, ...] + is_managed: bool + can_enable: bool = True + + @property + def kind(self) -> str: + return "managed" if self.is_managed else "unmanaged" + + +@dataclass(frozen=True) +class McpInventoryIssue: + name: str + reason: str + + +@dataclass(frozen=True) +class McpInventory: + columns: tuple[str, ...] + entries: tuple[McpInventoryEntry, ...] + issues: tuple[McpInventoryIssue, ...] = () + + +class McpHarnessAdapter(Protocol): + harness: str + label: str + logo_key: str | None + config_path: Path + + def status(self) -> McpHarnessStatus: ... + + def scan(self, specs: tuple[McpServerSpec, ...]) -> McpHarnessScan: ... + + def has_binding(self, name: str) -> bool: ... + + def enable_server(self, spec: McpServerSpec) -> None: ... + + def disable_server(self, name: str) -> None: ... + + def invalidate(self) -> None: ... + + +__all__ = [ + "BindingState", + "McpBinding", + "McpHarnessAdapter", + "McpHarnessScan", + "McpHarnessStatus", + "McpInventory", + "McpInventoryEntry", + "McpInventoryIssue", + "McpObservedEntry", +] diff --git a/skill_manager/application/mcp/enrichment.py b/skill_manager/application/mcp/enrichment.py new file mode 100644 index 0000000..216272f --- /dev/null +++ b/skill_manager/application/mcp/enrichment.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass +from threading import Lock +from typing import Mapping + +from .marketplace.catalog import McpMarketplaceCatalog + +@dataclass(frozen=True) +class MarketplaceLink: + qualified_name: str + display_name: str + icon_url: str | None + external_url: str + description: str + is_remote: bool + is_verified: bool + + def to_dict(self) -> dict[str, object]: + return { + "qualifiedName": self.qualified_name, + "displayName": self.display_name, + "iconUrl": self.icon_url, + "externalUrl": self.external_url, + "description": self.description, + "isRemote": self.is_remote, + "isVerified": self.is_verified, + } + + +def _canonical_lookup_key(qualified_name: str) -> str: + """Reverse of mutations._canonical_name; used to map local name → smithery id.""" + cleaned = qualified_name.lstrip("@") + if "/" in cleaned: + cleaned = cleaned.split("/", 1)[1] + return cleaned.replace("@", "-").replace("/", "-").lower() + + +class McpEnrichmentService: + """Maps a local server name to a smithery marketplace entry, when one exists. + + Lookups go through three tiers: + 1. In-memory cache (per-process, hit immediately). + 2. Popular list scan (one network call per process; warm cache covers ~most servers). + 3. On-demand verified search by name. + + Negative results are also cached (None) to avoid repeated misses. + """ + + def __init__(self, catalog: McpMarketplaceCatalog) -> None: + self._catalog = catalog + self._cache: dict[str, MarketplaceLink | None] = {} + self._lock = Lock() + self._popular_warmed = False + + def warm_from_popular(self) -> None: + with self._lock: + if self._popular_warmed: + return + self._popular_warmed = True + try: + page = self._catalog.popular_page(limit=100, offset=0) + except Exception: + return + items = page.get("items") if isinstance(page, dict) else [] + if not isinstance(items, list): + return + with self._lock: + for item in items: + if not isinstance(item, Mapping): + continue + qualified_name = item.get("qualifiedName") + if not isinstance(qualified_name, str) or not qualified_name: + continue + key = _canonical_lookup_key(qualified_name) + if key in self._cache: + continue + self._cache[key] = MarketplaceLink( + qualified_name=qualified_name, + display_name=str(item.get("displayName") or key), + icon_url=_optional_str(item.get("iconUrl")), + external_url=str(item.get("externalUrl") or ""), + description=str(item.get("description") or ""), + is_remote=bool(item.get("isRemote", False)), + is_verified=bool(item.get("isVerified", False)), + ) + + def lookup(self, name: str, *, allow_search: bool = True) -> MarketplaceLink | None: + if not name: + return None + key = name.lower() + self.warm_from_popular() + with self._lock: + if key in self._cache: + return self._cache[key] + if not allow_search: + return None + link = self._search_by_name(name) + with self._lock: + self._cache[key] = link + return link + + def invalidate(self) -> None: + with self._lock: + self._cache.clear() + self._popular_warmed = False + + def _search_by_name(self, name: str) -> MarketplaceLink | None: + try: + page = self._catalog.search_page(name, limit=10, offset=0, verified=True) + except Exception: + return None + items = page.get("items") if isinstance(page, dict) else [] + if not isinstance(items, list): + return None + target_key = name.lower() + # Prefer exact canonical-name match before falling back to first result. + for item in items: + if not isinstance(item, Mapping): + continue + qualified_name = item.get("qualifiedName") + if not isinstance(qualified_name, str) or not qualified_name: + continue + if _canonical_lookup_key(qualified_name) == target_key: + return _link_from_item(item, qualified_name) + return None + + +def _link_from_item(item: Mapping[str, object], qualified_name: str) -> MarketplaceLink: + return MarketplaceLink( + qualified_name=qualified_name, + display_name=str(item.get("displayName") or qualified_name), + icon_url=_optional_str(item.get("iconUrl")), + external_url=str(item.get("externalUrl") or ""), + description=str(item.get("description") or ""), + is_remote=bool(item.get("isRemote", False)), + is_verified=bool(item.get("isVerified", False)), + ) + + +def _optional_str(value: object) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +__all__ = ["McpEnrichmentService", "MarketplaceLink"] diff --git a/skill_manager/application/mcp/env.py b/skill_manager/application/mcp/env.py new file mode 100644 index 0000000..a9f6b2a --- /dev/null +++ b/skill_manager/application/mcp/env.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import re +from typing import Mapping + + +_ENV_REF_PATTERN = re.compile(r"^\$\{env:[A-Z][A-Z0-9_]*\}$") + + +def is_env_var_reference(value: str) -> bool: + return bool(_ENV_REF_PATTERN.match(value or "")) + + +def annotate_env( + env: Mapping[str, str] | tuple[tuple[str, str], ...] | None, +) -> list[dict[str, object]]: + """Render env entries exactly as stored in the local manifest.""" + if not env: + return [] + pairs = env.items() if isinstance(env, Mapping) else env + return [ + { + "key": key, + "value": value, + "isEnvRef": is_env_var_reference(value), + } + for key, value in pairs + ] + + +__all__ = ["annotate_env", "is_env_var_reference"] diff --git a/skill_manager/application/mcp/identity.py b/skill_manager/application/mcp/identity.py new file mode 100644 index 0000000..2b36819 --- /dev/null +++ b/skill_manager/application/mcp/identity.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Iterable + +from .contracts import McpHarnessScan +from .store import McpServerSpec + + +@dataclass(frozen=True) +class HarnessSighting: + harness: str + label: str + logo_key: str | None + config_path: str | None + payload: dict[str, object] + spec: McpServerSpec + + +@dataclass(frozen=True) +class ServerIdentityGroup: + name: str + identical: bool + canonical_spec: McpServerSpec | None + sightings: tuple[HarnessSighting, ...] + + @property + def harnesses(self) -> tuple[str, ...]: + return tuple(s.harness for s in self.sightings) + + +@dataclass(frozen=True) +class AdoptionIssue: + name: str + harness: str + label: str + config_path: str | None + reason: str + payload: dict[str, object] | None + + +@dataclass(frozen=True) +class AdoptionPlan: + groups: tuple[ServerIdentityGroup, ...] + issues: tuple[AdoptionIssue, ...] + + +def build_identity_plan( + scans: Iterable[McpHarnessScan], + *, + excluded_names: Iterable[str] = (), +) -> AdoptionPlan: + excluded = set(excluded_names) + by_name: dict[str, list[HarnessSighting]] = {} + issues: list[AdoptionIssue] = [] + + for scan in scans: + for entry in scan.entries: + if entry.state != "unmanaged": + continue + if entry.name in excluded: + continue + if entry.parsed_spec is None: + issues.append( + AdoptionIssue( + name=entry.name, + harness=scan.harness, + label=scan.label, + config_path=str(scan.config_path) if scan.config_present else None, + reason=entry.parse_issue or "unable to parse unmanaged MCP entry", + payload=entry.raw_payload, + ) + ) + continue + by_name.setdefault(entry.name, []).append( + HarnessSighting( + harness=scan.harness, + label=scan.label, + logo_key=scan.logo_key, + config_path=str(scan.config_path) if scan.config_present else None, + payload=dict(entry.raw_payload or {}), + spec=entry.parsed_spec, + ) + ) + + groups: list[ServerIdentityGroup] = [] + for name, sightings in sorted(by_name.items()): + keys = {_structural_key(s.spec) for s in sightings} + identical = len(keys) == 1 + groups.append( + ServerIdentityGroup( + name=name, + identical=identical, + canonical_spec=sightings[0].spec if identical else None, + sightings=tuple(sightings), + ) + ) + return AdoptionPlan(groups=tuple(groups), issues=tuple(issues)) + + +def _structural_key(spec: McpServerSpec) -> str: + payload = { + "name": spec.name, + "transport": spec.transport, + "command": spec.command, + "args": list(spec.args) if spec.args else None, + "env": dict(spec.env) if spec.env else None, + "url": spec.url, + "headers": dict(spec.headers) if spec.headers else None, + } + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + + +__all__ = [ + "AdoptionIssue", + "AdoptionPlan", + "HarnessSighting", + "ServerIdentityGroup", + "build_identity_plan", +] diff --git a/skill_manager/application/mcp/installers.py b/skill_manager/application/mcp/installers.py new file mode 100644 index 0000000..f86c429 --- /dev/null +++ b/skill_manager/application/mcp/installers.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Protocol + +from skill_manager.errors import MutationError + + +_OPENCLAW_UNSUPPORTED_REASON = "Smithery does not provide an OpenClaw MCP installer target" +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") + + +@dataclass(frozen=True) +class McpInstallResult: + qualified_name: str + source_harness: str + installer: str + stdout: str + stderr: str + + +@dataclass(frozen=True) +class SmitheryClientTarget: + harness: str + smithery_client: str | None + supported: bool + reason: str | None = None + + +_SMITHERY_CLIENT_TARGETS: tuple[SmitheryClientTarget, ...] = ( + SmitheryClientTarget(harness="codex", smithery_client="codex", supported=True), + SmitheryClientTarget(harness="claude", smithery_client="claude-code", supported=True), + SmitheryClientTarget(harness="cursor", smithery_client="cursor", supported=True), + SmitheryClientTarget(harness="opencode", smithery_client="opencode", supported=True), + SmitheryClientTarget( + harness="openclaw", + smithery_client=None, + supported=False, + reason=_OPENCLAW_UNSUPPORTED_REASON, + ), +) +_SMITHERY_TARGETS_BY_HARNESS = {target.harness: target for target in _SMITHERY_CLIENT_TARGETS} + + +class McpInstallProvider(Protocol): + def install_targets(self) -> tuple[SmitheryClientTarget, ...]: ... + + def install( + self, + *, + qualified_name: str, + source_harness: str, + ) -> McpInstallResult: ... + + +class SmitheryCliInstallProvider: + def __init__( + self, + *, + env: Mapping[str, str] | None = None, + cwd: Path | None = None, + timeout_seconds: float = 120.0, + runner=subprocess.run, + ) -> None: + self._env = dict(env or {}) + self._cwd = cwd + self._timeout_seconds = timeout_seconds + self._runner = runner + + def install_targets(self) -> tuple[SmitheryClientTarget, ...]: + return _SMITHERY_CLIENT_TARGETS + + def install( + self, + *, + qualified_name: str, + source_harness: str, + ) -> McpInstallResult: + target = _SMITHERY_TARGETS_BY_HARNESS.get(source_harness) + if target is None or not target.supported or target.smithery_client is None: + message = ( + target.reason + if target and target.reason + else f"Smithery install is not supported for source harness: {source_harness}" + ) + raise MutationError( + message, + status=400, + ) + + command = [ + "npx", + "-y", + "@smithery/cli@latest", + "mcp", + "add", + qualified_name, + "--client", + target.smithery_client, + "--config", + "{}", + ] + env = dict(os.environ) + env.update(self._env) + env["NO_COLOR"] = "1" + + try: + result = self._runner( + command, + input="n\n", + text=True, + env=env, + cwd=str(self._cwd or Path(env.get("HOME", str(Path.home())))), + capture_output=True, + timeout=self._timeout_seconds, + ) + except subprocess.TimeoutExpired as error: + raise MutationError( + f"Smithery install timed out after {self._timeout_seconds:.0f}s", + status=504, + ) from error + except OSError as error: + raise MutationError(f"Unable to run Smithery installer: {error}", status=502) from error + + stdout = _clean_output(getattr(result, "stdout", "") or "") + stderr = _clean_output(getattr(result, "stderr", "") or "") + if getattr(result, "returncode", 1) != 0: + message = _summarize_failure(stdout, stderr) or "Smithery install failed" + raise MutationError(message, status=502) + + return McpInstallResult( + qualified_name=qualified_name, + source_harness=source_harness, + installer="smithery", + stdout=stdout, + stderr=stderr, + ) + + +def _clean_output(value: str) -> str: + return _ANSI_RE.sub("", value).strip() + + +def _summarize_failure(stdout: str, stderr: str) -> str: + combined = "\n".join(part for part in (stderr, stdout) if part) + lines = [line.strip() for line in combined.splitlines() if line.strip()] + if not lines: + return "" + return lines[-1][:500] + + +__all__ = [ + "McpInstallProvider", + "McpInstallResult", + "SmitheryCliInstallProvider", + "SmitheryClientTarget", +] diff --git a/skill_manager/application/mcp/inventory.py b/skill_manager/application/mcp/inventory.py new file mode 100644 index 0000000..52d364d --- /dev/null +++ b/skill_manager/application/mcp/inventory.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Iterable + +from .contracts import ( + McpBinding, + McpHarnessScan, + McpInventory, + McpInventoryEntry, + McpInventoryIssue, +) +from .store import McpServerSpec + + +def build_inventory( + *, + managed_servers: Iterable[McpServerSpec], + specs: Iterable[McpServerSpec], + scans: Iterable[McpHarnessScan], + issues: Iterable[McpInventoryIssue] = (), +) -> McpInventory: + """Combine central specs + per-harness scans into a server x harness matrix.""" + scans_tuple = tuple(scans) + specs_tuple = tuple(specs) + managed_tuple = tuple(managed_servers) + columns = tuple(scan.harness for scan in scans_tuple) + + bindings_by_name: dict[str, list[McpBinding]] = {} + for scan in scans_tuple: + for entry in scan.entries: + binding = McpBinding( + harness=scan.harness, + name=entry.name, + state=entry.state, + drift_detail=entry.drift_detail, + ) + bindings_by_name.setdefault(entry.name, []).append(binding) + + spec_by_name = {spec.name: spec for spec in specs_tuple} + entries: list[McpInventoryEntry] = [] + seen: set[str] = set() + + for server in sorted(managed_tuple, key=lambda s: s.display_name.lower()): + spec = spec_by_name.get(server.name) + bindings = tuple(bindings_by_name.get(server.name, ())) + entries.append( + McpInventoryEntry( + name=server.name, + display_name=server.display_name, + spec=spec, + sightings=bindings, + is_managed=True, + can_enable=spec is not None, + ) + ) + seen.add(server.name) + + for name in sorted(name for name in bindings_by_name if name not in seen): + entries.append( + McpInventoryEntry( + name=name, + display_name=name, + spec=spec_by_name.get(name), + sightings=tuple(bindings_by_name[name]), + is_managed=False, + can_enable=True, + ) + ) + + return McpInventory(columns=columns, entries=tuple(entries), issues=tuple(issues)) + + +__all__ = ["build_inventory"] diff --git a/skill_manager/application/mcp/mappers.py b/skill_manager/application/mcp/mappers.py new file mode 100644 index 0000000..5174d4c --- /dev/null +++ b/skill_manager/application/mcp/mappers.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from typing import Mapping, Protocol + +from skill_manager.errors import MutationError + +from .store import McpServerSpec, McpSource + + +class TransportMapper(Protocol): + """Translates between McpServerSpec and a single harness's per-server payload dict. + + Each harness puts MCP servers under a different sub-tree (e.g. "mcpServers", "mcp", + "mcp_servers") with slightly different keys. Managers handle the file IO; the + mapper handles the per-entry shape conversion. + """ + + def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]: ... + + def dict_to_spec( + self, name: str, raw: Mapping[str, object], *, source: McpSource | None = None + ) -> McpServerSpec: ... + + +# Claude Code / Cursor ------------------------------------------------------ + + +class _TypedMcpServersMapper: + """Shared mcpServers shape used by Claude Code and Cursor. + + Both clients require explicit ``type`` on writes. The reader intentionally + accepts older URL-only/command-only entries so Skill Manager can adopt and + repair configs written by older versions. + """ + + source_harness: str + + def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]: + if spec.transport == "stdio": + payload: dict[str, object] = {"type": "stdio"} + 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 = {"type": spec.transport} + if spec.url is not None: + payload["url"] = 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: + type_value = _str_or_none(raw.get("type")) or _str_or_none(raw.get("transport")) + if type_value == "stdio" or "command" in raw or "args" in raw: + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted(self.source_harness, name), + transport="stdio", + command=_str_or_none(raw.get("command")), + args=_str_tuple(raw.get("args")), + env=_str_pairs(raw.get("env")), + ) + if "url" in raw: + transport = "sse" if type_value == "sse" else "http" + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted(self.source_harness, name), + transport=transport, + url=_str_or_none(raw.get("url")), + headers=_str_pairs(raw.get("headers")), + ) + raise MutationError( + f"unsupported {self.source_harness} mcp entry '{name}': missing 'command' and 'url'", + status=400, + ) + + +class ClaudeCodeMapper(_TypedMcpServersMapper): + source_harness = "claude" + + +class CursorMapper(_TypedMcpServersMapper): + source_harness = "cursor" + + +# OpenCode ----------------------------------------------------------------- + + +class OpenCodeMapper: + """Used by opencode. Stdio = type:local + command:[cmd, ...args]; remote = type:remote. + + Reference: https://opencode.ai/docs/config/ + """ + + def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]: + if spec.transport == "stdio": + command_list: list[str] = [] + if spec.command is not None: + command_list.append(spec.command) + command_list.extend(spec.args_list()) + payload: dict[str, object] = { + "type": "local", + "command": command_list, + "enabled": True, + } + if spec.env: + payload["environment"] = dict(spec.env) + return payload + payload = { + "type": "remote", + "url": spec.url, + "enabled": True, + } + 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: + type_value = _str_or_none(raw.get("type")) + if type_value == "local": + command_list = raw.get("command") + command: str | None = None + args: tuple[str, ...] | None = None + if isinstance(command_list, list) and command_list: + command = str(command_list[0]) + rest = [str(x) for x in command_list[1:]] + args = tuple(rest) if rest else None + elif isinstance(command_list, str): + command = command_list + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted("opencode", name), + transport="stdio", + command=command, + args=args, + env=_str_pairs(raw.get("environment")), + ) + if type_value == "remote": + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted("opencode", name), + transport="http", + url=_str_or_none(raw.get("url")), + headers=_str_pairs(raw.get("headers")), + ) + raise MutationError( + f"unsupported opencode mcp entry '{name}': type must be 'local' or 'remote'", + status=400, + ) + + +# Codex -------------------------------------------------------------------- + + +class CodexMapper: + """Used by codex. Flat TOML table per server. + + stdio: {command, args, env} + http: {url, http_headers} + """ + + 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["url"] = spec.url + if spec.headers: + payload["http_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("codex", name), + transport="stdio", + command=_str_or_none(raw.get("command")), + args=_str_tuple(raw.get("args")), + env=_str_pairs(raw.get("env")), + ) + if "url" in raw: + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted("codex", name), + transport="http", + url=_str_or_none(raw.get("url")), + headers=_str_pairs(raw.get("http_headers") or raw.get("headers")), + ) + raise MutationError( + f"unsupported codex mcp entry '{name}': missing 'command' and 'url'", + status=400, + ) + + +# OpenClaw ----------------------------------------------------------------- + + +class OpenClawMapper: + """OpenClaw MCP config shape, used only when the local CLI supports it.""" + + 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 = { + "url": spec.url, + "transport": "streamable-http" if spec.transport == "http" else "sse", + } + 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("openclaw", name), + transport="stdio", + command=_str_or_none(raw.get("command")), + args=_str_tuple(raw.get("args")), + env=_str_pairs(raw.get("env")), + ) + if "url" in raw: + transport_raw = _str_or_none(raw.get("transport")) or _str_or_none(raw.get("type")) + transport = "http" if transport_raw in {None, "http", "streamable-http"} else "sse" + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted("openclaw", name), + transport=transport, + url=_str_or_none(raw.get("url")), + headers=_str_pairs(raw.get("headers")), + ) + raise MutationError( + f"unsupported openclaw mcp entry '{name}': missing 'command' and 'url'", + status=400, + ) + + +# Helpers ------------------------------------------------------------------ + + +def _str_or_none(value: object) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +def _str_tuple(value: object) -> tuple[str, ...] | None: + if isinstance(value, list): + return tuple(str(v) for v in value) + return None + + +def _str_pairs(value: object) -> tuple[tuple[str, str], ...] | None: + if isinstance(value, dict) and value: + return tuple((str(k), str(v)) for k, v in value.items()) + return None + + +_MAPPERS: dict[str, TransportMapper] = { + "claude-code": ClaudeCodeMapper(), + "cursor": CursorMapper(), + "opencode": OpenCodeMapper(), + "codex": CodexMapper(), + "openclaw": OpenClawMapper(), +} + + +def get_mapper(kind: str) -> TransportMapper: + if kind not in _MAPPERS: + raise ValueError(f"unknown mapper kind: {kind}") + return _MAPPERS[kind] + + +__all__ = [ + "ClaudeCodeMapper", + "CodexMapper", + "CursorMapper", + "OpenClawMapper", + "OpenCodeMapper", + "TransportMapper", + "get_mapper", +] diff --git a/skill_manager/application/mcp/marketplace/__init__.py b/skill_manager/application/mcp/marketplace/__init__.py new file mode 100644 index 0000000..0b9eda4 --- /dev/null +++ b/skill_manager/application/mcp/marketplace/__init__.py @@ -0,0 +1,4 @@ +from .catalog import McpMarketplaceCatalog +from .client import SmitheryClient + +__all__ = ["McpMarketplaceCatalog", "SmitheryClient"] diff --git a/skill_manager/application/mcp/marketplace/catalog.py b/skill_manager/application/mcp/marketplace/catalog.py new file mode 100644 index 0000000..05a80ab --- /dev/null +++ b/skill_manager/application/mcp/marketplace/catalog.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import hashlib +from typing import Callable +from urllib.parse import quote, urlencode + +from skill_manager.errors import MarketplaceUpstreamError +from skill_manager.application.marketplace_cache import MarketplaceCache + +from ..names import canonical_server_name +from ..stdio import parse_static_stdio_function +from .client import SmitheryClient + +Fetcher = Callable[[str], dict[str, object]] + +_SMITHERY_WEB_BASE_URL = "https://smithery.ai" +_DEFAULT_PAGE_SIZE = 30 +_MAX_PAGE_SIZE = 100 +_POPULAR_TTL_SECONDS = 3600 +_SEARCH_TTL_SECONDS = 900 +_DETAIL_TTL_SECONDS = 86400 + +_POPULAR_NAMESPACE = "smithery-popular-v1" +_SEARCH_NAMESPACE = "smithery-search-v1" +_DETAIL_NAMESPACE = "smithery-detail-v5" + + +class McpMarketplaceCatalog: + DEFAULT_PAGE_SIZE = _DEFAULT_PAGE_SIZE + MAX_PAGE_SIZE = _MAX_PAGE_SIZE + + def __init__( + self, + *, + fetcher: Fetcher | None = None, + cache: MarketplaceCache | None = None, + ) -> None: + self._fetcher = fetcher or SmitheryClient.from_environment().fetch_json + self._cache = cache or MarketplaceCache() + + @classmethod + def from_environment( + cls, + env: dict[str, str] | None = None, + *, + cache: MarketplaceCache | None = None, + ) -> "McpMarketplaceCatalog": + client = SmitheryClient.from_environment(env) + return cls( + fetcher=client.fetch_json, + cache=cache or MarketplaceCache.from_environment(env), + ) + + @property + def cache(self) -> MarketplaceCache: + return self._cache + + def popular_page(self, *, limit: int | None = None, offset: int = 0) -> dict[str, object]: + return self._list_page( + query=None, + limit=limit, + offset=offset, + remote=None, + verified=None, + namespace=_POPULAR_NAMESPACE, + ttl_seconds=_POPULAR_TTL_SECONDS, + ) + + def search_page( + self, + query: str, + *, + limit: int | None = None, + offset: int = 0, + remote: bool | None = None, + verified: bool | None = None, + ) -> dict[str, object]: + trimmed = (query or "").strip() + if len(trimmed) < 2 and (remote is None and verified is None): + raise ValueError("Enter at least 2 characters to search Smithery.") + return self._list_page( + query=trimmed or None, + limit=limit, + offset=offset, + remote=remote, + verified=verified, + namespace=_SEARCH_NAMESPACE, + ttl_seconds=_SEARCH_TTL_SECONDS, + ) + + def detail(self, qualified_name: str) -> dict[str, object] | None: + name = (qualified_name or "").strip() + if not name: + return None + cache_key = name + cached = self._cache.read(_DETAIL_NAMESPACE, cache_key, ttl_seconds=_DETAIL_TTL_SECONDS) + if cached is not None and isinstance(cached.payload, dict): + return cached.payload + try: + raw = self._fetcher(f"/servers/{quote(name, safe='/')}") + except MarketplaceUpstreamError as error: + if error.upstream_status == 404: + return None + raise + payload = _map_detail(raw, qualified_name=name) + self._cache.write(_DETAIL_NAMESPACE, cache_key, payload) + return payload + + def _list_page( + self, + *, + query: str | None, + limit: int | None, + offset: int, + remote: bool | None, + verified: bool | None, + namespace: str, + ttl_seconds: int, + ) -> dict[str, object]: + page_size = _normalize_limit(limit) + page_offset = max(offset, 0) + page_number = (page_offset // page_size) + 1 + + params: list[tuple[str, str]] = [ + ("pageSize", str(page_size)), + ("page", str(page_number)), + ] + if query: + params.append(("q", query)) + if remote is True: + params.append(("remote", "true")) + elif remote is False: + params.append(("remote", "false")) + if verified is True: + params.append(("verified", "true")) + + path = f"/servers?{urlencode(params)}" + cache_key = _cache_key_for_path(path) + cached = self._cache.read(namespace, cache_key, ttl_seconds=ttl_seconds) + raw: dict[str, object] | None = None + if cached is not None and isinstance(cached.payload, dict): + raw = cached.payload # type: ignore[assignment] + if raw is None: + raw = self._fetcher(path) + self._cache.write(namespace, cache_key, raw) + + servers_obj = raw.get("servers", []) if isinstance(raw, dict) else [] + servers = servers_obj if isinstance(servers_obj, list) else [] + pagination = raw.get("pagination", {}) if isinstance(raw, dict) else {} + total_pages = 0 + current_page = page_number + if isinstance(pagination, dict): + total_pages = _coerce_int(pagination.get("totalPages"), default=0) + current_page = _coerce_int(pagination.get("currentPage"), default=page_number) + + items = [_map_summary(server) for server in servers if isinstance(server, dict)] + has_more = current_page < total_pages and bool(items) + next_offset = page_offset + len(items) if has_more else None + return { + "items": items, + "nextOffset": next_offset, + "hasMore": has_more, + } + + +def _normalize_limit(limit: int | None) -> int: + if limit is None: + return _DEFAULT_PAGE_SIZE + return max(1, min(int(limit), _MAX_PAGE_SIZE)) + + +def _cache_key_for_path(path: str) -> str: + return hashlib.sha1(path.encode("utf-8")).hexdigest() + + +def _coerce_int(value: object, *, default: int) -> int: + if isinstance(value, bool): + return default + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return default + return default + + +def _coerce_str(value: object, *, default: str = "") -> str: + return value if isinstance(value, str) else default + + +def _coerce_optional_str(value: object) -> str | None: + if isinstance(value, str) and value.strip(): + return value + return None + + +def _coerce_bool(value: object, *, default: bool = False) -> bool: + return value if isinstance(value, bool) else default + + +def _map_summary(server: dict[str, object]) -> dict[str, object]: + qualified_name = _coerce_str(server.get("qualifiedName")) + return { + "qualifiedName": qualified_name, + "namespace": _coerce_str(server.get("namespace")), + "displayName": _coerce_str(server.get("displayName"), default=qualified_name), + "description": _coerce_str(server.get("description")), + "iconUrl": _coerce_optional_str(server.get("iconUrl")), + "isVerified": _coerce_bool(server.get("verified")), + "isRemote": _coerce_bool(server.get("remote")), + "isDeployed": _coerce_bool(server.get("isDeployed")), + "useCount": _coerce_int(server.get("useCount"), default=0), + "createdAt": _coerce_optional_str(server.get("createdAt")), + "homepage": _coerce_optional_str(server.get("homepage")), + "externalUrl": _external_url(qualified_name), + } + + +def _map_detail(raw: dict[str, object], *, qualified_name: str) -> dict[str, object]: + display_name = _coerce_str(raw.get("displayName"), default=qualified_name) + description = _coerce_str(raw.get("description")) + icon_url = _coerce_optional_str(raw.get("iconUrl")) + is_remote = _coerce_bool(raw.get("remote")) + deployment_url = _coerce_optional_str(raw.get("deploymentUrl")) + + connections_raw = raw.get("connections", []) + connections: list[dict[str, object]] = [] + if isinstance(connections_raw, list): + for connection in connections_raw: + if not isinstance(connection, dict): + continue + kind_raw = _coerce_str(connection.get("type"), default="unknown").lower() + kind = ( + "http" + if kind_raw in {"http", "streamable-http"} + else ("sse" if kind_raw == "sse" else ("stdio" if kind_raw == "stdio" else kind_raw or "unknown")) + ) + config_schema = connection.get("configSchema") + mapped_connection: dict[str, object] = { + "kind": kind, + "deploymentUrl": _coerce_optional_str(connection.get("deploymentUrl")), + "configSchema": config_schema if isinstance(config_schema, dict) else None, + } + if kind == "stdio": + stdio_function = _coerce_optional_str(connection.get("stdioFunction")) + bundle_url = _coerce_optional_str(connection.get("bundleUrl")) + runtime = _coerce_optional_str(connection.get("runtime")) + static_stdio = parse_static_stdio_function(stdio_function) + mapped_connection["stdioFunction"] = stdio_function + mapped_connection["bundleUrl"] = bundle_url + mapped_connection["runtime"] = runtime + mapped_connection["stdioCommand"] = static_stdio.command if static_stdio else None + mapped_connection["stdioArgs"] = list(static_stdio.args) if static_stdio else None + connections.append(mapped_connection) + + tools_raw = raw.get("tools", []) + tools: list[dict[str, object]] = [] + if isinstance(tools_raw, list): + for tool in tools_raw: + if not isinstance(tool, dict): + continue + name = _coerce_str(tool.get("name")) + if not name: + continue + tools.append( + { + "name": name, + "description": _coerce_str(tool.get("description")), + "parameters": _flatten_input_schema(tool.get("inputSchema")), + } + ) + + resources_raw = raw.get("resources", []) + resources: list[dict[str, object]] = [] + if isinstance(resources_raw, list): + for resource in resources_raw: + if not isinstance(resource, dict): + continue + resources.append( + { + "name": _coerce_str(resource.get("name")), + "uri": _coerce_str(resource.get("uri")), + "description": _coerce_str(resource.get("description")), + "mimeType": _coerce_optional_str(resource.get("mimeType")), + } + ) + + prompts_raw = raw.get("prompts", []) + prompts: list[dict[str, object]] = [] + if isinstance(prompts_raw, list): + for prompt in prompts_raw: + if not isinstance(prompt, dict): + continue + arguments_raw = prompt.get("arguments") + arguments: list[dict[str, object]] = [] + if isinstance(arguments_raw, list): + for argument in arguments_raw: + if not isinstance(argument, dict): + continue + arguments.append( + { + "name": _coerce_str(argument.get("name")), + "description": _coerce_str(argument.get("description")), + "required": _coerce_bool(argument.get("required")), + } + ) + prompts.append( + { + "name": _coerce_str(prompt.get("name")), + "description": _coerce_str(prompt.get("description")), + "arguments": arguments, + } + ) + + return { + "qualifiedName": qualified_name, + "managedName": canonical_server_name(qualified_name), + "displayName": display_name, + "description": description, + "iconUrl": icon_url, + "isRemote": is_remote, + "deploymentUrl": deployment_url, + "connections": connections, + "tools": tools, + "resources": resources, + "prompts": prompts, + "capabilityCounts": { + "tools": len(tools), + "resources": len(resources), + "prompts": len(prompts), + }, + "externalUrl": _external_url(qualified_name), + } + + +def _flatten_input_schema(schema: object) -> list[dict[str, object]]: + if not isinstance(schema, dict): + return [] + properties = schema.get("properties") + required_raw = schema.get("required") + required_set: set[str] = set() + if isinstance(required_raw, list): + required_set = {item for item in required_raw if isinstance(item, str)} + if not isinstance(properties, dict): + return [] + parameters: list[dict[str, object]] = [] + for name, value in properties.items(): + if not isinstance(name, str): + continue + entry = value if isinstance(value, dict) else {} + param: dict[str, object] = { + "name": name, + "type": _coerce_param_type(entry.get("type")), + "description": _coerce_str(entry.get("description")), + "required": name in required_set, + } + for hint_key in ("default", "minimum", "maximum", "minItems", "maxItems", "minLength", "maxLength"): + if hint_key in entry: + param[_camel(hint_key)] = entry.get(hint_key) + enum_value = entry.get("enum") + if isinstance(enum_value, list) and enum_value: + param["enum"] = enum_value + parameters.append(param) + return parameters + + +_VALID_PARAM_TYPES = {"string", "number", "integer", "boolean", "array", "object"} + + +def _coerce_param_type(value: object) -> str: + if isinstance(value, str) and value in _VALID_PARAM_TYPES: + return value + if isinstance(value, list): + for candidate in value: + if isinstance(candidate, str) and candidate in _VALID_PARAM_TYPES: + return candidate + return "unknown" + + +def _camel(value: str) -> str: + parts = value.split("_") + return parts[0] + "".join(part.title() for part in parts[1:]) + + +def _external_url(qualified_name: str) -> str: + if not qualified_name: + return _SMITHERY_WEB_BASE_URL + return f"{_SMITHERY_WEB_BASE_URL}/server/{quote(qualified_name, safe='/')}" + + +__all__ = [ + "McpMarketplaceCatalog", +] diff --git a/skill_manager/application/mcp/marketplace/client.py b/skill_manager/application/mcp/marketplace/client.py new file mode 100644 index 0000000..d481f6d --- /dev/null +++ b/skill_manager/application/mcp/marketplace/client.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import os +import socket +import ssl +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + +from skill_manager.errors import MarketplaceUpstreamError +from skill_manager.application.marketplace_http import ( + configured_marketplace_ca_file, + marketplace_ssl_context, +) + +DEFAULT_SMITHERY_BASE_URL = "https://api.smithery.ai" +SMITHERY_BASE_URL_ENV = "SKILL_MANAGER_MCP_MARKETPLACE_BASE_URL" +_TIMEOUT_SECONDS = 15 +_USER_AGENT = "skill-manager/0.1" + + +def configured_smithery_base_url(env: dict[str, str] | None = None) -> str: + active_env = os.environ if env is None else env + configured = active_env.get(SMITHERY_BASE_URL_ENV, DEFAULT_SMITHERY_BASE_URL).strip() + return (configured or DEFAULT_SMITHERY_BASE_URL).rstrip("/") + + +class SmitheryClient: + def __init__( + self, + *, + base_url: str = DEFAULT_SMITHERY_BASE_URL, + timeout_seconds: float = _TIMEOUT_SECONDS, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + self.base_url = (base_url or DEFAULT_SMITHERY_BASE_URL).rstrip("/") + self.timeout_seconds = timeout_seconds + self.ssl_context = ssl_context + + @classmethod + def from_environment(cls, env: dict[str, str] | None = None) -> "SmitheryClient": + return cls( + base_url=configured_smithery_base_url(env), + ssl_context=marketplace_ssl_context(env), + ) + + def absolute_url(self, path_or_url: str) -> str: + if path_or_url.startswith(("http://", "https://")): + return path_or_url + return urljoin(f"{self.base_url}/", path_or_url.lstrip("/")) + + def fetch_json(self, path_or_url: str) -> dict[str, object]: + url = self.absolute_url(path_or_url) + payload = self._request(path_or_url, accept="application/json") + try: + parsed = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise MarketplaceUpstreamError("payload", url, f"invalid JSON payload: {error}") from error + if not isinstance(parsed, dict): + raise MarketplaceUpstreamError("payload", url, "JSON payload must be an object") + return parsed + + def _request(self, path_or_url: str, *, accept: str | None = None) -> bytes: + url = self.absolute_url(path_or_url) + headers = {"User-Agent": _USER_AGENT} + if accept: + headers["Accept"] = accept + request = Request(url, headers=headers) + open_kwargs: dict[str, object] = {"timeout": self.timeout_seconds} + if self.ssl_context is not None: + open_kwargs["context"] = self.ssl_context + try: + with urlopen(request, **open_kwargs) as response: + return response.read() + except HTTPError as error: + raise MarketplaceUpstreamError( + "bad_status", + url, + f"upstream returned HTTP {error.code}", + upstream_status=error.code, + ) from error + except ssl.SSLCertVerificationError as error: + raise MarketplaceUpstreamError("tls", url, str(error)) from error + except TimeoutError as error: + raise MarketplaceUpstreamError("timeout", url, str(error)) from error + except URLError as error: + reason = error.reason + if isinstance(reason, ssl.SSLError): + kind = "tls" + elif isinstance(reason, (TimeoutError, socket.timeout)): + kind = "timeout" + else: + kind = "network" + raise MarketplaceUpstreamError(kind, url, str(reason)) from error + except OSError as error: + raise MarketplaceUpstreamError("network", url, str(error)) from error + + +__all__ = [ + "DEFAULT_SMITHERY_BASE_URL", + "SMITHERY_BASE_URL_ENV", + "SmitheryClient", + "configured_smithery_base_url", + "configured_marketplace_ca_file", +] diff --git a/skill_manager/application/mcp/mutations.py b/skill_manager/application/mcp/mutations.py new file mode 100644 index 0000000..1b5a617 --- /dev/null +++ b/skill_manager/application/mcp/mutations.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import Iterable + +from skill_manager.errors import MutationError + +from .enrichment import McpEnrichmentService +from .installers import McpInstallProvider +from .marketplace.catalog import McpMarketplaceCatalog +from .names import canonical_server_name +from .planner import McpAdoptionPlanner +from .read_models import McpReadModelService +from .store import McpServerSpec, McpServerStore, McpSource + + +class McpMutationService: + """Mutations for observed MCP configs. + + The managed manifest stores the canonical observed config. Harness files are + projections of that canonical spec. + """ + + def __init__( + self, + *, + store: McpServerStore, + read_models: McpReadModelService, + planner: McpAdoptionPlanner, + marketplace_catalog: McpMarketplaceCatalog, + install_provider: McpInstallProvider, + enrichment: McpEnrichmentService | None = None, + ) -> None: + self.store = store + self.read_models = read_models + self.planner = planner + self.marketplace = marketplace_catalog + self.install_provider = install_provider + self.enrichment = enrichment + + # Install / uninstall --------------------------------------------------- + + def install_from_marketplace( + self, + qualified_name: str, + *, + source_harness: str, + ) -> dict[str, object]: + if not qualified_name: + raise MutationError("qualifiedName is required", status=400) + if not source_harness: + raise MutationError("sourceHarness is required", status=400) + self._require_install_target(source_harness) + + managed_name = canonical_server_name(qualified_name) + existing = self._managed_for_marketplace(qualified_name) or self.store.get_managed(managed_name) + if existing is not None: + raise MutationError( + f"a server named '{existing.name}' is already installed", + status=409, + ) + detail = self.marketplace.detail(qualified_name) + if detail is None: + raise MutationError(f"server not found in marketplace: {qualified_name}", status=404) + + before_names = self._observed_names(source_harness) + self.install_provider.install( + qualified_name=qualified_name, + source_harness=source_harness, + ) + self.read_models.invalidate() + observed = self._find_installed_observation( + source_harness=source_harness, + preferred_name=managed_name, + before_names=before_names, + ) + source_spec = observed.parsed_spec + if source_spec is None: + raise MutationError( + f"Smithery installed '{qualified_name}', but no readable MCP entry was found in {source_harness}", + status=502, + ) + if self.store.get_managed(source_spec.name) is not None: + raise MutationError( + f"a server named '{source_spec.name}' is already installed", + status=409, + ) + + stored = self.store.upsert_from_spec( + replace( + source_spec, + display_name=str(detail.get("displayName") or source_spec.display_name), + source=McpSource.marketplace(qualified_name), + ) + ) + self.read_models.invalidate() + return {"ok": True, "server": stored.to_dict()} + + def install_targets(self) -> dict[str, object]: + return {"targets": self._resolved_install_targets()} + + def uninstall_server(self, name: str) -> dict[str, object]: + if self.store.get_managed(name) is None: + raise MutationError(f"unknown server: {name}", status=404) + bound_harnesses = self._harnesses_in_states(name, {"managed", "drifted"}) + succeeded: list[str] = [] + failures: list[dict[str, str]] = [] + for adapter in self.read_models.enabled_adapters(): + if adapter.harness not in bound_harnesses: + continue + try: + adapter.disable_server(name) + succeeded.append(adapter.harness) + except Exception as error: # noqa: BLE001 + failures.append({"harness": adapter.harness, "error": str(error)}) + if not failures: + self.store.remove(name) + if succeeded or not failures: + self.read_models.invalidate() + return { + "ok": not failures, + "succeeded": succeeded, + "failed": failures, + } + + # Per-harness toggle ---------------------------------------------------- + + def enable_server(self, name: str, harness: str) -> dict[str, bool]: + spec = self._require_server(name) + adapter = self.read_models.require_enabled_adapter(harness) + if adapter.has_binding(name): + return {"ok": True} + adapter.enable_server(spec) + self.read_models.invalidate() + return {"ok": True} + + def disable_server(self, name: str, harness: str) -> dict[str, bool]: + if self.store.get_managed(name) is None: + raise MutationError(f"unknown server: {name}", status=404) + adapter = self.read_models.require_enabled_adapter(harness) + adapter.disable_server(name) + self.read_models.invalidate() + return {"ok": True} + + def set_server_all_harnesses(self, name: str, target: str) -> dict[str, object]: + if target not in ("enabled", "disabled"): + raise MutationError("target must be 'enabled' or 'disabled'", status=400) + spec = self._require_server(name) + + bound_now = self._harnesses_in_states(name, {"managed", "drifted"}) + + succeeded: list[str] = [] + failures: list[dict[str, str]] = [] + flipped_any = False + + adapters = ( + self.read_models.enabled_writable_adapters() + if target == "enabled" + else self.read_models.enabled_addressable_adapters() + ) + for adapter in adapters: + if target == "enabled" and adapter.harness in bound_now: + continue + if target == "disabled" and adapter.harness not in bound_now: + continue + try: + if target == "enabled": + adapter.enable_server(spec) + else: + adapter.disable_server(name) + except Exception as error: # noqa: BLE001 + failures.append({"harness": adapter.harness, "error": str(error)}) + continue + succeeded.append(adapter.harness) + flipped_any = True + + if flipped_any: + self.read_models.invalidate() + + return { + "ok": not failures, + "succeeded": succeeded, + "failed": failures, + } + + # Reconciliation ------------------------------------------------------- + + def reconcile_server( + self, + name: str, + *, + source_kind: str, + source_harness: str | None = None, + harnesses: list[str] | None = None, + ) -> dict[str, object]: + if self.store.get_managed(name) is None: + raise MutationError(f"unknown server: {name}", status=404) + target_harnesses = ( + set(harnesses) + if harnesses is not None + else self._harnesses_in_states(name, {"managed", "drifted"}, addressable_only=True) + ) + current = self._require_server(name) + if source_kind == "managed": + source_spec = current + elif source_kind == "harness": + if not source_harness: + raise MutationError("sourceHarness is required when sourceKind is 'harness'", status=400) + observed_spec = self._observed_spec(name, source_harness) + source_spec = replace( + observed_spec, + name=current.name, + display_name=current.display_name, + source=current.source, + ) + self.store.upsert_from_spec(source_spec) + self.read_models.invalidate() + source_spec = self._require_server(name) + else: + raise MutationError("sourceKind must be 'managed' or 'harness'", status=400) + + stored = self.store.get_public_spec(name) or source_spec + binding_spec = self.store.get_binding_spec(name) or source_spec + succeeded, failures = self._write_spec_to_harnesses(binding_spec, target_harnesses) + if succeeded: + self.read_models.invalidate() + return { + "ok": not failures, + "server": stored.to_dict(), + "succeeded": succeeded, + "failed": failures, + } + + # Adoption ------------------------------------------------------------- + + def _apply_enrichment(self, spec: McpServerSpec) -> McpServerSpec: + if self.enrichment is None: + return spec + link = self.enrichment.lookup(spec.name) + if link is None: + return spec + return replace( + spec, + display_name=link.display_name or spec.display_name, + source=McpSource.marketplace(link.qualified_name), + ) + + def adopt( + self, + name: str, + *, + source_harness: str | None = None, + harnesses: list[str] | None = None, + ) -> dict[str, object]: + if self.store.get_managed(name) is not None: + raise MutationError( + f"a managed server named '{name}' already exists", status=409 + ) + group = self.planner.require_group(name) + if source_harness: + target_spec = next( + (sighting.spec for sighting in group.sightings if sighting.harness == source_harness), + None, + ) + if target_spec is None: + raise MutationError( + f"server '{name}' was not observed in harness '{source_harness}'", + status=400, + ) + else: + target_spec = group.canonical_spec + if target_spec is None: + raise MutationError( + f"server '{name}' has different configs across harnesses; choose a sourceHarness to adopt", + status=409, + ) + if target_spec.name != name: + target_spec = replace(target_spec, name=name) + target_spec = self._apply_enrichment(target_spec) + + target_harnesses = set(harnesses) if harnesses else {s.harness for s in group.sightings} + stored = self.store.upsert_from_spec(target_spec) + stored_binding_spec = self.store.get_binding_spec(stored.name) + if stored_binding_spec is None: + raise MutationError(f"unknown server: {name}", status=404) + + succeeded, failures = self._write_spec_to_harnesses( + stored_binding_spec, + target_harnesses, + ) + + self.read_models.invalidate() + response_spec = self.store.get_public_spec(stored.name) or stored_binding_spec + return { + "ok": not failures, + "server": response_spec.to_dict(), + "succeeded": succeeded, + "failed": failures, + } + + # Internal helpers ----------------------------------------------------- + + def _observed_names(self, harness: str) -> set[str]: + adapter = self._source_adapter(harness) + scan = adapter.scan(self.store.list_binding_specs()) + return {entry.name for entry in scan.entries if entry.state != "missing"} + + def _resolved_install_targets(self) -> list[dict[str, object]]: + provider_targets = { + target.harness: target for target in self.install_provider.install_targets() + } + enabled = set(self.read_models.enabled_harnesses()) + targets: list[dict[str, object]] = [] + for status in self.read_models.harness_statuses(): + provider_target = provider_targets.get(status.harness) + smithery_client = provider_target.smithery_client if provider_target else None + supported = bool(provider_target and provider_target.supported and smithery_client) + reason = ( + provider_target.reason + if provider_target and provider_target.reason + else None + ) + if supported and status.harness not in enabled: + supported = False + reason = "Harness support is disabled" + elif supported and not status.mcp_writable: + supported = False + reason = status.mcp_unavailable_reason or "MCP config is not writable for this harness" + elif not supported and reason is None: + reason = "Smithery does not provide an MCP installer target for this harness" + targets.append( + { + "harness": status.harness, + "label": status.label, + "logoKey": status.logo_key, + "smitheryClient": smithery_client, + "supported": supported, + "reason": reason, + } + ) + return targets + + def _require_install_target(self, harness: str) -> None: + for target in self._resolved_install_targets(): + if target["harness"] != harness: + continue + if target["supported"]: + return + reason = target.get("reason") + raise MutationError(str(reason or f"source harness is not installable: {harness}"), status=400) + raise MutationError(f"unknown MCP source harness: {harness}", status=400) + + def _find_installed_observation(self, *, source_harness: str, preferred_name: str, before_names: set[str]): + adapter = self._source_adapter(source_harness) + scan = adapter.scan(self.store.list_binding_specs()) + entries = [entry for entry in scan.entries if entry.state in {"unmanaged", "drifted", "managed"}] + for entry in entries: + if entry.name == preferred_name: + return entry + new_entries = [entry for entry in entries if entry.name not in before_names] + if len(new_entries) == 1: + return new_entries[0] + raise MutationError( + f"Smithery installed the server, but Skill Manager could not identify the new {source_harness} config entry", + status=502, + ) + + def _source_adapter(self, harness: str): + if harness not in self.read_models.enabled_harnesses(): + raise MutationError(f"harness support is disabled: {harness}", status=400) + adapter = self.read_models.find_adapter(harness) + if adapter is None: + raise MutationError(f"unknown MCP source harness: {harness}", status=400) + return adapter + + def _harnesses_in_states( + self, + name: str, + states: Iterable[str], + *, + addressable_only: bool = False, + ) -> set[str]: + allowed_states = set(states) + addressable = ( + {adapter.harness for adapter in self.read_models.enabled_addressable_adapters()} + if addressable_only + else set(self.read_models.enabled_harnesses()) + ) + snapshot = self.read_models.snapshot() + result: set[str] = set() + for scan in snapshot.harness_scans: + if scan.harness not in addressable: + continue + for entry in scan.entries: + if entry.name == name and entry.state in allowed_states: + result.add(scan.harness) + return result + + def _observed_spec(self, name: str, harness: str) -> McpServerSpec: + snapshot = self.read_models.snapshot() + for scan in snapshot.harness_scans: + if scan.harness != harness: + continue + for entry in scan.entries: + if entry.name != name: + continue + if entry.parsed_spec is None: + raise MutationError( + entry.parse_issue or f"unable to parse '{name}' in {harness}", + status=409, + ) + return entry.parsed_spec + raise MutationError(f"server '{name}' was not observed in harness '{harness}'", status=404) + + def _write_spec_to_harnesses( + self, + spec: McpServerSpec, + harnesses: Iterable[str], + ) -> tuple[list[str], list[dict[str, str]]]: + targets = set(harnesses) + succeeded: list[str] = [] + failures: list[dict[str, str]] = [] + for adapter in self.read_models.enabled_adapters(): + if adapter.harness not in targets: + continue + try: + adapter.enable_server(spec) + except Exception as error: # noqa: BLE001 + failures.append({"harness": adapter.harness, "error": str(error)}) + continue + succeeded.append(adapter.harness) + return succeeded, failures + + def _require_server(self, name: str) -> McpServerSpec: + spec = self.store.get_binding_spec(name) + if spec is None: + raise MutationError(f"unknown server: {name}", status=404) + return spec + + def _managed_for_marketplace(self, qualified_name: str) -> McpServerSpec | None: + for server in self.store.list_managed(): + if server.source.kind == "marketplace" and server.source.locator == qualified_name: + return server + return None + + +__all__ = ["McpMutationService"] diff --git a/skill_manager/application/mcp/names.py b/skill_manager/application/mcp/names.py new file mode 100644 index 0000000..5b7f820 --- /dev/null +++ b/skill_manager/application/mcp/names.py @@ -0,0 +1,14 @@ +from __future__ import annotations + + +def canonical_server_name(qualified_name: str) -> str: + """Normalize a marketplace qualified name into the managed MCP key.""" + if not qualified_name: + return "" + cleaned = qualified_name.lstrip("@") + if "/" in cleaned: + cleaned = cleaned.split("/", 1)[1] + return cleaned.replace("@", "-").replace("/", "-").lower() + + +__all__ = ["canonical_server_name"] diff --git a/skill_manager/application/mcp/planner.py b/skill_manager/application/mcp/planner.py new file mode 100644 index 0000000..55ebe27 --- /dev/null +++ b/skill_manager/application/mcp/planner.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from skill_manager.errors import MutationError + +from .identity import AdoptionPlan, ServerIdentityGroup, build_identity_plan +from .read_models import McpReadModelService + + +class McpAdoptionPlanner: + def __init__(self, read_models: McpReadModelService) -> None: + self.read_models = read_models + + def plan(self) -> AdoptionPlan: + snapshot = self.read_models.snapshot() + managed_names = {server.name for server in self.read_models.store.list_managed()} + return build_identity_plan( + snapshot.harness_scans, + excluded_names=managed_names, + ) + + def require_group(self, name: str) -> ServerIdentityGroup: + plan = self.plan() + for group in plan.groups: + if group.name == name: + return group + raise MutationError(f"no unmanaged server named '{name}'", status=404) + + +__all__ = ["McpAdoptionPlanner"] diff --git a/skill_manager/application/mcp/query.py b/skill_manager/application/mcp/query.py new file mode 100644 index 0000000..530d689 --- /dev/null +++ b/skill_manager/application/mcp/query.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from skill_manager.errors import MutationError + +from .contracts import McpBinding, McpHarnessScan, McpInventory, McpInventoryIssue +from .enrichment import McpEnrichmentService +from .inventory import build_inventory +from .planner import McpAdoptionPlanner +from .read_models import McpReadModelService +from .env import annotate_env + + +class McpQueryService: + """Read-side service exposing raw managed MCP config and inventory views.""" + + def __init__( + self, + read_models: McpReadModelService, + *, + planner: McpAdoptionPlanner | None = None, + enrichment: McpEnrichmentService | None = None, + ) -> None: + self.read_models = read_models + self.planner = planner + self.enrichment = enrichment + + def list_servers(self) -> dict[str, object]: + snapshot = self.read_models.snapshot() + inventory = self._inventory(snapshot.harness_scans) + return _inventory_to_payload(inventory, self.read_models.visible_scans(snapshot)) + + def get_server(self, name: str) -> dict[str, object]: + snapshot = self.read_models.snapshot() + inventory = self._inventory(snapshot.harness_scans) + visible_scans = self.read_models.visible_scans(snapshot) + for entry in inventory.entries: + if entry.name == name: + payload = _entry_to_payload(entry, visible_scans) + if entry.spec is not None: + payload["env"] = annotate_env(entry.spec.env) + payload["configChoices"] = _config_choices_payload( + name, + entry.spec, + visible_scans, + ) + link = self.enrichment.lookup(name) if self.enrichment else None + if link is not None: + payload["marketplaceLink"] = link.to_dict() + return payload + raise MutationError(f"unknown mcp server: {name}", status=404) + + def list_unmanaged_by_server(self) -> dict[str, object]: + if self.planner is None: + raise RuntimeError("unmanaged MCP planner is not configured") + snapshot = self.read_models.snapshot() + plan = self.planner.plan() + visible_scans = self.read_models.visible_scans(snapshot) + visible_harnesses = {scan.harness for scan in visible_scans} + harness_meta = [ + { + "harness": scan.harness, + "label": scan.label, + "logoKey": scan.logo_key, + "installed": scan.installed, + "configPresent": scan.config_present, + "configPath": str(scan.config_path), + "mcpWritable": scan.mcp_writable, + "mcpUnavailableReason": scan.mcp_unavailable_reason, + } + for scan in visible_scans + ] + issues_payload = [ + { + "harness": scan.harness, + "label": scan.label, + "logoKey": scan.logo_key, + "name": f"{scan.label} config", + "configPath": str(scan.config_path), + "payloadPreview": None, + "reason": scan.scan_issue, + } + for scan in visible_scans + if scan.scan_issue + ] + issues_payload.extend( + [ + { + "harness": issue.harness, + "label": issue.label, + "logoKey": issue.logo_key, + "name": issue.name, + "configPath": issue.config_path, + "payloadPreview": issue.payload, + "reason": issue.reason, + } + for issue in plan.issues + if issue.harness in visible_harnesses + ] + ) + servers_payload: list[dict[str, object]] = [] + for group in plan.groups: + sightings = tuple( + sighting for sighting in group.sightings if sighting.harness in visible_harnesses + ) + if not sightings: + continue + sightings_payload = [ + { + "harness": s.harness, + "label": s.label, + "logoKey": s.logo_key, + "configPath": s.config_path, + "payloadPreview": s.payload, + "spec": s.spec.to_dict(), + "env": annotate_env(s.spec.env), + } + for s in sightings + ] + link = self.enrichment.lookup(group.name) if self.enrichment else None + servers_payload.append( + { + "name": group.name, + "identical": group.identical, + "canonicalSpec": group.canonical_spec.to_dict() + if group.canonical_spec is not None + else None, + "sightings": sightings_payload, + "marketplaceLink": link.to_dict() if link is not None else None, + } + ) + return {"harnesses": harness_meta, "servers": servers_payload, "issues": issues_payload} + + def _inventory(self, scans: tuple[McpHarnessScan, ...]) -> McpInventory: + issues = [ + McpInventoryIssue(name=issue.name, reason=issue.reason) + for issue in self.read_models.store.manifest_issues() + ] + issues.extend( + McpInventoryIssue(name=f"{scan.label} config", reason=scan.scan_issue) + for scan in scans + if scan.scan_issue + ) + return build_inventory( + managed_servers=self.read_models.store.list_managed(), + specs=self.read_models.store.list_public_specs(), + scans=scans, + issues=issues, + ) + + +def _binding_to_dict(binding: McpBinding) -> dict[str, object]: + payload: dict[str, object] = { + "harness": binding.harness, + "state": binding.state, + } + if binding.drift_detail: + payload["driftDetail"] = binding.drift_detail + return payload + + +def _entry_to_payload(entry, scans: tuple[McpHarnessScan, ...]) -> dict[str, object]: + visible_harnesses = {scan.harness for scan in scans} + spec_payload = entry.spec.to_dict() if entry.spec is not None else None + return { + "name": entry.name, + "displayName": entry.display_name, + "kind": entry.kind, + "spec": spec_payload, + "canEnable": entry.can_enable, + "sightings": [ + _binding_to_dict(binding) + for binding in entry.sightings + if binding.harness in visible_harnesses + ], + } + + +def _config_choices_payload( + name: str, + managed_spec, + scans: tuple[McpHarnessScan, ...], +) -> list[dict[str, object]]: + choices: list[dict[str, object]] = [ + { + "sourceKind": "managed", + "sourceHarness": None, + "label": "Managed config", + "logoKey": None, + "configPath": None, + "payloadPreview": managed_spec.to_dict(), + "spec": managed_spec.to_dict(), + "env": annotate_env(managed_spec.env), + } + ] + for scan in scans: + for observed in scan.entries: + if observed.name != name or observed.state != "drifted": + continue + if observed.parsed_spec is None: + continue + choices.append( + { + "sourceKind": "harness", + "sourceHarness": scan.harness, + "label": f"{scan.label} config", + "logoKey": scan.logo_key, + "configPath": str(scan.config_path) if scan.config_present else None, + "payloadPreview": dict(observed.raw_payload or {}), + "spec": observed.parsed_spec.to_dict(), + "env": annotate_env(observed.parsed_spec.env), + } + ) + return choices + + +def _inventory_to_payload(inventory: McpInventory, scans: tuple[McpHarnessScan, ...]) -> dict[str, object]: + visible_harnesses = {scan.harness for scan in scans} + return { + "columns": [ + { + "harness": scan.harness, + "label": scan.label, + "logoKey": scan.logo_key, + "installed": scan.installed, + "configPresent": scan.config_present, + "mcpWritable": scan.mcp_writable, + "mcpUnavailableReason": scan.mcp_unavailable_reason, + } + for scan in scans + ], + "entries": [ + _entry_to_payload(entry, scans) + for entry in inventory.entries + if entry.kind == "managed" + or any(binding.harness in visible_harnesses for binding in entry.sightings) + ], + "issues": [ + {"name": issue.name, "reason": issue.reason} + for issue in inventory.issues + ], + } + + +__all__ = ["McpQueryService"] diff --git a/skill_manager/application/mcp/read_models.py b/skill_manager/application/mcp/read_models.py new file mode 100644 index 0000000..c3c8d8a --- /dev/null +++ b/skill_manager/application/mcp/read_models.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from threading import Lock + +from skill_manager.errors import MutationError +from skill_manager.harness import HarnessKernelService + +from .adapters import build_mcp_adapters +from .contracts import McpHarnessAdapter, McpHarnessScan, McpHarnessStatus +from .store import McpServerStore + + +@dataclass(frozen=True) +class McpReadModelSnapshot: + harness_scans: tuple[McpHarnessScan, ...] + + +@dataclass(frozen=True) +class _CachedSnapshot: + snapshot: McpReadModelSnapshot + captured_at: float + + +class McpReadModelService: + def __init__( + self, + *, + store: McpServerStore, + adapters: tuple[McpHarnessAdapter, ...], + kernel: HarnessKernelService, + snapshot_ttl_seconds: float = 1.0, + ) -> None: + self.store = store + self.adapters = adapters + self.kernel = kernel + self.snapshot_ttl_seconds = snapshot_ttl_seconds + self._cache: _CachedSnapshot | None = None + self._lock = Lock() + + @classmethod + def from_kernel( + cls, + *, + store: McpServerStore, + kernel: HarnessKernelService, + ) -> "McpReadModelService": + return cls(store=store, adapters=build_mcp_adapters(kernel), kernel=kernel) + + def find_adapter(self, harness: str) -> McpHarnessAdapter | None: + return next((adapter for adapter in self.adapters if adapter.harness == harness), None) + + def enabled_harnesses(self) -> tuple[str, ...]: + return self.kernel.enabled_harness_ids_for_family("mcp") + + def visible_harnesses(self) -> tuple[str, ...]: + return self.enabled_harnesses() + + def enabled_adapters(self) -> tuple[McpHarnessAdapter, ...]: + enabled = set(self.enabled_harnesses()) + return tuple(adapter for adapter in self.adapters if adapter.harness in enabled) + + def enabled_addressable_adapters(self) -> tuple[McpHarnessAdapter, ...]: + result: list[McpHarnessAdapter] = [] + for adapter in self.enabled_adapters(): + status = adapter.status() + if status.installed or status.config_present: + result.append(adapter) + return tuple(result) + + def enabled_writable_adapters(self) -> tuple[McpHarnessAdapter, ...]: + result: list[McpHarnessAdapter] = [] + for adapter in self.enabled_adapters(): + status = adapter.status() + if status.mcp_writable and (status.installed or status.config_present): + result.append(adapter) + return tuple(result) + + def visible_scans( + self, + snapshot: McpReadModelSnapshot | None = None, + ) -> tuple[McpHarnessScan, ...]: + current = snapshot or self.snapshot() + visible = set(self.visible_harnesses()) + return tuple(scan for scan in current.harness_scans if scan.harness in visible) + + def require_enabled_adapter(self, harness: str) -> McpHarnessAdapter: + adapter = self.find_adapter(harness) + if adapter is None: + raise MutationError(f"unknown harness: {harness}", status=400) + if harness not in self.enabled_harnesses(): + raise MutationError(f"harness support is disabled: {harness}", status=400) + status = adapter.status() + if not status.installed and not status.config_present: + raise MutationError( + f"{adapter.label} is not installed and has no MCP config file", + status=400, + ) + return adapter + + def harness_statuses(self) -> tuple[McpHarnessStatus, ...]: + return tuple(adapter.status() for adapter in self.adapters) + + def snapshot(self) -> McpReadModelSnapshot: + with self._lock: + cached = self._cache + if cached is not None and (time.time() - cached.captured_at) < self.snapshot_ttl_seconds: + return cached.snapshot + + specs = self.store.list_binding_specs() + if not self.adapters: + scans: tuple[McpHarnessScan, ...] = () + else: + with ThreadPoolExecutor(max_workers=max(2, len(self.adapters))) as executor: + scans = tuple(executor.map(lambda adapter: adapter.scan(specs), self.adapters)) + snapshot = McpReadModelSnapshot(harness_scans=scans) + with self._lock: + self._cache = _CachedSnapshot(snapshot=snapshot, captured_at=time.time()) + return snapshot + + def invalidate(self) -> None: + with self._lock: + self._cache = None + for adapter in self.adapters: + adapter.invalidate() + + +__all__ = ["McpReadModelService", "McpReadModelSnapshot"] diff --git a/skill_manager/application/mcp/stdio.py b/skill_manager/application/mcp/stdio.py new file mode 100644 index 0000000..7ae035e --- /dev/null +++ b/skill_manager/application/mcp/stdio.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class StaticStdioCommand: + command: str + args: tuple[str, ...] = () + + +_JS_STRING_PATTERN = r"""(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')""" +_JS_STRING_RE = re.compile(_JS_STRING_PATTERN) + + +def parse_static_stdio_function(value: object) -> StaticStdioCommand | None: + """Extract a static command recipe from Smithery's stdioFunction string. + + The marketplace field is JavaScript source. We intentionally parse only the + simple object-literal subset and never evaluate code. + """ + if not isinstance(value, str) or not value.strip(): + return None + if _has_dynamic_config_reference(value): + return None + command_literal = _find_js_string_property(value, "command") + if command_literal is None: + return None + command = _decode_js_string(command_literal) + if not command: + return None + args = _find_js_string_array_property(value, "args") + if args is None: + return None + return StaticStdioCommand(command=command, args=args) + + +def _has_dynamic_config_reference(source: str) -> bool: + without_strings = _JS_STRING_RE.sub("", source) + return re.search(r"\bconfig\s*(?:\.|\[)", without_strings) is not None + + +def _find_js_string_property(source: str, key: str) -> str | None: + pattern = re.compile(rf"\b{re.escape(key)}\s*:\s*(?P{_JS_STRING_PATTERN})") + match = pattern.search(source) + if match is None: + return None + return match.group("literal") + + +def _find_js_string_array_property(source: str, key: str) -> tuple[str, ...] | None: + pattern = re.compile(rf"\b{re.escape(key)}\s*:\s*\[(?P[^\]]*)\]", re.DOTALL) + match = pattern.search(source) + if match is None: + return () + body = match.group("body") + literals = [match.group(0) for match in _JS_STRING_RE.finditer(body)] + remainder = _JS_STRING_RE.sub("", body) + if remainder.replace(",", "").strip(): + return None + args = tuple(_decode_js_string(literal) for literal in literals) + if any(arg is None for arg in args): + return None + return tuple(arg for arg in args if arg is not None) + + +def _decode_js_string(literal: str) -> str | None: + try: + decoded = ast.literal_eval(literal) + except (SyntaxError, ValueError): + return None + return decoded if isinstance(decoded, str) and decoded else None + + +__all__ = ["StaticStdioCommand", "parse_static_stdio_function"] diff --git a/skill_manager/application/mcp/store.py b/skill_manager/application/mcp/store.py new file mode 100644 index 0000000..410c333 --- /dev/null +++ b/skill_manager/application/mcp/store.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal, Mapping + +from skill_manager.atomic_files import atomic_write_text, file_lock + + +McpTransport = Literal["stdio", "http", "sse"] +McpSourceKind = Literal["marketplace", "adopted", "manual"] +CURRENT_MCP_MANIFEST_VERSION = 5 + + +@dataclass(frozen=True) +class McpManifestIssue: + name: str + reason: str + + def to_dict(self) -> dict[str, str]: + return {"name": self.name, "reason": self.reason} + + +@dataclass(frozen=True) +class McpSource: + kind: McpSourceKind + locator: str + + def to_dict(self) -> dict[str, str]: + return {"kind": self.kind, "locator": self.locator} + + @classmethod + def marketplace(cls, qualified_name: str) -> "McpSource": + return cls(kind="marketplace", locator=qualified_name) + + @classmethod + def adopted(cls, harness: str, name: str) -> "McpSource": + return cls(kind="adopted", locator=f"{harness}:{name}") + + @classmethod + def manual(cls, name: str) -> "McpSource": + return cls(kind="manual", locator=name) + + +@dataclass(frozen=True) +class McpServerSpec: + name: str + display_name: str + source: McpSource + transport: McpTransport + command: str | None = None + args: tuple[str, ...] | None = None + env: tuple[tuple[str, str], ...] | None = None + url: str | None = None + headers: tuple[tuple[str, str], ...] | None = None + installed_at: str = "" + revision: str = "" + + def env_dict(self) -> dict[str, str]: + return dict(self.env) if self.env else {} + + def headers_dict(self) -> dict[str, str]: + return dict(self.headers) if self.headers else {} + + def args_list(self) -> list[str]: + return list(self.args) if self.args else [] + + def to_dict(self) -> dict[str, object]: + payload: dict[str, object] = { + "name": self.name, + "displayName": self.display_name, + "source": self.source.to_dict(), + "transport": self.transport, + "installedAt": self.installed_at, + "revision": self.revision, + } + if self.command is not None: + payload["command"] = self.command + if self.args is not None: + payload["args"] = list(self.args) + if self.env is not None: + payload["env"] = dict(self.env) + if self.url is not None: + payload["url"] = self.url + if self.headers is not None: + payload["headers"] = dict(self.headers) + return payload + + @classmethod + def from_dict(cls, payload: Mapping[str, object]) -> "McpServerSpec": + source_raw = payload.get("source", {}) + source = ( + McpSource( + kind=source_raw.get("kind", "manual"), # type: ignore[arg-type] + locator=source_raw.get("locator", payload.get("name", "")), # type: ignore[arg-type] + ) + if isinstance(source_raw, Mapping) + else McpSource.manual(str(payload.get("name", ""))) + ) + env_raw = payload.get("env") + headers_raw = payload.get("headers") + args_raw = payload.get("args") + return cls( + name=str(payload["name"]), + display_name=str(payload.get("displayName", payload["name"])), + source=source, + transport=str(payload.get("transport", "stdio")), # type: ignore[arg-type] + command=_optional_str(payload.get("command")), + args=tuple(str(a) for a in args_raw) if isinstance(args_raw, list) else None, + env=tuple((str(k), str(v)) for k, v in env_raw.items()) if isinstance(env_raw, Mapping) else None, + url=_optional_str(payload.get("url")), + headers=tuple((str(k), str(v)) for k, v in headers_raw.items()) if isinstance(headers_raw, Mapping) else None, + installed_at=str(payload.get("installedAt", "")), + revision=str(payload.get("revision", "")), + ) + + +@dataclass(frozen=True) +class McpManagedManifest: + entries: tuple[McpServerSpec, ...] = field(default_factory=tuple) + + def to_dict(self) -> dict[str, object]: + return { + "version": CURRENT_MCP_MANIFEST_VERSION, + "servers": [entry.to_dict() for entry in self.entries], + } + + +@dataclass(frozen=True) +class _ManifestLoadResult: + manifest: McpManagedManifest + issues: tuple[McpManifestIssue, ...] = () + + +def _optional_str(value: object) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +def compute_revision(spec: McpServerSpec) -> str: + payload = { + "name": spec.name, + "transport": spec.transport, + "command": spec.command, + "args": list(spec.args) if spec.args else None, + "env": dict(spec.env) if spec.env else None, + "url": spec.url, + "headers": dict(spec.headers) if spec.headers else None, + } + digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + return digest[:16] + + +def now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def prepare_managed_spec(spec: McpServerSpec) -> McpServerSpec: + stamped = spec if spec.installed_at else replace(spec, installed_at=now_iso()) + return replace(stamped, revision=compute_revision(stamped)) + + +def write_mcp_manifest(path: Path, manifest: McpManagedManifest) -> None: + atomic_write_text( + path, + json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2, sort_keys=False) + "\n", + ) + + +class McpServerStore: + """Cleartext local manifest of canonical observed MCP configs.""" + + def __init__(self, manifest_path: Path) -> None: + self.manifest_path = manifest_path + + @property + def _lock_path(self) -> Path: + return self.manifest_path.with_suffix(".lock") + + def list_managed(self) -> tuple[McpServerSpec, ...]: + return self._load_manifest_result().manifest.entries + + def list_binding_specs(self) -> tuple[McpServerSpec, ...]: + return self.list_managed() + + def list_public_specs(self) -> tuple[McpServerSpec, ...]: + return self.list_managed() + + def get_managed(self, name: str) -> McpServerSpec | None: + for entry in self.list_managed(): + if entry.name == name: + return entry + return None + + def get_binding_spec(self, name: str) -> McpServerSpec | None: + return self.get_managed(name) + + def get_public_spec(self, name: str) -> McpServerSpec | None: + return self.get_managed(name) + + def upsert_from_spec(self, spec: McpServerSpec) -> McpServerSpec: + return self.upsert_managed(spec) + + def upsert_managed(self, server: McpServerSpec) -> McpServerSpec: + with file_lock(self._lock_path): + manifest = self._load_manifest_result().manifest + stamped = prepare_managed_spec(server) + new_entries = tuple( + stamped if entry.name == stamped.name else entry for entry in manifest.entries + ) + if not any(entry.name == stamped.name for entry in manifest.entries): + new_entries = manifest.entries + (stamped,) + write_mcp_manifest(self.manifest_path, McpManagedManifest(entries=new_entries)) + return stamped + + def remove(self, name: str) -> bool: + with file_lock(self._lock_path): + manifest = self._load_manifest_result().manifest + new_entries = tuple(entry for entry in manifest.entries if entry.name != name) + if len(new_entries) == len(manifest.entries): + return False + write_mcp_manifest(self.manifest_path, McpManagedManifest(entries=new_entries)) + return True + + def manifest_issues(self) -> tuple[McpManifestIssue, ...]: + return self._load_manifest_result().issues + + def _load_manifest_result(self) -> _ManifestLoadResult: + if not self.manifest_path.is_file(): + return _ManifestLoadResult(McpManagedManifest()) + payload = json.loads(self.manifest_path.read_text(encoding="utf-8")) + raw_entries = payload.get("servers", []) + if not isinstance(raw_entries, list): + return _ManifestLoadResult( + McpManagedManifest(), + issues=(McpManifestIssue(name="", reason="'servers' must be a list"),), + ) + entries: list[McpServerSpec] = [] + issues: list[McpManifestIssue] = [] + for item in raw_entries: + if not isinstance(item, dict): + issues.append(McpManifestIssue(name="", reason="server entry must be an object")) + continue + name = str(item.get("name", "")) + try: + entries.append(McpServerSpec.from_dict(item)) + except (KeyError, TypeError, ValueError) as error: + issues.append(McpManifestIssue(name=name, reason=str(error) or error.__class__.__name__)) + continue + return _ManifestLoadResult( + McpManagedManifest(entries=tuple(entries)), + issues=tuple(issues), + ) + + +__all__ = [ + "CURRENT_MCP_MANIFEST_VERSION", + "McpManagedManifest", + "McpManifestIssue", + "McpServerSpec", + "McpServerStore", + "McpSource", + "McpSourceKind", + "McpTransport", + "compute_revision", + "now_iso", + "prepare_managed_spec", + "write_mcp_manifest", +] diff --git a/skill_manager/application/read_model_service.py b/skill_manager/application/read_model_service.py deleted file mode 100644 index 6fc2c1a..0000000 --- a/skill_manager/application/read_model_service.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from threading import Lock -import time - -from skill_manager.domain import HarnessScan, StoreScan -from skill_manager.errors import MutationError -from skill_manager.harness import HarnessDriver, HarnessManager, HarnessStatus, collect_harness_statuses, create_default_drivers, scan_all_harnesses, supported_harness_ids -from skill_manager.paths import resolve_app_paths -from skill_manager.store import HarnessSupportStore, SharedStore - - -@dataclass(frozen=True) -class ReadModelSnapshot: - store_scan: StoreScan - harness_scans: tuple[HarnessScan, ...] - - -@dataclass(frozen=True) -class CachedSnapshot: - snapshot: ReadModelSnapshot - captured_at: float - - -class ReadModelService: - def __init__( - self, - *, - store: SharedStore, - harness_drivers: tuple[HarnessDriver, ...], - support_store: HarnessSupportStore, - snapshot_ttl_seconds: float = 1.0, - ) -> None: - self.store = store - self.harness_drivers = harness_drivers - self.support_store = support_store - self.snapshot_ttl_seconds = snapshot_ttl_seconds - self._snapshot_cache: CachedSnapshot | None = None - self._lock = Lock() - - @classmethod - def from_environment( - cls, - env: dict[str, str] | None = None, - *, - support_store: HarnessSupportStore | None = None, - ) -> "ReadModelService": - active_env = env or {} - paths = resolve_app_paths(active_env) - store = SharedStore(paths.shared_store_root, manifest_path=paths.shared_store_manifest) - active_support_store = support_store or HarnessSupportStore(paths.settings_path) - drivers = create_default_drivers(active_env) - return cls(store=store, harness_drivers=drivers, support_store=active_support_store) - - def find_driver(self, harness: str) -> HarnessDriver | None: - return next((driver for driver in self.harness_drivers if driver.harness == harness), None) - - def find_manager(self, harness: str) -> HarnessManager | None: - driver = self.find_driver(harness) - if driver is None: - return None - return driver.manager() - - def require_enabled_manager(self, harness: str) -> HarnessManager: - driver = self.find_driver(harness) - if driver is None: - raise MutationError(f"unknown harness: {harness}", status=400) - if harness not in self.enabled_harnesses(): - raise MutationError(f"harness support is disabled: {harness}", status=400) - status = driver.status() - if not status.installed: - raise MutationError(f"{driver.label} is not installed or not available on PATH", status=400) - manager = driver.manager() - if manager is None: - raise MutationError(f"harness cannot be managed: {harness}", status=400) - return manager - - def enabled_harnesses(self) -> tuple[str, ...]: - return self.support_store.enabled_harnesses(supported_harness_ids()) - - def enabled_managers(self) -> tuple[tuple[str, HarnessManager], ...]: - enabled = set(self.enabled_harnesses()) - managers: list[tuple[str, HarnessManager]] = [] - for driver in self.harness_drivers: - if driver.harness not in enabled: - continue - manager = driver.manager() - if manager is not None: - managers.append((driver.harness, manager)) - return tuple(managers) - - def all_managers(self) -> tuple[tuple[str, HarnessManager], ...]: - managers: list[tuple[str, HarnessManager]] = [] - for driver in self.harness_drivers: - manager = driver.manager() - if manager is not None: - managers.append((driver.harness, manager)) - return tuple(managers) - - def harness_statuses(self) -> tuple[HarnessStatus, ...]: - return collect_harness_statuses(self.harness_drivers) - - def snapshot(self) -> ReadModelSnapshot: - with self._lock: - cached = self._snapshot_cache - if cached is not None and (time.time() - cached.captured_at) < self.snapshot_ttl_seconds: - return cached.snapshot - - store_scan = self.store.scan() - enabled = set(self.enabled_harnesses()) - active_drivers = tuple( - driver for driver in self.harness_drivers if driver.harness in enabled - ) - harness_scans = scan_all_harnesses(active_drivers) - snapshot = ReadModelSnapshot( - store_scan=store_scan, - harness_scans=harness_scans, - ) - with self._lock: - self._snapshot_cache = CachedSnapshot(snapshot=snapshot, captured_at=time.time()) - return snapshot - - def invalidate(self) -> None: - with self._lock: - self._snapshot_cache = None - for driver in self.harness_drivers: - driver.invalidate() diff --git a/skill_manager/application/settings/mutations.py b/skill_manager/application/settings/mutations.py index 9bcbcb3..88e7954 100644 --- a/skill_manager/application/settings/mutations.py +++ b/skill_manager/application/settings/mutations.py @@ -1,24 +1,25 @@ from __future__ import annotations from skill_manager.errors import MutationError -from skill_manager.harness import supported_harness_ids -from skill_manager.store import HarnessSupportStore +from skill_manager.harness import HarnessKernelService, HarnessSupportStore -from ..read_model_service import ReadModelService +from ..invalidation import InvalidationFanout class SettingsMutationService: def __init__( self, - read_models: ReadModelService, + harness_kernel: HarnessKernelService, support_store: HarnessSupportStore, + invalidation: InvalidationFanout, ) -> None: - self.read_models = read_models + self.harness_kernel = harness_kernel self.support_store = support_store + self.invalidation = invalidation def set_harness_support(self, harness: str, enabled: bool) -> dict[str, object]: - if harness not in supported_harness_ids(): + if not self.harness_kernel.is_known_harness(harness): raise MutationError(f"unknown harness: {harness}", status=404) self.support_store.set_enabled(harness, enabled) - self.read_models.invalidate() + self.invalidation.invalidate_all() return {"ok": True, "enabled": enabled} diff --git a/skill_manager/application/settings/presenters.py b/skill_manager/application/settings/presenters.py index 38760aa..a793865 100644 --- a/skill_manager/application/settings/presenters.py +++ b/skill_manager/application/settings/presenters.py @@ -1,5 +1,6 @@ from __future__ import annotations -from skill_manager.harness import HarnessLocation, HarnessStatus + +from skill_manager.harness import HarnessStatus def settings_payload( @@ -28,10 +29,5 @@ def harness_payload( "logoKey": status.logo_key, "supportEnabled": support_enabled, "installed": status.installed, - "managedLocation": managed_location_payload(status.locations), + "managedLocation": str(status.managed_location) if status.managed_location is not None else None, } - - -def managed_location_payload(locations: tuple[HarnessLocation, ...]) -> str | None: - store = next((location for location in locations if location.kind == "managed-root"), None) - return str(store.path) if store is not None else None diff --git a/skill_manager/application/settings/queries.py b/skill_manager/application/settings/queries.py index 2612d2d..94bf2d0 100644 --- a/skill_manager/application/settings/queries.py +++ b/skill_manager/application/settings/queries.py @@ -1,22 +1,16 @@ from __future__ import annotations -from skill_manager.store import HarnessSupportStore +from skill_manager.harness import HarnessKernelService -from ..read_model_service import ReadModelService from .presenters import settings_payload class SettingsQueryService: - def __init__( - self, - read_models: ReadModelService, - support_store: HarnessSupportStore, - ) -> None: - self.read_models = read_models - self.support_store = support_store + def __init__(self, harness_kernel: HarnessKernelService) -> None: + self.harness_kernel = harness_kernel def get_settings(self) -> dict[str, object]: return settings_payload( - harness_statuses=self.read_models.harness_statuses(), - enabled_harnesses=self.read_models.enabled_harnesses(), + harness_statuses=self.harness_kernel.harness_statuses(), + enabled_harnesses=self.harness_kernel.enabled_harness_ids(), ) diff --git a/skill_manager/application/skills/adapters.py b/skill_manager/application/skills/adapters.py new file mode 100644 index 0000000..f0f087f --- /dev/null +++ b/skill_manager/application/skills/adapters.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +import shutil +from uuid import uuid4 + +from skill_manager.errors import MutationError +from skill_manager.harness import ( + FileTreeBindingProfile, + HarnessKernelService, +) + +from .contracts import SkillsHarnessAdapter, SkillsHarnessStatus +from .identity import SourceDescriptor +from .observations import SkillObservation, SkillsHarnessScan +from .package import SkillParseError, find_skill_roots, parse_skill_package + + +class FileTreeSkillsAdapter(SkillsHarnessAdapter): + def __init__( + self, + *, + harness: str, + label: str, + logo_key: str | None, + install_probe: str, + path_env: str | None, + managed_root: Path, + discovery_roots: tuple["_ResolvedRoot", ...], + ) -> None: + self.harness = harness + self.label = label + self.logo_key = logo_key + self._install_probe = install_probe + self._path_env = path_env + self.managed_root = managed_root + self._discovery_roots = self._dedupe_roots(discovery_roots) + + def status(self) -> SkillsHarnessStatus: + return SkillsHarnessStatus( + harness=self.harness, + label=self.label, + logo_key=self.logo_key, + installed=self._is_installed(), + managed_root=self.managed_root, + ) + + def scan(self) -> SkillsHarnessScan: + observations = _scan_skill_roots( + harness=self.harness, + label=self.label, + roots=self._discovery_roots, + ) + return SkillsHarnessScan( + harness=self.harness, + label=self.label, + logo_key=self.logo_key, + installed=self._is_installed(), + skills=tuple(observations), + ) + + def enable_shared_package(self, package_path: Path) -> None: + resolved_target = package_path.resolve() + link = self.managed_root / package_path.name + if link.is_symlink(): + if link.resolve() == resolved_target: + return + raise MutationError( + f"symlink already exists but points to {link.resolve()}, not {resolved_target}" + ) + if link.exists(): + raise MutationError(f"real directory exists at {link}; will not overwrite") + self.managed_root.mkdir(parents=True, exist_ok=True) + link.symlink_to(resolved_target) + + def disable_shared_package(self, package_dir: str) -> None: + link = self.managed_root / package_dir + if not link.exists() and not link.is_symlink(): + return + if not link.is_symlink(): + raise MutationError(f"not a symlink at {link}; will not delete real directory") + link.unlink() + + def adopt_local_copy(self, existing_dir: Path, package_path: Path) -> None: + resolved_target = package_path.resolve() + if not existing_dir.exists() and not existing_dir.is_symlink(): + raise MutationError(f"directory does not exist: {existing_dir}") + if existing_dir.is_symlink(): + if existing_dir.resolve() == resolved_target: + return + raise MutationError( + f"symlink exists but points to {existing_dir.resolve()}, not {resolved_target}" + ) + shutil.rmtree(existing_dir) + existing_dir.symlink_to(resolved_target) + + def has_binding(self, package_dir: str) -> bool: + candidate = self.managed_root / package_dir + return candidate.exists() or candidate.is_symlink() + + def prepare_materialize(self, package_dir: str, expected_target: Path) -> None: + existing_link = self.managed_root / package_dir + if not existing_link.exists() and not existing_link.is_symlink(): + raise MutationError(f"directory does not exist: {existing_link}") + if not existing_link.is_symlink(): + raise MutationError(f"not a symlink at {existing_link}; will not overwrite real directory") + resolved_target = expected_target.resolve() + if existing_link.resolve() != resolved_target: + raise MutationError( + f"symlink exists but points to {existing_link.resolve()}, not {resolved_target}" + ) + + def materialize_binding(self, package_dir: str, source_path: Path) -> None: + existing_link = self.managed_root / package_dir + resolved_target = source_path.resolve() + self.prepare_materialize(package_dir=package_dir, expected_target=resolved_target) + + temp_copy = existing_link.parent / f".{existing_link.name}.materialize-{uuid4().hex}" + backup_link = existing_link.parent / f".{existing_link.name}.backup-{uuid4().hex}" + + try: + shutil.copytree(resolved_target, temp_copy) + existing_link.rename(backup_link) + temp_copy.rename(existing_link) + except OSError as error: + if backup_link.exists() and not existing_link.exists(): + backup_link.rename(existing_link) + if temp_copy.exists(): + shutil.rmtree(temp_copy, ignore_errors=True) + raise MutationError(f"unable to restore local copy at {existing_link}: {error}") from error + + if backup_link.exists(): + backup_link.unlink() + + def prepare_remove(self, package_dir: str) -> None: + link = self.managed_root / package_dir + if not link.exists() and not link.is_symlink(): + return + if not link.is_symlink(): + raise MutationError(f"not a symlink at {link}; will not delete real directory") + + def remove_binding(self, package_dir: str) -> None: + self.disable_shared_package(package_dir) + + def invalidate(self) -> None: + return None + + def _is_installed(self) -> bool: + return shutil.which(self._install_probe, path=self._path_env) is not None + + def _dedupe_roots( + self, + roots: tuple["_ResolvedRoot", ...], + ) -> tuple["_ResolvedRoot", ...]: + selected: list[_ResolvedRoot] = [] + seen: set[Path] = set() + for root in roots: + path = root.path.resolve(strict=False) + if path in seen: + continue + seen.add(path) + selected.append(root) + return tuple(selected) + + +@dataclass(frozen=True) +class _ResolvedRoot: + kind: str + scope: str + label: str + path: Path + + +def build_skills_adapters(kernel: HarnessKernelService) -> tuple[FileTreeSkillsAdapter, ...]: + adapters: list[FileTreeSkillsAdapter] = [] + for binding in kernel.bindings_for_family("skills"): + definition = binding.definition + profile = binding.profile + if not isinstance(profile, FileTreeBindingProfile): + continue + managed_root = profile.resolve_managed_root(kernel.context) + resolved_roots = ( + _ResolvedRoot( + kind="managed-root", + scope="canonical", + label="Managed skills root", + path=managed_root, + ), + *tuple( + _ResolvedRoot( + kind=root.kind, + scope=root.scope, + label=root.label, + path=root.path_resolver(kernel.context), + ) + for root in profile.discovery_roots + ), + ) + adapters.append( + FileTreeSkillsAdapter( + harness=definition.harness, + label=definition.label, + logo_key=definition.logo_key, + install_probe=definition.install_probe, + path_env=kernel.context.env.get("PATH"), + managed_root=managed_root, + discovery_roots=resolved_roots, + ) + ) + return tuple(adapters) + + +def scan_all_adapters(adapters: tuple[SkillsHarnessAdapter, ...]) -> tuple[SkillsHarnessScan, ...]: + if not adapters: + return () + with ThreadPoolExecutor(max_workers=len(adapters)) as executor: + return tuple(executor.map(lambda adapter: adapter.scan(), adapters)) + + +def _scan_skill_roots( + *, + harness: str, + label: str, + roots: tuple[_ResolvedRoot, ...], +) -> list[SkillObservation]: + observations: list[SkillObservation] = [] + for root in roots: + for skill_root in find_skill_roots(root.path): + try: + package = parse_skill_package( + skill_root, + default_source=SourceDescriptor( + kind="harness-local", + locator=f"{harness}:{root.scope}:{skill_root.name}", + ), + ) + except SkillParseError: + continue + observations.append( + SkillObservation( + harness=harness, + label=label, + scope=root.scope, + package=package, + ) + ) + return observations + + +__all__ = ["FileTreeSkillsAdapter", "build_skills_adapters", "scan_all_adapters"] diff --git a/skill_manager/application/skills/contracts.py b/skill_manager/application/skills/contracts.py new file mode 100644 index 0000000..bdde9e6 --- /dev/null +++ b/skill_manager/application/skills/contracts.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from .observations import SkillsHarnessScan + + +@dataclass(frozen=True) +class SkillsHarnessStatus: + harness: str + label: str + logo_key: str | None + installed: bool + managed_root: Path + + +class SkillsHarnessAdapter(Protocol): + harness: str + label: str + logo_key: str | None + managed_root: Path + + def status(self) -> SkillsHarnessStatus: ... + + def scan(self) -> SkillsHarnessScan: ... + + def enable_shared_package(self, package_path: Path) -> None: ... + + def disable_shared_package(self, package_dir: str) -> None: ... + + def adopt_local_copy(self, existing_dir: Path, package_path: Path) -> None: ... + + def has_binding(self, package_dir: str) -> bool: ... + + def prepare_materialize(self, package_dir: str, expected_target: Path) -> None: ... + + def materialize_binding(self, package_dir: str, source_path: Path) -> None: ... + + def prepare_remove(self, package_dir: str) -> None: ... + + def remove_binding(self, package_dir: str) -> None: ... + + def invalidate(self) -> None: ... + + +__all__ = ["SkillsHarnessAdapter", "SkillsHarnessStatus"] diff --git a/skill_manager/application/document_utils.py b/skill_manager/application/skills/document_utils.py similarity index 100% rename from skill_manager/application/document_utils.py rename to skill_manager/application/skills/document_utils.py diff --git a/skill_manager/domain/health.py b/skill_manager/application/skills/health.py similarity index 100% rename from skill_manager/domain/health.py rename to skill_manager/application/skills/health.py diff --git a/skill_manager/domain/identity.py b/skill_manager/application/skills/identity.py similarity index 86% rename from skill_manager/domain/identity.py rename to skill_manager/application/skills/identity.py index e416a3f..ee8958e 100644 --- a/skill_manager/domain/identity.py +++ b/skill_manager/application/skills/identity.py @@ -19,7 +19,7 @@ class SourceDescriptor: @property def is_source_backed(self) -> bool: - return self.kind not in {"builtin", "harness-local", "shared-store", "unmanaged-local"} + return self.kind not in {"harness-local", "shared-store", "unmanaged-local"} @dataclass(frozen=True) diff --git a/skill_manager/application/skills/inventory.py b/skill_manager/application/skills/inventory.py index 9aa78ea..58ff691 100644 --- a/skill_manager/application/skills/inventory.py +++ b/skill_manager/application/skills/inventory.py @@ -4,10 +4,11 @@ from pathlib import Path from typing import Literal -from skill_manager.domain import HarnessScan, SourceDescriptor, StoreScan, stable_id +from .identity import SourceDescriptor, stable_id +from .observations import SkillStoreScan, SkillsHarnessScan -EntryKind = Literal["managed", "unmanaged", "builtin"] +EntryKind = Literal["managed", "unmanaged"] @dataclass(frozen=True) @@ -15,11 +16,12 @@ class InventoryColumn: harness: str label: str logo_key: str | None + installed: bool @dataclass(frozen=True) class InventorySighting: - kind: Literal["shared", "harness", "builtin"] + kind: Literal["shared", "harness"] harness: str | None label: str scope: str | None @@ -48,7 +50,7 @@ def add_sighting(self, sighting: InventorySighting) -> None: self.sightings.append(sighting) def detail_sightings(self) -> list[InventorySighting]: - order = {"shared": 0, "harness": 1, "builtin": 2} + order = {"shared": 0, "harness": 1} return sorted( self.sightings, key=lambda item: ( @@ -84,13 +86,22 @@ def __init__( self._by_ref = {entry.skill_ref: entry for entry in entries} @classmethod - def from_snapshot(cls, *, store_scan: StoreScan, harness_scans: tuple[HarnessScan, ...]) -> "SkillInventory": + def from_snapshot( + cls, + *, + store_scan: SkillStoreScan, + harness_scans: tuple[SkillsHarnessScan, ...], + ) -> "SkillInventory": from .policy import sort_entries columns = tuple( - InventoryColumn(harness=scan.harness, label=scan.label, logo_key=scan.logo_key) + InventoryColumn( + harness=scan.harness, + label=scan.label, + logo_key=scan.logo_key, + installed=scan.installed, + ) for scan in harness_scans - if scan.manageable ) entries: list[InventoryEntry] = [] shared_path_index: dict[Path, InventoryEntry] = {} @@ -127,7 +138,6 @@ def from_snapshot(cls, *, store_scan: StoreScan, harness_scans: tuple[HarnessSca shared_match_index[_managed_entry_key(entry)] = entry unmanaged_entries: dict[str, InventoryEntry] = {} - builtin_entries: dict[str, InventoryEntry] = {} for scan in harness_scans: for observation in scan.skills: @@ -167,34 +177,7 @@ def from_snapshot(cls, *, store_scan: StoreScan, harness_scans: tuple[HarnessSca unmanaged_entries[key] = entry entry.add_sighting(sighting) - for builtin in scan.builtins: - source = SourceDescriptor(kind="builtin", locator=f"{builtin.harness}:{builtin.builtin_id}") - key = stable_id("builtin", builtin.declared_name, builtin.builtin_id) - entry = builtin_entries.get(key) - if entry is None: - entry = InventoryEntry( - skill_ref=f"builtin:{key}", - name=builtin.declared_name, - description=builtin.detail, - kind="builtin", - source=source, - ) - builtin_entries[key] = entry - entry.add_sighting( - InventorySighting( - kind="builtin", - harness=builtin.harness, - label=builtin.label, - scope=None, - path=None, - revision=None, - source=source, - detail=builtin.detail, - ) - ) - entries.extend(unmanaged_entries.values()) - entries.extend(builtin_entries.values()) sort_entries(entries) return cls( columns=columns, diff --git a/skill_manager/store/manifest.py b/skill_manager/application/skills/manifest.py similarity index 75% rename from skill_manager/store/manifest.py rename to skill_manager/application/skills/manifest.py index 475b49b..d0e7df4 100644 --- a/skill_manager/store/manifest.py +++ b/skill_manager/application/skills/manifest.py @@ -4,11 +4,11 @@ import json from pathlib import Path -from ._atomic import atomic_write_text +from skill_manager.atomic_files import atomic_write_text @dataclass(frozen=True) -class ManifestEntry: +class SkillStoreEntry: package_dir: str declared_name: str source_kind: str @@ -33,19 +33,19 @@ def to_dict(self) -> dict[str, str]: @dataclass(frozen=True) -class StoreManifest: - entries: tuple[ManifestEntry, ...] +class SkillStoreManifest: + entries: tuple[SkillStoreEntry, ...] def to_dict(self) -> dict[str, object]: return {"entries": [entry.to_dict() for entry in self.entries]} -def load_manifest(path: Path) -> StoreManifest: +def load_skill_store_manifest(path: Path) -> SkillStoreManifest: if not path.is_file(): - return StoreManifest(entries=()) + return SkillStoreManifest(entries=()) payload = json.loads(path.read_text(encoding="utf-8")) entries = tuple( - ManifestEntry( + SkillStoreEntry( package_dir=item["packageDir"], declared_name=item["declaredName"], source_kind=item["sourceKind"], @@ -56,11 +56,19 @@ def load_manifest(path: Path) -> StoreManifest: ) for item in payload.get("entries", []) ) - return StoreManifest(entries=entries) + return SkillStoreManifest(entries=entries) -def write_manifest(path: Path, manifest: StoreManifest) -> None: +def write_skill_store_manifest(path: Path, manifest: SkillStoreManifest) -> None: atomic_write_text( path, json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2) + "\n", ) + + +__all__ = [ + "SkillStoreEntry", + "SkillStoreManifest", + "load_skill_store_manifest", + "write_skill_store_manifest", +] diff --git a/skill_manager/application/marketplace/__init__.py b/skill_manager/application/skills/marketplace/__init__.py similarity index 100% rename from skill_manager/application/marketplace/__init__.py rename to skill_manager/application/skills/marketplace/__init__.py diff --git a/skill_manager/application/marketplace/catalog.py b/skill_manager/application/skills/marketplace/catalog.py similarity index 98% rename from skill_manager/application/marketplace/catalog.py rename to skill_manager/application/skills/marketplace/catalog.py index 8c9b402..6d8e87e 100644 --- a/skill_manager/application/marketplace/catalog.py +++ b/skill_manager/application/skills/marketplace/catalog.py @@ -12,7 +12,8 @@ from skill_manager.errors import MarketplaceUpstreamError from skill_manager.sources import github_repo_url -from .cache import MarketplaceCache +from skill_manager.application.marketplace_cache import MarketplaceCache + from .client import DEFAULT_SKILLS_SH_BASE_URL, SkillsShClient from .models import MarketplaceCard, MarketplacePageResult, RepoDisplayMetadata, SkillsShSkill from .resolver import DetailEnrichment, GitHubSkillResolver @@ -76,6 +77,7 @@ def from_environment( leaderboard_fetcher: LeaderboardFetcher | None = None, search_fetcher: SearchFetcher | None = None, detail_fetcher: DetailFetcher | None = None, + cache: MarketplaceCache | None = None, warm_on_init: bool = True, ) -> "MarketplaceCatalog": client = SkillsShClient.from_environment(env) @@ -83,7 +85,7 @@ def from_environment( leaderboard_fetcher=leaderboard_fetcher or (lambda: fetch_all_time_leaderboard(client=client)), search_fetcher=search_fetcher or (lambda query, limit: search_skills(query, limit=limit, client=client)), detail_fetcher=detail_fetcher or (lambda detail_url: fetch_detail_page(detail_url, client=client)), - cache=MarketplaceCache.from_environment(env), + cache=cache or MarketplaceCache.from_environment(env), warm_on_init=warm_on_init, ) diff --git a/skill_manager/application/marketplace/client.py b/skill_manager/application/skills/marketplace/client.py similarity index 86% rename from skill_manager/application/marketplace/client.py rename to skill_manager/application/skills/marketplace/client.py index 52c9849..de668a7 100644 --- a/skill_manager/application/marketplace/client.py +++ b/skill_manager/application/skills/marketplace/client.py @@ -5,14 +5,15 @@ from pathlib import Path import socket import ssl -import sys from urllib.error import HTTPError, URLError from urllib.parse import quote, urljoin from urllib.request import Request, urlopen -import certifi - from skill_manager.errors import MarketplaceUpstreamError +from skill_manager.application.marketplace_http import ( + configured_marketplace_ca_file, + marketplace_ssl_context, +) DEFAULT_SKILLS_SH_BASE_URL = "https://skills.sh" MARKETPLACE_BASE_URL_ENV = "SKILL_MANAGER_MARKETPLACE_BASE_URL" @@ -26,23 +27,6 @@ def configured_marketplace_base_url(env: dict[str, str] | None = None) -> str: return (configured or DEFAULT_SKILLS_SH_BASE_URL).rstrip("/") -def configured_marketplace_ca_file(env: dict[str, str] | None = None) -> Path | None: - active_env = os.environ if env is None else env - override = active_env.get("SSL_CERT_FILE", "").strip() - if override: - return Path(override) - if _is_packaged_runtime(): - return Path(certifi.where()) - return None - - -def marketplace_ssl_context(env: dict[str, str] | None = None) -> ssl.SSLContext | None: - cafile = configured_marketplace_ca_file(env) - if cafile is None: - return None - return ssl.create_default_context(cafile=str(cafile)) - - def skills_sh_detail_url(repo: str, skill_id: str, *, base_url: str = DEFAULT_SKILLS_SH_BASE_URL) -> str: normalized = (base_url or DEFAULT_SKILLS_SH_BASE_URL).rstrip("/") return f"{normalized}/{quote(repo, safe='/')}/{quote(skill_id, safe='')}" @@ -123,7 +107,3 @@ def _request(self, path_or_url: str, *, accept: str | None = None) -> bytes: raise MarketplaceUpstreamError(kind, url, str(reason)) from error except OSError as error: raise MarketplaceUpstreamError("network", url, str(error)) from error - - -def _is_packaged_runtime() -> bool: - return bool(getattr(sys, "frozen", False)) diff --git a/skill_manager/application/marketplace/documents.py b/skill_manager/application/skills/marketplace/documents.py similarity index 94% rename from skill_manager/application/marketplace/documents.py rename to skill_manager/application/skills/marketplace/documents.py index 61b4c89..470bf10 100644 --- a/skill_manager/application/marketplace/documents.py +++ b/skill_manager/application/skills/marketplace/documents.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from skill_manager.errors import MutationError +from skill_manager.application.marketplace_cache import MarketplaceCache from ..document_utils import read_skill_document_markdown -from ..source_fetch_service import SourceFetchService -from .cache import MarketplaceCache +from ..source_fetch import SourceFetchService from .models import SkillsShSkill diff --git a/skill_manager/application/marketplace/installs.py b/skill_manager/application/skills/marketplace/installs.py similarity index 93% rename from skill_manager/application/marketplace/installs.py rename to skill_manager/application/skills/marketplace/installs.py index 22a5795..b1c2ef8 100644 --- a/skill_manager/application/marketplace/installs.py +++ b/skill_manager/application/skills/marketplace/installs.py @@ -2,7 +2,7 @@ from skill_manager.errors import MutationError -from ..skills.mutations import SkillsMutationService +from ..mutations import SkillsMutationService from .catalog import MarketplaceCatalog diff --git a/skill_manager/application/marketplace/models.py b/skill_manager/application/skills/marketplace/models.py similarity index 100% rename from skill_manager/application/marketplace/models.py rename to skill_manager/application/skills/marketplace/models.py diff --git a/skill_manager/application/marketplace/queries.py b/skill_manager/application/skills/marketplace/queries.py similarity index 95% rename from skill_manager/application/marketplace/queries.py rename to skill_manager/application/skills/marketplace/queries.py index 6e08188..4b87e75 100644 --- a/skill_manager/application/marketplace/queries.py +++ b/skill_manager/application/skills/marketplace/queries.py @@ -2,16 +2,16 @@ from skill_manager.sources import github_repo_url -from ..read_model_service import ReadModelService +from ..inventory import SkillInventory +from ..read_models import SkillsReadModelService from .catalog import MarketplaceCatalog from .documents import MarketplaceDocumentService -from ..skills.inventory import SkillInventory class MarketplaceQueryService: def __init__( self, - read_models: ReadModelService, + read_models: SkillsReadModelService, catalog: MarketplaceCatalog, document_service: MarketplaceDocumentService, ) -> None: @@ -109,5 +109,5 @@ def inventory(self) -> SkillInventory: snapshot = self.read_models.snapshot() return SkillInventory.from_snapshot( store_scan=snapshot.store_scan, - harness_scans=snapshot.harness_scans, + harness_scans=self.read_models.visible_scans(snapshot), ) diff --git a/skill_manager/application/marketplace/repo_snapshots.py b/skill_manager/application/skills/marketplace/repo_snapshots.py similarity index 98% rename from skill_manager/application/marketplace/repo_snapshots.py rename to skill_manager/application/skills/marketplace/repo_snapshots.py index 51d693a..fcaf2b9 100644 --- a/skill_manager/application/marketplace/repo_snapshots.py +++ b/skill_manager/application/skills/marketplace/repo_snapshots.py @@ -12,7 +12,8 @@ is_valid_github_repo, ) -from .cache import MarketplaceCache +from skill_manager.application.marketplace_cache import MarketplaceCache + from .models import RepoDisplayMetadata diff --git a/skill_manager/application/marketplace/resolver.py b/skill_manager/application/skills/marketplace/resolver.py similarity index 100% rename from skill_manager/application/marketplace/resolver.py rename to skill_manager/application/skills/marketplace/resolver.py diff --git a/skill_manager/application/marketplace/skillssh.py b/skill_manager/application/skills/marketplace/skillssh.py similarity index 100% rename from skill_manager/application/marketplace/skillssh.py rename to skill_manager/application/skills/marketplace/skillssh.py diff --git a/skill_manager/application/skills/mutations.py b/skill_manager/application/skills/mutations.py index 83518b2..4aa7061 100644 --- a/skill_manager/application/skills/mutations.py +++ b/skill_manager/application/skills/mutations.py @@ -3,21 +3,22 @@ from pathlib import Path from tempfile import TemporaryDirectory -from skill_manager.domain import SourceDescriptor, parse_skill_package from skill_manager.errors import MutationError -from skill_manager.harness import HarnessManager -from ..read_model_service import ReadModelService -from ..source_fetch_service import SourceFetchService +from .contracts import SkillsHarnessAdapter +from .identity import SourceDescriptor from .inventory import InventoryEntry -from .policy import can_delete, can_manage, can_stop_managing, can_update, display_status +from .package import parse_skill_package +from .policy import can_delete, can_manage, can_stop_managing, can_update, display_status, has_local_changes from .queries import SkillsQueryService +from .read_models import SkillsReadModelService +from .source_fetch import SourceFetchService class SkillsMutationService: def __init__( self, - read_models: ReadModelService, + read_models: SkillsReadModelService, queries: SkillsQueryService, source_fetcher: SourceFetchService, ) -> None: @@ -31,8 +32,8 @@ def enable_skill(self, skill_ref: str, harness: str) -> dict[str, bool]: raise MutationError(f"only managed skills can be toggled; this is {display_status(entry)}", status=400) if entry.package_path is None: raise MutationError("managed skill is missing its shared package path", status=500) - manager = self.read_models.require_enabled_manager(harness) - manager.enable_shared_package(entry.package_path) + adapter = self.read_models.require_enabled_adapter(harness) + adapter.enable_shared_package(entry.package_path) self.read_models.invalidate() return {"ok": True} @@ -42,11 +43,59 @@ def disable_skill(self, skill_ref: str, harness: str) -> dict[str, bool]: raise MutationError(f"only managed skills can be toggled; this is {display_status(entry)}", status=400) if entry.package_dir is None: raise MutationError("managed skill is missing its package directory name", status=500) - manager = self.read_models.require_enabled_manager(harness) - manager.disable_shared_package(entry.package_dir) + adapter = self.read_models.require_enabled_adapter(harness) + adapter.disable_shared_package(entry.package_dir) self.read_models.invalidate() return {"ok": True} + def set_skill_all_harnesses(self, skill_ref: str, target: str) -> dict[str, object]: + if target not in ("enabled", "disabled"): + raise MutationError("target must be 'enabled' or 'disabled'", status=400) + entry = self.queries.require_entry(skill_ref) + if entry.kind != "managed": + raise MutationError( + f"only managed skills can be toggled; this is {display_status(entry)}", + status=400, + ) + if entry.package_dir is None: + raise MutationError("managed skill is missing its package directory name", status=500) + if target == "enabled" and entry.package_path is None: + raise MutationError("managed skill is missing its shared package path", status=500) + + succeeded: list[str] = [] + failures: list[dict[str, str]] = [] + flipped_any = False + + # Bulk set-all only targets harnesses whose CLI is installed on the + # system. Enabling on an uninstalled harness would write a symlink + # into a folder no runtime reads, which is misleading and happens + # to cascade across overlapping discovery roots in the catalog. + for adapter in self.read_models.enabled_installed_adapters(): + has_binding = adapter.has_binding(entry.package_dir) + if target == "enabled" and has_binding: + continue + if target == "disabled" and not has_binding: + continue + try: + if target == "enabled": + adapter.enable_shared_package(entry.package_path) # type: ignore[arg-type] + else: + adapter.disable_shared_package(entry.package_dir) + except Exception as error: # noqa: BLE001 — aggregate partial failures + failures.append({"harness": adapter.harness, "error": str(error)}) + continue + succeeded.append(adapter.harness) + flipped_any = True + + if flipped_any: + self.read_models.invalidate() + + return { + "ok": not failures, + "succeeded": succeeded, + "failed": failures, + } + def manage_skill(self, skill_ref: str) -> dict[str, bool]: entry = self.queries.require_entry(skill_ref) if entry.kind != "unmanaged": @@ -88,6 +137,8 @@ def manage_all_skills(self) -> dict[str, object]: def update_skill(self, skill_ref: str) -> dict[str, bool]: entry = self.queries.require_entry(skill_ref) if not can_update(entry): + if has_local_changes(entry): + raise MutationError("Local changes detected. Source updates are disabled.", status=400) raise MutationError("skill cannot be updated from its source", status=400) if entry.package_dir is None: raise MutationError("managed skill is missing its package directory name", status=500) @@ -113,13 +164,13 @@ def unmanage_skill(self, skill_ref: str) -> dict[str, bool]: entry = self.queries.require_entry(skill_ref) if not can_stop_managing(entry): raise MutationError( - f"only managed or custom shared-store skills can be moved back to unmanaged; this is {display_status(entry)}", + f"only managed shared-store skills can be moved back to unmanaged; this is {display_status(entry)}", status=400, ) if entry.package_dir is None or entry.package_path is None: raise MutationError("managed skill is missing its shared package metadata", status=500) - enabled_bindings, disabled_bindings = self._partition_bound_managers(entry.package_dir) + enabled_bindings, disabled_bindings = self._partition_bound_adapters(entry.package_dir) if disabled_bindings: raise MutationError( "cannot stop managing while disabled harnesses still have bindings: " @@ -134,11 +185,11 @@ def unmanage_skill(self, skill_ref: str) -> dict[str, bool]: except ValueError as error: raise MutationError(str(error), status=409) from error - for _harness, manager in enabled_bindings: - manager.prepare_materialize(entry.package_dir, entry.package_path) + for _harness, adapter in enabled_bindings: + adapter.prepare_materialize(entry.package_dir, entry.package_path) - for _harness, manager in enabled_bindings: - manager.materialize_binding(entry.package_dir, entry.package_path) + for _harness, adapter in enabled_bindings: + adapter.materialize_binding(entry.package_dir, entry.package_path) try: self.read_models.store.delete(entry.package_dir) @@ -151,13 +202,13 @@ def delete_skill(self, skill_ref: str) -> dict[str, bool]: entry = self.queries.require_entry(skill_ref) if not can_delete(entry): raise MutationError( - f"only managed or custom shared-store skills can be deleted; this is {display_status(entry)}", + f"only managed shared-store skills can be deleted; this is {display_status(entry)}", status=400, ) if entry.package_dir is None: raise MutationError("managed skill is missing its package directory name", status=500) - _enabled_bindings, disabled_bindings = self._partition_bound_managers(entry.package_dir) + enabled_bindings, disabled_bindings = self._partition_bound_adapters(entry.package_dir) if disabled_bindings: raise MutationError( "cannot delete while disabled harnesses still have bindings: " @@ -168,10 +219,10 @@ def delete_skill(self, skill_ref: str) -> dict[str, bool]: self.read_models.store.ensure_deletable(entry.package_dir) except ValueError as error: raise MutationError(str(error), status=409) from error - for _harness, manager in self.read_models.enabled_managers(): - manager.prepare_remove(entry.package_dir) - for _harness, manager in self.read_models.enabled_managers(): - manager.remove_binding(entry.package_dir) + for _harness, adapter in enabled_bindings: + adapter.prepare_remove(entry.package_dir) + for _harness, adapter in enabled_bindings: + adapter.remove_binding(entry.package_dir) try: self.read_models.store.delete(entry.package_dir) except ValueError as error: @@ -225,33 +276,32 @@ def _manage_entry(self, entry: InventoryEntry) -> None: raise MutationError(str(error), status=409) from error canonical_bound_harnesses: set[str] = set() for sighting in harness_sightings: - manager = self.read_models.require_enabled_manager(sighting.harness) + adapter = self.read_models.require_enabled_adapter(sighting.harness) if sighting.scope == "canonical": - manager.adopt_local_copy(existing_dir=sighting.path, package_path=ingested) + adapter.adopt_local_copy(existing_dir=sighting.path, package_path=ingested) canonical_bound_harnesses.add(sighting.harness) for sighting in harness_sightings: if sighting.harness in canonical_bound_harnesses: continue - manager = self.read_models.require_enabled_manager(sighting.harness) - manager.enable_shared_package(ingested) + adapter = self.read_models.require_enabled_adapter(sighting.harness) + adapter.enable_shared_package(ingested) canonical_bound_harnesses.add(sighting.harness) - def _partition_bound_managers(self, package_dir: str) -> tuple[list[tuple[str, HarnessManager]], list[tuple[str, HarnessManager]]]: + def _partition_bound_adapters( + self, + package_dir: str, + ) -> tuple[list[tuple[str, SkillsHarnessAdapter]], list[tuple[str, SkillsHarnessAdapter]]]: enabled = set(self.read_models.enabled_harnesses()) - enabled_bindings: list[tuple[str, HarnessManager]] = [] - disabled_bindings: list[tuple[str, HarnessManager]] = [] - for harness, manager in self.read_models.all_managers(): - if not manager.has_binding(package_dir): + enabled_bindings: list[tuple[str, SkillsHarnessAdapter]] = [] + disabled_bindings: list[tuple[str, SkillsHarnessAdapter]] = [] + for adapter in self.read_models.all_adapters(): + if not adapter.has_binding(package_dir): continue - if harness in enabled: - enabled_bindings.append((harness, manager)) + if adapter.harness in enabled: + enabled_bindings.append((adapter.harness, adapter)) else: - disabled_bindings.append((harness, manager)) + disabled_bindings.append((adapter.harness, adapter)) return enabled_bindings, disabled_bindings - def _describe_harnesses(self, bindings: list[tuple[str, HarnessManager]]) -> str: - labels: list[str] = [] - for harness, _manager in bindings: - driver = self.read_models.find_driver(harness) - labels.append(driver.label if driver is not None else harness) - return ", ".join(labels) + def _describe_harnesses(self, bindings: list[tuple[str, SkillsHarnessAdapter]]) -> str: + return ", ".join(adapter.label for _harness, adapter in bindings) diff --git a/skill_manager/domain/observations.py b/skill_manager/application/skills/observations.py similarity index 74% rename from skill_manager/domain/observations.py rename to skill_manager/application/skills/observations.py index 603cb73..892e2c1 100644 --- a/skill_manager/domain/observations.py +++ b/skill_manager/application/skills/observations.py @@ -14,15 +14,6 @@ class SkillObservation: package: SkillPackage -@dataclass(frozen=True) -class BuiltinObservation: - harness: str - label: str - builtin_id: str - declared_name: str - detail: str = "" - - @dataclass(frozen=True) class StorePackageObservation: package: SkillPackage @@ -32,17 +23,23 @@ class StorePackageObservation: @dataclass(frozen=True) -class HarnessScan: +class SkillsHarnessScan: harness: str label: str logo_key: str | None installed: bool - manageable: bool skills: tuple[SkillObservation, ...] = () - builtins: tuple[BuiltinObservation, ...] = () @dataclass(frozen=True) -class StoreScan: +class SkillStoreScan: packages: tuple[StorePackageObservation, ...] = () issues: tuple[str, ...] = () + + +__all__ = [ + "SkillObservation", + "SkillStoreScan", + "SkillsHarnessScan", + "StorePackageObservation", +] diff --git a/skill_manager/domain/package.py b/skill_manager/application/skills/package.py similarity index 100% rename from skill_manager/domain/package.py rename to skill_manager/application/skills/package.py diff --git a/skill_manager/application/skills/policy.py b/skill_manager/application/skills/policy.py index 4c1c5c6..e4294cd 100644 --- a/skill_manager/application/skills/policy.py +++ b/skill_manager/application/skills/policy.py @@ -5,12 +5,12 @@ from .inventory import InventoryEntry -DisplayStatus = Literal["Managed", "Unmanaged", "Custom", "Built-in"] -HarnessCellState = Literal["enabled", "disabled", "found", "builtin", "empty"] +DisplayStatus = Literal["Managed", "Unmanaged"] +HarnessCellState = Literal["enabled", "disabled", "found", "empty"] StopManagingStatus = Literal["available", "disabled_no_enabled"] -def is_custom(entry: InventoryEntry) -> bool: +def has_local_changes(entry: InventoryEntry) -> bool: return ( entry.kind == "managed" and entry.recorded_revision is not None @@ -20,18 +20,14 @@ def is_custom(entry: InventoryEntry) -> bool: def display_status(entry: InventoryEntry) -> DisplayStatus: - if entry.kind == "builtin": - return "Built-in" if entry.kind == "unmanaged": return "Unmanaged" - if is_custom(entry): - return "Custom" return "Managed" def attention_message(entry: InventoryEntry) -> str | None: - if is_custom(entry): - return "Modified locally; source updates are disabled." + if has_local_changes(entry): + return "Local changes detected. Source updates are disabled." return None @@ -40,7 +36,7 @@ def can_manage(entry: InventoryEntry) -> bool: def can_update(entry: InventoryEntry) -> bool: - return entry.kind == "managed" and not is_custom(entry) and entry.source.kind == "github" + return entry.kind == "managed" and not has_local_changes(entry) and entry.source.kind == "github" def can_delete(entry: InventoryEntry) -> bool: @@ -52,8 +48,6 @@ def can_stop_managing(entry: InventoryEntry) -> bool: def cell_state(entry: InventoryEntry, harness: str) -> HarnessCellState: - if entry.kind == "builtin": - return "builtin" if any(s.harness == harness for s in entry.sightings) else "empty" if entry.kind == "unmanaged": return "found" if any(s.harness == harness for s in entry.sightings) else "empty" return ( @@ -74,9 +68,7 @@ def stop_managing_status(entry: InventoryEntry) -> StopManagingStatus | None: def sort_entries(entries: list[InventoryEntry]) -> None: order = { "Managed": 0, - "Custom": 1, - "Unmanaged": 2, - "Built-in": 3, + "Unmanaged": 1, } entries.sort( key=lambda entry: ( diff --git a/skill_manager/application/skills/presenters.py b/skill_manager/application/skills/presenters.py index cbab3ca..853b386 100644 --- a/skill_manager/application/skills/presenters.py +++ b/skill_manager/application/skills/presenters.py @@ -7,6 +7,7 @@ can_manage, cell_state, display_status, + stop_managing_status, ) @@ -14,8 +15,6 @@ def skills_page_payload(inventory: SkillInventory) -> dict[str, object]: counts = { "managed": sum(1 for entry in inventory.entries if display_status(entry) == "Managed"), "unmanaged": sum(1 for entry in inventory.entries if display_status(entry) == "Unmanaged"), - "custom": sum(1 for entry in inventory.entries if display_status(entry) == "Custom"), - "builtIn": sum(1 for entry in inventory.entries if display_status(entry) == "Built-in"), } return { "summary": counts, @@ -55,8 +54,13 @@ def source_status_payload(update_status: str | None) -> dict[str, object]: return {"updateStatus": update_status} -def column_payload(column: InventoryColumn) -> dict[str, str | None]: - return {"harness": column.harness, "label": column.label, "logoKey": column.logo_key} +def column_payload(column: InventoryColumn) -> dict[str, object]: + return { + "harness": column.harness, + "label": column.label, + "logoKey": column.logo_key, + "installed": column.installed, + } def row_payload(entry: InventoryEntry, columns: tuple[InventoryColumn, ...]) -> dict[str, object]: @@ -65,9 +69,10 @@ def row_payload(entry: InventoryEntry, columns: tuple[InventoryColumn, ...]) -> "name": entry.name, "description": entry.description, "displayStatus": display_status(entry), - "attentionMessage": attention_message(entry), "actions": { "canManage": can_manage(entry), + "canStopManaging": stop_managing_status(entry) == "available", + "canDelete": can_delete(entry), }, "cells": [cell_payload(entry, column) for column in columns], } @@ -75,12 +80,20 @@ def row_payload(entry: InventoryEntry, columns: tuple[InventoryColumn, ...]) -> def cell_payload(entry: InventoryEntry, column: InventoryColumn) -> dict[str, object]: state = cell_state(entry, column.harness) + # `interactive` is the single source of truth for "this cell can be + # flipped right now". It requires both a toggleable state AND an + # installed harness CLI — flipping a cell whose CLI doesn't exist + # would write a symlink no runtime reads (and cascades misleadingly + # through overlapping discovery roots in the catalog). Every + # consumer downstream (card counts, board bucketing, harness chip + # stack) reads `interactive` and is correct for free. + is_interactive = state in {"enabled", "disabled"} and column.installed return { "harness": column.harness, "label": column.label, "logoKey": column.logo_key, "state": state, - "interactive": state in {"enabled", "disabled"}, + "interactive": is_interactive, } diff --git a/skill_manager/application/skills/queries.py b/skill_manager/application/skills/queries.py index 5537e69..a7f7082 100644 --- a/skill_manager/application/skills/queries.py +++ b/skill_manager/application/skills/queries.py @@ -4,22 +4,22 @@ from tempfile import TemporaryDirectory from typing import Literal -from skill_manager.domain import fingerprint_package from skill_manager.errors import MutationError from skill_manager.sources import github_folder_url, github_repo_from_locator, github_repo_url -from ..document_utils import read_skill_document_markdown -from ..read_model_service import ReadModelService -from ..source_fetch_service import SourceFetchService +from .document_utils import read_skill_document_markdown from .inventory import InventoryEntry, SkillInventory -from .policy import can_stop_managing, can_update +from .package import fingerprint_package +from .policy import can_stop_managing, can_update, has_local_changes from .presenters import skill_detail_payload, skills_page_payload, source_status_payload +from .read_models import SkillsReadModelService +from .source_fetch import SourceFetchService class SkillsQueryService: def __init__( self, - read_models: ReadModelService, + read_models: SkillsReadModelService, source_fetcher: SourceFetchService, ) -> None: self.read_models = read_models @@ -60,7 +60,7 @@ def inventory(self) -> SkillInventory: snapshot = self.read_models.snapshot() return SkillInventory.from_snapshot( store_scan=snapshot.store_scan, - harness_scans=snapshot.harness_scans, + harness_scans=self.read_models.visible_scans(snapshot), ) def require_entry(self, skill_ref: str) -> InventoryEntry: @@ -126,9 +126,11 @@ def _github_folder_url(self, entry: InventoryEntry, repo: str) -> str | None: def resolve_update_status( self, entry: InventoryEntry, - ) -> Literal["update_available", "no_update_available", "no_source_available"] | None: + ) -> Literal["update_available", "no_update_available", "no_source_available", "local_changes_detected"] | None: if entry.kind != "managed": return None + if has_local_changes(entry): + return "local_changes_detected" if not can_update(entry): return "no_source_available" if self.check_for_update(entry): diff --git a/skill_manager/application/skills/read_models.py b/skill_manager/application/skills/read_models.py new file mode 100644 index 0000000..b8872d2 --- /dev/null +++ b/skill_manager/application/skills/read_models.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +from threading import Lock +import time + +from skill_manager.errors import MutationError +from skill_manager.harness import HarnessKernelService + +from .adapters import build_skills_adapters, scan_all_adapters +from .contracts import SkillsHarnessAdapter, SkillsHarnessStatus +from .observations import SkillStoreScan, SkillsHarnessScan +from .store import SkillStore + + +@dataclass(frozen=True) +class SkillsReadModelSnapshot: + store_scan: SkillStoreScan + harness_scans: tuple[SkillsHarnessScan, ...] + + +@dataclass(frozen=True) +class _CachedSnapshot: + snapshot: SkillsReadModelSnapshot + captured_at: float + + +class SkillsReadModelService: + def __init__( + self, + *, + store: SkillStore, + adapters: tuple[SkillsHarnessAdapter, ...], + kernel: HarnessKernelService, + snapshot_ttl_seconds: float = 1.0, + ) -> None: + self.store = store + self.adapters = adapters + self.kernel = kernel + self.snapshot_ttl_seconds = snapshot_ttl_seconds + self._cache: _CachedSnapshot | None = None + self._lock = Lock() + + @classmethod + def from_kernel( + cls, + *, + store: SkillStore, + kernel: HarnessKernelService, + ) -> "SkillsReadModelService": + return cls( + store=store, + adapters=build_skills_adapters(kernel), + kernel=kernel, + ) + + def find_adapter(self, harness: str) -> SkillsHarnessAdapter | None: + return next((adapter for adapter in self.adapters if adapter.harness == harness), None) + + def visible_harnesses(self) -> tuple[str, ...]: + return self.kernel.enabled_harness_ids_for_family("skills") + + def enabled_harnesses(self) -> tuple[str, ...]: + return self.visible_harnesses() + + def enabled_adapters(self) -> tuple[SkillsHarnessAdapter, ...]: + enabled = set(self.enabled_harnesses()) + return tuple(adapter for adapter in self.adapters if adapter.harness in enabled) + + def enabled_installed_adapters(self) -> tuple[SkillsHarnessAdapter, ...]: + return tuple(adapter for adapter in self.enabled_adapters() if adapter.status().installed) + + def all_adapters(self) -> tuple[SkillsHarnessAdapter, ...]: + return self.adapters + + def require_enabled_adapter(self, harness: str) -> SkillsHarnessAdapter: + adapter = self.find_adapter(harness) + if adapter is None: + raise MutationError(f"unknown harness: {harness}", status=400) + if harness not in self.enabled_harnesses(): + raise MutationError(f"harness support is disabled: {harness}", status=400) + status = adapter.status() + if not status.installed: + raise MutationError(f"{adapter.label} is not installed or not available on PATH", status=400) + return adapter + + def harness_statuses(self) -> tuple[SkillsHarnessStatus, ...]: + return tuple(adapter.status() for adapter in self.adapters) + + def visible_scans( + self, + snapshot: SkillsReadModelSnapshot | None = None, + ) -> tuple[SkillsHarnessScan, ...]: + current = snapshot or self.snapshot() + visible = set(self.visible_harnesses()) + return tuple(scan for scan in current.harness_scans if scan.harness in visible) + + def snapshot(self) -> SkillsReadModelSnapshot: + with self._lock: + cached = self._cache + if cached is not None and (time.time() - cached.captured_at) < self.snapshot_ttl_seconds: + return cached.snapshot + + snapshot = SkillsReadModelSnapshot( + store_scan=self.store.scan(), + harness_scans=scan_all_adapters(self.adapters), + ) + with self._lock: + self._cache = _CachedSnapshot(snapshot=snapshot, captured_at=time.time()) + return snapshot + + def invalidate(self) -> None: + with self._lock: + self._cache = None + for adapter in self.adapters: + adapter.invalidate() + + +__all__ = ["SkillsReadModelService", "SkillsReadModelSnapshot"] diff --git a/skill_manager/application/source_fetch_service.py b/skill_manager/application/skills/source_fetch.py similarity index 100% rename from skill_manager/application/source_fetch_service.py rename to skill_manager/application/skills/source_fetch.py diff --git a/skill_manager/store/shared_store.py b/skill_manager/application/skills/store.py similarity index 72% rename from skill_manager/store/shared_store.py rename to skill_manager/application/skills/store.py index cce7268..e3810d8 100644 --- a/skill_manager/store/shared_store.py +++ b/skill_manager/application/skills/store.py @@ -3,33 +3,33 @@ import shutil from pathlib import Path -from skill_manager.domain import ( - CheckIssue, - SourceDescriptor, - StorePackageObservation, - StoreScan, - find_skill_roots, - fingerprint_package, - parse_skill_package, -) +from skill_manager.atomic_files import file_lock -from ._atomic import file_lock -from .manifest import ManifestEntry, StoreManifest, load_manifest, write_manifest +from .health import CheckIssue +from .identity import SourceDescriptor +from .manifest import ( + SkillStoreEntry, + SkillStoreManifest, + load_skill_store_manifest, + write_skill_store_manifest, +) +from .observations import SkillStoreScan, StorePackageObservation +from .package import find_skill_roots, fingerprint_package, parse_skill_package -class SharedStore: +class SkillStore: def __init__(self, root: Path, manifest_path: Path | None = None) -> None: self.root = root self.manifest_path = manifest_path or root.parent / "manifest.json" @property - def _lock_path(self) -> Path: + def lock_path(self) -> Path: return self.manifest_path.with_suffix(".lock") - def scan(self) -> StoreScan: - manifest = load_manifest(self.manifest_path) + def scan(self) -> SkillStoreScan: + manifest = load_skill_store_manifest(self.manifest_path) manifest_index = {entry.package_dir: entry for entry in manifest.entries} - packages = [] + packages: list[StorePackageObservation] = [] for path in find_skill_roots(self.root): entry = manifest_index.get(path.name) source = SourceDescriptor( @@ -44,7 +44,10 @@ def scan(self) -> StoreScan: recorded_source_path=entry.source_path if entry else None, ) ) - return StoreScan(packages=tuple(packages), issues=tuple(issue.message for issue in self.check_integrity())) + return SkillStoreScan( + packages=tuple(packages), + issues=tuple(issue.message for issue in self.check_integrity()), + ) def ingest( self, @@ -56,16 +59,15 @@ def ingest( source_ref: str | None = None, source_path_hint: str | None = None, ) -> Path: - """Copy a skill package into the shared store and update the manifest.""" self.root.mkdir(parents=True, exist_ok=True) - with file_lock(self._lock_path): + with file_lock(self.lock_path): dest = self.root / source_path.name if dest.exists(): raise ValueError(f"package directory already exists in store: {source_path.name}") shutil.copytree(source_path, dest) fingerprint, _ = fingerprint_package(dest) - manifest = load_manifest(self.manifest_path) - entry = ManifestEntry( + manifest = load_skill_store_manifest(self.manifest_path) + entry = SkillStoreEntry( package_dir=source_path.name, declared_name=declared_name, source_kind=source_kind, @@ -74,7 +76,10 @@ def ingest( source_ref=source_ref, source_path=source_path_hint, ) - write_manifest(self.manifest_path, StoreManifest(entries=manifest.entries + (entry,))) + write_skill_store_manifest( + self.manifest_path, + SkillStoreManifest(entries=manifest.entries + (entry,)), + ) return dest def update( @@ -85,8 +90,7 @@ def update( source_ref: str | None = None, source_path_hint: str | None = None, ) -> tuple[Path, bool]: - """Replace a shared package with a new version. Returns (path, changed).""" - with file_lock(self._lock_path): + with file_lock(self.lock_path): dest = self.root / package_dir if not dest.is_dir(): raise ValueError(f"package not in store: {package_dir}") @@ -96,9 +100,9 @@ def update( return dest, False shutil.rmtree(dest) shutil.copytree(source_path, dest) - manifest = load_manifest(self.manifest_path) + manifest = load_skill_store_manifest(self.manifest_path) updated = tuple( - ManifestEntry( + SkillStoreEntry( e.package_dir, e.declared_name, e.source_kind, @@ -111,23 +115,29 @@ def update( else e for e in manifest.entries ) - write_manifest(self.manifest_path, StoreManifest(entries=updated)) + write_skill_store_manifest( + self.manifest_path, + SkillStoreManifest(entries=updated), + ) return dest, True def delete(self, package_dir: str) -> None: - with file_lock(self._lock_path): + with file_lock(self.lock_path): self.ensure_deletable(package_dir) dest = self.root / package_dir - manifest = load_manifest(self.manifest_path) + manifest = load_skill_store_manifest(self.manifest_path) shutil.rmtree(dest) updated = tuple(entry for entry in manifest.entries if entry.package_dir != package_dir) - write_manifest(self.manifest_path, StoreManifest(entries=updated)) + write_skill_store_manifest( + self.manifest_path, + SkillStoreManifest(entries=updated), + ) def ensure_deletable(self, package_dir: str) -> None: dest = self.root / package_dir if not dest.is_dir(): raise ValueError(f"package not in store: {package_dir}") - manifest = load_manifest(self.manifest_path) + manifest = load_skill_store_manifest(self.manifest_path) if not any(entry.package_dir == package_dir for entry in manifest.entries): raise ValueError(f"package missing from manifest: {package_dir}") @@ -145,3 +155,6 @@ def check_integrity(self) -> tuple[CheckIssue, ...]: ) ) return tuple(issues) + + +__all__ = ["SkillStore"] diff --git a/skill_manager/store/_atomic.py b/skill_manager/atomic_files.py similarity index 100% rename from skill_manager/store/_atomic.py rename to skill_manager/atomic_files.py diff --git a/skill_manager/domain/__init__.py b/skill_manager/domain/__init__.py deleted file mode 100644 index f37bb06..0000000 --- a/skill_manager/domain/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -from .health import CheckIssue, CheckReport -from .identity import SkillRef, SourceDescriptor, stable_id -from .observations import BuiltinObservation, HarnessScan, SkillObservation, StorePackageObservation, StoreScan -from .package import ( - SkillManifest, - SkillPackage, - SkillParseError, - fingerprint_package, - find_skill_roots, - parse_skill_manifest_text, - parse_skill_package, -) - -__all__ = [ - "BuiltinObservation", - "CheckIssue", - "CheckReport", - "HarnessScan", - "SkillManifest", - "SkillPackage", - "SkillParseError", - "SkillObservation", - "SkillRef", - "SourceDescriptor", - "StorePackageObservation", - "StoreScan", - "fingerprint_package", - "find_skill_roots", - "parse_skill_manifest_text", - "parse_skill_package", - "stable_id", -] diff --git a/skill_manager/harness/__init__.py b/skill_manager/harness/__init__.py index 0e51b6c..a7277cf 100644 --- a/skill_manager/harness/__init__.py +++ b/skill_manager/harness/__init__.py @@ -1,18 +1,38 @@ -from .catalog import HarnessDefinition, supported_harness_definitions, supported_harness_ids -from .contracts import HarnessDriver, HarnessLocation, HarnessManager, HarnessStatus -from .managers import SymlinkHarnessManager -from .registry import collect_harness_statuses, create_default_drivers, scan_all_harnesses +from .catalog import harness_definitions_for_family, supported_harness_definitions, supported_harness_ids +from .contracts import ( + BindingProfile, + ConfigSubtreeBindingProfile, + FamilyKey, + FileTreeBindingProfile, + FileTreeDiscoveryRoot, + HarnessDefinition, + HarnessStatus, + PathResolver, + SubtreePath, + SubtreePathResolver, +) +from .kernel import FamilyBinding, HarnessKernelService +from .resolution import ResolutionContext, resolve_context +from .support_store import HarnessSupportPreferences, HarnessSupportStore __all__ = [ - "HarnessDriver", + "BindingProfile", + "ConfigSubtreeBindingProfile", + "FamilyBinding", + "FamilyKey", + "FileTreeBindingProfile", + "FileTreeDiscoveryRoot", "HarnessDefinition", - "HarnessLocation", - "HarnessManager", + "HarnessKernelService", "HarnessStatus", - "SymlinkHarnessManager", - "collect_harness_statuses", - "create_default_drivers", - "scan_all_harnesses", + "HarnessSupportPreferences", + "HarnessSupportStore", + "PathResolver", + "ResolutionContext", + "SubtreePath", + "SubtreePathResolver", + "harness_definitions_for_family", + "resolve_context", "supported_harness_definitions", "supported_harness_ids", ] diff --git a/skill_manager/harness/catalog.py b/skill_manager/harness/catalog.py index 3160c35..7a1c99b 100644 --- a/skill_manager/harness/catalog.py +++ b/skill_manager/harness/catalog.py @@ -1,44 +1,14 @@ from __future__ import annotations -from dataclasses import dataclass from pathlib import Path -from typing import Callable -from .contracts import HarnessDefinitionLike, HarnessDiscoveryRoot, HarnessDriver -from .drivers import GlobalHarnessDriver -from .resolution import ResolutionContext - - -DriverFactory = Callable[[ResolutionContext, "HarnessDefinition"], HarnessDriver] -PathResolver = Callable[[ResolutionContext], Path] - - -@dataclass(frozen=True) -class DiscoveryRootDefinition: - kind: str - scope: str - label: str - path_resolver: PathResolver - - -@dataclass(frozen=True) -class HarnessDefinition(HarnessDefinitionLike): - harness: str - label: str - logo_key: str | None - install_probe: str - managed_env: str | None - managed_default: PathResolver - driver_factory: DriverFactory - discovery_roots: tuple[DiscoveryRootDefinition, ...] = () - builtins_env: str | None = None - builtins_default: PathResolver | None = None - - def create_driver( - self, - context: ResolutionContext, - ) -> HarnessDriver: - return self.driver_factory(context, self) +from .contracts import ( + ConfigSubtreeBindingProfile, + FamilyKey, + FileTreeBindingProfile, + FileTreeDiscoveryRoot, + HarnessDefinition, +) def supported_harness_definitions() -> tuple[HarnessDefinition, ...]: @@ -49,44 +19,10 @@ def supported_harness_ids() -> tuple[str, ...]: return tuple(definition.harness for definition in SUPPORTED_HARNESS_DEFINITIONS) -def _global_driver() -> DriverFactory: - def factory(context: ResolutionContext, definition: HarnessDefinition) -> HarnessDriver: - env = context.env - managed_root = Path(env.get(definition.managed_env, definition.managed_default(context))) if definition.managed_env else definition.managed_default(context) - discovery_roots: list[HarnessDiscoveryRoot] = [ - HarnessDiscoveryRoot( - kind="managed-root", - scope="canonical", - label="Managed skills root", - path=managed_root, - writable=True, - ) - ] - for root in definition.discovery_roots: - discovery_roots.append( - HarnessDiscoveryRoot( - kind=root.kind, - scope=root.scope, - label=root.label, - path=root.path_resolver(context), - writable=False, - ) - ) - builtins_path = None - if definition.builtins_default is not None: - if definition.builtins_env: - builtins_path = Path(env.get(definition.builtins_env, definition.builtins_default(context))) - else: - builtins_path = definition.builtins_default(context) - return GlobalHarnessDriver( - definition=definition, - install_probe=definition.install_probe, - path_env=env.get("PATH"), - discovery_roots=tuple(discovery_roots), - builtins_path=builtins_path, - ) - - return factory +def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition, ...]: + return tuple( + definition for definition in SUPPORTED_HARNESS_DEFINITIONS if definition.supports_family(family) + ) SUPPORTED_HARNESS_DEFINITIONS: tuple[HarnessDefinition, ...] = ( @@ -95,82 +31,146 @@ def factory(context: ResolutionContext, definition: HarnessDefinition) -> Harnes label="Codex", logo_key="codex", install_probe="codex", - managed_env="SKILL_MANAGER_CODEX_ROOT", - managed_default=lambda context: context.home / ".agents" / "skills", - discovery_roots=( - DiscoveryRootDefinition( - kind="admin-root", - scope="admin", - label="Admin skills root", - path_resolver=lambda _context: Path("/etc/codex/skills"), + bindings={ + "skills": FileTreeBindingProfile( + managed_env="SKILL_MANAGER_CODEX_ROOT", + managed_default=lambda context: context.home / ".agents" / "skills", + discovery_roots=( + FileTreeDiscoveryRoot( + kind="admin-root", + scope="admin", + label="Admin skills root", + path_resolver=lambda _context: Path("/etc/codex/skills"), + ), + FileTreeDiscoveryRoot( + kind="legacy-root", + scope="legacy", + label="Legacy import root", + path_resolver=lambda context: context.home / ".codex" / "skills", + ), + ), ), - DiscoveryRootDefinition( - kind="legacy-root", - scope="legacy", - label="Legacy import root", - path_resolver=lambda context: context.home / ".codex" / "skills", + "mcp": ConfigSubtreeBindingProfile( + config_path_resolver=lambda context: context.home / ".codex" / "config.toml", + file_format="toml", + subtree_path=("mcp_servers",), + codec="codex", ), - ), - driver_factory=_global_driver(), + }, ), HarnessDefinition( harness="claude", label="Claude", logo_key="claude", install_probe="claude", - managed_env="SKILL_MANAGER_CLAUDE_ROOT", - managed_default=lambda context: context.home / ".claude" / "skills", - driver_factory=_global_driver(), + bindings={ + "skills": FileTreeBindingProfile( + managed_env="SKILL_MANAGER_CLAUDE_ROOT", + managed_default=lambda context: context.home / ".claude" / "skills", + ), + "mcp": ConfigSubtreeBindingProfile( + config_path_resolver=lambda context: context.home / ".claude.json", + file_format="json", + subtree_path=("mcpServers",), + discovery_subtree_path_resolvers=( + lambda context: ("projects", str(context.home), "mcpServers"), + lambda context: ("projects", str(context.home.resolve()), "mcpServers"), + ), + codec="claude-code", + ), + }, ), HarnessDefinition( harness="cursor", label="Cursor", logo_key="cursor", install_probe="cursor-agent", - managed_env="SKILL_MANAGER_CURSOR_ROOT", - managed_default=lambda context: context.home / ".cursor" / "skills", - driver_factory=_global_driver(), + bindings={ + "skills": FileTreeBindingProfile( + managed_env="SKILL_MANAGER_CURSOR_ROOT", + managed_default=lambda context: context.home / ".cursor" / "skills", + ), + "mcp": ConfigSubtreeBindingProfile( + config_path_resolver=lambda context: context.home / ".cursor" / "mcp.json", + file_format="json", + subtree_path=("mcpServers",), + codec="cursor", + ), + }, ), HarnessDefinition( harness="opencode", label="OpenCode", logo_key="opencode", install_probe="opencode", - managed_env="SKILL_MANAGER_OPENCODE_ROOT", - managed_default=lambda context: context.xdg_config_home / "opencode" / "skills", - discovery_roots=( - DiscoveryRootDefinition( - kind="compat-root", - scope="claude-compat", - label="Claude compatibility root", - path_resolver=lambda context: context.home / ".claude" / "skills", + bindings={ + "skills": FileTreeBindingProfile( + managed_env="SKILL_MANAGER_OPENCODE_ROOT", + managed_default=lambda context: context.xdg_config_home / "opencode" / "skills", + discovery_roots=( + FileTreeDiscoveryRoot( + kind="compat-root", + scope="claude-compat", + label="Claude compatibility root", + path_resolver=lambda context: context.home / ".claude" / "skills", + ), + FileTreeDiscoveryRoot( + kind="compat-root", + scope="agents-compat", + label="Agents compatibility root", + path_resolver=lambda context: context.home / ".agents" / "skills", + ), + ), ), - DiscoveryRootDefinition( - kind="compat-root", - scope="agents-compat", - label="Agents compatibility root", - path_resolver=lambda context: context.home / ".agents" / "skills", + "mcp": ConfigSubtreeBindingProfile( + config_path_resolver=lambda context: context.home / ".opencode" / "opencode.jsonc", + discovery_config_path_resolvers=( + lambda context: context.xdg_config_home / "opencode" / "opencode.json", + ), + source_install_config_path_resolvers=( + lambda context: context.home / ".opencode" / "opencode.jsonc", + ), + file_format="jsonc", + subtree_path=("mcp",), + codec="opencode", ), - ), - builtins_env="SKILL_MANAGER_OPENCODE_BUILTINS", - builtins_default=lambda context: context.xdg_config_home / "opencode" / "builtins.json", - driver_factory=_global_driver(), + }, ), HarnessDefinition( harness="openclaw", label="OpenClaw", logo_key="openclaw", install_probe="openclaw", - managed_env=None, - managed_default=lambda context: context.home / ".openclaw" / "skills", - discovery_roots=( - DiscoveryRootDefinition( - kind="personal-root", - scope="personal-agent", - label="Personal agent skills root", - path_resolver=lambda context: context.home / ".agents" / "skills", + bindings={ + "skills": FileTreeBindingProfile( + managed_default=lambda context: context.home / ".openclaw" / "skills", + discovery_roots=( + FileTreeDiscoveryRoot( + kind="personal-root", + scope="personal-agent", + label="Personal agent skills root", + path_resolver=lambda context: context.home / ".agents" / "skills", + ), + ), + ), + "mcp": ConfigSubtreeBindingProfile( + config_path_resolver=lambda context: context.home / ".openclaw" / "openclaw.json", + file_format="json", + subtree_path=("mcp", "servers"), + codec="openclaw", + capability_probe="openclaw-mcp-command", + capability_unavailable_reason=( + "Installed OpenClaw does not expose MCP config support" + ), ), - ), - driver_factory=_global_driver(), + }, ), ) + + +__all__ = [ + "SUPPORTED_HARNESS_DEFINITIONS", + "harness_definitions_for_family", + "supported_harness_definitions", + "supported_harness_ids", +] diff --git a/skill_manager/harness/contracts.py b/skill_manager/harness/contracts.py index 2385d04..16a8da7 100644 --- a/skill_manager/harness/contracts.py +++ b/skill_manager/harness/contracts.py @@ -1,85 +1,133 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from typing import Protocol +from typing import Callable, Literal, Mapping, TypeAlias -from skill_manager.domain import HarnessScan +from .resolution import ResolutionContext -class HarnessDefinitionLike: - harness: str - label: str - logo_key: str | None +FamilyKey = Literal["skills", "mcp"] +PathResolver = Callable[[ResolutionContext], Path] +SubtreePath: TypeAlias = tuple[str, ...] +SubtreePathResolver = Callable[[ResolutionContext], SubtreePath] @dataclass(frozen=True) -class HarnessDiscoveryRoot: +class FileTreeDiscoveryRoot: kind: str scope: str label: str - path: Path - writable: bool = False + path_resolver: PathResolver @dataclass(frozen=True) -class HarnessLocation: - kind: str - label: str - path: Path - present: bool +class FileTreeBindingProfile: + shape: Literal["file-tree"] = "file-tree" + managed_env: str | None = None + managed_default: PathResolver | None = None + discovery_roots: tuple[FileTreeDiscoveryRoot, ...] = () + + def resolve_managed_root(self, context: ResolutionContext) -> Path: + if self.managed_default is None: + raise ValueError("file-tree binding profile is missing a managed_default resolver") + if self.managed_env: + override = context.env.get(self.managed_env) + if override: + return Path(override) + return self.managed_default(context) @dataclass(frozen=True) -class HarnessStatus: +class ConfigSubtreeBindingProfile: + shape: Literal["config-subtree"] = "config-subtree" + config_path_resolver: PathResolver | None = None + discovery_config_path_resolvers: tuple[PathResolver, ...] = () + source_install_config_path_resolvers: tuple[PathResolver, ...] = () + file_format: Literal["json", "jsonc", "toml"] = "json" + subtree_path: SubtreePath = () + discovery_subtree_path_resolvers: tuple[SubtreePathResolver, ...] = () + codec: str = "default" + capability_probe: str | None = None + capability_unavailable_reason: str | None = None + + def resolve_config_path(self, context: ResolutionContext) -> Path: + if self.config_path_resolver is None: + raise ValueError("config-subtree binding profile is missing a config_path_resolver") + return self.config_path_resolver(context) + + def resolve_discovery_config_paths(self, context: ResolutionContext) -> tuple[Path, ...]: + if self.config_path_resolver is None: + raise ValueError("config-subtree binding profile is missing a config_path_resolver") + paths = [self.config_path_resolver(context)] + paths.extend(resolver(context) for resolver in self.discovery_config_path_resolvers) + paths.extend(resolver(context) for resolver in self.source_install_config_path_resolvers) + return tuple(_dedupe_paths(paths)) + + def resolve_discovery_subtree_paths(self, context: ResolutionContext) -> tuple[SubtreePath, ...]: + paths = [self.subtree_path] + paths.extend(resolver(context) for resolver in self.discovery_subtree_path_resolvers) + return tuple(_dedupe_subtree_paths(paths)) + + +def _dedupe_subtree_paths(paths: list[SubtreePath]) -> list[SubtreePath]: + seen: set[SubtreePath] = set() + result: list[SubtreePath] = [] + for path in paths: + if not path or path in seen: + continue + seen.add(path) + result.append(path) + return result + + +def _dedupe_paths(paths: list[Path]) -> list[Path]: + seen: set[Path] = set() + result: list[Path] = [] + for path in paths: + if path in seen: + continue + seen.add(path) + result.append(path) + return result + + +BindingProfile: TypeAlias = FileTreeBindingProfile | ConfigSubtreeBindingProfile + + +@dataclass(frozen=True) +class HarnessDefinition: harness: str label: str logo_key: str | None - installed: bool - locations: tuple[HarnessLocation, ...] = () - - -class HarnessManager(Protocol): - managed_root: Path - - def enable_shared_package(self, package_path: Path) -> None: - ... + install_probe: str + bindings: Mapping[FamilyKey, BindingProfile] = field(default_factory=dict) - def disable_shared_package(self, package_dir: str) -> None: - ... + def supports_family(self, family: FamilyKey) -> bool: + return family in self.bindings - def adopt_local_copy(self, existing_dir: Path, package_path: Path) -> None: - ... + def binding_for(self, family: FamilyKey) -> BindingProfile | None: + return self.bindings.get(family) - def has_binding(self, package_dir: str) -> bool: - ... - def prepare_materialize(self, package_dir: str, expected_target: Path) -> None: - ... - - def materialize_binding(self, package_dir: str, source_path: Path) -> None: - ... - - def prepare_remove(self, package_dir: str) -> None: - ... - - def remove_binding(self, package_dir: str) -> None: - ... - - -class HarnessDriver(Protocol): +@dataclass(frozen=True) +class HarnessStatus: harness: str label: str logo_key: str | None - - def manager(self) -> HarnessManager | None: - ... - - def status(self) -> HarnessStatus: - ... - - def scan(self) -> HarnessScan: - ... - - def invalidate(self) -> None: - ... + installed: bool + managed_location: Path | None = None + + +__all__ = [ + "BindingProfile", + "ConfigSubtreeBindingProfile", + "FamilyKey", + "FileTreeBindingProfile", + "FileTreeDiscoveryRoot", + "HarnessDefinition", + "HarnessStatus", + "PathResolver", + "SubtreePath", + "SubtreePathResolver", +] diff --git a/skill_manager/harness/drivers.py b/skill_manager/harness/drivers.py deleted file mode 100644 index ff72b5f..0000000 --- a/skill_manager/harness/drivers.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -import shutil - -from skill_manager.domain import ( - BuiltinObservation, - HarnessScan, - SkillObservation, - SkillParseError, - SourceDescriptor, - find_skill_roots, - parse_skill_package, -) - -from .contracts import HarnessDefinitionLike, HarnessDiscoveryRoot, HarnessDriver, HarnessLocation, HarnessManager, HarnessStatus -from .managers import SymlinkHarnessManager - - -class GlobalHarnessDriver(HarnessDriver): - def __init__( - self, - *, - definition: HarnessDefinitionLike, - install_probe: str, - path_env: str | None, - discovery_roots: tuple[HarnessDiscoveryRoot, ...], - builtins_path: Path | None, - ) -> None: - self.harness = definition.harness - self.label = definition.label - self.logo_key = definition.logo_key - self._install_probe = install_probe - self._path_env = path_env - self._discovery_roots = _dedupe_roots(discovery_roots) - self._builtins_path = builtins_path - - def manager(self) -> HarnessManager | None: - return SymlinkHarnessManager(self._managed_root()) - - def status(self) -> HarnessStatus: - locations = [ - HarnessLocation( - kind=root.kind, - label=root.label, - path=root.path, - present=root.path.exists(), - ) - for root in self._discovery_roots - ] - if self._builtins_path is not None: - locations.append( - HarnessLocation( - kind="builtins", - label="Builtins catalog", - path=self._builtins_path, - present=self._builtins_path.is_file(), - ) - ) - return HarnessStatus( - harness=self.harness, - label=self.label, - logo_key=self.logo_key, - installed=self._is_installed(), - locations=tuple(locations), - ) - - def scan(self) -> HarnessScan: - observations = _scan_skill_roots( - harness=self.harness, - label=self.label, - roots=self._discovery_roots, - ) - builtins = tuple(_load_builtins(self.harness, self.label, self._builtins_path)) - return HarnessScan( - harness=self.harness, - label=self.label, - logo_key=self.logo_key, - installed=self._is_installed(), - manageable=self.manager() is not None, - skills=tuple(observations), - builtins=builtins, - ) - - def invalidate(self) -> None: - return None - - def _managed_root(self) -> Path: - return next(root.path for root in self._discovery_roots if root.writable) - - def _is_installed(self) -> bool: - return shutil.which(self._install_probe, path=self._path_env) is not None - - -def _scan_skill_roots( - *, - harness: str, - label: str, - roots: tuple[HarnessDiscoveryRoot, ...], -) -> list[SkillObservation]: - observations: list[SkillObservation] = [] - for root in roots: - for skill_root in find_skill_roots(root.path): - try: - package = parse_skill_package( - skill_root, - default_source=SourceDescriptor( - kind="harness-local", - locator=f"{harness}:{root.scope}:{skill_root.name}", - ), - ) - except SkillParseError: - continue - observations.append( - SkillObservation( - harness=harness, - label=label, - scope=root.scope, - package=package, - ) - ) - return observations - - -def _load_builtins(harness: str, label: str, builtins_path: Path | None) -> list[BuiltinObservation]: - if builtins_path is None or not builtins_path.is_file(): - return [] - payload = json.loads(builtins_path.read_text(encoding="utf-8")) - builtins: list[BuiltinObservation] = [] - for item in payload.get("builtins", payload.get("skills", [])): - builtins.append( - BuiltinObservation( - harness=harness, - label=label, - builtin_id=item["id"], - declared_name=item["name"], - detail=item.get("detail", ""), - ) - ) - return builtins - - -def _dedupe_roots(roots: tuple[HarnessDiscoveryRoot, ...]) -> tuple[HarnessDiscoveryRoot, ...]: - selected: list[HarnessDiscoveryRoot] = [] - seen: set[Path] = set() - for root in roots: - resolved = root.path.resolve(strict=False) - if resolved in seen: - continue - seen.add(resolved) - selected.append(root) - return tuple(selected) diff --git a/skill_manager/harness/kernel.py b/skill_manager/harness/kernel.py new file mode 100644 index 0000000..1de43b4 --- /dev/null +++ b/skill_manager/harness/kernel.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import shutil +from dataclasses import dataclass + +from .catalog import harness_definitions_for_family, supported_harness_definitions, supported_harness_ids +from .contracts import ( + BindingProfile, + FamilyKey, + FileTreeBindingProfile, + HarnessDefinition, + HarnessStatus, +) +from .resolution import ResolutionContext, resolve_context +from .support_store import HarnessSupportStore + + +@dataclass(frozen=True) +class FamilyBinding: + definition: HarnessDefinition + profile: BindingProfile + + +class HarnessKernelService: + def __init__( + self, + *, + definitions: tuple[HarnessDefinition, ...], + context: ResolutionContext, + support_store: HarnessSupportStore, + ) -> None: + self._definitions = definitions + self.context = context + self.support_store = support_store + + @classmethod + def from_environment( + cls, + env: dict[str, str] | None = None, + *, + support_store: HarnessSupportStore, + ) -> "HarnessKernelService": + return cls( + definitions=supported_harness_definitions(), + context=resolve_context(env), + support_store=support_store, + ) + + def supported_harness_ids(self) -> tuple[str, ...]: + return supported_harness_ids() + + def is_known_harness(self, harness: str) -> bool: + return any(definition.harness == harness for definition in self._definitions) + + def definition(self, harness: str) -> HarnessDefinition | None: + return next((definition for definition in self._definitions if definition.harness == harness), None) + + def enabled_harness_ids(self) -> tuple[str, ...]: + return self.support_store.enabled_harnesses(self.supported_harness_ids()) + + def enabled_harness_ids_for_family(self, family: FamilyKey) -> tuple[str, ...]: + supported = tuple(binding.definition.harness for binding in self.bindings_for_family(family)) + return self.support_store.enabled_harnesses(supported) + + def bindings_for_family(self, family: FamilyKey) -> tuple[FamilyBinding, ...]: + bindings: list[FamilyBinding] = [] + for definition in self._definitions: + profile = definition.binding_for(family) + if profile is None: + continue + bindings.append(FamilyBinding(definition=definition, profile=profile)) + return tuple(bindings) + + def binding_for(self, harness: str, family: FamilyKey) -> BindingProfile | None: + definition = self.definition(harness) + if definition is None: + return None + return definition.binding_for(family) + + def harness_statuses(self) -> tuple[HarnessStatus, ...]: + statuses: list[HarnessStatus] = [] + for definition in self._definitions: + skills_binding = definition.binding_for("skills") + managed_location = None + if isinstance(skills_binding, FileTreeBindingProfile): + managed_location = skills_binding.resolve_managed_root(self.context) + statuses.append( + HarnessStatus( + harness=definition.harness, + label=definition.label, + logo_key=definition.logo_key, + installed=shutil.which( + definition.install_probe, + path=self.context.env.get("PATH"), + ) + is not None, + managed_location=managed_location, + ) + ) + return tuple(statuses) + + +__all__ = ["FamilyBinding", "HarnessKernelService", "harness_definitions_for_family"] diff --git a/skill_manager/harness/managers.py b/skill_manager/harness/managers.py deleted file mode 100644 index a37a982..0000000 --- a/skill_manager/harness/managers.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import shutil -from uuid import uuid4 - -from skill_manager.errors import MutationError - - -class SymlinkHarnessManager: - def __init__(self, managed_root: Path) -> None: - self.managed_root = managed_root - - def enable_shared_package(self, package_path: Path) -> None: - resolved_target = package_path.resolve() - link = self.managed_root / package_path.name - if link.is_symlink(): - if link.resolve() == resolved_target: - return - raise MutationError(f"symlink already exists but points to {link.resolve()}, not {resolved_target}") - if link.exists(): - raise MutationError(f"real directory exists at {link}; will not overwrite") - self.managed_root.mkdir(parents=True, exist_ok=True) - link.symlink_to(resolved_target) - - def disable_shared_package(self, package_dir: str) -> None: - link = self.managed_root / package_dir - if not link.exists() and not link.is_symlink(): - return - if not link.is_symlink(): - raise MutationError(f"not a symlink at {link}; will not delete real directory") - link.unlink() - - def adopt_local_copy(self, existing_dir: Path, package_path: Path) -> None: - resolved_target = package_path.resolve() - if not existing_dir.exists() and not existing_dir.is_symlink(): - raise MutationError(f"directory does not exist: {existing_dir}") - if existing_dir.is_symlink(): - if existing_dir.resolve() == resolved_target: - return - raise MutationError(f"symlink exists but points to {existing_dir.resolve()}, not {resolved_target}") - shutil.rmtree(existing_dir) - existing_dir.symlink_to(resolved_target) - - def has_binding(self, package_dir: str) -> bool: - candidate = self.managed_root / package_dir - return candidate.exists() or candidate.is_symlink() - - def prepare_materialize(self, package_dir: str, expected_target: Path) -> None: - existing_link = self.managed_root / package_dir - if not existing_link.exists() and not existing_link.is_symlink(): - raise MutationError(f"directory does not exist: {existing_link}") - if not existing_link.is_symlink(): - raise MutationError(f"not a symlink at {existing_link}; will not overwrite real directory") - resolved_target = expected_target.resolve() - if existing_link.resolve() != resolved_target: - raise MutationError(f"symlink exists but points to {existing_link.resolve()}, not {resolved_target}") - - def materialize_binding(self, package_dir: str, source_path: Path) -> None: - existing_link = self.managed_root / package_dir - resolved_target = source_path.resolve() - self.prepare_materialize(package_dir=package_dir, expected_target=resolved_target) - - temp_copy = existing_link.parent / f".{existing_link.name}.materialize-{uuid4().hex}" - backup_link = existing_link.parent / f".{existing_link.name}.backup-{uuid4().hex}" - - try: - shutil.copytree(resolved_target, temp_copy) - existing_link.rename(backup_link) - temp_copy.rename(existing_link) - except OSError as error: - if backup_link.exists() and not existing_link.exists(): - backup_link.rename(existing_link) - if temp_copy.exists(): - shutil.rmtree(temp_copy, ignore_errors=True) - raise MutationError(f"unable to restore local copy at {existing_link}: {error}") from error - - if backup_link.exists(): - backup_link.unlink() - - def prepare_remove(self, package_dir: str) -> None: - link = self.managed_root / package_dir - if not link.exists() and not link.is_symlink(): - return - if not link.is_symlink(): - raise MutationError(f"not a symlink at {link}; will not delete real directory") - - def remove_binding(self, package_dir: str) -> None: - self.disable_shared_package(package_dir) diff --git a/skill_manager/harness/registry.py b/skill_manager/harness/registry.py deleted file mode 100644 index eeeff26..0000000 --- a/skill_manager/harness/registry.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor - -from skill_manager.domain import HarnessScan - -from .catalog import supported_harness_definitions -from .contracts import HarnessDriver, HarnessStatus -from .resolution import resolve_context - - -def create_default_drivers( - env: dict[str, str] | None = None, -) -> tuple[HarnessDriver, ...]: - context = resolve_context(env) - return tuple( - definition.create_driver(context) - for definition in supported_harness_definitions() - ) - - -def scan_all_harnesses(drivers: tuple[HarnessDriver, ...]) -> tuple[HarnessScan, ...]: - if not drivers: - return () - with ThreadPoolExecutor(max_workers=len(drivers)) as executor: - scans = executor.map(lambda driver: driver.scan(), drivers) - return tuple(scans) - - -def collect_harness_statuses(drivers: tuple[HarnessDriver, ...]) -> tuple[HarnessStatus, ...]: - if not drivers: - return () - with ThreadPoolExecutor(max_workers=len(drivers)) as executor: - statuses = executor.map(lambda driver: driver.status(), drivers) - return tuple(statuses) diff --git a/skill_manager/store/harness_support.py b/skill_manager/harness/support_store.py similarity index 96% rename from skill_manager/store/harness_support.py rename to skill_manager/harness/support_store.py index c923919..0c96d4d 100644 --- a/skill_manager/store/harness_support.py +++ b/skill_manager/harness/support_store.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from ._atomic import atomic_write_text, file_lock +from skill_manager.atomic_files import atomic_write_text, file_lock @dataclass(frozen=True) diff --git a/skill_manager/paths.py b/skill_manager/paths.py index 23f7e72..70fb93e 100644 --- a/skill_manager/paths.py +++ b/skill_manager/paths.py @@ -17,9 +17,10 @@ class AppPaths: config_dir: Path data_dir: Path state_dir: Path - shared_store_root: Path - shared_store_manifest: Path + skills_store_root: Path + skills_store_manifest: Path marketplace_cache_root: Path + mcp_store_manifest: Path settings_path: Path runtime_state_path: Path server_log_path: Path @@ -34,9 +35,10 @@ def resolve_app_paths(env: dict[str, str] | None = None) -> AppPaths: config_dir=config_dir, data_dir=data_dir, state_dir=state_dir, - shared_store_root=data_dir / "shared", - shared_store_manifest=data_dir / "manifest.json", + skills_store_root=data_dir / "shared", + skills_store_manifest=data_dir / "manifest.json", marketplace_cache_root=data_dir / "marketplace", + mcp_store_manifest=data_dir / "mcp" / "manifest.json", settings_path=settings_path, runtime_state_path=state_dir / "runtime.json", server_log_path=state_dir / "server.log", diff --git a/skill_manager/store/__init__.py b/skill_manager/store/__init__.py deleted file mode 100644 index b19db2b..0000000 --- a/skill_manager/store/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .harness_support import HarnessSupportPreferences, HarnessSupportStore -from .manifest import ManifestEntry, StoreManifest, load_manifest, write_manifest -from .shared_store import SharedStore - -__all__ = [ - "HarnessSupportPreferences", - "HarnessSupportStore", - "ManifestEntry", - "SharedStore", - "StoreManifest", - "load_manifest", - "write_manifest", -] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c3f496d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,10 @@ +# Test Layers + +Backend tests stay split by behavior: + +- `tests/unit/` covers pure storage, mappers, catalog clients, codecs, and service helpers. +- `tests/integration/` covers API routes and workflow-level mutations against fake homes. +- `tests/support/` owns shared harness, filesystem, and app fixture utilities. +- `tests/fixtures/` stores representative payloads used by backend tests. + +Frontend tests live beside the component, screen, or model they protect. Shared frontend render, fetch, and DTO builders live under `frontend/src/test/` so feature tests can avoid rebuilding providers and common API payloads. diff --git a/tests/integration/test_cli_marketplace_api.py b/tests/integration/test_cli_marketplace_api.py new file mode 100644 index 0000000..ea0306a --- /dev/null +++ b/tests/integration/test_cli_marketplace_api.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import unittest + +from skill_manager.application.cli_marketplace import CliMarketplaceCatalog +from skill_manager.application.marketplace_cache import MarketplaceCache +from tests.support.app_harness import AppTestHarness + + +_FIXTURE_CLIS: dict[str, object] = { + "clis": [ + { + "slug": "ollama", + "name": "Ollama", + "description": "Run local models.", + "long_description": "Run local models from a CLI.", + "install": "brew install ollama", + "github": "https://github.com/ollama/ollama", + "website": "https://ollama.com", + "stars": 120000, + "language": "Go", + "category": "AI", + "has_mcp": True, + "is_official": True, + }, + { + "slug": "lazygit", + "name": "lazygit", + "description": "Terminal UI for git.", + "github": "https://github.com/jesseduffield/lazygit", + "has_skill": True, + "is_tui": True, + }, + ] +} + + +def _fixture_catalog() -> CliMarketplaceCatalog: + def fetcher(path: str) -> dict[str, object]: + if path == "/api/clis": + return _FIXTURE_CLIS + if path.startswith("/api/search?q="): + return {"clis": [_FIXTURE_CLIS["clis"][1]]} + raise AssertionError(path) + + return CliMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + +class CliMarketplaceApiTests(unittest.TestCase): + def test_cli_marketplace_popular_returns_preview_page(self) -> None: + with AppTestHarness(cli_marketplace=_fixture_catalog()) as harness: + payload = harness.get_json("/api/marketplace/clis/popular?limit=1&offset=0") + + self.assertEqual(payload["items"][0]["id"], "clisdev:ollama") + self.assertEqual(payload["items"][0]["marketplaceUrl"], "https://clis.dev/cli/ollama") + self.assertEqual(payload["items"][0]["githubUrl"], "https://github.com/ollama/ollama") + self.assertEqual(payload["items"][0]["iconUrl"], "https://github.com/ollama.png?size=96") + self.assertEqual(payload["nextOffset"], 1) + self.assertTrue(payload["hasMore"]) + + def test_cli_marketplace_search_paginates_from_search_response(self) -> None: + with AppTestHarness(cli_marketplace=_fixture_catalog()) as harness: + payload = harness.get_json("/api/marketplace/clis/search?q=git&limit=10&offset=0") + + self.assertEqual([item["slug"] for item in payload["items"]], ["lazygit"]) + self.assertFalse(payload["hasMore"]) + + def test_cli_marketplace_search_rejects_short_queries(self) -> None: + with AppTestHarness(cli_marketplace=_fixture_catalog()) as harness: + payload = harness.get_json("/api/marketplace/clis/search?q=g", expected_status=400) + + self.assertIn("Enter at least 2 characters", payload["error"]) + + def test_cli_marketplace_detail_resolves_from_index(self) -> None: + with AppTestHarness(cli_marketplace=_fixture_catalog()) as harness: + payload = harness.get_json("/api/marketplace/clis/items/ollama") + + self.assertEqual(payload["slug"], "ollama") + self.assertEqual(payload["longDescription"], "Run local models from a CLI.") + self.assertEqual(payload["installCommand"], "brew install ollama") + + def test_cli_marketplace_detail_returns_404_for_unknown_slug(self) -> None: + with AppTestHarness(cli_marketplace=_fixture_catalog()) as harness: + payload = harness.get_json("/api/marketplace/clis/items/missing", expected_status=404) + + self.assertIn("unknown CLI", payload["error"]) + + def test_cli_marketplace_does_not_add_management_routes(self) -> None: + with AppTestHarness(cli_marketplace=_fixture_catalog()) as harness: + payload = harness.post_json("/api/clis/install", {}, expected_status=405) + + self.assertIn("Method Not Allowed", payload.get("error", payload.get("detail", ""))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_http_api.py b/tests/integration/test_http_api.py index f5ca781..5f6b5ef 100644 --- a/tests/integration/test_http_api.py +++ b/tests/integration/test_http_api.py @@ -17,7 +17,7 @@ def test_empty_fixture_returns_skills_settings_and_health(self) -> None: settings = harness.get_json("/api/settings") self.assertTrue(health["ok"]) - self.assertEqual(skills["summary"]["managed"], 0) + self.assertEqual(skills["summary"], {"managed": 0, "unmanaged": 0}) self.assertEqual(skills["rows"], []) self.assertEqual(len(settings["harnesses"]), 5) openclaw = next(item for item in settings["harnesses"] if item["harness"] == "openclaw") @@ -35,7 +35,7 @@ def test_health_skills_and_settings_work_without_openclaw_state(self) -> None: settings = harness.get_json("/api/settings") self.assertTrue(health["ok"]) - self.assertEqual(skills["summary"]["managed"], 0) + self.assertEqual(skills["summary"], {"managed": 0, "unmanaged": 0}) self.assertEqual(skills["rows"], []) openclaw = next(item for item in settings["harnesses"] if item["harness"] == "openclaw") self.assertFalse(openclaw["installed"]) @@ -63,14 +63,15 @@ def test_mixed_fixture_returns_skills_page_and_detail(self) -> None: skills = harness.get_json("/api/skills") shared_audit = next(row for row in skills["rows"] if row["name"] == "Shared Audit") + trace_lens = next(row for row in skills["rows"] if row["name"] == "Trace Lens") detail = harness.get_json(f"/api/skills/{shared_audit['skillRef']}") source_status = harness.get_json(f"/api/skills/{shared_audit['skillRef']}/source-status") - review_helper = next(row for row in skills["rows"] if row["name"] == "Review Helper") - builtin_detail = harness.get_json(f"/api/skills/{review_helper['skillRef']}") - builtin_source_status = harness.get_json(f"/api/skills/{review_helper['skillRef']}/source-status") + self.assertEqual(skills["summary"], {"managed": 1, "unmanaged": 2}) self.assertEqual(shared_audit["displayStatus"], "Managed") - self.assertNotIn("isBuiltin", shared_audit) + self.assertEqual(shared_audit["actions"], {"canManage": False, "canStopManaging": False, "canDelete": True}) + self.assertEqual(trace_lens["displayStatus"], "Unmanaged") + self.assertEqual(trace_lens["actions"], {"canManage": True, "canStopManaging": False, "canDelete": False}) self.assertEqual(detail["displayStatus"], "Managed") self.assertEqual( [cell["label"] for cell in detail["harnessCells"]], @@ -91,24 +92,6 @@ def test_mixed_fixture_returns_skills_page_and_detail(self) -> None: "repoUrl": "https://github.com/mode-io/shared-audit", "folderUrl": None, }) - self.assertFalse(builtin_detail["actions"]["canDelete"]) - self.assertIsNone(builtin_source_status["updateStatus"]) - self.assertIsNone(builtin_detail["actions"]["stopManagingStatus"]) - self.assertEqual(builtin_detail["actions"]["stopManagingHarnessLabels"], []) - self.assertEqual(builtin_detail["actions"]["deleteHarnessLabels"], []) - self.assertEqual( - builtin_detail["harnessCells"], - [ - {"harness": "codex", "label": "Codex", "logoKey": "codex", "state": "empty", "interactive": False}, - {"harness": "claude", "label": "Claude", "logoKey": "claude", "state": "empty", "interactive": False}, - {"harness": "cursor", "label": "Cursor", "logoKey": "cursor", "state": "empty", "interactive": False}, - {"harness": "opencode", "label": "OpenCode", "logoKey": "opencode", "state": "builtin", "interactive": False}, - {"harness": "openclaw", "label": "OpenClaw", "logoKey": "openclaw", "state": "empty", "interactive": False}, - ], - ) - self.assertIsNone(builtin_detail["documentMarkdown"]) - self.assertNotIn("advanced", builtin_detail) - self.assertIsNone(builtin_detail["sourceLinks"]) def test_managed_detail_returns_shared_store_location_before_tool_links(self) -> None: with AppTestHarness(fixture_factory=seed_managed_linked_fixture) as harness: diff --git a/tests/integration/test_mcp_routes.py b/tests/integration/test_mcp_routes.py new file mode 100644 index 0000000..58d52e4 --- /dev/null +++ b/tests/integration/test_mcp_routes.py @@ -0,0 +1,754 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from skill_manager.application.mcp.installers import McpInstallResult +from skill_manager.application.mcp.installers import SmitheryClientTarget +from skill_manager.application.mcp.mappers import get_mapper +from skill_manager.application.mcp.names import canonical_server_name +from skill_manager.application.mcp.stdio import parse_static_stdio_function +from skill_manager.application.mcp.store import McpServerSpec, McpSource + +from tests.support.app_harness import AppTestHarness + + +class FakeMcpMarketplace: + """In-memory marketplace stub returning a deterministic Exa-like server.""" + + def __init__( + self, + qualified_name: str = "exa", + config_schema: dict[str, object] | None = None, + *, + is_remote: bool = True, + deployment_url: str | None = "https://mcp.exa.ai", + connections: list[dict[str, object]] | None = None, + source_name: str | None = None, + ) -> None: + self.qualified_name = qualified_name + self._payload = { + "qualifiedName": qualified_name, + "displayName": "Exa Search" if qualified_name == "exa" else qualified_name.title(), + "description": "Search the web", + "iconUrl": None, + "isRemote": is_remote, + "deploymentUrl": deployment_url, + "connections": connections + if connections is not None + else [ + {"kind": "http", "deploymentUrl": deployment_url, "configSchema": config_schema} + ], + "tools": [], + "resources": [], + "prompts": [], + } + + def detail(self, qualified_name: str): + if qualified_name == self.qualified_name: + return self._payload + return None + + +class _Container: + """Wraps AppTestHarness and replaces the mcp marketplace catalog with a stub.""" + + def __init__( + self, + harness: AppTestHarness, + qualified_name: str = "exa", + config_schema: dict[str, object] | None = None, + *, + is_remote: bool = True, + deployment_url: str | None = "https://mcp.exa.ai", + connections: list[dict[str, object]] | None = None, + source_name: str | None = None, + ) -> None: + self.harness = harness + # Patch the in-memory mutation service to use the fake marketplace. + marketplace = FakeMcpMarketplace( + qualified_name, + config_schema, + is_remote=is_remote, + deployment_url=deployment_url, + connections=connections, + ) + harness.container.mcp_mutations.marketplace = marketplace + harness.container.mcp_mutations.install_provider = FakeMcpInstallProvider( + harness, + { + qualified_name: _source_spec_from_marketplace_payload( + marketplace._payload, # noqa: SLF001 - test stub data + qualified_name=qualified_name, + source_name=source_name, + ) + }, + ) + + +class FakeMcpInstallProvider: + def __init__(self, harness: AppTestHarness, specs: dict[str, McpServerSpec]) -> None: + self.harness = harness + self.specs = specs + + def install_targets(self) -> tuple[SmitheryClientTarget, ...]: + return ( + SmitheryClientTarget(harness="codex", smithery_client="codex", supported=True), + SmitheryClientTarget(harness="claude", smithery_client="claude-code", supported=True), + SmitheryClientTarget(harness="cursor", smithery_client="cursor", supported=True), + SmitheryClientTarget(harness="opencode", smithery_client="opencode", supported=True), + SmitheryClientTarget( + harness="openclaw", + smithery_client=None, + supported=False, + reason="Smithery does not provide an OpenClaw MCP installer target", + ), + ) + + def install(self, *, qualified_name: str, source_harness: str) -> McpInstallResult: + spec = self.specs[qualified_name] + if source_harness == "claude": + self._write_claude_code_project_scope(spec) + return McpInstallResult( + qualified_name=qualified_name, + source_harness=source_harness, + installer="fake", + stdout="", + stderr="", + ) + adapter = self.harness.container.mcp_read_models.find_adapter(source_harness) + if adapter is None: + raise AssertionError(f"missing test adapter for {source_harness}") + adapter.enable_server(spec) + return McpInstallResult( + qualified_name=qualified_name, + source_harness=source_harness, + installer="fake", + stdout="", + stderr="", + ) + + def _write_claude_code_project_scope(self, spec: McpServerSpec) -> None: + path = self.harness.spec.home / ".claude.json" + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + projects = payload.setdefault("projects", {}) + if not isinstance(projects, dict): + projects = {} + payload["projects"] = projects + project_key = str(self.harness.spec.home.resolve()) + project = projects.setdefault(project_key, {}) + if not isinstance(project, dict): + project = {} + projects[project_key] = project + servers = project.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + servers = {} + project["mcpServers"] = servers + servers[spec.name] = get_mapper("claude-code").spec_to_dict(spec) + path.write_text(json.dumps(payload), encoding="utf-8") + + +def _source_spec_from_marketplace_payload( + payload: dict[str, object], + *, + qualified_name: str, + source_name: str | None = None, +) -> McpServerSpec: + name = source_name or canonical_server_name(qualified_name) + connections = payload.get("connections") + first = connections[0] if isinstance(connections, list) and connections else {} + first = first if isinstance(first, dict) else {} + kind = str(first.get("kind") or first.get("type") or "http").lower() + display_name = str(payload.get("displayName") or name) + if kind == "stdio": + stdio = parse_static_stdio_function(first.get("stdioFunction")) + if stdio is None: + raise AssertionError("test stdio fixture must include a static stdioFunction") + return McpServerSpec( + name=name, + display_name=display_name, + source=McpSource.marketplace(qualified_name), + transport="stdio", + command=stdio.command, + args=stdio.args, + ) + transport = "sse" if kind == "sse" else "http" + url = str(first.get("deploymentUrl") or payload.get("deploymentUrl") or "https://mcp.example") + return McpServerSpec( + name=name, + display_name=display_name, + source=McpSource.marketplace(qualified_name), + transport=transport, # type: ignore[arg-type] + url=url, + ) + + +def _install(harness: AppTestHarness, name: str = "exa") -> None: + harness.post_json("/api/mcp/servers", {"qualifiedName": name, "sourceHarness": "cursor"}) + + +def _seed_manual_remote(harness: AppTestHarness, name: str = "remote") -> None: + harness.container.mcp_store.upsert_from_spec( + McpServerSpec( + name=name, + display_name="Remote", + source=McpSource.manual(name), + transport="http", + url="https://mcp.example.com", + ) + ) + harness.container.mcp_read_models.invalidate() + + +class McpRoutesTests(unittest.TestCase): + def test_list_servers_starts_empty(self) -> None: + with AppTestHarness() as harness: + payload = harness.get_json("/api/mcp/servers") + assert isinstance(payload, dict) + self.assertEqual(payload.get("entries"), []) + # Columns reflect enabled harnesses (codex, claude, cursor, opencode, openclaw) + cols = [col["harness"] for col in payload["columns"]] + self.assertIn("codex", cols) + self.assertIn("claude", cols) + + def test_marketplace_install_targets_are_backend_owned(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + payload = harness.get_json("/api/marketplace/mcp/install-targets") + assert isinstance(payload, dict) + targets = {target["harness"]: target for target in payload["targets"]} + + self.assertEqual(targets["codex"]["smitheryClient"], "codex") + self.assertEqual(targets["claude"]["smitheryClient"], "claude-code") + self.assertEqual(targets["cursor"]["smitheryClient"], "cursor") + self.assertEqual(targets["opencode"]["smitheryClient"], "opencode") + self.assertTrue(targets["claude"]["supported"]) + self.assertFalse(targets["openclaw"]["supported"]) + self.assertEqual( + targets["openclaw"]["reason"], + "Smithery does not provide an OpenClaw MCP installer target", + ) + + def test_install_delegates_to_source_harness_then_imports_raw_spec(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + response = harness.post_json( + "/api/mcp/servers", {"qualifiedName": "exa", "sourceHarness": "cursor"} + ) + self.assertTrue(response["ok"]) + self.assertEqual(response["server"]["name"], "exa") + self.assertEqual(response["server"]["transport"], "http") + self.assertEqual(response["server"]["url"], "https://mcp.exa.ai") + + # Central manifest contains it. + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + names = [entry["name"] for entry in servers["entries"]] + self.assertIn("exa", names) + + # The source harness was written by the native installer; others are untouched. + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertEqual(cursor_cfg["mcpServers"]["exa"]["url"], "https://mcp.exa.ai") + self.assertFalse((harness.spec.home / ".claude.json").exists()) + self.assertFalse((harness.spec.home / ".codex" / "config.toml").exists()) + + def test_install_can_import_claude_code_project_scoped_config(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + response = harness.post_json( + "/api/mcp/servers", {"qualifiedName": "exa", "sourceHarness": "claude"} + ) + self.assertTrue(response["ok"]) + self.assertEqual(response["server"]["name"], "exa") + self.assertEqual(response["server"]["url"], "https://mcp.exa.ai") + + claude_cfg = json.loads((harness.spec.home / ".claude.json").read_text()) + self.assertNotIn("mcpServers", claude_cfg) + project = claude_cfg["projects"][str(harness.spec.home.resolve())] + self.assertEqual( + project["mcpServers"]["exa"]["url"], + "https://mcp.exa.ai", + ) + self.assertEqual(project["mcpServers"]["exa"]["type"], "http") + + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + entry = next(item for item in servers["entries"] if item["name"] == "exa") + states = {sighting["harness"]: sighting["state"] for sighting in entry["sightings"]} + self.assertEqual(states["claude"], "managed") + + def test_enable_writes_to_target_harness_only(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + _install(harness) + harness.post_json("/api/mcp/servers/exa/enable", {"harness": "claude"}) + + claude_cfg = harness.spec.home / ".claude.json" + self.assertTrue(claude_cfg.is_file()) + payload = json.loads(claude_cfg.read_text(encoding="utf-8")) + self.assertIn("exa", payload["mcpServers"]) + self.assertEqual(payload["mcpServers"]["exa"]["url"], "https://mcp.exa.ai") + + # Other harnesses untouched + self.assertFalse((harness.spec.home / ".codex" / "config.toml").exists()) + + def test_disable_removes_from_harness_but_keeps_central(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + _install(harness) + harness.post_json("/api/mcp/servers/exa/enable", {"harness": "cursor"}) + harness.post_json("/api/mcp/servers/exa/disable", {"harness": "cursor"}) + + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + self.assertTrue(cursor_cfg.is_file()) + payload = json.loads(cursor_cfg.read_text(encoding="utf-8")) + self.assertNotIn("exa", payload.get("mcpServers", {})) + + # Central retained + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + self.assertIn("exa", [e["name"] for e in servers["entries"]]) + + def test_set_harnesses_fan_out(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + _install(harness) + response = harness.post_json( + "/api/mcp/servers/exa/set-harnesses", {"target": "enabled"} + ) + self.assertTrue(response["ok"]) + # All five harnesses should have written + self.assertEqual(set(response["succeeded"]), {"codex", "claude", "opencode", "openclaw"}) + + # Verify each config file + self.assertTrue((harness.spec.home / ".cursor" / "mcp.json").is_file()) + self.assertTrue((harness.spec.home / ".claude.json").is_file()) + 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()) + + def test_uninstall_cleans_all_harnesses_and_central(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + _install(harness) + harness.post_json("/api/mcp/servers/exa/set-harnesses", {"target": "enabled"}) + + # urlopen with custom method — use AppTestHarness internals + from urllib.request import Request, urlopen + req = Request(f"{harness.base_url}/api/mcp/servers/exa", method="DELETE") + with urlopen(req) as resp: + payload = json.loads(resp.read().decode("utf-8")) + self.assertTrue(payload["ok"]) + + # Central gone + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + self.assertEqual(servers["entries"], []) + + # All harness files cleaned of the entry + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertNotIn("exa", cursor_cfg.get("mcpServers", {})) + + def test_install_unknown_qualified_name_returns_404(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "exa") + harness.post_json( + "/api/mcp/servers", + {"qualifiedName": "nonexistent", "sourceHarness": "cursor"}, + expected_status=404, + ) + + def test_marketplace_schema_metadata_does_not_change_observed_install(self) -> None: + schema = { + "type": "object", + "required": ["browserbaseApiKey"], + "properties": { + "browserbaseApiKey": { + "type": "string", + "description": "Browserbase API key", + "x-from": {"query": "browserbaseApiKey"}, + } + }, + } + with AppTestHarness() as harness: + _Container(harness, "browserbase", schema) + install = harness.post_json( + "/api/mcp/servers", + {"qualifiedName": "browserbase", "sourceHarness": "cursor"}, + ) + + self.assertTrue(install["ok"]) + self.assertEqual(install["server"]["name"], "browserbase") + self.assertEqual(install["server"]["url"], "https://mcp.exa.ai") + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertEqual( + cursor_cfg["mcpServers"]["browserbase"]["url"], + "https://mcp.exa.ai", + ) + + def test_install_stores_the_observed_source_harness_key(self) -> None: + with AppTestHarness() as harness: + _Container(harness, "@vendor/pkg", source_name="vendor-package") + + install = harness.post_json( + "/api/mcp/servers", + {"qualifiedName": "@vendor/pkg", "sourceHarness": "cursor"}, + ) + + self.assertEqual(install["server"]["name"], "vendor-package") + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + self.assertIn("vendor-package", [entry["name"] for entry in servers["entries"]]) + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertIn("vendor-package", cursor_cfg["mcpServers"]) + + def test_static_stdio_marketplace_install_can_enable(self) -> None: + with AppTestHarness() as harness: + _Container( + harness, + "desktop", + is_remote=False, + deployment_url=None, + connections=[ + { + "kind": "stdio", + "stdioFunction": "(config) => ({ command: 'npx', args: ['-y', '@acme/desktop'] })", + "configSchema": {"type": "object", "properties": {}}, + } + ], + ) + install = harness.post_json( + "/api/mcp/servers", + {"qualifiedName": "desktop", "sourceHarness": "cursor"}, + ) + self.assertEqual(install["server"]["transport"], "stdio") + + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + payload = cursor_cfg["mcpServers"]["desktop"] + self.assertEqual(payload["command"], "npx") + self.assertEqual(payload["args"], ["-y", "@acme/desktop"]) + + def test_get_unknown_server_returns_404(self) -> None: + with AppTestHarness() as harness: + harness.get_json("/api/mcp/servers/missing", expected_status=404) + + # Identity-first unmanaged MCP flows and update compatibility ----------- + + def test_unmanaged_by_server_dedupes_identical_entries_across_harnesses(self) -> None: + with AppTestHarness() as harness: + # Seed identical `context7` entries in cursor AND claude. + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text( + json.dumps( + {"mcpServers": {"context7": {"command": "uvx", "args": ["context7-mcp"]}}} + ) + ) + claude_cfg = harness.spec.home / ".claude.json" + claude_cfg.write_text( + json.dumps( + {"mcpServers": {"context7": {"command": "uvx", "args": ["context7-mcp"]}}} + ) + ) + + response = harness.get_json("/api/mcp/unmanaged/by-server") + assert isinstance(response, dict) + servers = response["servers"] + self.assertEqual(len(servers), 1) + self.assertEqual(servers[0]["name"], "context7") + self.assertTrue(servers[0]["identical"]) + harnesses_seen = {s["harness"] for s in servers[0]["sightings"]} + self.assertEqual(harnesses_seen, {"cursor", "claude"}) + + def test_unmanaged_by_server_marks_differing_payloads(self) -> None: + with AppTestHarness() as harness: + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text( + json.dumps({"mcpServers": {"foo": {"url": "https://cursor.example"}}}) + ) + claude_cfg = harness.spec.home / ".claude.json" + claude_cfg.write_text( + json.dumps({"mcpServers": {"foo": {"url": "https://claude.example"}}}) + ) + response = harness.get_json("/api/mcp/unmanaged/by-server") + assert isinstance(response, dict) + self.assertFalse(response["servers"][0]["identical"]) + self.assertIsNone(response["servers"][0]["canonicalSpec"]) + + def test_unmanaged_by_server_returns_raw_preview_fields(self) -> None: + with AppTestHarness() as harness: + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text( + json.dumps( + { + "mcpServers": { + "secreted": { + "url": "https://api.example/mcp?api_key=live_secret_value", + "headers": {"Authorization": "Bearer live_secret_value"}, + }, + "secretenv": { + "command": "npx", + "args": ["-y", "secretenv"], + "env": {"EXA_API_KEY": "live_secret_value"}, + } + } + } + ) + ) + + response = harness.get_json("/api/mcp/unmanaged/by-server") + assert isinstance(response, dict) + encoded = json.dumps(response) + self.assertIn("live_secret_value", encoded) + servers = {server["name"]: server for server in response["servers"]} + remote = servers["secreted"] + self.assertIn("api_key=live_secret_value", remote["canonicalSpec"]["url"]) + self.assertEqual( + remote["sightings"][0]["spec"]["headers"]["Authorization"], + "Bearer live_secret_value", + ) + stdio = servers["secretenv"] + self.assertEqual(stdio["canonicalSpec"]["env"]["EXA_API_KEY"], "live_secret_value") + self.assertEqual(stdio["sightings"][0]["env"][0]["value"], "live_secret_value") + + def test_adopt_identical_promotes_all_harnesses_in_one_call(self) -> None: + with AppTestHarness() as harness: + payload = {"command": "uvx", "args": ["context7-mcp"]} + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text(json.dumps({"mcpServers": {"context7": payload}})) + claude_cfg = harness.spec.home / ".claude.json" + claude_cfg.write_text(json.dumps({"mcpServers": {"context7": payload}})) + + result = harness.post_json("/api/mcp/unmanaged/adopt", {"name": "context7"}) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + self.assertEqual(set(result["succeeded"]), {"cursor", "claude"}) + + # Central store has the server. + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + self.assertIn("context7", [e["name"] for e in servers["entries"]]) + + def test_adopt_differing_without_source_harness_returns_409(self) -> None: + with AppTestHarness() as harness: + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text( + json.dumps({"mcpServers": {"foo": {"url": "https://a.example"}}}) + ) + claude_cfg = harness.spec.home / ".claude.json" + claude_cfg.write_text( + json.dumps({"mcpServers": {"foo": {"url": "https://b.example"}}}) + ) + harness.post_json( + "/api/mcp/unmanaged/adopt", + {"name": "foo"}, + expected_status=409, + ) + + def test_adopt_differing_uses_selected_source_harness(self) -> None: + with AppTestHarness() as harness: + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text( + json.dumps({"mcpServers": {"foo": {"url": "https://cursor.example"}}}) + ) + claude_cfg = harness.spec.home / ".claude.json" + claude_cfg.write_text( + json.dumps({"mcpServers": {"foo": {"url": "https://claude.example"}}}) + ) + + result = harness.post_json( + "/api/mcp/unmanaged/adopt", + {"name": "foo", "sourceHarness": "claude"}, + ) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + self.assertEqual(result["server"]["url"], "https://claude.example") + + def test_adopt_silently_enriches_when_marketplace_match_exists(self) -> None: + from skill_manager.application.mcp.enrichment import MarketplaceLink + + with AppTestHarness() as harness: + payload = {"command": "uvx", "args": ["context7-mcp"]} + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.parent.mkdir(parents=True, exist_ok=True) + cursor_cfg.write_text(json.dumps({"mcpServers": {"context7": payload}})) + + # Seed enrichment cache with a marketplace link for "context7". + enrichment = harness.container.mcp_mutations.enrichment + assert enrichment is not None + enrichment._cache["context7"] = MarketplaceLink( # noqa: SLF001 + qualified_name="@upstash/context7", + display_name="Context7", + icon_url="https://icon.example/ctx7.png", + external_url="https://smithery.ai/server/@upstash/context7", + description="Docs MCP", + is_remote=False, + is_verified=True, + ) + enrichment._popular_warmed = True # noqa: SLF001 — skip network warm + + result = harness.post_json("/api/mcp/unmanaged/adopt", {"name": "context7"}) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + # Silent enrichment: displayName and source upgraded automatically. + self.assertEqual(result["server"]["displayName"], "Context7") + self.assertEqual(result["server"]["source"]["kind"], "marketplace") + self.assertEqual(result["server"]["source"]["locator"], "@upstash/context7") + + def test_disable_drifted_harness_removes_entry(self) -> None: + with AppTestHarness() as harness: + _seed_manual_remote(harness) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.write_text( + json.dumps({"mcpServers": {"remote": {"url": "https://hand-edited.example"}}}) + ) + result = harness.post_json("/api/mcp/servers/remote/disable", {"harness": "cursor"}) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + + cursor_payload = json.loads(cursor_cfg.read_text()) + self.assertNotIn("remote", cursor_payload.get("mcpServers", {})) + + def test_set_harnesses_disabled_removes_managed_and_different_configs(self) -> None: + with AppTestHarness() as harness: + _seed_manual_remote(harness) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "claude"}) + + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.write_text( + json.dumps({"mcpServers": {"remote": {"url": "https://hand-edited.example"}}}) + ) + + result = harness.post_json( + "/api/mcp/servers/remote/set-harnesses", + {"target": "disabled"}, + ) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + self.assertEqual(set(result["succeeded"]), {"cursor", "claude"}) + cursor_payload = json.loads(cursor_cfg.read_text()) + claude_payload = json.loads((harness.spec.home / ".claude.json").read_text()) + self.assertNotIn("remote", cursor_payload.get("mcpServers", {})) + self.assertNotIn("remote", claude_payload.get("mcpServers", {})) + + def test_uninstall_removes_managed_and_different_configs_before_manifest(self) -> None: + with AppTestHarness() as harness: + _seed_manual_remote(harness) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "claude"}) + + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.write_text( + json.dumps({"mcpServers": {"remote": {"url": "https://hand-edited.example"}}}) + ) + + result = harness.delete_json("/api/mcp/servers/remote") + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + self.assertEqual(set(result["succeeded"]), {"cursor", "claude"}) + + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + self.assertEqual(servers["entries"], []) + cursor_payload = json.loads(cursor_cfg.read_text()) + claude_payload = json.loads((harness.spec.home / ".claude.json").read_text()) + self.assertNotIn("remote", cursor_payload.get("mcpServers", {})) + self.assertNotIn("remote", claude_payload.get("mcpServers", {})) + + def test_uninstall_keeps_manifest_when_harness_removal_fails(self) -> None: + with AppTestHarness() as harness: + _seed_manual_remote(harness) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + adapter = harness.container.mcp_read_models.find_adapter("cursor") + assert adapter is not None + + def fail_disable(_name: str) -> None: + raise RuntimeError("write failed") + + adapter.disable_server = fail_disable # type: ignore[method-assign] + + result = harness.delete_json("/api/mcp/servers/remote") + assert isinstance(result, dict) + self.assertFalse(result["ok"]) + self.assertEqual(result["failed"][0]["harness"], "cursor") + + servers = harness.get_json("/api/mcp/servers") + assert isinstance(servers, dict) + self.assertIn("remote", [entry["name"] for entry in servers["entries"]]) + + def test_reconcile_managed_overwrites_different_entry_with_managed_config(self) -> None: + with AppTestHarness() as harness: + _seed_manual_remote(harness) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.write_text( + json.dumps({"mcpServers": {"remote": {"url": "https://hand-edited.example"}}}) + ) + result = harness.post_json( + "/api/mcp/servers/remote/reconcile", + {"sourceKind": "managed", "harnesses": ["cursor"]}, + ) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + + cursor_cfg = json.loads((harness.spec.home / ".cursor" / "mcp.json").read_text()) + self.assertEqual(cursor_cfg["mcpServers"]["remote"]["url"], "https://mcp.example.com") + + def test_reconcile_harness_config_replaces_managed_config_and_applies_to_current_bindings(self) -> None: + with AppTestHarness() as harness: + _seed_manual_remote(harness) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "cursor"}) + harness.post_json("/api/mcp/servers/remote/enable", {"harness": "claude"}) + + cursor_cfg = harness.spec.home / ".cursor" / "mcp.json" + cursor_cfg.write_text( + json.dumps({"mcpServers": {"remote": {"url": "https://hand-edited.example"}}}) + ) + result = harness.post_json( + "/api/mcp/servers/remote/reconcile", + {"sourceKind": "harness", "sourceHarness": "cursor"}, + ) + assert isinstance(result, dict) + self.assertTrue(result["ok"]) + self.assertEqual(result["server"]["url"], "https://hand-edited.example") + self.assertEqual(set(result["succeeded"]), {"cursor", "claude"}) + + detail = harness.get_json("/api/mcp/servers/remote") + assert isinstance(detail, dict) + self.assertEqual(detail["spec"]["url"], "https://hand-edited.example") + claude_cfg = json.loads((harness.spec.home / ".claude.json").read_text()) + self.assertEqual(claude_cfg["mcpServers"]["remote"]["url"], "https://hand-edited.example") + + def test_get_server_includes_env_annotations(self) -> None: + with AppTestHarness() as harness: + harness.container.mcp_store.upsert_from_spec( + McpServerSpec( + name="exa", + display_name="Exa", + source=McpSource.manual("exa"), + transport="stdio", + command="npx", + env=(("EXA_API_KEY", "long-secret-value-xxxx"),), + ) + ) + harness.container.mcp_read_models.invalidate() + detail = harness.get_json("/api/mcp/servers/exa") + assert isinstance(detail, dict) + env_rows = {row["key"]: row for row in detail["env"]} + self.assertEqual(env_rows["EXA_API_KEY"]["value"], "long-secret-value-xxxx") + self.assertFalse(env_rows["EXA_API_KEY"]["isEnvRef"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_marketplace_api.py b/tests/integration/test_skills_marketplace_api.py similarity index 95% rename from tests/integration/test_marketplace_api.py rename to tests/integration/test_skills_marketplace_api.py index bf70332..52253d2 100644 --- a/tests/integration/test_marketplace_api.py +++ b/tests/integration/test_skills_marketplace_api.py @@ -4,11 +4,11 @@ from tempfile import TemporaryDirectory import unittest -from skill_manager.application.marketplace import MarketplaceCatalog -from skill_manager.application.marketplace.client import SkillsShClient -from skill_manager.application.marketplace.models import RepoDisplayMetadata -from skill_manager.application.marketplace.skillssh import fetch_all_time_leaderboard, fetch_detail_page, search_skills -from skill_manager.application.source_fetch_service import SourceFetchService +from skill_manager.application.skills.marketplace import MarketplaceCatalog +from skill_manager.application.skills.marketplace.client import SkillsShClient +from skill_manager.application.skills.marketplace.models import RepoDisplayMetadata +from skill_manager.application.skills.marketplace.skillssh import fetch_all_time_leaderboard, fetch_detail_page, search_skills +from skill_manager.application.skills.source_fetch import SourceFetchService from skill_manager.errors import MARKETPLACE_UNAVAILABLE_MESSAGE, MutationError from skill_manager.sources import ResolvedGitHubSkill, github_owner_avatar_url from tests.support.app_harness import AppTestHarness @@ -76,7 +76,7 @@ def _fixture_catalog(env: dict[str, str], *, broken_repos: set[str] | None = Non ) -class MarketplaceApiTests(unittest.TestCase): +class SkillsMarketplaceApiTests(unittest.TestCase): def test_marketplace_popular_uses_https_fixture_when_trusted(self) -> None: with MarketplaceFixtureServer() as fixture: with AppTestHarness(marketplace=_fixture_catalog(fixture.env())) as harness: diff --git a/tests/integration/test_mutations.py b/tests/integration/test_skills_mutations.py similarity index 59% rename from tests/integration/test_mutations.py rename to tests/integration/test_skills_mutations.py index 8d42041..89bc59b 100644 --- a/tests/integration/test_mutations.py +++ b/tests/integration/test_skills_mutations.py @@ -3,16 +3,16 @@ from tempfile import TemporaryDirectory import unittest -from skill_manager.domain import fingerprint_package -from skill_manager.store import ManifestEntry +from skill_manager.application.skills.manifest import SkillStoreEntry +from skill_manager.application.skills.package import fingerprint_package from tests.support.app_harness import AppTestHarness -from tests.support.fake_home import seed_mixed_fixture, seed_shared_only_fixture, seed_skill_package, seed_store_manifest +from tests.support.fake_home import seed_shared_only_fixture, seed_skill_package, seed_store_manifest -def seed_custom_fixture(spec): +def seed_local_changes_fixture(spec): package_root = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "audit-skill", "Audit Skill", body="customized version", @@ -23,7 +23,7 @@ def seed_custom_fixture(spec): seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="audit-skill", declared_name="Audit Skill", source_kind="github", @@ -36,7 +36,7 @@ def seed_custom_fixture(spec): def seed_delete_fixture(spec): seed_shared_only_fixture(spec) - target = spec.shared_store_root / "shared-audit" + target = spec.skills_store_root / "shared-audit" for path in ( spec.codex_root / "shared-audit", spec.claude_root / "shared-audit", @@ -48,14 +48,14 @@ def seed_delete_fixture(spec): def seed_delete_preflight_failure_fixture(spec): seed_shared_only_fixture(spec) - target = spec.shared_store_root / "shared-audit" + target = spec.skills_store_root / "shared-audit" (spec.codex_root / "shared-audit").symlink_to(target) seed_skill_package(spec.claude_root, "shared-audit", "Shared Audit", body="local conflict") def seed_unmanage_fixture(spec): seed_shared_only_fixture(spec) - target = spec.shared_store_root / "shared-audit" + target = spec.skills_store_root / "shared-audit" for path in ( spec.codex_root / "shared-audit", spec.claude_root / "shared-audit", @@ -63,7 +63,7 @@ def seed_unmanage_fixture(spec): path.symlink_to(target) -class MutationTests(unittest.TestCase): +class SkillsMutationTests(unittest.TestCase): def test_enable_managed_skill_creates_symlink(self) -> None: with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: skills = harness.get_json("/api/skills") @@ -85,6 +85,114 @@ def test_disable_managed_skill_removes_symlink(self) -> None: self.assertTrue(result["ok"]) self.assertFalse((harness.spec.codex_root / "shared-audit").exists()) + def test_set_skill_harnesses_enables_every_live_harness(self) -> None: + with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: + skills = harness.get_json("/api/skills") + shared_entry = next(row for row in skills["rows"] if row["name"] == "Shared Audit") + + result = harness.post_json( + f"/api/skills/{shared_entry['skillRef']}/set-harnesses", + {"target": "enabled"}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(result["failed"], []) + self.assertGreater(len(result["succeeded"]), 0) + # Every reported harness flip ran through the sequential server-side + # fanout, so each one must now be symlinked without the old parallel race. + for harness_name in result["succeeded"]: + link_root = getattr(harness.spec, f"{harness_name}_root", None) + if link_root is None: + continue + self.assertTrue((link_root / "shared-audit").is_symlink(), harness_name) + + def test_set_skill_harnesses_disables_every_live_harness(self) -> None: + with AppTestHarness(fixture_factory=seed_delete_fixture) as harness: + skills = harness.get_json("/api/skills") + shared_entry = next(row for row in skills["rows"] if row["name"] == "Shared Audit") + + result = harness.post_json( + f"/api/skills/{shared_entry['skillRef']}/set-harnesses", + {"target": "disabled"}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(result["failed"], []) + self.assertGreater(len(result["succeeded"]), 0) + self.assertFalse((harness.spec.codex_root / "shared-audit").exists()) + self.assertFalse((harness.spec.claude_root / "shared-audit").exists()) + self.assertFalse((harness.spec.opencode_root / "shared-audit").exists()) + + def test_set_skill_harnesses_is_noop_when_already_at_target(self) -> None: + with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: + skills = harness.get_json("/api/skills") + shared_entry = next(row for row in skills["rows"] if row["name"] == "Shared Audit") + + # Fresh shared-only fixture has zero symlinks, so target=disabled is a no-op. + result = harness.post_json( + f"/api/skills/{shared_entry['skillRef']}/set-harnesses", + {"target": "disabled"}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(result["succeeded"], []) + self.assertEqual(result["failed"], []) + + def test_set_skill_harnesses_rejects_invalid_target(self) -> None: + with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: + skills = harness.get_json("/api/skills") + shared_entry = next(row for row in skills["rows"] if row["name"] == "Shared Audit") + + harness.post_json( + f"/api/skills/{shared_entry['skillRef']}/set-harnesses", + {"target": "sideways"}, + expected_status=422, + ) + + def test_set_skill_harnesses_only_targets_installed_harnesses(self) -> None: + """Bulk set-all must not write symlinks into folders no runtime reads. + + With only codex + claude CLIs available on PATH, enabling-all should + produce symlinks in those two managed roots only, regardless of how + many harnesses are supported in the catalog. + """ + with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: + # Simulate a machine that only has codex + claude installed by + # removing the other CLI stubs from the fake PATH. + for cli in ("cursor-agent", "opencode", "openclaw"): + stub = harness.spec.bin_dir / cli + if stub.exists(): + stub.unlink() + harness.container.skills_read_models.invalidate() + + skills = harness.get_json("/api/skills") + shared_entry = next(row for row in skills["rows"] if row["name"] == "Shared Audit") + + # Inventory columns still include every supported harness, but + # each column carries the honest `installed` flag. + installed_by_harness = {col["harness"]: col["installed"] for col in skills["harnessColumns"]} + self.assertTrue(installed_by_harness["codex"]) + self.assertTrue(installed_by_harness["claude"]) + self.assertFalse(installed_by_harness["cursor"]) + self.assertFalse(installed_by_harness["opencode"]) + self.assertFalse(installed_by_harness["openclaw"]) + + result = harness.post_json( + f"/api/skills/{shared_entry['skillRef']}/set-harnesses", + {"target": "enabled"}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual(result["failed"], []) + # Only installed harnesses should flip. + self.assertEqual(set(result["succeeded"]), {"codex", "claude"}) + self.assertTrue((harness.spec.codex_root / "shared-audit").is_symlink()) + self.assertTrue((harness.spec.claude_root / "shared-audit").is_symlink()) + # Uninstalled harness folders remain untouched. + self.assertFalse((harness.spec.cursor_root / "shared-audit").exists()) + self.assertFalse((harness.spec.opencode_root / "shared-audit").exists()) + self.assertFalse((harness.spec.openclaw_managed_root / "shared-audit").exists()) + def test_manage_skill_replaces_found_local_copy_with_managed_links(self) -> None: with AppTestHarness(mixed=True) as harness: skills = harness.get_json("/api/skills") @@ -130,13 +238,18 @@ def test_manage_unknown_skill_returns_404(self) -> None: result = harness.post_json("/api/skills/missing-ref/manage", expected_status=404) self.assertIn("unknown skill ref", result["error"]) - def test_update_refuses_custom_skill(self) -> None: - with AppTestHarness(fixture_factory=seed_custom_fixture) as harness: + def test_update_refuses_locally_modified_managed_skill(self) -> None: + with AppTestHarness(fixture_factory=seed_local_changes_fixture) as harness: skills = harness.get_json("/api/skills") audit = next(row for row in skills["rows"] if row["name"] == "Audit Skill") + detail = harness.get_json(f"/api/skills/{audit['skillRef']}") + source_status = harness.get_json(f"/api/skills/{audit['skillRef']}/source-status") result = harness.post_json(f"/api/skills/{audit['skillRef']}/update", expected_status=400) - self.assertIn("cannot be updated", result["error"]) + self.assertEqual(detail["displayStatus"], "Managed") + self.assertEqual(detail["attentionMessage"], "Local changes detected. Source updates are disabled.") + self.assertEqual(source_status["updateStatus"], "local_changes_detected") + self.assertEqual(result["error"], "Local changes detected. Source updates are disabled.") def test_unmanage_restores_real_local_copies_for_currently_enabled_harnesses(self) -> None: with AppTestHarness(fixture_factory=seed_unmanage_fixture) as harness: @@ -146,7 +259,7 @@ def test_unmanage_restores_real_local_copies_for_currently_enabled_harnesses(sel result = harness.post_json(f"/api/skills/{shared_entry['skillRef']}/unmanage") self.assertTrue(result["ok"]) - self.assertFalse((harness.spec.shared_store_root / "shared-audit").exists()) + self.assertFalse((harness.spec.skills_store_root / "shared-audit").exists()) self.assertTrue((harness.spec.codex_root / "shared-audit").is_dir()) self.assertFalse((harness.spec.codex_root / "shared-audit").is_symlink()) self.assertTrue((harness.spec.claude_root / "shared-audit").is_dir()) @@ -166,7 +279,7 @@ def test_unmanage_rejects_skills_with_no_enabled_harnesses(self) -> None: result = harness.post_json(f"/api/skills/{shared_entry['skillRef']}/unmanage", expected_status=400) self.assertIn("turn on at least one harness", result["error"]) - self.assertTrue((harness.spec.shared_store_root / "shared-audit").is_dir()) + self.assertTrue((harness.spec.skills_store_root / "shared-audit").is_dir()) def test_unmanage_refuses_to_touch_disabled_harness_bindings(self) -> None: with AppTestHarness(fixture_factory=seed_unmanage_fixture) as harness: @@ -178,19 +291,16 @@ def test_unmanage_refuses_to_touch_disabled_harness_bindings(self) -> None: self.assertIn("disabled harnesses still have bindings", result["error"]) self.assertTrue((harness.spec.codex_root / "shared-audit").is_symlink()) - self.assertTrue((harness.spec.shared_store_root / "shared-audit").is_dir()) + self.assertTrue((harness.spec.skills_store_root / "shared-audit").is_dir()) - def test_unmanage_rejects_unmanaged_and_builtin_skills(self) -> None: + def test_unmanage_rejects_unmanaged_skills(self) -> None: with AppTestHarness(mixed=True) as harness: skills = harness.get_json("/api/skills") unmanaged = next(row for row in skills["rows"] if row["name"] == "Trace Lens") - builtin = next(row for row in skills["rows"] if row["name"] == "Review Helper") unmanaged_result = harness.post_json(f"/api/skills/{unmanaged['skillRef']}/unmanage", expected_status=400) - builtin_result = harness.post_json(f"/api/skills/{builtin['skillRef']}/unmanage", expected_status=400) - self.assertIn("only managed or custom", unmanaged_result["error"]) - self.assertIn("only managed or custom", builtin_result["error"]) + self.assertIn("only managed shared-store skills can be moved back to unmanaged", unmanaged_result["error"]) def test_delete_managed_skill_removes_shared_package_and_all_links(self) -> None: with AppTestHarness(fixture_factory=seed_delete_fixture) as harness: @@ -200,7 +310,7 @@ def test_delete_managed_skill_removes_shared_package_and_all_links(self) -> None result = harness.post_json(f"/api/skills/{shared_entry['skillRef']}/delete") self.assertTrue(result["ok"]) - self.assertFalse((harness.spec.shared_store_root / "shared-audit").exists()) + self.assertFalse((harness.spec.skills_store_root / "shared-audit").exists()) self.assertFalse((harness.spec.codex_root / "shared-audit").exists()) self.assertFalse((harness.spec.claude_root / "shared-audit").exists()) self.assertFalse((harness.spec.opencode_root / "shared-audit").exists()) @@ -209,27 +319,24 @@ def test_delete_managed_skill_removes_shared_package_and_all_links(self) -> None refreshed = harness.get_json("/api/skills") self.assertNotIn(shared_entry["skillRef"], [row["skillRef"] for row in refreshed["rows"]]) - def test_delete_custom_skill_is_allowed(self) -> None: - with AppTestHarness(fixture_factory=seed_custom_fixture) as harness: + def test_delete_locally_modified_managed_skill_is_allowed(self) -> None: + with AppTestHarness(fixture_factory=seed_local_changes_fixture) as harness: skills = harness.get_json("/api/skills") audit = next(row for row in skills["rows"] if row["name"] == "Audit Skill") result = harness.post_json(f"/api/skills/{audit['skillRef']}/delete") self.assertTrue(result["ok"]) - self.assertFalse((harness.spec.shared_store_root / "audit-skill").exists()) + self.assertFalse((harness.spec.skills_store_root / "audit-skill").exists()) - def test_delete_rejects_unmanaged_and_builtin_skills(self) -> None: + def test_delete_rejects_unmanaged_skills(self) -> None: with AppTestHarness(mixed=True) as harness: skills = harness.get_json("/api/skills") unmanaged = next(row for row in skills["rows"] if row["name"] == "Trace Lens") - builtin = next(row for row in skills["rows"] if row["name"] == "Review Helper") unmanaged_result = harness.post_json(f"/api/skills/{unmanaged['skillRef']}/delete", expected_status=400) - builtin_result = harness.post_json(f"/api/skills/{builtin['skillRef']}/delete", expected_status=400) - self.assertIn("only managed or custom", unmanaged_result["error"]) - self.assertIn("only managed or custom", builtin_result["error"]) + self.assertIn("only managed shared-store skills can be deleted", unmanaged_result["error"]) def test_delete_refuses_to_touch_disabled_harness_bindings(self) -> None: with AppTestHarness(fixture_factory=seed_delete_fixture) as harness: @@ -240,7 +347,7 @@ def test_delete_refuses_to_touch_disabled_harness_bindings(self) -> None: result = harness.post_json(f"/api/skills/{shared_entry['skillRef']}/delete", expected_status=409) self.assertIn("disabled harnesses still have bindings", result["error"]) - self.assertTrue((harness.spec.shared_store_root / "shared-audit").is_dir()) + self.assertTrue((harness.spec.skills_store_root / "shared-audit").is_dir()) self.assertTrue((harness.spec.openclaw_managed_root / "shared-audit").exists()) def test_delete_aborts_before_mutation_when_any_target_is_real_directory(self) -> None: @@ -251,7 +358,7 @@ def test_delete_aborts_before_mutation_when_any_target_is_real_directory(self) - result = harness.post_json(f"/api/skills/{shared_entry['skillRef']}/delete", expected_status=409) self.assertIn("not a symlink", result["error"]) - self.assertTrue((harness.spec.shared_store_root / "shared-audit").is_dir()) + self.assertTrue((harness.spec.skills_store_root / "shared-audit").is_dir()) self.assertTrue((harness.spec.codex_root / "shared-audit").is_symlink()) self.assertTrue((harness.spec.claude_root / "shared-audit").is_dir()) diff --git a/tests/support/app_harness.py b/tests/support/app_harness.py index 1195dc9..1f3b2d5 100644 --- a/tests/support/app_harness.py +++ b/tests/support/app_harness.py @@ -9,8 +9,10 @@ from urllib.request import Request, urlopen from skill_manager.application import build_backend_container -from skill_manager.application.marketplace import MarketplaceCatalog -from skill_manager.application.source_fetch_service import SourceFetchService +from skill_manager.application.cli_marketplace import CliMarketplaceCatalog +from skill_manager.application.mcp.installers import McpInstallProvider +from skill_manager.application.skills.marketplace import MarketplaceCatalog +from skill_manager.application.skills.source_fetch import SourceFetchService from skill_manager.runtime.server import serve_in_thread from .fake_home import FakeHomeSpec, create_fake_home_spec, seed_mixed_fixture @@ -25,8 +27,10 @@ def __init__( seed_openclaw: bool = True, fixture_factory: Callable[[FakeHomeSpec], None] | None = None, marketplace: MarketplaceCatalog | None = None, + cli_marketplace: CliMarketplaceCatalog | None = None, env_overrides: dict[str, str] | None = None, source_fetcher: SourceFetchService | None = None, + mcp_install_provider: McpInstallProvider | None = None, ) -> None: self._tempdir = TemporaryDirectory(prefix="skill-manager-tests-") self.spec = create_fake_home_spec(Path(self._tempdir.name), seed_openclaw_state=seed_openclaw) @@ -42,16 +46,20 @@ def __init__( self.container = build_backend_container( active_env, marketplace_catalog=MarketplaceCatalog.from_environment(active_env, warm_on_init=False), + cli_marketplace_catalog=cli_marketplace, source_fetcher=source_fetcher, + mcp_install_provider=mcp_install_provider, ) else: self.container = build_backend_container( active_env, marketplace_catalog=marketplace, + cli_marketplace_catalog=cli_marketplace, source_fetcher=source_fetcher, + mcp_install_provider=mcp_install_provider, ) # Ensure tests exercising a custom catalog use the same read-model root. - self.container.read_models.invalidate() + self.container.skills_read_models.invalidate() self.server = serve_in_thread(self.container, frontend_dist=frontend_dist) self.base_url = self.server.base_url @@ -78,6 +86,12 @@ def post_json(self, path: str, body: object = None, *, expected_status: int = 20 def put_json(self, path: str, body: object = None, *, expected_status: int = 200) -> object: return self._send_json("PUT", path, body, expected_status=expected_status) + def patch_json(self, path: str, body: object = None, *, expected_status: int = 200) -> object: + return self._send_json("PATCH", path, body, expected_status=expected_status) + + def delete_json(self, path: str, *, expected_status: int = 200) -> object: + return self._send_json("DELETE", path, None, expected_status=expected_status) + def _send_json(self, method: str, path: str, body: object = None, *, expected_status: int = 200) -> object: data = json.dumps(body).encode("utf-8") if body is not None else b"" request = Request( diff --git a/tests/support/fake_home.py b/tests/support/fake_home.py index 76e4c3d..0e9963e 100644 --- a/tests/support/fake_home.py +++ b/tests/support/fake_home.py @@ -1,11 +1,14 @@ from __future__ import annotations from dataclasses import dataclass -import json from pathlib import Path -from skill_manager.domain import fingerprint_package -from skill_manager.store import ManifestEntry, StoreManifest, write_manifest +from skill_manager.application.skills.manifest import ( + SkillStoreEntry, + SkillStoreManifest, + write_skill_store_manifest, +) +from skill_manager.application.skills.package import fingerprint_package @dataclass(frozen=True) @@ -16,7 +19,7 @@ class FakeHomeSpec: xdg_data_home: Path @property - def shared_store_root(self) -> Path: + def skills_store_root(self) -> Path: return self.xdg_data_home / "skill-manager" / "shared" @property @@ -39,10 +42,6 @@ def cursor_root(self) -> Path: def opencode_root(self) -> Path: return self.xdg_config_home / "opencode" / "skills" - @property - def opencode_builtins(self) -> Path: - return self.xdg_config_home / "opencode" / "builtins.json" - @property def openclaw_home(self) -> Path: return self.home / ".openclaw" @@ -72,7 +71,7 @@ def create_fake_home_spec(root: Path, *, seed_openclaw_state: bool = True) -> Fa xdg_data_home=root / "data", ) for path in ( - spec.shared_store_root, + spec.skills_store_root, spec.codex_root, spec.codex_legacy_root, spec.claude_root, @@ -128,24 +127,22 @@ def seed_skill_package( return package_root -def seed_builtin_catalog(path: Path, items: list[dict[str, str]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"builtins": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def seed_store_manifest(spec: FakeHomeSpec, entries: list[ManifestEntry]) -> None: - write_manifest(spec.shared_store_root.parent / "manifest.json", StoreManifest(entries=tuple(entries))) +def seed_store_manifest(spec: FakeHomeSpec, entries: list[SkillStoreEntry]) -> None: + write_skill_store_manifest( + spec.skills_store_root.parent / "manifest.json", + SkillStoreManifest(entries=tuple(entries)), + ) def seed_malformed_shared_directory(spec: FakeHomeSpec, directory_name: str) -> None: - broken = spec.shared_store_root / directory_name + broken = spec.skills_store_root / directory_name broken.mkdir(parents=True, exist_ok=True) (broken / "notes.txt").write_text("missing SKILL.md", encoding="utf-8") def seed_mixed_fixture(spec: FakeHomeSpec) -> None: shared_audit = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "shared-audit", "Shared Audit", body="Shared package fixture.", @@ -154,7 +151,7 @@ def seed_mixed_fixture(spec: FakeHomeSpec) -> None: seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="shared-audit", declared_name="Shared Audit", source_kind="github", @@ -168,10 +165,6 @@ def seed_mixed_fixture(spec: FakeHomeSpec) -> None: seed_skill_package(spec.codex_legacy_root, "trace-lens", "Trace Lens", body="trace", support_files=shared_support) seed_skill_package(spec.claude_root, "trace-lens-copy", "Trace Lens", body="trace", support_files=shared_support) seed_skill_package(spec.opencode_root, "policy-kit", "Policy Kit", body="opencode policy") - seed_builtin_catalog( - spec.opencode_builtins, - [{"id": "builtin-opencode-review", "name": "Review Helper", "detail": "Bundled with OpenCode"}], - ) seed_malformed_shared_directory(spec, "broken-shared") @@ -198,7 +191,7 @@ def seed_divergent_source_fixture(spec: FakeHomeSpec) -> None: def seed_shared_only_fixture(spec: FakeHomeSpec) -> None: shared_audit = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "shared-audit", "Shared Audit", body="Shared package fixture.", @@ -206,7 +199,7 @@ def seed_shared_only_fixture(spec: FakeHomeSpec) -> None: seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="shared-audit", declared_name="Shared Audit", source_kind="github", @@ -219,7 +212,7 @@ def seed_shared_only_fixture(spec: FakeHomeSpec) -> None: def seed_managed_linked_fixture(spec: FakeHomeSpec) -> None: seed_shared_only_fixture(spec) - target = spec.shared_store_root / "shared-audit" + target = spec.skills_store_root / "shared-audit" codex_link = spec.codex_root / "shared-audit" codex_link.symlink_to(target) diff --git a/tests/support/marketplace_fixture.py b/tests/support/marketplace_fixture.py index f4e33ab..0b6cae5 100644 --- a/tests/support/marketplace_fixture.py +++ b/tests/support/marketplace_fixture.py @@ -3,11 +3,11 @@ from pathlib import Path from tempfile import mkdtemp -from skill_manager.application.marketplace import MarketplaceCatalog -from skill_manager.application.marketplace.cache import MarketplaceCache -from skill_manager.application.marketplace.models import SkillsShSkill -from skill_manager.application.marketplace.repo_snapshots import GitHubRepoSnapshotService -from skill_manager.application.marketplace.resolver import DetailEnrichment, GitHubSkillResolver +from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.application.skills.marketplace import MarketplaceCatalog +from skill_manager.application.skills.marketplace.models import SkillsShSkill +from skill_manager.application.skills.marketplace.repo_snapshots import GitHubRepoSnapshotService +from skill_manager.application.skills.marketplace.resolver import DetailEnrichment, GitHubSkillResolver from skill_manager.sources import GitHubRepoMetadata, GitHubRepoMetadataClient from tests.support.marketplace_payloads import FIXTURE_FOLDER_URLS, FIXTURE_SKILLS diff --git a/tests/unit/test_adapters.py b/tests/unit/test_adapters.py deleted file mode 100644 index f7c2c9d..0000000 --- a/tests/unit/test_adapters.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from tempfile import TemporaryDirectory -import unittest - -from skill_manager.harness import create_default_drivers - -from tests.support.fake_home import create_fake_home_spec, seed_skill_package - - -class AdapterTests(unittest.TestCase): - def test_default_adapters_report_installation_and_global_skill_discovery(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - seed_skill_package(spec.codex_legacy_root, "trace-lens", "Trace Lens") - seed_skill_package(spec.openclaw_managed_root, "watch", "Workspace Watch") - - drivers = create_default_drivers(spec.env()) - scans = {scan.harness: scan for scan in (driver.scan() for driver in drivers)} - statuses = {driver.harness: driver.status() for driver in drivers} - - self.assertTrue(scans["codex"].installed) - self.assertEqual(scans["codex"].skills[0].package.declared_name, "Trace Lens") - self.assertTrue(scans["claude"].installed) - self.assertEqual(scans["claude"].skills, ()) - self.assertTrue(scans["openclaw"].installed) - self.assertEqual([skill.package.declared_name for skill in scans["openclaw"].skills], ["Workspace Watch"]) - self.assertEqual(scans["openclaw"].builtins, ()) - self.assertEqual(statuses["openclaw"].locations[0].label, "Managed skills root") - self.assertEqual(statuses["openclaw"].locations[1].label, "Personal agent skills root") - - def test_missing_openclaw_cli_reports_not_installed_even_when_root_exists(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir), seed_openclaw_state=False) - - drivers = create_default_drivers(spec.env()) - scans = {scan.harness: scan for scan in (driver.scan() for driver in drivers)} - statuses = {driver.harness: driver.status() for driver in drivers} - - self.assertFalse(scans["openclaw"].installed) - self.assertEqual(scans["openclaw"].skills, ()) - self.assertFalse(statuses["openclaw"].installed) - self.assertEqual( - statuses["openclaw"].locations[0].path, - spec.openclaw_managed_root, - ) - - def test_installed_harness_remains_installed_when_canonical_root_is_missing(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - spec.codex_root.rmdir() - - drivers = create_default_drivers(spec.env()) - scans = {scan.harness: scan for scan in (driver.scan() for driver in drivers)} - statuses = {driver.harness: driver.status() for driver in drivers} - - self.assertTrue(scans["codex"].installed) - self.assertTrue(statuses["codex"].installed) - self.assertFalse(statuses["codex"].locations[0].present) - self.assertEqual(statuses["codex"].locations[0].path, spec.codex_root) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_atomic.py b/tests/unit/test_atomic.py index 7d480e3..b6b635c 100644 --- a/tests/unit/test_atomic.py +++ b/tests/unit/test_atomic.py @@ -6,7 +6,7 @@ from tempfile import TemporaryDirectory from unittest import mock -from skill_manager.store._atomic import atomic_write_text, file_lock +from skill_manager.atomic_files import atomic_write_text, file_lock class AtomicWriteTextTests(unittest.TestCase): diff --git a/tests/unit/test_application_service.py b/tests/unit/test_backend_container.py similarity index 84% rename from tests/unit/test_application_service.py rename to tests/unit/test_backend_container.py index f409ad6..0dd1d10 100644 --- a/tests/unit/test_application_service.py +++ b/tests/unit/test_backend_container.py @@ -5,9 +5,9 @@ import unittest from skill_manager.application import build_backend_container -from skill_manager.domain import fingerprint_package +from skill_manager.application.skills.manifest import SkillStoreEntry +from skill_manager.application.skills.package import fingerprint_package from skill_manager.sources import ResolvedGitHubSkill -from skill_manager.store import ManifestEntry from tests.support.fake_home import ( create_fake_home_spec, @@ -47,30 +47,41 @@ def resolve(self, locator: str, work_dir: Path) -> ResolvedGitHubSkill: ) -class BackendContainerTests(unittest.TestCase): - def test_list_skills_groups_identical_local_copies_and_preserves_builtins(self) -> None: +class BackendContainerServiceTests(unittest.TestCase): + def test_list_skills_groups_identical_local_copies_and_emits_two_public_statuses(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) seed_mixed_fixture(spec) container = build_backend_container(spec.env()) payload = container.skills_queries.list_skills() + self.assertEqual(payload["summary"], {"managed": 1, "unmanaged": 2}) + + shared_audit = next(row for row in payload["rows"] if row["name"] == "Shared Audit") trace_lens = next(row for row in payload["rows"] if row["name"] == "Trace Lens") + policy_kit = next(row for row in payload["rows"] if row["name"] == "Policy Kit") + + self.assertEqual(shared_audit["displayStatus"], "Managed") + self.assertEqual( + shared_audit["actions"], + {"canManage": False, "canStopManaging": False, "canDelete": True}, + ) self.assertEqual(trace_lens["displayStatus"], "Unmanaged") + self.assertEqual( + trace_lens["actions"], + {"canManage": True, "canStopManaging": False, "canDelete": False}, + ) self.assertEqual( {cell["harness"] for cell in trace_lens["cells"] if cell["state"] == "found"}, {"codex", "claude", "opencode"}, ) - - builtin = next(row for row in payload["rows"] if row["name"] == "Review Helper") - self.assertEqual(builtin["displayStatus"], "Built-in") - self.assertNotIn("isBuiltin", trace_lens) + self.assertEqual(policy_kit["displayStatus"], "Unmanaged") def test_detail_and_source_status_are_split(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) package_root = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "audit-skill", "Audit Skill", body="current managed version", @@ -81,7 +92,7 @@ def test_detail_and_source_status_are_split(self) -> None: seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="audit-skill", declared_name="Audit Skill", source_kind="github", @@ -100,10 +111,10 @@ def test_detail_and_source_status_are_split(self) -> None: assert detail is not None assert source_status is not None - self.assertEqual(detail["displayStatus"], "Custom") - self.assertEqual(detail["attentionMessage"], "Modified locally; source updates are disabled.") + self.assertEqual(detail["displayStatus"], "Managed") + self.assertEqual(detail["attentionMessage"], "Local changes detected. Source updates are disabled.") self.assertNotIn("updateStatus", detail["actions"]) - self.assertEqual(source_status["updateStatus"], "no_source_available") + self.assertEqual(source_status["updateStatus"], "local_changes_detected") self.assertEqual(detail["actions"]["stopManagingStatus"], "disabled_no_enabled") self.assertEqual(detail["actions"]["stopManagingHarnessLabels"], []) @@ -135,7 +146,7 @@ def test_settings_surface_store_issues(self) -> None: self.assertNotIn("centralStore", settings) self.assertNotIn("topology", settings) - def test_skill_detail_exposes_document_markdown_for_local_and_shared_skills(self) -> None: + def test_skill_detail_exposes_document_markdown_for_shared_and_unmanaged_skills(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) seed_mixed_fixture(spec) @@ -144,28 +155,22 @@ def test_skill_detail_exposes_document_markdown_for_local_and_shared_skills(self payload = container.skills_queries.list_skills() shared = next(row for row in payload["rows"] if row["name"] == "Shared Audit") found = next(row for row in payload["rows"] if row["name"] == "Trace Lens") - builtin = next(row for row in payload["rows"] if row["name"] == "Review Helper") shared_detail = container.skills_queries.get_skill_detail(shared["skillRef"]) found_detail = container.skills_queries.get_skill_detail(found["skillRef"]) - builtin_detail = container.skills_queries.get_skill_detail(builtin["skillRef"]) assert shared_detail is not None assert found_detail is not None - assert builtin_detail is not None self.assertIn("Shared package fixture.", shared_detail["documentMarkdown"]) self.assertIn("trace", found_detail["documentMarkdown"]) - self.assertIsNone(builtin_detail["documentMarkdown"]) self.assertNotIn("advanced", shared_detail) self.assertEqual(shared_detail["actions"]["stopManagingStatus"], "disabled_no_enabled") self.assertEqual(shared_detail["actions"]["stopManagingHarnessLabels"], []) self.assertIsNone(found_detail["actions"]["stopManagingStatus"]) - self.assertIsNone(builtin_detail["actions"]["stopManagingStatus"]) - self.assertEqual( - [cell["state"] for cell in builtin_detail["harnessCells"]], - ["empty", "empty", "empty", "builtin", "empty"], - ) + self.assertEqual(found_detail["actions"]["stopManagingHarnessLabels"], ["Claude"]) + self.assertEqual(found_detail["actions"]["deleteHarnessLabels"], ["Claude"]) + self.assertIsNone(found_detail["sourceLinks"]) def test_skill_detail_orders_managed_locations_with_shared_store_first(self) -> None: with TemporaryDirectory() as temp_dir: @@ -180,7 +185,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(detail["locations"][0]["path"], str(spec.shared_store_root / "shared-audit")) + 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") self.assertEqual(detail["actions"]["stopManagingHarnessLabels"], ["Codex"]) @@ -189,7 +194,7 @@ def test_source_links_use_persisted_exact_folder_url(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) package_root = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "shared-audit", "Shared Audit", body="Shared package fixture.", @@ -199,7 +204,7 @@ def test_source_links_use_persisted_exact_folder_url(self) -> None: seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="shared-audit", declared_name="Shared Audit", source_kind="github", @@ -231,7 +236,7 @@ def test_source_links_fall_back_to_exact_github_resolution_for_legacy_entries(se with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) package_root = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "agent-browser", "agent-browser", body="Shared package fixture.", @@ -241,7 +246,7 @@ def test_source_links_fall_back_to_exact_github_resolution_for_legacy_entries(se seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="agent-browser", declared_name="agent-browser", source_kind="github", @@ -252,7 +257,7 @@ def test_source_links_fall_back_to_exact_github_resolution_for_legacy_entries(se ) container = build_backend_container(spec.env()) - container.source_fetcher._github = _StaticGitHubSource( # type: ignore[assignment] + container.skills_source_fetcher._github = _StaticGitHubSource( # type: ignore[assignment] package_root, repo="vercel-labs/agent-browser", ref="main", @@ -274,7 +279,7 @@ def test_marketplace_queries_mark_matching_managed_source_as_installed(self) -> with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) package_root = seed_skill_package( - spec.shared_store_root, + spec.skills_store_root, "mode-switch", "Mode Switch", body="Managed package fixture.", @@ -284,7 +289,7 @@ def test_marketplace_queries_mark_matching_managed_source_as_installed(self) -> seed_store_manifest( spec, [ - ManifestEntry( + SkillStoreEntry( package_dir="mode-switch", declared_name="Mode Switch", source_kind="github", @@ -299,10 +304,10 @@ def test_marketplace_queries_mark_matching_managed_source_as_installed(self) -> marketplace_catalog=create_fixture_marketplace_service(), ) - page = container.marketplace_queries.popular_page() + page = container.skills_marketplace_queries.popular_page() item = next(row for row in page["items"] if row["name"] == "Mode Switch") - detail = container.marketplace_queries.get_item_detail(item["id"]) - document = container.marketplace_queries.get_item_document(item["id"]) + detail = container.skills_marketplace_queries.get_item_detail(item["id"]) + document = container.skills_marketplace_queries.get_item_document(item["id"]) self.assertEqual(item["installation"], { "status": "installed", diff --git a/tests/unit/test_cli_marketplace.py b/tests/unit/test_cli_marketplace.py new file mode 100644 index 0000000..5d6dfcc --- /dev/null +++ b/tests/unit/test_cli_marketplace.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import json +import unittest +from unittest import mock + +from skill_manager.application.cli_marketplace.catalog import CliMarketplaceCatalog +from skill_manager.application.cli_marketplace.client import ( + ClisDevClient, + configured_clis_dev_base_url, +) +from skill_manager.application.marketplace_cache import MarketplaceCache + + +_LIST_RESPONSE_SAMPLE: dict[str, object] = { + "count": 4, + "clis": [ + { + "slug": "ollama", + "name": "Ollama", + "description": "Run local models.", + "long_description": "Run and manage local language models.\n\nWorks offline.", + "category": "AI", + "install": "brew install ollama", + "github": "https://github.com/ollama/ollama", + "website": "https://ollama.com", + "stars": 120000, + "language": "Go", + "has_mcp": True, + "has_skill": False, + "is_official": True, + "is_tui": False, + "source_type": "official", + "vendor_name": "Ollama", + }, + { + "slug": "lazygit", + "name": "lazygit", + "description": "Terminal UI for git.", + "category": "Developer Tools", + "install": "brew install lazygit", + "github": "https://github.com/jesseduffield/lazygit/tree/master", + "website": "https://github.com/jesseduffield/lazygit", + "stars": "61000", + "language": "Go", + "has_mcp": False, + "has_skill": True, + "is_official": False, + "is_tui": True, + "source_type": "github", + "vendor_name": "jesseduffield", + }, + { + "slug": "broken-repo", + "name": "Broken repo", + "description": "No stable repo URL.", + "github": "https://example.com/not/github", + "source_url": "https://docs.example.com/broken-repo", + }, + { + "slug": "no-repo", + "name": "No repo", + "long_description": "Fallback summary.\n\nDetailed body.", + "github": "notaurl", + }, + ], +} + + +class ClisDevClientTests(unittest.TestCase): + def test_base_url_override_is_normalized(self) -> None: + self.assertEqual( + configured_clis_dev_base_url({"SKILL_MANAGER_CLIS_DEV_BASE_URL": "https://fixture.local/"}), + "https://fixture.local", + ) + + def test_fetches_list_endpoint(self) -> None: + response = mock.MagicMock() + response.read.return_value = json.dumps({"clis": []}).encode("utf-8") + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=None) + + with mock.patch("skill_manager.application.cli_marketplace.client.urlopen", return_value=response) as urlopen: + client = ClisDevClient(base_url="https://fixture.local", ssl_context=None) + payload = client.list_clis() + + request = urlopen.call_args.args[0] + self.assertEqual(request.full_url, "https://fixture.local/api/clis") + self.assertEqual(request.headers["Accept"], "application/json") + self.assertEqual(payload, {"clis": []}) + + def test_search_endpoint_encodes_query(self) -> None: + response = mock.MagicMock() + response.read.return_value = json.dumps({"clis": []}).encode("utf-8") + response.__enter__ = mock.Mock(return_value=response) + response.__exit__ = mock.Mock(return_value=None) + + with mock.patch("skill_manager.application.cli_marketplace.client.urlopen", return_value=response) as urlopen: + client = ClisDevClient(base_url="https://fixture.local", ssl_context=None) + client.search_clis("git ui") + + request = urlopen.call_args.args[0] + self.assertEqual(request.full_url, "https://fixture.local/api/search?q=git%20ui") + + +class CliMarketplaceCatalogTests(unittest.TestCase): + def test_popular_page_normalizes_and_paginates_locally(self) -> None: + catalog = CliMarketplaceCatalog( + fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, + cache=MarketplaceCache(), + ) + + page = catalog.popular_page(limit=2, offset=1) + + self.assertEqual([item["slug"] for item in page["items"]], ["lazygit", "broken-repo"]) + self.assertTrue(page["hasMore"]) + self.assertEqual(page["nextOffset"], 3) + first = page["items"][0] + self.assertEqual(first["id"], "clisdev:lazygit") + self.assertEqual(first["githubUrl"], "https://github.com/jesseduffield/lazygit") + self.assertEqual(first["iconUrl"], "https://github.com/jesseduffield.png?size=96") + self.assertEqual(first["stars"], 61000) + self.assertTrue(first["isTui"]) + self.assertTrue(first["hasSkill"]) + + def test_invalid_github_url_is_omitted(self) -> None: + catalog = CliMarketplaceCatalog( + fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, + cache=MarketplaceCache(), + ) + + detail = catalog.detail("broken-repo") + + self.assertIsNotNone(detail) + self.assertIsNone(detail["githubUrl"]) + self.assertIsNone(detail["iconUrl"]) + self.assertEqual(detail["websiteUrl"], "https://docs.example.com/broken-repo") + + def test_detail_resolves_clisdev_id_and_preview_fields(self) -> None: + catalog = CliMarketplaceCatalog( + fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, + cache=MarketplaceCache(), + ) + + detail = catalog.detail("clisdev:ollama") + + self.assertIsNotNone(detail) + self.assertEqual(detail["slug"], "ollama") + self.assertEqual(detail["marketplaceUrl"], "https://clis.dev/cli/ollama") + self.assertEqual(detail["iconUrl"], "https://github.com/ollama.png?size=96") + self.assertEqual(detail["longDescription"], "Run and manage local language models.\n\nWorks offline.") + self.assertEqual(detail["installCommand"], "brew install ollama") + self.assertTrue(detail["isOfficial"]) + + def test_search_requires_minimum_query_and_uses_search_endpoint(self) -> None: + paths: list[str] = [] + + def fetcher(path: str) -> dict[str, object]: + paths.append(path) + return {"clis": [_LIST_RESPONSE_SAMPLE["clis"][1]]} + + catalog = CliMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + with self.assertRaises(ValueError): + catalog.search_page("g") + + page = catalog.search_page("git ui", limit=10, offset=0) + + self.assertEqual(paths, ["/api/search?q=git%20ui"]) + self.assertEqual(page["items"][0]["slug"], "lazygit") + self.assertFalse(page["hasMore"]) + + def test_detail_falls_back_to_search_when_index_misses(self) -> None: + def fetcher(path: str) -> dict[str, object]: + if path == "/api/clis": + return {"clis": []} + if path == "/api/search?q=lazygit": + return {"clis": [_LIST_RESPONSE_SAMPLE["clis"][1]]} + raise AssertionError(path) + + catalog = CliMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + + detail = catalog.detail("lazygit") + + self.assertIsNotNone(detail) + self.assertEqual(detail["slug"], "lazygit") + + def test_unknown_detail_returns_none(self) -> None: + catalog = CliMarketplaceCatalog( + fetcher=lambda _path: {"clis": []}, + cache=MarketplaceCache(), + ) + + self.assertIsNone(catalog.detail("missing")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_github_repo_metadata.py b/tests/unit/test_github_repo_metadata.py index a00f6ef..f11330a 100644 --- a/tests/unit/test_github_repo_metadata.py +++ b/tests/unit/test_github_repo_metadata.py @@ -6,8 +6,8 @@ import time import unittest -from skill_manager.application.marketplace.cache import MarketplaceCache -from skill_manager.application.marketplace.repo_snapshots import GitHubRepoSnapshotService +from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.application.skills.marketplace.repo_snapshots import GitHubRepoSnapshotService from skill_manager.sources import ( GitHubRepoMetadata, GitHubRepoMetadataClient, diff --git a/tests/unit/test_marketplace_client.py b/tests/unit/test_marketplace_client.py index a6c86ab..e455028 100644 --- a/tests/unit/test_marketplace_client.py +++ b/tests/unit/test_marketplace_client.py @@ -5,12 +5,12 @@ from unittest import mock import unittest -from skill_manager.application.marketplace.client import ( +from skill_manager.application.skills.marketplace.client import ( SkillsShClient, configured_marketplace_base_url, configured_marketplace_ca_file, ) -from skill_manager.application.marketplace.skillssh import fetch_all_time_leaderboard, search_skills +from skill_manager.application.skills.marketplace.skillssh import fetch_all_time_leaderboard, search_skills from skill_manager.errors import MARKETPLACE_UNAVAILABLE_MESSAGE, MarketplaceUpstreamError @@ -29,13 +29,13 @@ def test_ssl_cert_override_takes_precedence(self) -> None: def test_packaged_runtime_uses_certifi_when_no_override_exists(self) -> None: with ( - mock.patch("skill_manager.application.marketplace.client._is_packaged_runtime", return_value=True), - mock.patch("skill_manager.application.marketplace.client.certifi.where", return_value="/tmp/certifi-ca.pem"), + mock.patch("skill_manager.application.marketplace_http._is_packaged_runtime", return_value=True), + mock.patch("skill_manager.application.marketplace_http.certifi.where", return_value="/tmp/certifi-ca.pem"), ): self.assertEqual(str(configured_marketplace_ca_file({})), "/tmp/certifi-ca.pem") def test_source_runtime_uses_system_trust_when_no_override_exists(self) -> None: - with mock.patch("skill_manager.application.marketplace.client._is_packaged_runtime", return_value=False): + with mock.patch("skill_manager.application.marketplace_http._is_packaged_runtime", return_value=False): self.assertIsNone(configured_marketplace_ca_file({})) @@ -100,7 +100,7 @@ def test_fetch_json_maps_http_error_to_upstream_error(self) -> None: hdrs=None, fp=None, ) - with mock.patch("skill_manager.application.marketplace.client.urlopen", side_effect=http_error): + with mock.patch("skill_manager.application.skills.marketplace.client.urlopen", side_effect=http_error): with self.assertRaises(MarketplaceUpstreamError) as captured: client.fetch_json("/api/search?q=trace&limit=20") @@ -111,7 +111,7 @@ def test_fetch_json_maps_http_error_to_upstream_error(self) -> None: def test_fetch_text_maps_timeout_to_upstream_error(self) -> None: client = SkillsShClient(base_url="https://fixture.local") timeout_error = URLError(socket.timeout("timed out")) - with mock.patch("skill_manager.application.marketplace.client.urlopen", side_effect=timeout_error): + with mock.patch("skill_manager.application.skills.marketplace.client.urlopen", side_effect=timeout_error): with self.assertRaises(MarketplaceUpstreamError) as captured: client.fetch_text("/") diff --git a/tests/unit/test_mcp_adapters.py b/tests/unit/test_mcp_adapters.py new file mode 100644 index 0000000..2b73b1d --- /dev/null +++ b/tests/unit/test_mcp_adapters.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import json +import tomllib +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from skill_manager.application.mcp import FileBackedMcpAdapter +from skill_manager.application.mcp.store import McpServerSpec, McpServerStore, McpSource +from skill_manager.errors import MutationError +from skill_manager.harness import HarnessKernelService, HarnessSupportStore + + +def _spec(name: str = "exa") -> McpServerSpec: + return McpServerSpec( + name=name, + display_name=name.title(), + source=McpSource.marketplace(f"@user/{name}"), + transport="stdio", + command="npx", + args=("-y", f"{name}-mcp-server"), + env=(("KEY", "value"),), + ) + + +def _adapter( + harness: str, + *, + home: Path, + xdg_config_home: Path | None = None, +) -> FileBackedMcpAdapter: + env = { + "HOME": str(home), + "XDG_CONFIG_HOME": str(xdg_config_home or (home / ".config")), + "PATH": "", + } + kernel = HarnessKernelService.from_environment( + env, + support_store=HarnessSupportStore(home / "settings.json"), + ) + binding = next( + binding for binding in kernel.bindings_for_family("mcp") if binding.definition.harness == harness + ) + return FileBackedMcpAdapter( + definition=binding.definition, + profile=binding.profile, + context=kernel.context, + ) + + +class FileBackedMcpAdapterTests(unittest.TestCase): + def test_classifies_managed_when_content_matches(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + store.upsert_from_spec(_spec("exa")) + adapter = _adapter("cursor", home=home) + + adapter.enable_server(store.get_binding_spec("exa")) # type: ignore[arg-type] + scan = adapter.scan(store.list_binding_specs()) + + states = {entry.name: entry.state for entry in scan.entries} + self.assertEqual(states.get("exa"), "managed") + + def test_classifies_drifted_when_user_edits_entry(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + store.upsert_from_spec(_spec("exa")) + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + json.dumps( + {"mcpServers": {"exa": {"command": "npx", "args": ["different"]}}} + ), + encoding="utf-8", + ) + + scan = adapter.scan(store.list_binding_specs()) + states = {entry.name: entry.state for entry in scan.entries} + self.assertEqual(states.get("exa"), "drifted") + + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + store.upsert_from_spec(_spec("exa")) + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + json.dumps( + {"mcpServers": {"exa": {"headers": {"Authorization": "Bearer x"}}}} + ), + encoding="utf-8", + ) + + scan = adapter.scan(store.list_binding_specs()) + drifted = next(entry for entry in scan.entries if entry.name == "exa") + self.assertEqual(drifted.state, "drifted") + self.assertIsNotNone(drifted.parse_issue) + + def test_classifies_unmanaged_when_no_central_spec(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + json.dumps({"mcpServers": {"legacy-foo": {"command": "ls"}}}), + encoding="utf-8", + ) + + scan = adapter.scan(store.list_binding_specs()) + unmanaged = [entry for entry in scan.entries if entry.state == "unmanaged"] + self.assertEqual(len(unmanaged), 1) + self.assertEqual(unmanaged[0].name, "legacy-foo") + + def test_managed_spec_with_no_binding_is_missing(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + store.upsert_from_spec(_spec("exa")) + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text(json.dumps({"mcpServers": {}}), encoding="utf-8") + + scan = adapter.scan(store.list_binding_specs()) + states = {entry.name: entry.state for entry in scan.entries} + self.assertEqual(states.get("exa"), "missing") + + def test_enable_preserves_non_mcp_keys_for_json(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + json.dumps( + { + "models": ["gpt-5"], + "mcpServers": {"existing": {"command": "ls"}}, + } + ), + encoding="utf-8", + ) + + adapter.enable_server(_spec()) + payload = json.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload["models"], ["gpt-5"]) + self.assertIn("existing", payload["mcpServers"]) + self.assertIn("exa", payload["mcpServers"]) + + def test_enable_uses_opencode_nested_subtree(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + xdg_config_home = home / ".config" + store = McpServerStore(home / "manifest.json") + adapter = _adapter("opencode", home=home, xdg_config_home=xdg_config_home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + json.dumps( + { + "models": ["x"], + "mcp": {"other": {"type": "local", "command": ["ls"]}}, + } + ), + encoding="utf-8", + ) + + adapter.enable_server(_spec()) + payload = json.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload["models"], ["x"]) + self.assertIn("other", payload["mcp"]) + self.assertIn("exa", payload["mcp"]) + self.assertEqual(payload["mcp"]["exa"]["type"], "local") + + def test_enable_and_disable_round_trip_for_toml(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + adapter = _adapter("codex", home=home) + + adapter.enable_server(_spec()) + payload = tomllib.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload["mcp_servers"]["exa"]["command"], "npx") + self.assertNotIn("transport", payload["mcp_servers"]["exa"]) + + adapter.disable_server("exa") + payload = tomllib.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload.get("mcp_servers", {}), {}) + + def test_cursor_writes_explicit_type_for_stdio_and_http(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("cursor", home=home) + + adapter.enable_server(_spec()) + payload = json.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload["mcpServers"]["exa"]["type"], "stdio") + + adapter.enable_server( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.marketplace("@remote/server"), + transport="http", + url="https://mcp.example.com", + ) + ) + payload = json.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload["mcpServers"]["remote"]["type"], "http") + + def test_claude_writes_explicit_type_for_http(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("claude", home=home) + + adapter.enable_server( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.marketplace("@remote/server"), + transport="http", + url="https://mcp.example.com", + ) + ) + payload = json.loads(adapter.config_path.read_text(encoding="utf-8")) + self.assertEqual(payload["mcpServers"]["remote"]["type"], "http") + + def test_enable_removes_opencode_duplicate_from_xdg_config(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + xdg_config_home = home / ".config" + adapter = _adapter("opencode", home=home, xdg_config_home=xdg_config_home) + official_path = xdg_config_home / "opencode" / "opencode.json" + official_path.parent.mkdir(parents=True, exist_ok=True) + official_path.write_text( + json.dumps( + { + "mcp": { + "exa": { + "type": "remote", + "url": "https://old.example.com", + } + } + } + ), + encoding="utf-8", + ) + + adapter.enable_server(_spec()) + + canonical = json.loads(adapter.config_path.read_text(encoding="utf-8")) + official = json.loads(official_path.read_text(encoding="utf-8")) + self.assertIn("exa", canonical["mcp"]) + self.assertNotIn("mcp", official) + + def test_disable_removes_opencode_from_all_discovery_paths(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + xdg_config_home = home / ".config" + adapter = _adapter("opencode", home=home, xdg_config_home=xdg_config_home) + adapter.enable_server(_spec()) + official_path = xdg_config_home / "opencode" / "opencode.json" + official_path.parent.mkdir(parents=True, exist_ok=True) + official_path.write_text( + json.dumps({"mcp": {"exa": {"type": "local", "command": ["npx"]}}}), + encoding="utf-8", + ) + + adapter.disable_server("exa") + + canonical = json.loads(adapter.config_path.read_text(encoding="utf-8")) + official = json.loads(official_path.read_text(encoding="utf-8")) + self.assertNotIn("mcp", canonical) + self.assertNotIn("mcp", official) + + def test_openclaw_without_mcp_command_is_not_writable(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("openclaw", home=home) + + status = adapter.status() + self.assertFalse(status.mcp_writable) + self.assertIn("OpenClaw", status.mcp_unavailable_reason or "") + with self.assertRaises(MutationError): + adapter.enable_server(_spec()) + + def test_has_binding_after_enable(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + adapter = _adapter("cursor", home=home) + + self.assertFalse(adapter.has_binding("exa")) + adapter.enable_server(_spec()) + self.assertTrue(adapter.has_binding("exa")) + + def test_claude_scans_smithery_project_scoped_servers(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + store.upsert_from_spec( + McpServerSpec( + name="exa", + display_name="Exa", + source=McpSource.marketplace("exa"), + transport="http", + url="https://server.smithery.ai/exa/mcp", + ) + ) + adapter = _adapter("claude", home=home) + adapter.config_path.write_text( + json.dumps( + { + "projects": { + str(home.resolve()): { + "mcpServers": { + "exa": {"type": "http", "url": "https://server.smithery.ai/exa/mcp"} + } + } + } + } + ), + encoding="utf-8", + ) + + scan = adapter.scan(store.list_binding_specs()) + states = {entry.name: entry.state for entry in scan.entries} + self.assertEqual(states.get("exa"), "managed") + self.assertTrue(adapter.has_binding("exa")) + + def test_claude_disable_removes_project_scoped_servers(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("claude", home=home) + adapter.config_path.write_text( + json.dumps( + { + "projects": { + str(home.resolve()): { + "mcpServers": { + "exa": {"type": "http", "url": "https://server.smithery.ai/exa/mcp"} + } + } + } + } + ), + encoding="utf-8", + ) + + adapter.disable_server("exa") + + payload = json.loads(adapter.config_path.read_text(encoding="utf-8")) + project = payload["projects"][str(home.resolve())] + self.assertNotIn("mcpServers", project) + + def test_invalid_json_raises_mutation_error(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text("{not json", encoding="utf-8") + + with self.assertRaises(MutationError): + adapter.enable_server(_spec()) + + def test_scan_reports_malformed_config_without_raising(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + store = McpServerStore(home / "manifest.json") + store.upsert_from_spec(_spec("exa")) + adapter = _adapter("cursor", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text("{not json", encoding="utf-8") + + scan = adapter.scan(store.list_binding_specs()) + + self.assertIn("not valid JSON", scan.scan_issue or "") + states = {entry.name: entry.state for entry in scan.entries} + self.assertEqual(states["exa"], "missing") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_enrichment.py b/tests/unit/test_mcp_enrichment.py new file mode 100644 index 0000000..eaf1e46 --- /dev/null +++ b/tests/unit/test_mcp_enrichment.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from skill_manager.application.mcp.enrichment import McpEnrichmentService + + +def _popular(items: list[dict]) -> dict: + return {"items": items, "nextOffset": None, "hasMore": False} + + +class McpEnrichmentServiceTests(unittest.TestCase): + def test_warm_from_popular_caches_entries(self) -> None: + catalog = MagicMock() + catalog.popular_page.return_value = _popular( + [ + { + "qualifiedName": "@exa/exa-mcp", + "displayName": "Exa", + "iconUrl": "https://icon.example/exa.png", + "externalUrl": "https://smithery.ai/server/@exa/exa-mcp", + "description": "Web search", + "isRemote": True, + "isVerified": True, + }, + ] + ) + service = McpEnrichmentService(catalog) + link = service.lookup("exa-mcp", allow_search=False) + self.assertIsNotNone(link) + assert link is not None + self.assertEqual(link.qualified_name, "@exa/exa-mcp") + self.assertEqual(link.display_name, "Exa") + catalog.popular_page.assert_called_once() + + def test_cold_miss_triggers_search(self) -> None: + catalog = MagicMock() + catalog.popular_page.return_value = _popular([]) + catalog.search_page.return_value = { + "items": [ + { + "qualifiedName": "@other/context7", + "displayName": "Context7", + "iconUrl": None, + "externalUrl": "https://smithery.ai/server/@other/context7", + "description": "", + "isRemote": False, + "isVerified": True, + }, + ], + } + service = McpEnrichmentService(catalog) + link = service.lookup("context7") + self.assertIsNotNone(link) + assert link is not None + self.assertEqual(link.qualified_name, "@other/context7") + catalog.search_page.assert_called_once_with("context7", limit=10, offset=0, verified=True) + + def test_cache_prevents_double_search(self) -> None: + catalog = MagicMock() + catalog.popular_page.return_value = _popular([]) + catalog.search_page.return_value = {"items": []} + service = McpEnrichmentService(catalog) + self.assertIsNone(service.lookup("unknown")) + self.assertIsNone(service.lookup("unknown")) + # Popular called once; search called once; second lookup hits cached None. + self.assertEqual(catalog.popular_page.call_count, 1) + self.assertEqual(catalog.search_page.call_count, 1) + + def test_invalidate_clears_cache(self) -> None: + catalog = MagicMock() + catalog.popular_page.return_value = _popular([]) + catalog.search_page.return_value = {"items": []} + service = McpEnrichmentService(catalog) + service.lookup("x") + service.invalidate() + service.lookup("x") + self.assertEqual(catalog.popular_page.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_env.py b/tests/unit/test_mcp_env.py new file mode 100644 index 0000000..ed4f5cd --- /dev/null +++ b/tests/unit/test_mcp_env.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import unittest + +from skill_manager.application.mcp.env import annotate_env, is_env_var_reference + + +class IsEnvVarReferenceTests(unittest.TestCase): + def test_matches_env_syntax(self) -> None: + self.assertTrue(is_env_var_reference("${env:EXA_API_KEY}")) + self.assertTrue(is_env_var_reference("${env:A1_B2}")) + + def test_rejects_non_references(self) -> None: + self.assertFalse(is_env_var_reference("abc-123")) + self.assertFalse(is_env_var_reference("")) + self.assertFalse(is_env_var_reference("env:EXA_API_KEY")) + self.assertFalse(is_env_var_reference("${EXA_API_KEY}")) + + +class AnnotateEnvTests(unittest.TestCase): + def test_returns_raw_values(self) -> None: + rows = annotate_env({"EXA_API_KEY": "literal-secret", "PORT": "80"}) + by_key = {row["key"]: row for row in rows} + + self.assertEqual(by_key["EXA_API_KEY"]["value"], "literal-secret") + self.assertFalse(by_key["EXA_API_KEY"]["isEnvRef"]) + self.assertEqual(by_key["PORT"]["value"], "80") + + def test_marks_env_ref_and_keeps_value(self) -> None: + rows = annotate_env({"EXA_API_KEY": "${env:EXA_API_KEY}"}) + row = rows[0] + + self.assertEqual(row["value"], "${env:EXA_API_KEY}") + self.assertTrue(row["isEnvRef"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_identity.py b/tests/unit/test_mcp_identity.py new file mode 100644 index 0000000..640340a --- /dev/null +++ b/tests/unit/test_mcp_identity.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from skill_manager.application.mcp.contracts import McpHarnessScan, McpObservedEntry +from skill_manager.application.mcp.identity import build_identity_plan +from skill_manager.application.mcp.store import McpServerSpec, McpSource + + +def _http_spec(name: str, url: str) -> McpServerSpec: + return McpServerSpec( + name=name, + display_name=name.title(), + source=McpSource.adopted("cursor", name), + transport="http", + url=url, + ) + + +def _stdio_spec(name: str, args: tuple[str, ...]) -> McpServerSpec: + return McpServerSpec( + name=name, + display_name=name.title(), + source=McpSource.adopted("cursor", name), + transport="stdio", + command="uvx", + args=args, + ) + + +def _scan( + harness: str, + label: str, + entries: list[McpObservedEntry], +) -> McpHarnessScan: + return McpHarnessScan( + harness=harness, + label=label, + logo_key=harness, + installed=True, + config_present=True, + config_path=Path(f"/tmp/{harness}.json"), + entries=tuple(entries), + ) + + +class BuildIdentityPlanTests(unittest.TestCase): + def test_identical_entries_merge_into_one_group(self) -> None: + scans = [ + _scan( + "cursor", + "Cursor", + [ + McpObservedEntry( + name="exa", + state="unmanaged", + raw_payload={"url": "https://exa.run"}, + parsed_spec=_http_spec("exa", "https://exa.run"), + ) + ], + ), + _scan( + "claude", + "Claude", + [ + McpObservedEntry( + name="exa", + state="unmanaged", + raw_payload={"url": "https://exa.run"}, + parsed_spec=_http_spec("exa", "https://exa.run"), + ) + ], + ), + ] + plan = build_identity_plan(scans) + self.assertEqual(len(plan.groups), 1) + self.assertEqual(plan.groups[0].name, "exa") + self.assertTrue(plan.groups[0].identical) + self.assertIsNotNone(plan.groups[0].canonical_spec) + self.assertEqual({s.harness for s in plan.groups[0].sightings}, {"cursor", "claude"}) + self.assertEqual(plan.issues, ()) + + def test_differing_specs_classify_as_differs(self) -> None: + scans = [ + _scan( + "cursor", + "Cursor", + [ + McpObservedEntry( + name="exa", + state="unmanaged", + raw_payload={"url": "https://exa.run"}, + parsed_spec=_http_spec("exa", "https://exa.run"), + ) + ], + ), + _scan( + "claude", + "Claude", + [ + McpObservedEntry( + name="exa", + state="unmanaged", + raw_payload={"command": "uvx", "args": ["exa-mcp"]}, + parsed_spec=_stdio_spec("exa", ("exa-mcp",)), + ) + ], + ), + ] + plan = build_identity_plan(scans) + self.assertEqual(len(plan.groups), 1) + self.assertFalse(plan.groups[0].identical) + self.assertIsNone(plan.groups[0].canonical_spec) + self.assertEqual(len(plan.groups[0].sightings), 2) + + def test_excluded_names_are_skipped(self) -> None: + scans = [ + _scan( + "cursor", + "Cursor", + [ + McpObservedEntry( + name="exa", + state="unmanaged", + raw_payload={"url": "https://exa.run"}, + parsed_spec=_http_spec("exa", "https://exa.run"), + ) + ], + ), + _scan( + "claude", + "Claude", + [ + McpObservedEntry( + name="other", + state="unmanaged", + raw_payload={"url": "https://other.run"}, + parsed_spec=_http_spec("other", "https://other.run"), + ) + ], + ), + ] + plan = build_identity_plan(scans, excluded_names=["exa"]) + self.assertEqual([group.name for group in plan.groups], ["other"]) + + def test_unparseable_entries_are_reported_as_issues(self) -> None: + scans = [ + _scan( + "cursor", + "Cursor", + [ + McpObservedEntry( + name="broken", + state="unmanaged", + raw_payload={"command": ["unexpected"]}, + parse_issue="command must be a string", + ) + ], + ) + ] + plan = build_identity_plan(scans) + self.assertEqual(plan.groups, ()) + self.assertEqual(len(plan.issues), 1) + self.assertEqual(plan.issues[0].name, "broken") + self.assertEqual(plan.issues[0].harness, "cursor") + self.assertEqual(plan.issues[0].reason, "command must be a string") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_installers.py b/tests/unit/test_mcp_installers.py new file mode 100644 index 0000000..1a7d049 --- /dev/null +++ b/tests/unit/test_mcp_installers.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import unittest + +from skill_manager.application.mcp.installers import SmitheryCliInstallProvider +from skill_manager.errors import MutationError + + +class SmitheryCliInstallProviderTests(unittest.TestCase): + def test_install_targets_include_all_observable_smithery_clients(self) -> None: + provider = SmitheryCliInstallProvider() + + targets = {target.harness: target for target in provider.install_targets()} + + self.assertEqual(targets["codex"].smithery_client, "codex") + self.assertEqual(targets["claude"].smithery_client, "claude-code") + self.assertEqual(targets["cursor"].smithery_client, "cursor") + self.assertEqual(targets["opencode"].smithery_client, "opencode") + self.assertTrue(targets["claude"].supported) + self.assertFalse(targets["openclaw"].supported) + self.assertEqual( + targets["openclaw"].reason, + "Smithery does not provide an OpenClaw MCP installer target", + ) + + def test_unsupported_target_fails_before_running_cli(self) -> None: + calls: list[list[str]] = [] + + def runner(command, **_kwargs): # noqa: ANN001 + calls.append(command) + raise AssertionError("runner should not be called") + + provider = SmitheryCliInstallProvider(runner=runner) + + with self.assertRaises(MutationError): + provider.install(qualified_name="exa", source_harness="openclaw") + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_mappers.py b/tests/unit/test_mcp_mappers.py new file mode 100644 index 0000000..ad8004d --- /dev/null +++ b/tests/unit/test_mcp_mappers.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import unittest + +from skill_manager.application.mcp import ( + ClaudeCodeMapper, + CodexMapper, + CursorMapper, + OpenClawMapper, + OpenCodeMapper, +) +from skill_manager.application.mcp.store import McpServerSpec, McpSource + + +def _stdio() -> McpServerSpec: + return McpServerSpec( + name="exa", + display_name="Exa", + source=McpSource.marketplace("@exa/exa-mcp"), + transport="stdio", + command="npx", + args=("-y", "exa-mcp-server"), + env=(("EXA_API_KEY", "secret"),), + ) + + +def _http() -> McpServerSpec: + return McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.marketplace("@remote/server"), + transport="http", + url="https://mcp.example.com", + headers=(("Authorization", "Bearer x"),), + ) + + +class ClaudeCodeMapperTests(unittest.TestCase): + def test_stdio_round_trip_emits_type(self) -> None: + mapper = ClaudeCodeMapper() + spec = _stdio() + d = mapper.spec_to_dict(spec) + self.assertEqual(d["type"], "stdio") + self.assertEqual(d["command"], "npx") + self.assertEqual(d["args"], ["-y", "exa-mcp-server"]) + self.assertEqual(d["env"], {"EXA_API_KEY": "secret"}) + round_trip = mapper.dict_to_spec("exa", d) + self.assertEqual(round_trip.transport, "stdio") + self.assertEqual(round_trip.command, "npx") + self.assertEqual(round_trip.args, ("-y", "exa-mcp-server")) + self.assertEqual(dict(round_trip.env or ()), {"EXA_API_KEY": "secret"}) + + def test_http_round_trip_emits_type(self) -> None: + mapper = ClaudeCodeMapper() + d = mapper.spec_to_dict(_http()) + self.assertEqual(d["type"], "http") + self.assertEqual(d["url"], "https://mcp.example.com") + self.assertEqual(d["headers"], {"Authorization": "Bearer x"}) + round_trip = mapper.dict_to_spec("remote", d) + self.assertEqual(round_trip.transport, "http") + self.assertEqual(round_trip.url, "https://mcp.example.com") + + def test_accepts_legacy_url_only_entry(self) -> None: + mapper = ClaudeCodeMapper() + round_trip = mapper.dict_to_spec("remote", {"url": "https://mcp.example.com"}) + self.assertEqual(round_trip.transport, "http") + self.assertEqual(round_trip.url, "https://mcp.example.com") + + def test_sse_uses_type_key(self) -> None: + mapper = ClaudeCodeMapper() + spec = McpServerSpec( + name="sse", + display_name="SSE", + source=McpSource.manual("sse"), + transport="sse", + url="https://sse.example.com", + ) + d = mapper.spec_to_dict(spec) + self.assertEqual(d.get("type"), "sse") + round_trip = mapper.dict_to_spec("sse", d) + self.assertEqual(round_trip.transport, "sse") + + +class CursorMapperTests(unittest.TestCase): + def test_http_round_trip_emits_type(self) -> None: + mapper = CursorMapper() + d = mapper.spec_to_dict(_http()) + self.assertEqual(d["type"], "http") + self.assertEqual(d["url"], "https://mcp.example.com") + round_trip = mapper.dict_to_spec("remote", d) + self.assertEqual(round_trip.transport, "http") + + +class OpenCodeMapperTests(unittest.TestCase): + def test_stdio_local_format(self) -> None: + mapper = OpenCodeMapper() + d = mapper.spec_to_dict(_stdio()) + self.assertEqual(d["type"], "local") + self.assertEqual(d["command"], ["npx", "-y", "exa-mcp-server"]) + self.assertEqual(d["environment"], {"EXA_API_KEY": "secret"}) + self.assertTrue(d["enabled"]) + round_trip = mapper.dict_to_spec("exa", d) + self.assertEqual(round_trip.transport, "stdio") + self.assertEqual(round_trip.command, "npx") + self.assertEqual(round_trip.args, ("-y", "exa-mcp-server")) + + def test_http_remote_format(self) -> None: + mapper = OpenCodeMapper() + d = mapper.spec_to_dict(_http()) + self.assertEqual(d["type"], "remote") + self.assertEqual(d["url"], "https://mcp.example.com") + self.assertEqual(d["headers"], {"Authorization": "Bearer x"}) + round_trip = mapper.dict_to_spec("remote", d) + self.assertEqual(round_trip.transport, "http") + + +class CodexMapperTests(unittest.TestCase): + def test_stdio_uses_native_cli_shape(self) -> None: + mapper = CodexMapper() + d = mapper.spec_to_dict(_stdio()) + self.assertNotIn("transport", d) + self.assertNotIn("enabled", d) + self.assertEqual(d["command"], "npx") + self.assertEqual(d["args"], ["-y", "exa-mcp-server"]) + round_trip = mapper.dict_to_spec("exa", d) + self.assertEqual(round_trip.transport, "stdio") + self.assertEqual(round_trip.command, "npx") + + def test_http_uses_http_headers_key(self) -> None: + mapper = CodexMapper() + d = mapper.spec_to_dict(_http()) + self.assertEqual(d["url"], "https://mcp.example.com") + self.assertEqual(d["http_headers"], {"Authorization": "Bearer x"}) + self.assertNotIn("enabled", d) + round_trip = mapper.dict_to_spec("remote", d) + self.assertEqual(round_trip.transport, "http") + self.assertEqual(round_trip.url, "https://mcp.example.com") + + +class OpenClawMapperTests(unittest.TestCase): + def test_http_uses_streamable_http_transport(self) -> None: + mapper = OpenClawMapper() + d = mapper.spec_to_dict(_http()) + self.assertEqual(d["transport"], "streamable-http") + self.assertEqual(d["url"], "https://mcp.example.com") + round_trip = mapper.dict_to_spec("remote", d) + self.assertEqual(round_trip.transport, "http") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_stdio.py b/tests/unit/test_mcp_stdio.py new file mode 100644 index 0000000..04893aa --- /dev/null +++ b/tests/unit/test_mcp_stdio.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import unittest + +from skill_manager.application.mcp.stdio import parse_static_stdio_function + + +class StaticStdioParserTests(unittest.TestCase): + def test_parses_static_command_and_args(self) -> None: + recipe = "(config) => ({ command: 'npx', args: ['-y', '@acme/server'] })" + + command = parse_static_stdio_function(recipe) + + assert command is not None + self.assertEqual(command.command, "npx") + self.assertEqual(command.args, ("-y", "@acme/server")) + + def test_rejects_dynamic_config_reference(self) -> None: + recipe = "(config) => ({ command: 'npx', args: ['-y', config.package] })" + + self.assertIsNone(parse_static_stdio_function(recipe)) + + def test_treats_missing_args_as_empty(self) -> None: + recipe = "() => ({ command: 'uvx' })" + + command = parse_static_stdio_function(recipe) + + assert command is not None + self.assertEqual(command.command, "uvx") + self.assertEqual(command.args, ()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mcp_store.py b/tests/unit/test_mcp_store.py new file mode 100644 index 0000000..d0e2bf3 --- /dev/null +++ b/tests/unit/test_mcp_store.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from skill_manager.application.mcp.store import McpServerSpec, McpServerStore, McpSource + + +def _spec(name: str = "exa", **overrides) -> McpServerSpec: + base = dict( + name=name, + display_name=name.title(), + source=McpSource.marketplace(f"@user/{name}"), + transport="stdio", + command="npx", + args=("-y", f"{name}-mcp-server"), + env=(("EXA_API_KEY", "secret"),), + ) + base.update(overrides) + return McpServerSpec(**base) + + +class McpServerStoreTests(unittest.TestCase): + def test_upsert_then_list(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + store.upsert_from_spec(_spec("exa")) + store.upsert_from_spec(_spec("context7", command="uvx", args=("context7-mcp",), env=None)) + + entries = store.list_binding_specs() + + self.assertEqual({entry.name for entry in entries}, {"exa", "context7"}) + + def test_upsert_replaces_existing(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + store.upsert_from_spec(_spec("exa", env=(("EXA_API_KEY", "old"),))) + store.upsert_from_spec(_spec("exa", env=(("EXA_API_KEY", "new"),))) + + entries = store.list_binding_specs() + + self.assertEqual(len(entries), 1) + self.assertEqual(dict(entries[0].env or ()), {"EXA_API_KEY": "new"}) + + def test_get_returns_none_when_missing(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + + self.assertIsNone(store.get_binding_spec("exa")) + + def test_remove_returns_false_when_missing(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + + self.assertFalse(store.remove("exa")) + + def test_remove_returns_true_and_drops_entry(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + store.upsert_from_spec(_spec("exa")) + + self.assertTrue(store.remove("exa")) + self.assertEqual(store.list_binding_specs(), ()) + + def test_revision_changes_when_payload_differs(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + store.upsert_from_spec(_spec("exa")) + stored = store.get_binding_spec("exa") + assert stored is not None + + store.upsert_from_spec(_spec("exa", command="bunx")) + stored2 = store.get_binding_spec("exa") + assert stored2 is not None + + self.assertTrue(stored.revision) + self.assertNotEqual(stored.revision, stored2.revision) + + def test_manifest_is_valid_json(self) -> None: + with TemporaryDirectory() as tmp: + manifest_path = Path(tmp) / "manifest.json" + store = McpServerStore(manifest_path) + store.upsert_from_spec(_spec("exa")) + + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + + self.assertEqual(payload["version"], 5) + self.assertEqual(len(payload["servers"]), 1) + self.assertEqual(payload["servers"][0]["name"], "exa") + + def test_round_trip_http_spec_preserves_headers_cleartext(self) -> None: + with TemporaryDirectory() as tmp: + store = McpServerStore(Path(tmp) / "manifest.json") + store.upsert_from_spec( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.marketplace("@remote/server"), + transport="http", + url="https://mcp.example.com?api_key=literal", + headers=(("Authorization", "Bearer literal"),), + ) + ) + + loaded = store.get_binding_spec("remote") + public = store.get_public_spec("remote") + + assert loaded is not None + assert public is not None + self.assertEqual(loaded.transport, "http") + self.assertEqual(loaded.url, "https://mcp.example.com?api_key=literal") + self.assertEqual(dict(loaded.headers or ()), {"Authorization": "Bearer literal"}) + self.assertEqual(public.to_dict(), loaded.to_dict()) + + def test_reads_do_not_rewrite_legacy_manifest(self) -> None: + with TemporaryDirectory() as tmp: + manifest_path = Path(tmp) / "manifest.json" + original = json.dumps( + { + "version": 3, + "servers": [ + { + "name": "exa", + "displayName": "Exa Search", + "source": {"kind": "marketplace", "locator": "exa"}, + "transport": "http", + "url": "https://mcp.exa.ai", + "setupState": "missing", + "setupFields": [], + } + ], + }, + indent=2, + ) + manifest_path.write_text(original, encoding="utf-8") + + store = McpServerStore(manifest_path) + managed = store.list_managed() + binding = store.get_binding_spec("exa") + + self.assertEqual(len(managed), 1) + assert binding is not None + self.assertEqual(binding.url, "https://mcp.exa.ai") + self.assertEqual(manifest_path.read_text(encoding="utf-8"), original) + + def test_manifest_issues_report_malformed_entries_without_dropping_valid_entries(self) -> None: + with TemporaryDirectory() as tmp: + manifest_path = Path(tmp) / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "servers": [ + { + "name": "valid", + "displayName": "Valid", + "source": {"kind": "manual", "locator": "valid"}, + "transport": "http", + "url": "https://valid.example", + }, + {"displayName": "Missing Name"}, + ], + } + ), + encoding="utf-8", + ) + store = McpServerStore(manifest_path) + + self.assertEqual([server.name for server in store.list_managed()], ["valid"]) + self.assertEqual(len(store.manifest_issues()), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 057a5b0..aa66b87 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -34,8 +34,8 @@ def test_macos_default_layout_collapses_to_application_support(self) -> None: self.assertEqual(paths.config_dir, base) self.assertEqual(paths.data_dir, base) self.assertEqual(paths.state_dir, base) - self.assertEqual(paths.shared_store_root, base / "shared") - self.assertEqual(paths.shared_store_manifest, base / "manifest.json") + self.assertEqual(paths.skills_store_root, base / "shared") + self.assertEqual(paths.skills_store_manifest, base / "manifest.json") self.assertEqual(paths.marketplace_cache_root, base / "marketplace") self.assertEqual(paths.settings_path, base / "settings.json") self.assertEqual(paths.runtime_state_path, base / "runtime.json") @@ -54,7 +54,7 @@ def test_xdg_overrides_each_dir_independently(self) -> None: self.assertEqual(paths.config_dir, root / "cfg" / APP_NAME) self.assertEqual(paths.data_dir, root / "data" / APP_NAME) self.assertEqual(paths.state_dir, root / "state" / APP_NAME) - self.assertEqual(paths.shared_store_root, root / "data" / APP_NAME / "shared") + self.assertEqual(paths.skills_store_root, root / "data" / APP_NAME / "shared") self.assertEqual(paths.settings_path, root / "cfg" / APP_NAME / "settings.json") def test_settings_path_env_overrides_settings_path(self) -> None: diff --git a/tests/unit/test_manifest.py b/tests/unit/test_skill_manifest.py similarity index 77% rename from tests/unit/test_manifest.py rename to tests/unit/test_skill_manifest.py index 1fdcf26..bac4fd2 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_skill_manifest.py @@ -4,16 +4,21 @@ from tempfile import TemporaryDirectory import unittest -from skill_manager.store import ManifestEntry, StoreManifest, load_manifest, write_manifest +from skill_manager.application.skills.manifest import ( + SkillStoreEntry, + SkillStoreManifest, + load_skill_store_manifest as load_manifest, + write_skill_store_manifest as write_manifest, +) -class ManifestTests(unittest.TestCase): +class SkillStoreManifestTests(unittest.TestCase): def test_manifest_round_trip(self) -> None: with TemporaryDirectory() as temp_dir: manifest_path = Path(temp_dir) / "manifest.json" - manifest = StoreManifest( + manifest = SkillStoreManifest( entries=( - ManifestEntry( + SkillStoreEntry( package_dir="shared-audit", declared_name="Shared Audit", source_kind="github", diff --git a/tests/unit/test_skills.py b/tests/unit/test_skill_package.py similarity index 96% rename from tests/unit/test_skills.py rename to tests/unit/test_skill_package.py index adf184f..16373f1 100644 --- a/tests/unit/test_skills.py +++ b/tests/unit/test_skill_package.py @@ -4,7 +4,13 @@ from tempfile import TemporaryDirectory import unittest -from skill_manager.domain import SourceDescriptor, SkillParseError, fingerprint_package, parse_skill_manifest_text, parse_skill_package +from skill_manager.application.skills.identity import SourceDescriptor +from skill_manager.application.skills.package import ( + SkillParseError, + fingerprint_package, + parse_skill_manifest_text, + parse_skill_package, +) from tests.support.fake_home import seed_skill_package diff --git a/tests/unit/test_store.py b/tests/unit/test_skill_store.py similarity index 84% rename from tests/unit/test_store.py rename to tests/unit/test_skill_store.py index cf40429..48b8d13 100644 --- a/tests/unit/test_store.py +++ b/tests/unit/test_skill_store.py @@ -4,17 +4,18 @@ from tempfile import TemporaryDirectory import unittest -from skill_manager.store import SharedStore, load_manifest +from skill_manager.application.skills.manifest import load_skill_store_manifest as load_manifest +from skill_manager.application.skills.store import SkillStore from tests.support.fake_home import create_fake_home_spec, seed_skill_package -class SharedStoreIngestTests(unittest.TestCase): +class SkillStoreIngestTests(unittest.TestCase): def test_ingest_copies_package_and_updates_manifest(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) source = seed_skill_package(spec.home / ".codex" / "skills", "audit", "Audit Skill") - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) dest = store.ingest( source_path=source, declared_name="Audit Skill", @@ -35,9 +36,9 @@ def test_ingest_copies_package_and_updates_manifest(self) -> None: def test_ingest_refuses_existing_directory(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - seed_skill_package(spec.shared_store_root, "audit", "Existing") + seed_skill_package(spec.skills_store_root, "audit", "Existing") source = seed_skill_package(spec.home / ".codex" / "skills", "audit", "Audit Skill") - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) with self.assertRaises(ValueError) as ctx: store.ingest( source_path=source, @@ -51,7 +52,7 @@ def test_ingest_creates_store_root_if_missing(self) -> None: with TemporaryDirectory() as temp_dir: source = seed_skill_package(Path(temp_dir) / "harness", "audit", "Audit Skill") missing_root = Path(temp_dir) / "new-store" / "shared" - store = SharedStore(missing_root) + store = SkillStore(missing_root) dest = store.ingest( source_path=source, declared_name="Audit Skill", @@ -62,17 +63,17 @@ def test_ingest_creates_store_root_if_missing(self) -> None: self.assertTrue(missing_root.is_dir()) -class SharedStoreUpdateTests(unittest.TestCase): +class SkillStoreUpdateTests(unittest.TestCase): def test_update_replaces_changed_package(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) source_v1 = seed_skill_package(Path(temp_dir) / "v1", "audit", "Audit", body="version 1") store.ingest(source_path=source_v1, declared_name="Audit", source_kind="github", source_locator="github:test/test/audit") source_v2 = seed_skill_package(Path(temp_dir) / "v2", "audit", "Audit", body="version 2") _, changed = store.update("audit", source_path=source_v2, source_ref="main", source_path_hint="skills/audit") self.assertTrue(changed) - content = (spec.shared_store_root / "audit" / "SKILL.md").read_text() + content = (spec.skills_store_root / "audit" / "SKILL.md").read_text() self.assertIn("version 2", content) manifest = load_manifest(store.manifest_path) self.assertEqual(len(manifest.entries), 1) @@ -82,7 +83,7 @@ def test_update_replaces_changed_package(self) -> None: def test_update_noop_when_identical(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) source = seed_skill_package(Path(temp_dir) / "original", "audit", "Audit", body="same content") store.ingest(source_path=source, declared_name="Audit", source_kind="github", source_locator="github:test/test/audit") source_copy = seed_skill_package(Path(temp_dir) / "copy", "audit", "Audit", body="same content") @@ -92,18 +93,18 @@ def test_update_noop_when_identical(self) -> None: def test_update_refuses_missing_package(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) source = seed_skill_package(Path(temp_dir) / "src", "audit", "Audit") with self.assertRaises(ValueError) as ctx: store.update("nonexistent", source_path=source) self.assertIn("not in store", str(ctx.exception)) -class SharedStoreDeleteTests(unittest.TestCase): +class SkillStoreDeleteTests(unittest.TestCase): def test_delete_removes_package_and_manifest_entry(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) source = seed_skill_package(Path(temp_dir) / "src", "audit", "Audit") store.ingest( source_path=source, @@ -114,14 +115,14 @@ def test_delete_removes_package_and_manifest_entry(self) -> None: store.delete("audit") - self.assertFalse((spec.shared_store_root / "audit").exists()) + self.assertFalse((spec.skills_store_root / "audit").exists()) manifest = load_manifest(store.manifest_path) self.assertEqual(manifest.entries, ()) def test_delete_refuses_missing_package(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - store = SharedStore(spec.shared_store_root) + store = SkillStore(spec.skills_store_root) with self.assertRaises(ValueError) as ctx: store.delete("missing") @@ -131,13 +132,13 @@ def test_delete_refuses_missing_package(self) -> None: def test_delete_refuses_package_missing_from_manifest(self) -> None: with TemporaryDirectory() as temp_dir: spec = create_fake_home_spec(Path(temp_dir)) - seed_skill_package(spec.shared_store_root, "audit", "Audit") - store = SharedStore(spec.shared_store_root) + seed_skill_package(spec.skills_store_root, "audit", "Audit") + store = SkillStore(spec.skills_store_root) with self.assertRaises(ValueError) as ctx: store.delete("audit") self.assertIn("missing from manifest", str(ctx.exception)) - self.assertTrue((spec.shared_store_root / "audit").is_dir()) + self.assertTrue((spec.skills_store_root / "audit").is_dir()) if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_shared_store_concurrent.py b/tests/unit/test_skill_store_concurrent.py similarity index 86% rename from tests/unit/test_shared_store_concurrent.py rename to tests/unit/test_skill_store_concurrent.py index df92287..d61cabe 100644 --- a/tests/unit/test_shared_store_concurrent.py +++ b/tests/unit/test_skill_store_concurrent.py @@ -5,17 +5,18 @@ from pathlib import Path from tempfile import TemporaryDirectory -from skill_manager.store import SharedStore, load_manifest +from skill_manager.application.skills.manifest import load_skill_store_manifest as load_manifest +from skill_manager.application.skills.store import SkillStore from tests.support.fake_home import seed_skill_package -class SharedStoreConcurrentIngestTests(unittest.TestCase): +class SkillStoreConcurrentIngestTests(unittest.TestCase): def test_two_threads_ingesting_distinct_packages_persist_both_entries(self) -> None: for iteration in range(20): with TemporaryDirectory() as temp: store_root = Path(temp) / "shared" - store = SharedStore(store_root) + store = SkillStore(store_root) staging = Path(temp) / "staging" staging.mkdir() diff --git a/tests/unit/test_skills_adapters.py b/tests/unit/test_skills_adapters.py new file mode 100644 index 0000000..0c49005 --- /dev/null +++ b/tests/unit/test_skills_adapters.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +from skill_manager.application.skills.adapters import build_skills_adapters +from skill_manager.errors import MutationError +from skill_manager.harness import HarnessKernelService, HarnessSupportStore + +from tests.support.fake_home import create_fake_home_spec, seed_skill_package + + +def _adapter(harness: str, spec) : + kernel = HarnessKernelService.from_environment( + spec.env(), + support_store=HarnessSupportStore(spec.root / "settings.json"), + ) + return next(adapter for adapter in build_skills_adapters(kernel) if adapter.harness == harness) + + +class SkillsAdapterTests(unittest.TestCase): + def test_adapter_scans_discovery_roots_and_reports_installation(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + seed_skill_package(spec.codex_legacy_root, "trace-lens", "Trace Lens") + seed_skill_package(spec.openclaw_managed_root, "watch", "Workspace Watch") + + codex = _adapter("codex", spec) + claude = _adapter("claude", spec) + openclaw = _adapter("openclaw", spec) + + codex_scan = codex.scan() + claude_scan = claude.scan() + openclaw_scan = openclaw.scan() + + self.assertTrue(codex_scan.installed) + self.assertEqual(codex_scan.skills[0].package.declared_name, "Trace Lens") + self.assertTrue(claude_scan.installed) + self.assertEqual(claude_scan.skills, ()) + self.assertTrue(openclaw_scan.installed) + self.assertEqual( + [skill.package.declared_name for skill in openclaw_scan.skills], + ["Workspace Watch"], + ) + + def test_adapter_reports_missing_cli_as_not_installed(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir), seed_openclaw_state=False) + + openclaw = _adapter("openclaw", spec) + + self.assertFalse(openclaw.status().installed) + self.assertEqual(openclaw.scan().skills, ()) + + def test_enable_creates_symlink(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + package = seed_skill_package(spec.skills_store_root, "audit", "Audit") + codex = _adapter("codex", spec) + + codex.enable_shared_package(package) + + link = spec.codex_root / "audit" + self.assertTrue(link.is_symlink()) + self.assertEqual(link.resolve(), package.resolve()) + + def test_enable_refuses_real_directory(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + package = seed_skill_package(spec.skills_store_root, "audit", "Audit") + seed_skill_package(spec.codex_root, "audit", "Local Audit") + codex = _adapter("codex", spec) + + with self.assertRaises(MutationError) as ctx: + codex.enable_shared_package(package) + + self.assertIn("real directory", str(ctx.exception)) + + def test_adopt_local_copy_replaces_dir_with_symlink(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + store_pkg = seed_skill_package(spec.skills_store_root, "audit", "Audit") + harness_pkg = seed_skill_package(spec.codex_root, "audit", "Audit") + codex = _adapter("codex", spec) + + codex.adopt_local_copy(harness_pkg, store_pkg) + + self.assertTrue(harness_pkg.is_symlink()) + self.assertEqual(harness_pkg.resolve(), store_pkg.resolve()) + + def test_materialize_binding_restores_real_directory(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + store_pkg = seed_skill_package( + spec.skills_store_root, + "audit", + "Audit", + body="shared version", + ) + link = spec.codex_root / "audit" + link.symlink_to(store_pkg.resolve()) + codex = _adapter("codex", spec) + + codex.materialize_binding("audit", store_pkg) + + self.assertTrue(link.is_dir()) + self.assertFalse(link.is_symlink()) + self.assertIn("shared version", (link / "SKILL.md").read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_marketplace_service.py b/tests/unit/test_skills_marketplace_catalog.py similarity index 96% rename from tests/unit/test_marketplace_service.py rename to tests/unit/test_skills_marketplace_catalog.py index 6945014..c2c9e03 100644 --- a/tests/unit/test_marketplace_service.py +++ b/tests/unit/test_skills_marketplace_catalog.py @@ -4,11 +4,11 @@ from tempfile import mkdtemp import unittest -from skill_manager.application.marketplace import MarketplaceCatalog -from skill_manager.application.marketplace.cache import MarketplaceCache -from skill_manager.application.marketplace.models import SkillsShSkill -from skill_manager.application.marketplace.repo_snapshots import GitHubRepoSnapshotService -from skill_manager.application.marketplace.resolver import DetailEnrichment, GitHubSkillResolver +from skill_manager.application.skills.marketplace import MarketplaceCatalog +from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.application.skills.marketplace.models import SkillsShSkill +from skill_manager.application.skills.marketplace.repo_snapshots import GitHubRepoSnapshotService +from skill_manager.application.skills.marketplace.resolver import DetailEnrichment, GitHubSkillResolver from skill_manager.errors import MARKETPLACE_UNAVAILABLE_MESSAGE, MarketplaceUpstreamError from skill_manager.sources import GitHubRepoMetadata, GitHubRepoMetadataClient from tests.support.marketplace_fixture import create_fixture_marketplace_service @@ -27,7 +27,7 @@ def _resolver( return GitHubSkillResolver(snapshot_service) -class MarketplaceServiceTests(unittest.TestCase): +class SkillsMarketplaceCatalogTests(unittest.TestCase): def test_popular_returns_install_sorted_cards_with_repo_links(self) -> None: payload = create_fixture_marketplace_service().popular_page()["items"] diff --git a/tests/unit/test_smithery_catalog.py b/tests/unit/test_smithery_catalog.py new file mode 100644 index 0000000..4ae08ce --- /dev/null +++ b/tests/unit/test_smithery_catalog.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import unittest +from unittest import mock + +from skill_manager.application.marketplace_cache import MarketplaceCache +from skill_manager.application.mcp.marketplace.catalog import ( + McpMarketplaceCatalog, + _flatten_input_schema, + _map_detail, + _map_summary, +) +from skill_manager.errors import MarketplaceUpstreamError + + +_EXA_DETAIL_SAMPLE: dict[str, object] = { + "qualifiedName": "exa", + "displayName": "Exa Search", + "description": "Fast, intelligent web search and web crawling.", + "iconUrl": "https://api.smithery.ai/servers/exa/icon", + "remote": True, + "deploymentUrl": "https://exa.run.tools", + "connections": [ + { + "type": "http", + "deploymentUrl": "https://exa.run.tools", + "configSchema": {}, + } + ], + "security": None, + "tools": [ + { + "name": "web_search_exa", + "description": "Search the web for any topic.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language search query.", + }, + "numResults": { + "type": "number", + "description": "Number of search results to return.", + "minimum": 1, + "maximum": 100, + "default": 10, + }, + }, + "required": ["query"], + }, + } + ], + "resources": [ + { + "name": "tools_list", + "uri": "exa://tools/list", + "description": "List of available tools", + "mimeType": "application/json", + } + ], + "prompts": [ + { + "name": "web_search_help", + "description": "Get help with web search.", + "arguments": [], + } + ], +} + + +_DESKTOP_DETAIL_SAMPLE: dict[str, object] = { + "qualifiedName": "wonderwhy-er/desktop-commander", + "displayName": "Desktop Commander", + "description": "Execute terminal commands.", + "iconUrl": "https://icons.duckduckgo.com/ip3/desktopcommander.app.ico", + "remote": False, + "deploymentUrl": None, + "connections": [ + { + "type": "stdio", + "stdioFunction": "(config) => ({ command: 'npx', args: ['-y', '@wonderwhy-er/desktop-commander'] })", + "configSchema": {"type": "object", "properties": {}}, + } + ], + "security": None, + "tools": [ + { + "name": "read_file", + "description": "Read a file.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "isUrl": {"type": "boolean", "default": False}, + }, + "required": ["path"], + }, + } + ], + "resources": [], + "prompts": [], +} + + +_SSE_DETAIL_SAMPLE: dict[str, object] = { + "qualifiedName": "@acme/stream-server", + "displayName": "Stream Server", + "description": "Remote SSE MCP server.", + "iconUrl": None, + "remote": True, + "deploymentUrl": "https://stream.example/mcp", + "connections": [ + { + "type": "sse", + "deploymentUrl": "https://stream.example/mcp", + "configSchema": None, + } + ], + "security": None, + "tools": [], + "resources": [], + "prompts": [], +} + + +_LIST_RESPONSE_SAMPLE: dict[str, object] = { + "servers": [ + { + "id": "uuid-1", + "qualifiedName": "exa", + "namespace": "exa", + "slug": "", + "displayName": "Exa Search", + "description": "Fast search.", + "iconUrl": "https://api.smithery.ai/servers/exa/icon", + "verified": True, + "useCount": 59906, + "remote": True, + "isDeployed": True, + "createdAt": "2024-12-13T15:46:50.750Z", + "homepage": "https://exa.ai", + "owner": "org_1", + "score": None, + }, + { + "id": "uuid-2", + "qualifiedName": "wonderwhy-er/desktop-commander", + "namespace": "wonderwhy-er", + "slug": "desktop-commander", + "displayName": "Desktop Commander", + "description": "Local terminal control.", + "iconUrl": None, + "verified": False, + "useCount": 728, + "remote": False, + "isDeployed": False, + "createdAt": "2025-02-01T00:00:00.000Z", + "homepage": None, + "owner": "org_2", + "score": None, + }, + ], + "pagination": { + "currentPage": 1, + "pageSize": 30, + "totalPages": 161, + "totalCount": 4814, + }, +} + + +class FlattenInputSchemaTests(unittest.TestCase): + def test_flattens_properties_and_required(self) -> None: + params = _flatten_input_schema( + { + "type": "object", + "properties": { + "query": {"type": "string", "description": "text"}, + "numResults": {"type": "number", "minimum": 1, "maximum": 100, "default": 10}, + }, + "required": ["query"], + } + ) + self.assertEqual(len(params), 2) + by_name = {param["name"]: param for param in params} + self.assertEqual(by_name["query"]["type"], "string") + self.assertTrue(by_name["query"]["required"]) + self.assertEqual(by_name["numResults"]["type"], "number") + self.assertFalse(by_name["numResults"]["required"]) + self.assertEqual(by_name["numResults"]["minimum"], 1) + self.assertEqual(by_name["numResults"]["maximum"], 100) + self.assertEqual(by_name["numResults"]["default"], 10) + + def test_missing_type_becomes_unknown(self) -> None: + params = _flatten_input_schema({"properties": {"odd": {"description": "no type"}}}) + self.assertEqual(len(params), 1) + self.assertEqual(params[0]["type"], "unknown") + + def test_type_as_array_picks_first_valid(self) -> None: + params = _flatten_input_schema( + {"properties": {"maybe": {"type": ["null", "string"]}}} + ) + self.assertEqual(params[0]["type"], "string") + + def test_empty_schema_returns_empty_list(self) -> None: + self.assertEqual(_flatten_input_schema(None), []) + self.assertEqual(_flatten_input_schema({"type": "object"}), []) + self.assertEqual(_flatten_input_schema({"type": "object", "properties": {}}), []) + + +class MapSummaryTests(unittest.TestCase): + def test_remote_verified_entry(self) -> None: + summary = _map_summary(_LIST_RESPONSE_SAMPLE["servers"][0]) + self.assertEqual(summary["qualifiedName"], "exa") + self.assertTrue(summary["isVerified"]) + self.assertTrue(summary["isRemote"]) + self.assertEqual(summary["useCount"], 59906) + self.assertEqual(summary["externalUrl"], "https://smithery.ai/server/exa") + + def test_local_unverified_entry(self) -> None: + summary = _map_summary(_LIST_RESPONSE_SAMPLE["servers"][1]) + self.assertFalse(summary["isVerified"]) + self.assertFalse(summary["isRemote"]) + self.assertIsNone(summary["iconUrl"]) + self.assertIsNone(summary["homepage"]) + self.assertEqual( + summary["externalUrl"], "https://smithery.ai/server/wonderwhy-er/desktop-commander" + ) + + +class MapDetailTests(unittest.TestCase): + def test_remote_detail_maps_connections_and_tools(self) -> None: + detail = _map_detail(_EXA_DETAIL_SAMPLE, qualified_name="exa") + self.assertTrue(detail["isRemote"]) + self.assertEqual(detail["managedName"], "exa") + self.assertEqual(detail["deploymentUrl"], "https://exa.run.tools") + self.assertEqual(detail["connections"][0]["kind"], "http") + self.assertEqual(detail["connections"][0]["deploymentUrl"], "https://exa.run.tools") + self.assertEqual(len(detail["tools"]), 1) + self.assertEqual(detail["tools"][0]["name"], "web_search_exa") + self.assertEqual(detail["capabilityCounts"], {"tools": 1, "resources": 1, "prompts": 1}) + + def test_local_detail_marks_stdio_connection(self) -> None: + detail = _map_detail( + _DESKTOP_DETAIL_SAMPLE, qualified_name="wonderwhy-er/desktop-commander" + ) + self.assertFalse(detail["isRemote"]) + self.assertIsNone(detail["deploymentUrl"]) + self.assertEqual(detail["connections"][0]["kind"], "stdio") + self.assertEqual(detail["connections"][0]["stdioCommand"], "npx") + self.assertEqual( + detail["connections"][0]["stdioArgs"], + ["-y", "@wonderwhy-er/desktop-commander"], + ) + self.assertEqual(detail["capabilityCounts"]["tools"], 1) + self.assertEqual(detail["capabilityCounts"]["resources"], 0) + + def test_sse_connection_is_preserved(self) -> None: + detail = _map_detail(_SSE_DETAIL_SAMPLE, qualified_name="@acme/stream-server") + self.assertEqual(detail["managedName"], "stream-server") + self.assertEqual(detail["connections"][0]["kind"], "sse") + + def test_config_schema_is_preserved_as_marketplace_metadata(self) -> None: + raw = { + **_EXA_DETAIL_SAMPLE, + "connections": [ + { + "type": "http", + "deploymentUrl": "https://exa.run.tools", + "configSchema": { + "type": "object", + "required": ["headers"], + "properties": { + "headers": {"type": "object", "description": "Custom headers"}, + }, + }, + } + ], + } + + detail = _map_detail(raw, qualified_name="exa") + + self.assertEqual( + detail["connections"][0]["configSchema"], + raw["connections"][0]["configSchema"], + ) + + def test_bundle_only_local_detail_preserves_metadata(self) -> None: + raw = { + **_DESKTOP_DETAIL_SAMPLE, + "connections": [ + { + "type": "stdio", + "bundleUrl": "https://backend.smithery.ai/storage/v1/object/public/bundles/@acme/server/server.mcpb", + "runtime": "node", + "configSchema": {"type": "object", "required": [], "properties": {}}, + } + ], + } + + detail = _map_detail(raw, qualified_name="acme/server") + + self.assertEqual(detail["connections"][0]["bundleUrl"], raw["connections"][0]["bundleUrl"]) + self.assertEqual(detail["connections"][0]["runtime"], "node") + + +class McpMarketplaceCatalogTests(unittest.TestCase): + def test_popular_page_maps_response_and_reports_has_more(self) -> None: + catalog = McpMarketplaceCatalog( + fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, + cache=MarketplaceCache(), + ) + page = catalog.popular_page(limit=30, offset=0) + self.assertEqual(len(page["items"]), 2) + self.assertEqual(page["items"][0]["qualifiedName"], "exa") + self.assertTrue(page["hasMore"]) + self.assertEqual(page["nextOffset"], 2) + + def test_search_with_only_filters_bypasses_min_query(self) -> None: + catalog = McpMarketplaceCatalog( + fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, + cache=MarketplaceCache(), + ) + page = catalog.search_page("", limit=30, offset=0, remote=False) + self.assertEqual(len(page["items"]), 2) + + def test_search_requires_min_query_when_no_filters(self) -> None: + catalog = McpMarketplaceCatalog( + fetcher=lambda _path: _LIST_RESPONSE_SAMPLE, + cache=MarketplaceCache(), + ) + with self.assertRaises(ValueError): + catalog.search_page("a", limit=30, offset=0) + + def test_detail_returns_none_on_404(self) -> None: + def fetcher(_path: str) -> dict[str, object]: + raise MarketplaceUpstreamError("bad_status", "u", "x", upstream_status=404) + + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + self.assertIsNone(catalog.detail("missing")) + + def test_detail_caches_within_ttl(self) -> None: + fetcher = mock.Mock(return_value=_EXA_DETAIL_SAMPLE) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + self.assertIsNone(catalog.detail(" ")) # empty name path + first = catalog.detail("exa") + second = catalog.detail("exa") + self.assertEqual(first, second) + # MarketplaceCache() with root=None does not persist, so each call hits fetcher. + # Use a tmp-backed cache to prove single fetch. + + def test_detail_path_encoded_for_namespaced_name(self) -> None: + fetcher = mock.Mock(return_value=_DESKTOP_DETAIL_SAMPLE) + catalog = McpMarketplaceCatalog(fetcher=fetcher, cache=MarketplaceCache()) + catalog.detail("wonderwhy-er/desktop-commander") + called_path = fetcher.call_args.args[0] + self.assertEqual(called_path, "/servers/wonderwhy-er/desktop-commander") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_sources.py b/tests/unit/test_sources.py index 2d43ca9..65326e2 100644 --- a/tests/unit/test_sources.py +++ b/tests/unit/test_sources.py @@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory import unittest -from skill_manager.application.marketplace.skillssh import ( +from skill_manager.application.skills.marketplace.skillssh import ( extract_detail_description, normalize_skill, parse_homepage_leaderboard, diff --git a/tests/unit/test_symlink_harness_manager.py b/tests/unit/test_symlink_harness_manager.py deleted file mode 100644 index 0d1b9a0..0000000 --- a/tests/unit/test_symlink_harness_manager.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from tempfile import TemporaryDirectory -import unittest - -from skill_manager.errors import MutationError -from skill_manager.harness.managers import SymlinkHarnessManager - -from tests.support.fake_home import create_fake_home_spec, seed_skill_package - - -class SymlinkHarnessManagerTests(unittest.TestCase): - def test_enable_creates_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - package = seed_skill_package(spec.shared_store_root, "audit", "Audit") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - manager.enable_shared_package(package) - - link = spec.home / ".codex" / "skills" / "audit" - self.assertTrue(link.is_symlink()) - self.assertEqual(link.resolve(), package.resolve()) - - def test_enable_is_idempotent_when_already_linked(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - package = seed_skill_package(spec.shared_store_root, "audit", "Audit") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - manager.enable_shared_package(package) - manager.enable_shared_package(package) - - self.assertTrue((spec.home / ".codex" / "skills" / "audit").is_symlink()) - - def test_enable_refuses_real_directory(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - package = seed_skill_package(spec.shared_store_root, "audit", "Audit") - seed_skill_package(spec.home / ".codex" / "skills", "audit", "Local Audit") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.enable_shared_package(package) - - self.assertIn("real directory", str(ctx.exception)) - - def test_enable_refuses_foreign_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - package = seed_skill_package(spec.shared_store_root, "audit", "Audit") - other = seed_skill_package(Path(temp_dir) / "other-store", "audit", "Other Audit") - (spec.home / ".codex" / "skills" / "audit").symlink_to(other.resolve()) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.enable_shared_package(package) - - self.assertIn("points to", str(ctx.exception)) - - def test_enable_creates_parent_directory(self) -> None: - with TemporaryDirectory() as temp_dir: - package = seed_skill_package(Path(temp_dir) / "store", "audit", "Audit") - manager = SymlinkHarnessManager(Path(temp_dir) / "new-harness" / "skills") - - manager.enable_shared_package(package) - - self.assertTrue((Path(temp_dir) / "new-harness" / "skills" / "audit").is_symlink()) - - def test_disable_removes_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - package = seed_skill_package(spec.shared_store_root, "audit", "Audit") - skills_root = spec.home / ".codex" / "skills" - (skills_root / "audit").symlink_to(package.resolve()) - manager = SymlinkHarnessManager(skills_root) - - manager.disable_shared_package("audit") - - self.assertFalse((skills_root / "audit").exists()) - - def test_disable_is_idempotent_when_absent(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - manager.disable_shared_package("missing") - - self.assertFalse((spec.home / ".codex" / "skills" / "missing").exists()) - - def test_disable_refuses_real_directory(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - seed_skill_package(spec.home / ".codex" / "skills", "audit", "Local Audit") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.disable_shared_package("audit") - - self.assertIn("not a symlink", str(ctx.exception)) - - def test_adopt_local_copy_replaces_dir_with_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit") - harness_pkg = seed_skill_package(spec.home / ".codex" / "skills", "audit", "Audit") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - manager.adopt_local_copy(harness_pkg, store_pkg) - - self.assertTrue(harness_pkg.is_symlink()) - self.assertEqual(harness_pkg.resolve(), store_pkg.resolve()) - - def test_adopt_local_copy_is_idempotent_when_already_linked(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit") - link = spec.home / ".codex" / "skills" / "audit" - link.symlink_to(store_pkg.resolve()) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - manager.adopt_local_copy(link, store_pkg) - - self.assertTrue(link.is_symlink()) - - def test_adopt_local_copy_refuses_missing_directory(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.adopt_local_copy(spec.home / ".codex" / "skills" / "missing", store_pkg) - - self.assertIn("does not exist", str(ctx.exception)) - - def test_adopt_local_copy_refuses_foreign_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit") - other = seed_skill_package(Path(temp_dir) / "other", "audit", "Other") - link = spec.home / ".codex" / "skills" / "audit" - link.symlink_to(other.resolve()) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.adopt_local_copy(link, store_pkg) - - self.assertIn("points to", str(ctx.exception)) - - def test_materialize_restores_real_directory_from_shared_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit", body="shared version") - link = spec.home / ".codex" / "skills" / "audit" - link.symlink_to(store_pkg.resolve()) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - manager.materialize_binding("audit", store_pkg) - - self.assertTrue(link.is_dir()) - self.assertFalse(link.is_symlink()) - self.assertIn("shared version", (link / "SKILL.md").read_text(encoding="utf-8")) - - def test_materialize_refuses_real_directory_targets(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit") - seed_skill_package(spec.home / ".codex" / "skills", "audit", "Audit", body="local") - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.materialize_binding("audit", store_pkg) - - self.assertIn("not a symlink", str(ctx.exception)) - - def test_materialize_refuses_foreign_symlink_targets(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - store_pkg = seed_skill_package(spec.shared_store_root, "audit", "Audit") - other_pkg = seed_skill_package(Path(temp_dir) / "other-store", "audit", "Other") - (spec.home / ".codex" / "skills" / "audit").symlink_to(other_pkg.resolve()) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - - with self.assertRaises(MutationError) as ctx: - manager.materialize_binding("audit", store_pkg) - - self.assertIn("points to", str(ctx.exception)) - - def test_has_binding_detects_real_directory_and_symlink(self) -> None: - with TemporaryDirectory() as temp_dir: - spec = create_fake_home_spec(Path(temp_dir)) - manager = SymlinkHarnessManager(spec.home / ".codex" / "skills") - self.assertFalse(manager.has_binding("audit")) - - seed_skill_package(spec.home / ".codex" / "skills", "audit", "Audit") - self.assertTrue(manager.has_binding("audit")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_test_support_structure.py b/tests/unit/test_test_support_structure.py deleted file mode 100644 index 4446d4b..0000000 --- a/tests/unit/test_test_support_structure.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import unittest - - -REPO_ROOT = Path(__file__).resolve().parents[2] -TESTS_ROOT = REPO_ROOT / "tests" - - -class TestSupportStructureTests(unittest.TestCase): - def test_support_package_is_not_a_barrel_export_surface(self) -> None: - init_source = (TESTS_ROOT / "support" / "__init__.py").read_text(encoding="utf-8") - self.assertNotIn("from .app_harness import", init_source) - self.assertNotIn("from .fake_home import", init_source) - self.assertNotIn("from .marketplace_fixture import", init_source) - - def test_tests_import_support_helpers_from_concrete_modules(self) -> None: - offenders: list[str] = [] - forbidden_import = "from tests." + "support import" - for path in TESTS_ROOT.rglob("test_*.py"): - source = path.read_text(encoding="utf-8") - if forbidden_import in source: - offenders.append(str(path.relative_to(REPO_ROOT))) - self.assertEqual(offenders, []) diff --git a/tsconfig.json b/tsconfig.json index eeebd8a..b8484b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["frontend/src", "frontend/e2e", "vite.config.ts", "playwright.config.ts"] + "include": ["frontend/src", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index cd474de..ab99849 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -39,7 +39,7 @@ export default defineConfig(({ mode }) => { environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], - include: ["src/**/*.test.tsx"], + include: ["src/**/*.test.{ts,tsx}"], }, }; });