diff --git a/README.md b/README.md index 9a5bbc0..0f40ae7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Plugin Library (sidecar browse UI) +# Plugin Library -Three-pane explorer for the Plugin Catalog: plugins → skills / agents / rules → the full `SKILL.md` rendered in a reading pane, sourced live from the on-disk plugin cache. "Send to a bot" picks a target from the live `gbot` roster and confirms before sending it directly. +A local explorer for your agent plugins. Three panes: the plugins on this machine and in the marketplace → a plugin's skills, agents and rules → the full `SKILL.md` rendered in a reading pane, straight from disk. "Send to a bot" picks a target from the live [`gbot`](https://github.com/ScriptedAlchemy/grok-bot-cli) roster and asks before anything is sent. + +Ships as a Cursor / Grok Bot plugin: `/plugin-library` starts the server and opens it. ## Tour @@ -18,123 +20,94 @@ Click a skill and the full `SKILL.md` opens in the reading pane. Front matter be ![A reference file](screens/04-reference-tab.png) -"Send to a bot" pulls the live roster from `gbot` (bots and groups, with their avatars), asks for confirmation, then calls `gbot send` directly. +"Send to a bot" pulls the live roster from `gbot` (bots and groups) and ends in one sentence you confirm. Nothing is sent until you click Send. + +![Pick a bot](screens/05-pick-a-bot.png) + +![Confirm](screens/06-confirm.png) Every installed plugin gets the same treatment, not only pstack. ![Cursor Team Kit](screens/07-cursor-team-kit.png) -Marketplace listings show catalog copy and can be sent to a bot. +Marketplace listings show catalog copy and can be sent to a bot too. ![Marketplace](screens/08-marketplace.png) -Regenerate: the screenshots come from a headless-Chrome tour; keep them at 1600×1000 @2x so they match. +## Run from a checkout -## Start - -Requires Node 22.19+, a Cursor plugin cache at `~/.cursor/plugins/cache`, and -the [`gbot`](https://github.com/ScriptedAlchemy/grok-bot-cli) CLI on `PATH` for -the bot picker (the explorer works without it; the picker reports the missing -roster). +Node 22.19+. The explorer reads `~/.cursor/plugins/cache` and `~/.cursor/plugins/local`; the bot picker needs `gbot` on `PATH` (without it the explorer still works and the picker says the roster is unavailable). ```bash git clone https://github.com/ScriptedAlchemy/plugin-library.git cd plugin-library -npm start +npm start # http://127.0.0.1:8787/ +``` + +Or let the launcher manage the process: + +```bash +node bin/plugin-library.mjs open [query] [--json] [--browser] # start if needed, print or open a deep link +node bin/plugin-library.mjs status --json +node bin/plugin-library.mjs stop ``` -Default **8787** (`PORT=8787`). Binds loopback (`127.0.0.1`) by default because it shells out to `gbot` and serves cache files without auth; set `HOST=0.0.0.0` only on a trusted network. Open `http://127.0.0.1:8787/`. +`open pstack` resolves to `#/p/9717366`; `open why` to the skill inside it. `open` is idempotent: it reuses a running server or starts one detached (pid in `logs/server.pid`, output in `logs/server.out`). + +Default port **8787** (`PORT`). Binds `127.0.0.1` because it shells out to `gbot` and serves cache files without auth; set `HOST=0.0.0.0` only on a network you trust. `CURSOR_PLUGIN_CACHE`, `CURSOR_PLUGIN_LOCAL` and `GBOT_BIN` override the two plugin directories and the CLI. -## Install +## Install as a plugin -### Cursor from GitHub +The `agent-bundle-artifact` branch is the generated, validated plugin root (built by CI on every push to `main`). -The `agent-bundle-artifact` branch is the generated, validated plugin root. -In Cursor Dashboard → Plugins → Team Marketplaces, choose **Add Marketplace**, -import `https://github.com/ScriptedAlchemy/plugin-library`, and select the -`agent-bundle-artifact` branch. Then install **Plugin Library** from Customize. -Team Marketplaces require a Cursor Teams or Enterprise plan. -If you previously imported `main`, re-import the marketplace and select the -generated branch. +**Cursor, from GitHub.** Dashboard → Plugins → Team Marketplaces → Add Marketplace, import `https://github.com/ScriptedAlchemy/plugin-library`, pick the `agent-bundle-artifact` branch, then install **Plugin Library**. (Team Marketplaces need a Teams or Enterprise plan.) -#### Without team marketplace +**Cursor, without a team marketplace.** ```sh -git clone --branch agent-bundle-artifact --depth 1 \ - https://github.com/ScriptedAlchemy/plugin-library.git +git clone --branch agent-bundle-artifact --depth 1 https://github.com/ScriptedAlchemy/plugin-library.git cd plugin-library && node ./install.mjs ``` -Reload Cursor (`Developer: Reload Window`). From that clone directory, verify -with `npx --no-install agent-bundle doctor --from . --host cursor`. +Reload Cursor (`Developer: Reload Window`). `npx --no-install agent-bundle doctor --from . --host cursor` verifies the install. -For a local proof from a checkout: +**Grok Bot.** The same generated root is an Agent Plugins 1.0.0 package: `plugin.json` plus `skills/`. Hand it to the host's plugin flow; the `plugin-library` skill teaches the bot to answer skill questions from the read-only API of an already-running explorer. The `/plugin-library` slash command is Cursor-only. + +**From a source checkout**, for a local proof: ```bash -npm ci -npm run build +npm ci && npm run build node artifact/install.mjs -npx --no-install agent-bundle doctor --from artifact --host cursor ``` -### Grok Bot plugin path - -This project treats Grok Bot's plugin path as an Agent Plugins 1.0.0 consumer. -Provide the generated root from the `agent-bundle-artifact` branch to that -host-managed flow; its `plugin.json` and `skills/` are the portable projection. -The `gbot` CLI has no plugin-install command, so the local gate proves the -portable pack, not remote Grok Bot activation. +In the installed plugin the launcher lives at `scripts/plugin-library.mjs` (the command file uses `${CURSOR_PLUGIN_ROOT}/scripts/plugin-library.mjs`); in this repo it is `bin/plugin-library.mjs`. -In a Cursor agent session: +Then in an agent session: - `/plugin-library` opens the explorer (embedded browser when the session has one, OS browser otherwise). - `/plugin-library pstack` or `/plugin-library why` deep-links to that plugin or skill. -On Grok Bot, load the `plugin-library` skill and use its read-only API against -an already-running explorer; the slash command is Cursor-only. +Agents never call `POST /api/send`; the bundled command and skill both say so. Sending to a bot is a click you make. -The command drives `scripts/plugin-library.mjs`, which is idempotent: it reuses -a running server or starts one detached (pid in `logs/server.pid`, output in -`logs/server.out`) and waits for `/api/library` to answer. - -```bash -node artifact/scripts/plugin-library.mjs open [query] [--json] [--browser] -node artifact/scripts/plugin-library.mjs status --json -node artifact/scripts/plugin-library.mjs stop -``` - -The bundled skill also tells agents how to answer skill questions from the read-only API without opening a window. Sending to a bot stays a user-confirmed click. - -`npm test` builds the generated artifact, then runs the `node --test` suite -(installer lifecycle, front matter, cache dedupe, path containment, -direct `gbot send`, and an HTTP smoke test against a throwaway cache). - -## Browse API (read-only) +## API | Route | Returns | |-------|---------| -| `GET /api/library` | `{ installed, marketplace, groups }`: catalog rows joined to the on-disk plugin cache (`~/.cursor/plugins/cache`), with skills, agents, rules and logo per installed plugin | -| `GET /api/local///doc/` | `{ meta, markdown }` for any `.md`/`.mdc` inside that cached plugin | -| `GET /api/local///file/` | any other file inside that cached plugin (logos, scripts). Symlinks and `..` that leave the plugin root are 404 | +| `GET /api/library` | `{ installed, marketplace, groups }`. *Installed* means present in this machine's plugin cache or local plugin directory; catalog rows supply copy, category and ids. Plugins with no catalog row get `local:`-style ids. | +| `GET /api/local///doc/` | `{ meta, markdown }` for any `.md`/`.mdc` inside that plugin | +| `GET /api/local///file/` | any other file inside that plugin (logos, scripts). Symlinks and `..` that leave the plugin root are 404 | | `GET /api/bots` | live bots + groups from `gbot bots list` / `gbot groups list` (20s cache; `?refresh=1` bypasses) | +| `POST /api/send` | `{ plugin_id, bot_ref, skill_id? }`, same-origin JSON only. Verifies the plugin, skill and target, then runs `gbot send `. Used by the UI after you confirm. | -Override the cache root with `CURSOR_PLUGIN_CACHE`, the CLI with `GBOT_BIN`. +## Data -## Send contract +`data/unified-catalog.json` is a snapshot of the Cursor marketplace (names, descriptions, categories, ids); `data/pstack.json` is the Catalog's suggested grouping for pstack's skills. Both are read server-side only and copied into the built plugin. Everything else (skills, agents, rules, logos) is read live from disk. When a plugin is cached twice (numeric id and slug), the copy Cursor marked `.installed` wins, then the highest version. -The UI calls `POST /api/send` only after the user confirms: +## Develop -```json -{ "plugin_id": "…", "bot_ref": "…", "skill_id?": "…" } +```bash +npm ci +npm run check # agent-bundle validate, tsc, build, node --test, artifact validate, packed-install smoke ``` -The server verifies the plugin and optional skill against its catalog, then -runs `gbot send --json`. There is no queue, Applier, install -branch, or per-bot attach fallback. - -## Data - -Catalog JSON in `data/` (from Plugin Catalog) is copied to `assets/data/` in -the built plugin and read server-side only. pstack skill grouping: -`data/pstack.json`. When a plugin is cached twice (numeric id and slug), the -copy marked `.installed` wins, then the highest version. +Screenshots in `screens/` are 1600×1000 @2x from a headless-Chrome tour with a stub `gbot` roster. diff --git a/data/installed-index.json b/data/installed-index.json deleted file mode 100644 index 642163b..0000000 --- a/data/installed-index.json +++ /dev/null @@ -1,177 +0,0 @@ -{ - "schema": "plugin-catalog.index.v1", - "source": "SearchPlugins", - "marketplace_count": 367, - "installed_count": 24, - "installed": [ - { - "plugin_id": "3644", - "name": "Agent Compatibility", - "skill_count": 1, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "47477538", - "name": "Browser Use", - "skill_count": 1, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "21020287", - "name": "Chrome Devtools for Agents", - "skill_count": 6, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "677", - "name": "Cursor Team Kit", - "skill_count": 18, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "6306", - "name": "Docs Canvas", - "skill_count": 1, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "3579816", - "name": "frontend-design", - "skill_count": 1, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "48677658", - "name": "GitHub", - "skill_count": 0, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "45893410", - "name": "Gmail", - "skill_count": 0, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "45893411", - "name": "Google Calendar", - "skill_count": 0, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "736", - "name": "Grafana Labs", - "skill_count": 1, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "56286216", - "name": "Kody", - "skill_count": 0, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "3579830", - "name": "mcp-server-dev", - "skill_count": 3, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "6307", - "name": "PR Review Canvas", - "skill_count": 1, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "9717366", - "name": "pstack", - "skill_count": 47, - "connector_count": 0, - "installed": true, - "detail": "pstack.json" - }, - { - "plugin_id": "3579838", - "name": "pyright-lsp", - "skill_count": 0, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "3579841", - "name": "rust-analyzer-lsp", - "skill_count": 0, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "6948", - "name": "shadcn/ui", - "skill_count": 1, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "674", - "name": "Slack", - "skill_count": 6, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "55647251", - "name": "Superdesign", - "skill_count": 1, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "684", - "name": "Superpowers", - "skill_count": 14, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "13566132", - "name": "Thermos", - "skill_count": 3, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "3579849", - "name": "typescript-lsp", - "skill_count": 0, - "connector_count": 0, - "installed": true - }, - { - "plugin_id": "49086599", - "name": "X", - "skill_count": 1, - "connector_count": 1, - "installed": true - }, - { - "plugin_id": "3579800", - "name": "xclaude-plugin", - "skill_count": 8, - "connector_count": 8, - "installed": true - } - ] -} diff --git a/lib/local-plugins.js b/lib/local-plugins.js index c650060..3bb3778 100644 --- a/lib/local-plugins.js +++ b/lib/local-plugins.js @@ -4,10 +4,12 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); -const CACHE_ROOTS = [ - process.env.CURSOR_PLUGIN_CACHE, - path.join(os.homedir(), ".cursor", "plugins", "cache"), -].filter(Boolean); +// Marketplace installs land in cache////; +// hand-installed plugins sit directly in local//. Env overrides exist +// for tests and for hosts that keep the plugin directory elsewhere. +const CACHE_ROOT = process.env.CURSOR_PLUGIN_CACHE || path.join(os.homedir(), ".cursor", "plugins", "cache"); +const LOCAL_ROOT = process.env.CURSOR_PLUGIN_LOCAL || path.join(os.homedir(), ".cursor", "plugins", "local"); +const LOCAL_MARKETPLACE = "local"; const INDEX_TTL_MS = 15_000; let cached = { at: 0, plugins: [] }; @@ -179,37 +181,54 @@ function normalizeName(s) { .replace(/[^a-z0-9]+/g, ""); } -/** One directory in the cache, before any file inside it has been read. */ -function scanCandidates(cacheRoot) { +function readManifest(root) { + return ( + readJsonFile(path.join(root, ".cursor-plugin", "plugin.json")) || + readJsonFile(path.join(root, "plugin.json")) || + {} + ); +} + +/** One plugin directory, before any file inside it has been read. */ +function candidate({ marketplace, slug, version, root, installedMarker }) { + const manifest = readManifest(root); + return { + key: `${marketplace}/${slug}`, + marketplace, + slug, + version, + root, + manifest, + installedMarker, + mtime: mtimeOf(root), + packageName: manifest.name || slug, + semver: typeof manifest.version === "string" ? manifest.version : null, + }; +} + +function scanCache(cacheRoot) { const out = []; for (const marketplace of listDirs(cacheRoot)) { for (const slug of listDirs(path.join(cacheRoot, marketplace))) { const pluginDir = path.join(cacheRoot, marketplace, slug); for (const version of listDirs(pluginDir).filter((v) => !v.startsWith("."))) { const root = path.join(pluginDir, version); - const manifest = - readJsonFile(path.join(root, ".cursor-plugin", "plugin.json")) || - readJsonFile(path.join(root, "plugin.json")) || - {}; - out.push({ - key: `${marketplace}/${slug}`, - marketplace, - slug, - version, - root, - manifest, - // Cursor marks the version it actually installed with a sibling `.installed`. - installedMarker: fs.existsSync(`${root}.installed`), - mtime: mtimeOf(root), - packageName: manifest.name || slug, - semver: typeof manifest.version === "string" ? manifest.version : null, - }); + // Cursor marks the version it actually installed with a sibling `.installed`. + out.push(candidate({ marketplace, slug, version, root, installedMarker: fs.existsSync(`${root}.installed`) })); } } } return out; } +function scanLocal(localRoot) { + return listDirs(localRoot) + .filter((name) => !name.startsWith(".")) + .map((name) => + candidate({ marketplace: LOCAL_MARKETPLACE, slug: name, version: "local", root: path.join(localRoot, name), installedMarker: true }), + ); +} + function readContents(c) { const { manifest, root } = c; return { @@ -282,8 +301,7 @@ function dedupe(candidates) { function indexLocalPlugins() { if (Date.now() - cached.at < INDEX_TTL_MS) return cached.plugins; - const candidates = []; - for (const root of CACHE_ROOTS) candidates.push(...scanCandidates(root)); + const candidates = [...scanCache(CACHE_ROOT), ...scanLocal(LOCAL_ROOT)]; cached = { at: Date.now(), plugins: dedupe(candidates).map(readContents) }; return cached.plugins; } diff --git a/package-lock.json b/package-lock.json index b8db817..25110a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plugin-library", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plugin-library", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@types/node": "^24.0.0", diff --git a/package.json b/package.json index 66720e2..5de3e0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plugin-library", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "Plugin Library explorer: browse installed and marketplace plugins, read every skill in full, hand one to a bot.", "license": "MIT", diff --git a/public/app.js b/public/app.js index fce53ab..d2ccb04 100644 --- a/public/app.js +++ b/public/app.js @@ -157,7 +157,8 @@ if (L && L.author) byline.push(esc(L.author)); if (L && L.semver) byline.push(`v${esc(L.semver)}`); if (p.category) byline.push(esc(p.category)); - byline.push(`#${esc(p.plugin_id)}`); + if (/^\d+$/.test(p.plugin_id)) byline.push(`#${esc(p.plugin_id)}`); + else if (L) byline.push(`${esc(L.key)}`); const chips = [p.installed ? `Installed` : `Not installed`]; if (L) { diff --git a/screens/05-pick-a-bot.png b/screens/05-pick-a-bot.png new file mode 100644 index 0000000..79e7885 Binary files /dev/null and b/screens/05-pick-a-bot.png differ diff --git a/screens/06-confirm.png b/screens/06-confirm.png new file mode 100644 index 0000000..54b6a97 Binary files /dev/null and b/screens/06-confirm.png differ diff --git a/server.js b/server.js index dc0428c..f856c0e 100755 --- a/server.js +++ b/server.js @@ -10,6 +10,7 @@ import bots from "./lib/bots.js"; const { findLocalPlugin, getLocalPlugin, + indexLocalPlugins, toPublic, resolveInside, parseFrontMatter, @@ -23,7 +24,7 @@ const PUBLIC_DIR = fs.existsSync(path.join(ROOT, "public")) : path.join(ROOT, "assets", "public"); const PORT = Number(process.env.PORT || 8787); // Loopback by default: this server shells out to gbot and serves cache files -// without auth. Set HOST=0.0.0.0 explicitly when exposing over Tailscale. +// without auth. Set HOST=0.0.0.0 explicitly only on a network you trust. const HOST = process.env.HOST || "127.0.0.1"; const MIME = { @@ -182,45 +183,59 @@ function oneLine(s) { } /** - * The browse model, joined once here: installed rows from the index, catalog - * copy from the unified catalog, on-disk contents from the plugin cache, and - * the Catalog's suggested skill grouping for pstack. + * Plugins present on this machine but absent from the catalog get their cache + * key as id (`local:gbot`, `cursor-public:foo`). Catalog ids are numeric, so + * the colon marks a synthetic id unambiguously and keeps the hash route intact. + */ +const syntheticId = (p) => `${p.marketplace}:${p.slug}`; + +/** + * The browse model, joined once here. "Installed" means present in this + * machine's plugin cache or local plugin directory; the catalog supplies + * marketplace copy, category and ids, and the Catalog's pstack dump supplies + * skill grouping. Catalog rows that name their cache directory are matched + * first so a looser name match can't claim a plugin another row owns. */ function buildLibrary() { - const installedRows = readJson("data/installed-index.json").installed || []; - const catalog = readJson("data/unified-catalog.json").plugins || []; - const catalogById = new Map(catalog.map((p) => [String(p.stableId ?? ""), p])); + const catalog = (readJson("data/unified-catalog.json").plugins || []).filter((p) => p.stableId != null); + const cacheHint = (p) => (p.cache && p.cache.slug ? `${p.cache.marketplace}/${p.cache.slug}` : undefined); + const ordered = [...catalog.filter(cacheHint), ...catalog.filter((p) => !cacheHint(p))]; - const installed = installedRows.map((row) => { - const id = String(row.plugin_id); - const cat = catalogById.get(id); - const hint = cat && cat.cache && cat.cache.slug ? `${cat.cache.marketplace}/${cat.cache.slug}` : undefined; - const local = findLocalPlugin(id, row.name, hint); - return { + const claimed = new Set(); + const installed = []; + const marketplace = []; + for (const cat of ordered) { + const id = String(cat.stableId); + const hit = findLocalPlugin(id, cat.name, cacheHint(cat)); + const local = hit && !claimed.has(hit.key) ? hit : null; + if (local) claimed.add(local.key); + (local ? installed : marketplace).push({ plugin_id: id, - name: row.name, - description: oneLine(row.description) || (local && local.description) || oneLine(cat && cat.description), - category: (cat && cat.category) || null, - installed: true, - skill_count: local ? local.skills.length : Number(row.skill_count) || 0, - connector_count: Number(row.connector_count) || 0, + name: cat.name || (local && local.name) || "(unnamed)", + description: oneLine(cat.description) || oneLine(local && local.description), + category: cat.category || null, + installed: Boolean(local), + skill_count: local ? local.skills.length : Number(cat.skillCountReported ?? cat.skillCount) || 0, + connector_count: Number(cat.connectorCount) || 0, local: local ? toPublic(local) : null, - }; - }); - const installedIds = new Set(installed.map((p) => p.plugin_id)); - - const marketplace = catalog - .filter((p) => p.stableId != null && !installedIds.has(String(p.stableId))) - .map((p) => ({ - plugin_id: String(p.stableId), - name: p.name || "(unnamed)", + }); + } + // A directory named after a catalog id whose row already claimed its slug + // twin is the same plugin cached twice, not a second install. + const catalogIds = new Set(catalog.map((p) => String(p.stableId))); + for (const p of indexLocalPlugins()) { + if (claimed.has(p.key) || catalogIds.has(p.slug)) continue; + installed.push({ + plugin_id: syntheticId(p), + name: p.name, description: oneLine(p.description), - category: p.category || null, - installed: false, - skill_count: Number(p.skillCountReported ?? p.skillCount) || 0, - connector_count: Number(p.connectorCount) || 0, - local: null, - })); + category: null, + installed: true, + skill_count: p.skills.length, + connector_count: p.hasMcp ? 1 : 0, + local: toPublic(p), + }); + } const groups = {}; try { diff --git a/test/data.test.js b/test/data.test.js new file mode 100644 index 0000000..1866d0b --- /dev/null +++ b/test/data.test.js @@ -0,0 +1,24 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); + +const DATA = path.join(__dirname, "..", "data"); + +// The Plugin Catalog bot regenerates data/ from a sandbox and likes to include +// its own absolute paths. None of them belong in a public repo, and the server +// never reads them. +test("data/ carries no machine or sandbox paths", () => { + for (const f of fs.readdirSync(DATA).filter((n) => n.endsWith(".json"))) { + const text = fs.readFileSync(path.join(DATA, f), "utf8"); + const hit = /"(\/home\/|\/workspace\/|\/Users\/|\/Volumes\/)[^"]*"/.exec(text); + assert.equal(hit, null, `${f} contains ${hit && hit[0]}`); + JSON.parse(text); + } +}); + +test("data/ holds exactly the files the server reads", () => { + assert.deepEqual(fs.readdirSync(DATA).sort(), ["pstack.json", "unified-catalog.json"]); +}); diff --git a/test/install.test.js b/test/install.test.js index 3512ac6..9ee444f 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -9,6 +9,8 @@ const { spawnSync } = require("node:child_process"); const artifactRoot = path.join(__dirname, "..", "artifact"); const installer = path.join(artifactRoot, "install.mjs"); +const { version } = require("../package.json"); +const V = version.replace(/\./g, "\\."); function makeHome() { const home = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-library-install-")); @@ -35,7 +37,7 @@ test("generated installer copies only the artifact and writes a lifecycle receip const result = runInstaller(home); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Installed plugin-library@0\.3\.0/); + assert.match(result.stdout, new RegExp(`Installed plugin-library@${V}`)); const installed = path.join(home, ".cursor", "plugins", "local", "plugin-library"); assert.equal(fs.lstatSync(installed).isSymbolicLink(), false); @@ -53,7 +55,7 @@ test("generated installer copies only the artifact and writes a lifecycle receip ); assert.equal(receipt.format, "agent-bundle-install-receipt/2"); assert.equal(receipt.plugin, "plugin-library"); - assert.equal(receipt.version, "0.3.0"); + assert.equal(receipt.version, version); assert.equal(receipt.host, "cursor"); assert.equal(receipt.mode, "local"); assert.equal(receipt.scope, "user"); @@ -72,7 +74,7 @@ test("generated installer is idempotent for identical content", (t) => { assert.equal(runInstaller(home).status, 0); const second = runInstaller(home); assert.equal(second.status, 0, second.stderr); - assert.match(second.stdout, /Already installed plugin-library@0\.3\.0/); + assert.match(second.stdout, new RegExp(`Already installed plugin-library@${V}`)); }); test("generated installer plans and performs receipt-owned uninstall", (t) => { @@ -83,11 +85,11 @@ test("generated installer plans and performs receipt-owned uninstall", (t) => { assert.equal(runInstaller(home).status, 0); const plan = runInstaller(home, ["--uninstall", "--plan"]); assert.equal(plan.status, 0, plan.stderr); - assert.match(plan.stdout, /Would uninstall plugin-library@0\.3\.0/); + assert.match(plan.stdout, new RegExp(`Would uninstall plugin-library@${V}`)); assert.equal(fs.existsSync(installed), true); const uninstall = runInstaller(home, ["--uninstall"]); assert.equal(uninstall.status, 0, uninstall.stderr); - assert.match(uninstall.stdout, /Uninstalled plugin-library@0\.3\.0/); + assert.match(uninstall.stdout, new RegExp(`Uninstalled plugin-library@${V}`)); assert.equal(fs.existsSync(installed), false); }); diff --git a/test/local-plugins.test.js b/test/local-plugins.test.js index a23f667..e4dbbb3 100644 --- a/test/local-plugins.test.js +++ b/test/local-plugins.test.js @@ -8,6 +8,7 @@ const path = require("path"); const { makeCache } = require("./helpers"); process.env.CURSOR_PLUGIN_CACHE = makeCache(); +process.env.CURSOR_PLUGIN_LOCAL = fs.mkdtempSync(path.join(os.tmpdir(), "pl-local-empty-")); const lib = require("../lib/local-plugins"); test("parseFrontMatter: quoted, folded, literal, nested map", () => { diff --git a/test/server.test.js b/test/server.test.js index 4247534..8c63e73 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -26,6 +26,13 @@ test.before(async () => { ); fs.writeFileSync(path.join(pstack, "skills", "alpha", "SKILL.md"), "# Alpha\n"); fs.writeFileSync(`${pstack}.installed`, ""); + // The same plugin cached again under its catalog id: an alias, not a second install. + fs.cpSync(pstack, path.join(cache, "cursor-public", "9717366", "hash"), { recursive: true }); + // A hand-installed plugin in plugins/local// with no catalog row. + const local = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-library-local-")); + fs.mkdirSync(path.join(local, "handmade", "skills", "solo"), { recursive: true }); + fs.writeFileSync(path.join(local, "handmade", "plugin.json"), JSON.stringify({ name: "handmade", displayName: "Handmade" })); + fs.writeFileSync(path.join(local, "handmade", "skills", "solo", "SKILL.md"), "# Solo\n"); fs.writeFileSync(gbot, `#!/usr/bin/env node const fs = require("node:fs"); const args = process.argv.slice(2).filter((arg) => arg !== "--json"); @@ -34,7 +41,7 @@ process.stdout.write(JSON.stringify(args[0] === "send" ? { ok: true } : args[0] `); fs.chmodSync(gbot, 0o755); child = spawn(process.execPath, [path.join(__dirname, "..", "server.js")], { - env: { ...process.env, PORT: "0", CURSOR_PLUGIN_CACHE: cache, GBOT_BIN: gbot, GBOT_CALLS: gbotCalls }, + env: { ...process.env, PORT: "0", CURSOR_PLUGIN_CACHE: cache, CURSOR_PLUGIN_LOCAL: local, GBOT_BIN: gbot, GBOT_CALLS: gbotCalls }, stdio: ["ignore", "pipe", "pipe"], }); const port = await new Promise((resolve, reject) => { @@ -53,13 +60,20 @@ test.after(() => child && child.kill()); const status = async (p, init) => (await fetch(base + p, init)).status; -test("/api/library joins installed rows to the cache", async () => { +test("/api/library: installed means present in this machine's cache or local dir", async () => { const j = await (await fetch(base + "/api/library")).json(); assert.equal(j.ok, true); - assert.ok(Array.isArray(j.installed) && Array.isArray(j.marketplace)); - assert.ok(j.installed.length > 0); - assert.ok(j.installed.every((p) => typeof p.plugin_id === "string" && typeof p.skill_count === "number")); - assert.ok(j.installed.every((p) => !p.local || !("root" in p.local)), "no filesystem paths leak"); + const ids = j.installed.map((p) => p.plugin_id).sort(); + assert.deepEqual(ids, ["9717366", "local:handmade", "mkt:demo-kit", "mkt:solo"]); + const pstack = j.installed.find((p) => p.plugin_id === "9717366"); + assert.equal(pstack.local.key, "cursor-public/pstack", "catalog row claims the slug dir, alias dir is folded in"); + assert.equal(pstack.category, "MCP", "catalog copy joins onto the cached plugin"); + const handmade = j.installed.find((p) => p.plugin_id === "local:handmade"); + assert.equal(handmade.name, "Handmade"); + assert.equal(handmade.skill_count, 1); + assert.ok(j.marketplace.every((p) => !p.installed && p.local === null)); + assert.ok(!j.marketplace.some((p) => p.plugin_id === "9717366"), "an installed plugin is not also listed for sale"); + assert.ok(j.installed.every((p) => !("root" in p.local)), "no filesystem paths leak"); }); test("doc and file routes serve from inside the plugin root only", async () => {