From bb28302515d9b74d6c6575fe92e291dca7b580e0 Mon Sep 17 00:00:00 2001 From: zFarbp Date: Wed, 23 Sep 2026 11:17:34 +0200 Subject: [PATCH] feat: agents plugin view --- package-lock.json | 6 +- packages/server/package.json | 2 +- packages/server/src/routes/plugins.ts | 550 +++++++++++++ packages/server/src/server.ts | 2 + packages/server/test/plugins.test.ts | 227 ++++++ packages/shared/package.json | 2 +- packages/web/package.json | 2 +- packages/web/src/lib/paletteTabs.ts | 1 + packages/web/src/panels/ArtifactPreview.vue | 86 ++- .../web/src/panels/DetailExpandControls.vue | 35 + packages/web/src/panels/DetailExpandModal.vue | 50 ++ packages/web/src/panels/MemoryBrowser.vue | 30 +- packages/web/src/panels/Palette.vue | 5 + packages/web/src/panels/PluginsBrowser.vue | 730 ++++++++++++++++++ packages/web/src/panels/SessionInfoPanel.vue | 134 +++- .../src/panels/palette/PalettePluginsPane.vue | 199 +++++ packages/web/src/panels/palette/index.ts | 1 + packages/web/src/views/GraphList.vue | 5 + packages/web/src/views/LineageView.vue | 154 +++- packages/web/src/views/MapView.vue | 57 +- packages/web/src/views/SessionBlueprint.vue | 288 ++++++- packages/web/src/views/SessionGrowth.vue | 73 +- packages/web/src/views/TimelineView.vue | 81 +- .../web/src/views/dashboard/AgentsView.vue | 191 ++++- .../web/src/views/dashboard/FilesView.vue | 80 +- .../web/src/views/dashboard/LibraryView.vue | 71 +- packages/web/src/views/dashboard/MetaView.vue | 147 +++- .../web/src/views/dashboard/SessionsView.vue | 45 +- packages/web/src/views/dashboard/chrome.css | 83 +- 29 files changed, 3245 insertions(+), 92 deletions(-) create mode 100644 packages/server/src/routes/plugins.ts create mode 100644 packages/server/test/plugins.test.ts create mode 100644 packages/web/src/panels/DetailExpandControls.vue create mode 100644 packages/web/src/panels/DetailExpandModal.vue create mode 100644 packages/web/src/panels/PluginsBrowser.vue create mode 100644 packages/web/src/panels/palette/PalettePluginsPane.vue diff --git a/package-lock.json b/package-lock.json index 0255c74..eb6f7ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6935,7 +6935,7 @@ }, "packages/server": { "name": "threadle", - "version": "1.0.9", + "version": "1.0.10", "license": "MIT", "dependencies": { "@hono/node-server": "^1.13.7", @@ -6966,14 +6966,14 @@ }, "packages/shared": { "name": "@threadle/shared", - "version": "1.0.9", + "version": "1.0.10", "dependencies": { "zod": "^4.6.5" } }, "packages/web": { "name": "@threadle/web", - "version": "1.0.9", + "version": "1.0.10", "dependencies": { "@threadle/shared": "*", "@vue-flow/background": "^1.3.2", diff --git a/packages/server/package.json b/packages/server/package.json index 38ffa77..e24a076 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "threadle", - "version": "1.0.9", + "version": "1.0.10", "description": "Local-first node-graph patchbay for Claude Code, opencode, Cursor Agent, Antigravity, Codex, Copilot, and Grok Build sessions.", "type": "module", "license": "MIT", diff --git a/packages/server/src/routes/plugins.ts b/packages/server/src/routes/plugins.ts new file mode 100644 index 0000000..a1f32b1 --- /dev/null +++ b/packages/server/src/routes/plugins.ts @@ -0,0 +1,550 @@ +import { Hono } from "hono"; +import fs from "node:fs"; +import path from "node:path"; +import { claudeHome } from "../providers/claude-code/discover.js"; +import { codexHome } from "../providers/codex/paths.js"; +import { copilotHome } from "../providers/copilot/paths.js"; +import { cursorHome } from "../providers/cursor/paths.js"; +import { grokHome } from "../providers/grok/paths.js"; +import { pathContained } from "../path-safe.js"; + +export type PluginProvider = + | "claude-code" + | "codex" + | "copilot" + | "grok" + | "cursor"; + +export type PluginOriginKind = + | "marketplace-catalog" + | "installed-tree" + | "cache" + | "skill-bundle"; + +export type PluginState = "catalog-only" | "cached" | "installed"; + +export type PluginChildKind = "skill" | "agent" | "mcp" | "command"; + +export interface PluginChild { + kind: PluginChildKind; + name: string; + description?: string; + path?: string; +} + +export interface PluginEntry { + provider: PluginProvider; + id: string; + name: string; + version?: string; + description?: string; + origin: { + kind: PluginOriginKind; + path: string; + marketplaceId?: string; + }; + state: PluginState; + children: PluginChild[]; +} + +interface ManifestFields { + name?: string; + version?: string; + description?: string; + displayName?: string; +} + +function readJson(file: string): unknown { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return undefined; + } +} + +function asRecord(v: unknown): Record | undefined { + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : undefined; +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.trim() ? v.trim() : undefined; +} + +function parseManifest(file: string): ManifestFields { + const raw = asRecord(readJson(file)); + if (!raw) return {}; + const iface = asRecord(raw.interface); + return { + name: str(raw.name), + version: str(raw.version), + description: str(raw.description) ?? str(iface?.shortDescription), + displayName: str(iface?.displayName), + }; +} + +/** Minimal YAML frontmatter: name + description (string or folded) */ +function skillFrontmatter(content: string): { name?: string; description?: string } { + if (!content.startsWith("---")) return {}; + const end = content.indexOf("\n---", 3); + if (end < 0) return {}; + const block = content.slice(3, end); + let name: string | undefined; + let description: string | undefined; + const lines = block.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const nm = /^name:\s*(.+)\s*$/.exec(line); + if (nm) { + name = nm[1]!.replace(/^["']|["']$/g, "").trim(); + continue; + } + const ds = /^description:\s*(.*)$/.exec(line); + if (ds) { + let rest = ds[1]!.trim(); + if (rest === ">" || rest === "|" || rest === "") { + const parts: string[] = []; + for (let j = i + 1; j < lines.length; j++) { + const L = lines[j]!; + if (/^\S/.test(L) && !/^\s/.test(L)) break; + if (/^[a-zA-Z0-9_-]+:\s*/.test(L) && !/^\s/.test(L)) break; + parts.push(L.replace(/^\s+/, "")); + i = j; + } + description = parts.join(" ").trim() || undefined; + } else { + description = rest.replace(/^["']|["']$/g, "").trim() || undefined; + } + } + } + return { name, description }; +} + +async function readSkillMeta( + skillMd: string, +): Promise<{ name: string; description?: string }> { + const folder = path.basename(path.dirname(skillMd)); + try { + const raw = await fs.promises.readFile(skillMd, "utf8"); + const fm = skillFrontmatter(raw.slice(0, 4_000)); + return { + name: fm.name || folder, + description: fm.description, + }; + } catch { + return { name: folder }; + } +} + +async function walkNamedFiles( + root: string, + fileName: string, + maxDepth = 8, +): Promise { + const out: string[] = []; + async function walk(dir: string, depth: number): Promise { + if (depth > maxDepth) return; + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.name === ".git" || e.name === "node_modules") continue; + const abs = path.join(dir, e.name); + if (!pathContained(abs, root) && abs !== root) continue; + if (e.isDirectory()) { + await walk(abs, depth + 1); + } else if (e.isFile() && e.name === fileName) { + out.push(abs); + } + } + } + await walk(root, 0); + return out; +} + +async function listPluginChildren(pluginRoot: string): Promise { + const children: PluginChild[] = []; + + async function addSkills(skillsRoot: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (!e.isDirectory()) continue; + const skillMd = path.join(skillsRoot, e.name, "SKILL.md"); + try { + await fs.promises.access(skillMd); + } catch { + continue; + } + const meta = await readSkillMeta(skillMd); + children.push({ + kind: "skill", + name: meta.name, + description: meta.description, + path: skillMd, + }); + } + } + + async function addMdDir( + dirName: string, + kind: PluginChildKind, + ): Promise { + const dir = path.join(pluginRoot, dirName); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (!e.isFile() || !e.name.endsWith(".md")) continue; + children.push({ + kind, + name: e.name.replace(/\.md$/i, ""), + path: path.join(dir, e.name), + }); + } + } + + await addSkills(path.join(pluginRoot, "skills")); + await addMdDir("agents", "agent"); + await addMdDir("commands", "command"); + + // MCP hint from .mcp.json / mcpServers + for (const mcpName of [".mcp.json", "mcp.json"]) { + const mcpPath = path.join(pluginRoot, mcpName); + const raw = asRecord(readJson(mcpPath)); + const servers = asRecord(raw?.mcpServers) ?? asRecord(raw?.servers); + if (servers) { + for (const key of Object.keys(servers)) { + children.push({ kind: "mcp", name: key, path: mcpPath }); + } + } + } + + return children; +} + +function pluginsRootClaude(): string { + return path.join(claudeHome(), "plugins"); +} + +/** Claude marketplace checkouts under ~/.claude/plugins/marketplaces/ */ +export async function listClaudePlugins(): Promise { + const root = pluginsRootClaude(); + const knownPath = path.join(root, "known_marketplaces.json"); + const known = asRecord(readJson(knownPath)) ?? {}; + const marketplacesDir = path.join(root, "marketplaces"); + + const marketplaceIds = new Set(); + const installById = new Map(); + + for (const [id, meta] of Object.entries(known)) { + marketplaceIds.add(id); + const m = asRecord(meta); + const loc = str(m?.installLocation); + if (loc) installById.set(id, loc); + } + + try { + for (const name of await fs.promises.readdir(marketplacesDir)) { + marketplaceIds.add(name); + if (!installById.has(name)) { + installById.set(name, path.join(marketplacesDir, name)); + } + } + } catch { + /* no marketplaces dir */ + } + + const out: PluginEntry[] = []; + const seen = new Set(); + + for (const marketplaceId of marketplaceIds) { + const base = installById.get(marketplaceId); + if (!base) continue; + const manifestPaths = await walkNamedFiles(base, "plugin.json", 6); + for (const manifestPath of manifestPaths) { + if (!manifestPath.includes(`${path.sep}.claude-plugin${path.sep}`)) { + continue; + } + const pluginRoot = path.dirname(path.dirname(manifestPath)); + if (seen.has(pluginRoot)) continue; + seen.add(pluginRoot); + const fields = parseManifest(manifestPath); + const id = fields.name || path.basename(pluginRoot); + out.push({ + provider: "claude-code", + id, + name: fields.displayName || id, + version: fields.version, + description: fields.description, + origin: { + kind: "marketplace-catalog", + path: pluginRoot, + marketplaceId, + }, + state: "catalog-only", + children: await listPluginChildren(pluginRoot), + }); + } + } + + return out; +} + +/** Codex cached curated plugins under ~/.codex/plugins/cache/ */ +export async function listCodexPlugins(): Promise { + const cacheRoot = path.join(codexHome(), "plugins", "cache"); + const manifests = await walkNamedFiles(cacheRoot, "plugin.json", 8); + const out: PluginEntry[] = []; + const seen = new Set(); + + for (const manifestPath of manifests) { + if (!manifestPath.includes(`${path.sep}.codex-plugin${path.sep}`)) continue; + const pluginRoot = path.dirname(path.dirname(manifestPath)); + if (seen.has(pluginRoot)) continue; + seen.add(pluginRoot); + + const fields = parseManifest(manifestPath); + const id = fields.name || path.basename(pluginRoot); + const version = + fields.version || + path.basename(pluginRoot).match(/^\d+\.\d+/)?.[0] || + undefined; + + // Parent may hold remote id sidecar + let marketplaceId: string | undefined; + const sidecar = path.join( + path.dirname(pluginRoot), + ".codex-remote-plugin-install.json", + ); + const side = asRecord(readJson(sidecar)); + marketplaceId = str(side?.remote_plugin_id); + + // cache//// + const parts = pluginRoot.split(path.sep); + const cacheIdx = parts.lastIndexOf("cache"); + if (cacheIdx >= 0 && parts[cacheIdx + 1]) { + marketplaceId = marketplaceId ?? parts[cacheIdx + 1]; + } + + out.push({ + provider: "codex", + id, + name: fields.displayName || id, + version, + description: fields.description, + origin: { + kind: "cache", + path: pluginRoot, + marketplaceId, + }, + state: "cached", + children: await listPluginChildren(pluginRoot), + }); + } + + return out; +} + +/** Copilot installed-plugins tree (often empty) */ +export async function listCopilotPlugins(): Promise { + const root = path.join(copilotHome(), "installed-plugins"); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(root, { withFileTypes: true }); + } catch { + return []; + } + + const out: PluginEntry[] = []; + for (const e of entries) { + if (!e.isDirectory()) continue; + if (e.name.startsWith(".")) continue; + const pluginRoot = path.join(root, e.name); + // Prefer known manifest locations + const candidates = [ + path.join(pluginRoot, ".claude-plugin", "plugin.json"), + path.join(pluginRoot, ".codex-plugin", "plugin.json"), + path.join(pluginRoot, ".grok-plugin", "plugin.json"), + path.join(pluginRoot, "plugin.json"), + path.join(pluginRoot, "package.json"), + ]; + let fields: ManifestFields = {}; + for (const c of candidates) { + if (fs.existsSync(c)) { + fields = parseManifest(c); + break; + } + } + const id = fields.name || e.name; + out.push({ + provider: "copilot", + id, + name: fields.displayName || id, + version: fields.version, + description: fields.description, + origin: { kind: "installed-tree", path: pluginRoot }, + state: "installed", + children: await listPluginChildren(pluginRoot), + }); + } + return out; +} + +/** Grok installed-plugins + marketplace-cache packs */ +export async function listGrokPlugins(): Promise { + const out: PluginEntry[] = []; + const seen = new Set(); + + async function addFromRoot( + root: string, + originKind: PluginOriginKind, + state: PluginState, + marketplaceId?: string, + ): Promise { + const manifests = await walkNamedFiles(root, "plugin.json", 8); + for (const manifestPath of manifests) { + const isGrok = manifestPath.includes(`${path.sep}.grok-plugin${path.sep}`); + const isClaude = manifestPath.includes( + `${path.sep}.claude-plugin${path.sep}`, + ); + if (!isGrok && !isClaude) continue; + const pluginRoot = path.dirname(path.dirname(manifestPath)); + if (seen.has(pluginRoot)) continue; + seen.add(pluginRoot); + const fields = parseManifest(manifestPath); + const id = fields.name || path.basename(pluginRoot); + out.push({ + provider: "grok", + id, + name: fields.displayName || id, + version: fields.version, + description: fields.description, + origin: { + kind: originKind, + path: pluginRoot, + marketplaceId, + }, + state, + children: await listPluginChildren(pluginRoot), + }); + } + } + + await addFromRoot( + path.join(grokHome(), "installed-plugins"), + "installed-tree", + "installed", + ); + + const cacheRoot = path.join(grokHome(), "marketplace-cache"); + let hashes: string[] = []; + try { + hashes = await fs.promises.readdir(cacheRoot); + } catch { + hashes = []; + } + for (const hash of hashes) { + const abs = path.join(cacheRoot, hash); + try { + const st = await fs.promises.stat(abs); + if (!st.isDirectory()) continue; + } catch { + continue; + } + await addFromRoot(abs, "cache", "cached", hash); + } + + return out; +} + +/** Cursor bundled skills under ~/.cursor/skills-cursor/ */ +export async function listCursorPlugins(): Promise { + const root = path.join(cursorHome(), "skills-cursor"); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(root, { withFileTypes: true }); + } catch { + return []; + } + + const sync = asRecord(readJson(path.join(root, ".sync-manifest.json"))); + const syncSkills = asRecord(sync?.skills); + + const out: PluginEntry[] = []; + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith(".")) continue; + const skillMd = path.join(root, e.name, "SKILL.md"); + try { + await fs.promises.access(skillMd); + } catch { + continue; + } + const meta = await readSkillMeta(skillMd); + const syncRow = asRecord(syncSkills?.[e.name]); + out.push({ + provider: "cursor", + id: e.name, + name: meta.name || e.name, + description: meta.description, + origin: { + kind: "skill-bundle", + path: path.join(root, e.name), + marketplaceId: "skills-cursor", + }, + state: "installed", + children: [ + { + kind: "skill", + name: meta.name || e.name, + description: meta.description, + path: skillMd, + }, + ], + }); + void syncRow; // reserved for lastSyncedAt if UI needs it later + } + return out; +} + +export async function listAllPlugins( + providerFilter?: string, +): Promise { + const want = providerFilter?.trim(); + const tasks: Array> = []; + if (!want || want === "claude-code" || want === "claude") { + tasks.push(listClaudePlugins()); + } + if (!want || want === "codex") tasks.push(listCodexPlugins()); + if (!want || want === "copilot") tasks.push(listCopilotPlugins()); + if (!want || want === "grok") tasks.push(listGrokPlugins()); + if (!want || want === "cursor") tasks.push(listCursorPlugins()); + + const chunks = await Promise.all(tasks); + const out = chunks.flat(); + out.sort((a, b) => { + const pc = a.provider.localeCompare(b.provider); + if (pc) return pc; + return a.name.localeCompare(b.name); + }); + return out; +} + +export const pluginRoutes = new Hono(); + +pluginRoutes.get("/", async (c) => { + const provider = c.req.query("provider") ?? undefined; + const items = await listAllPlugins(provider); + return c.json(items); +}); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index e3d89c1..fdf7f29 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -29,6 +29,7 @@ import { backupRoutes } from "./routes/backup.js"; import { gitRoutes } from "./routes/git.js"; import { favoriteRoutes } from "./routes/favorites.js"; import { memoryRoutes } from "./routes/memory.js"; +import { pluginRoutes } from "./routes/plugins.js"; import { gcTmpFiles } from "./gc.js"; import { appLog, captureConsole, compactJobHistory } from "./jobs.js"; @@ -277,6 +278,7 @@ export function createApp(opts: AppOptions) { app.route("/api/backup", backupRoutes); app.route("/api/favorites", favoriteRoutes); app.route("/api/memory", memoryRoutes); + app.route("/api/plugins", pluginRoutes); app.onError((err, c) => { const status = (err as { status?: number }).status; diff --git a/packages/server/test/plugins.test.ts b/packages/server/test/plugins.test.ts new file mode 100644 index 0000000..2fd3a3c --- /dev/null +++ b/packages/server/test/plugins.test.ts @@ -0,0 +1,227 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +let dir: string; +let prevClaude: string | undefined; +let prevCodex: string | undefined; +let prevCopilot: string | undefined; +let prevGrok: string | undefined; +let prevCursor: string | undefined; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "threadle-plugins-")); + prevClaude = process.env.CLAUDE_CONFIG_DIR; + prevCodex = process.env.CODEX_HOME; + prevCopilot = process.env.COPILOT_HOME; + prevGrok = process.env.GROK_HOME; + prevCursor = process.env.CURSOR_CONFIG_DIR; + + process.env.CLAUDE_CONFIG_DIR = path.join(dir, "claude"); + process.env.CODEX_HOME = path.join(dir, "codex"); + process.env.COPILOT_HOME = path.join(dir, "copilot"); + process.env.GROK_HOME = path.join(dir, "grok"); + process.env.CURSOR_CONFIG_DIR = path.join(dir, "cursor"); + + // Claude marketplace pack with skill + agent + command + const claudeMp = path.join( + dir, + "claude", + "plugins", + "marketplaces", + "claude-plugins-official", + ); + const featureDev = path.join(claudeMp, "plugins", "feature-dev"); + fs.mkdirSync(path.join(featureDev, ".claude-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(featureDev, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "feature-dev", + description: "Feature development workflow", + version: "1.2.0", + author: { name: "Anthropic" }, + }), + ); + fs.mkdirSync(path.join(featureDev, "skills", "explore"), { recursive: true }); + fs.writeFileSync( + path.join(featureDev, "skills", "explore", "SKILL.md"), + "---\nname: explore\ndescription: Explore a codebase\n---\n# Explore\n", + ); + fs.mkdirSync(path.join(featureDev, "agents"), { recursive: true }); + fs.writeFileSync( + path.join(featureDev, "agents", "architect.md"), + "# Architect\n", + ); + fs.mkdirSync(path.join(featureDev, "commands"), { recursive: true }); + fs.writeFileSync( + path.join(featureDev, "commands", "review.md"), + "# Review\n", + ); + fs.writeFileSync( + path.join(dir, "claude", "plugins", "known_marketplaces.json"), + JSON.stringify({ + "claude-plugins-official": { + source: { source: "github", repo: "anthropics/claude-plugins-official" }, + installLocation: claudeMp, + lastUpdated: "2026-09-21T00:00:00.000Z", + }, + }), + ); + + // Codex cached plugin + const codexPlug = path.join( + dir, + "codex", + "plugins", + "cache", + "openai-curated-remote", + "plugin-management", + "0.1.0", + ); + fs.mkdirSync(path.join(codexPlug, ".codex-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(codexPlug, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "plugin-management", + version: "0.1.0", + description: "Manage plugins", + interface: { displayName: "Plugin Management" }, + }), + ); + fs.mkdirSync(path.join(codexPlug, "skills", "plugin-management"), { + recursive: true, + }); + fs.writeFileSync( + path.join(codexPlug, "skills", "plugin-management", "SKILL.md"), + "---\nname: plugin-management\ndescription: Manage plugins\n---\n", + ); + + // Copilot empty installed-plugins + fs.mkdirSync(path.join(dir, "copilot", "installed-plugins"), { + recursive: true, + }); + fs.writeFileSync(path.join(dir, "copilot", "installed-plugins.lock"), ""); + + // Grok marketplace-cache pack + const grokNeon = path.join( + dir, + "grok", + "marketplace-cache", + "abc123", + "external_plugins", + "neon", + ); + fs.mkdirSync(path.join(grokNeon, ".grok-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(grokNeon, ".grok-plugin", "plugin.json"), + JSON.stringify({ + name: "neon", + version: "1.0.0", + description: "Neon postgres", + }), + ); + fs.mkdirSync(path.join(grokNeon, "skills", "neon"), { recursive: true }); + fs.writeFileSync( + path.join(grokNeon, "skills", "neon", "SKILL.md"), + "---\nname: neon\ndescription: Neon skill\n---\n", + ); + fs.mkdirSync(path.join(dir, "grok", "installed-plugins"), { recursive: true }); + + // Cursor skills-cursor + const cursorSkill = path.join(dir, "cursor", "skills-cursor", "create-skill"); + fs.mkdirSync(cursorSkill, { recursive: true }); + fs.writeFileSync( + path.join(cursorSkill, "SKILL.md"), + "---\nname: create-skill\ndescription: Create Cursor skills\n---\n# Skill\n", + ); + fs.writeFileSync( + path.join(dir, "cursor", "skills-cursor", ".sync-manifest.json"), + JSON.stringify({ + version: 1, + skills: { "create-skill": { lastSyncedAt: 1_700_000_000_000 } }, + }), + ); +}); + +afterAll(() => { + if (prevClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevClaude; + if (prevCodex === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prevCodex; + if (prevCopilot === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = prevCopilot; + if (prevGrok === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = prevGrok; + if (prevCursor === undefined) delete process.env.CURSOR_CONFIG_DIR; + else process.env.CURSOR_CONFIG_DIR = prevCursor; + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("plugins inventory", () => { + it("lists Claude marketplace packs with children", async () => { + const { listClaudePlugins } = await import("../src/routes/plugins.js"); + const items = await listClaudePlugins(); + expect(items.length).toBe(1); + const p = items[0]!; + expect(p.provider).toBe("claude-code"); + expect(p.id).toBe("feature-dev"); + expect(p.version).toBe("1.2.0"); + expect(p.origin.kind).toBe("marketplace-catalog"); + expect(p.origin.marketplaceId).toBe("claude-plugins-official"); + expect(p.state).toBe("catalog-only"); + const kinds = p.children.map((c) => c.kind).sort(); + expect(kinds).toEqual(["agent", "command", "skill"]); + expect(p.children.find((c) => c.kind === "skill")?.name).toBe("explore"); + }); + + it("lists Codex cached plugins", async () => { + const { listCodexPlugins } = await import("../src/routes/plugins.js"); + const items = await listCodexPlugins(); + expect(items.length).toBe(1); + expect(items[0]!.id).toBe("plugin-management"); + expect(items[0]!.name).toBe("Plugin Management"); + expect(items[0]!.state).toBe("cached"); + expect(items[0]!.children.some((c) => c.kind === "skill")).toBe(true); + }); + + it("returns empty for Copilot with no installs", async () => { + const { listCopilotPlugins } = await import("../src/routes/plugins.js"); + expect(await listCopilotPlugins()).toEqual([]); + }); + + it("lists Grok marketplace-cache packs", async () => { + const { listGrokPlugins } = await import("../src/routes/plugins.js"); + const items = await listGrokPlugins(); + expect(items.some((p) => p.id === "neon")).toBe(true); + const neon = items.find((p) => p.id === "neon")!; + expect(neon.state).toBe("cached"); + expect(neon.children[0]?.name).toBe("neon"); + }); + + it("lists Cursor skills-cursor bundles", async () => { + const { listCursorPlugins } = await import("../src/routes/plugins.js"); + const items = await listCursorPlugins(); + expect(items.length).toBe(1); + expect(items[0]!.provider).toBe("cursor"); + expect(items[0]!.id).toBe("create-skill"); + expect(items[0]!.origin.kind).toBe("skill-bundle"); + expect(items[0]!.children[0]?.path?.endsWith("SKILL.md")).toBe(true); + }); + + it("GET /api/plugins merges providers and filters", async () => { + const { listAllPlugins } = await import("../src/routes/plugins.js"); + const all = await listAllPlugins(); + expect(all.length).toBeGreaterThanOrEqual(4); + const providers = new Set(all.map((p) => p.provider)); + expect(providers.has("claude-code")).toBe(true); + expect(providers.has("codex")).toBe(true); + expect(providers.has("grok")).toBe(true); + expect(providers.has("cursor")).toBe(true); + expect(providers.has("copilot")).toBe(false); + + const onlyCodex = await listAllPlugins("codex"); + expect(onlyCodex.every((p) => p.provider === "codex")).toBe(true); + expect(onlyCodex.length).toBe(1); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index 02646fb..9e535ca 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@threadle/shared", - "version": "1.0.9", + "version": "1.0.10", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/web/package.json b/packages/web/package.json index 44025b7..7fcebe1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@threadle/web", - "version": "1.0.9", + "version": "1.0.10", "private": true, "type": "module", "scripts": { diff --git a/packages/web/src/lib/paletteTabs.ts b/packages/web/src/lib/paletteTabs.ts index 076b49f..cbc528f 100644 --- a/packages/web/src/lib/paletteTabs.ts +++ b/packages/web/src/lib/paletteTabs.ts @@ -13,6 +13,7 @@ export const PAL_RAIL_GROUPS = [ { id: "sessions", glyph: "❯", label: "sessions" }, { id: "nodes", glyph: "▦", label: "nodes" }, { id: "skills", glyph: "✦", label: "skills" }, + { id: "plugins", glyph: "▣", label: "plugins" }, { id: "rules", glyph: "§", label: "rules" }, { id: "library", glyph: "", label: "library" }, ] as const, diff --git a/packages/web/src/panels/ArtifactPreview.vue b/packages/web/src/panels/ArtifactPreview.vue index 4ff0587..aa10d41 100644 --- a/packages/web/src/panels/ArtifactPreview.vue +++ b/packages/web/src/panels/ArtifactPreview.vue @@ -10,7 +10,7 @@ :class="effectiveAuto ? 'on' : 'off'" >{{ effectiveAuto ? "auto" : "manual" }} - +
{{ artifact.source }} · {{ fmtBytes(displaySize) }} @@ -78,6 +78,62 @@ @click="onBodyClick" /> + + +
+
+
+ {{ artifact.kind }} + {{ artifact.name }} +
+ +
+
+ {{ artifact.source }} · {{ fmtBytes(displaySize) }} + · {{ artifact.description }} +
+
+ + + + +
+
{{ actionError }}
+
loading…
+
{{ error }}
+