feat(core): marketplace sources (git and Claude plugin marketplaces) with local cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
efb1294 parent: 792a0a8 added
packages/core/src/marketplace/builtin.ts +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +import type { MarketplaceSource } from '../catalog-model.ts' | |
| 2 | + | |
| 3 | +/** Well-known public skill sources (verified with `git ls-remote`). Ids are stable. */ | |
| 4 | +export const BUILTIN_SOURCES: MarketplaceSource[] = [ | |
| 5 | + { | |
| 6 | + id: 'anthropics-skills', | |
| 7 | + name: 'Anthropic Skills', | |
| 8 | + kind: 'git', | |
| 9 | + url: 'https://github.com/anthropics/skills.git', | |
| 10 | + builtIn: true, | |
| 11 | + }, | |
| 12 | + { | |
| 13 | + id: 'claude-plugins-official', | |
| 14 | + name: 'Claude Code official plugins', | |
| 15 | + kind: 'claude-marketplace', | |
| 16 | + url: 'https://github.com/anthropics/claude-plugins-official.git', | |
| 17 | + builtIn: true, | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + id: 'vercel-agent-skills', | |
| 21 | + name: 'Vercel Agent Skills', | |
| 22 | + kind: 'git', | |
| 23 | + url: 'https://github.com/vercel-labs/agent-skills.git', | |
| 24 | + builtIn: true, | |
| 25 | + }, | |
| 26 | +] | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +import type { MarketplaceSource } from '../catalog-model.ts' | ||
| 2 | + | ||
| 3 | +/** Well-known public skill sources (verified with `git ls-remote`). Ids are stable. */ | ||
| 4 | +export const BUILTIN_SOURCES: MarketplaceSource[] = [ | ||
| 5 | + { | ||
| 6 | + id: 'anthropics-skills', | ||
| 7 | + name: 'Anthropic Skills', | ||
| 8 | + kind: 'git', | ||
| 9 | + url: 'https://github.com/anthropics/skills.git', | ||
| 10 | + builtIn: true, | ||
| 11 | + }, | ||
| 12 | + { | ||
| 13 | + id: 'claude-plugins-official', | ||
| 14 | + name: 'Claude Code official plugins', | ||
| 15 | + kind: 'claude-marketplace', | ||
| 16 | + url: 'https://github.com/anthropics/claude-plugins-official.git', | ||
| 17 | + builtIn: true, | ||
| 18 | + }, | ||
| 19 | + { | ||
| 20 | + id: 'vercel-agent-skills', | ||
| 21 | + name: 'Vercel Agent Skills', | ||
| 22 | + kind: 'git', | ||
| 23 | + url: 'https://github.com/vercel-labs/agent-skills.git', | ||
| 24 | + builtIn: true, | ||
| 25 | + }, | ||
| 26 | +] | ||
added
packages/core/src/marketplace/git.ts +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +import { execFile } from 'node:child_process' | |
| 2 | + | |
| 3 | +const GIT_TIMEOUT_MS = 120_000 | |
| 4 | + | |
| 5 | +/** Runs git with prompts disabled and a timeout. Rejects with a readable Error. */ | |
| 6 | +export function runGit(args: string[], cwd?: string): Promise<string> { | |
| 7 | + return new Promise((resolve, reject) => { | |
| 8 | + execFile( | |
| 9 | + 'git', | |
| 10 | + args, | |
| 11 | + { | |
| 12 | + cwd, | |
| 13 | + timeout: GIT_TIMEOUT_MS, | |
| 14 | + maxBuffer: 16 * 1024 * 1024, | |
| 15 | + windowsHide: true, | |
| 16 | + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, | |
| 17 | + }, | |
| 18 | + (err, stdout, stderr) => { | |
| 19 | + if (!err) return resolve(stdout) | |
| 20 | + const e = err as NodeJS.ErrnoException & { killed?: boolean } | |
| 21 | + const label = `git ${args[0] ?? ''}` | |
| 22 | + if (e.code === 'ENOENT') return reject(new Error('git is not installed or not in PATH')) | |
| 23 | + if (e.killed) return reject(new Error(`${label} timed out after ${GIT_TIMEOUT_MS / 1000}s`)) | |
| 24 | + const detail = String(stderr || e.message).trim().split('\n').slice(-3).join(' ') | |
| 25 | + reject(new Error(`${label} failed: ${detail}`)) | |
| 26 | + }, | |
| 27 | + ) | |
| 28 | + }) | |
| 29 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +import { execFile } from 'node:child_process' | ||
| 2 | + | ||
| 3 | +const GIT_TIMEOUT_MS = 120_000 | ||
| 4 | + | ||
| 5 | +/** Runs git with prompts disabled and a timeout. Rejects with a readable Error. */ | ||
| 6 | +export function runGit(args: string[], cwd?: string): Promise<string> { | ||
| 7 | + return new Promise((resolve, reject) => { | ||
| 8 | + execFile( | ||
| 9 | + 'git', | ||
| 10 | + args, | ||
| 11 | + { | ||
| 12 | + cwd, | ||
| 13 | + timeout: GIT_TIMEOUT_MS, | ||
| 14 | + maxBuffer: 16 * 1024 * 1024, | ||
| 15 | + windowsHide: true, | ||
| 16 | + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, | ||
| 17 | + }, | ||
| 18 | + (err, stdout, stderr) => { | ||
| 19 | + if (!err) return resolve(stdout) | ||
| 20 | + const e = err as NodeJS.ErrnoException & { killed?: boolean } | ||
| 21 | + const label = `git ${args[0] ?? ''}` | ||
| 22 | + if (e.code === 'ENOENT') return reject(new Error('git is not installed or not in PATH')) | ||
| 23 | + if (e.killed) return reject(new Error(`${label} timed out after ${GIT_TIMEOUT_MS / 1000}s`)) | ||
| 24 | + const detail = String(stderr || e.message).trim().split('\n').slice(-3).join(' ') | ||
| 25 | + reject(new Error(`${label} failed: ${detail}`)) | ||
| 26 | + }, | ||
| 27 | + ) | ||
| 28 | + }) | ||
| 29 | +} | ||
modified
packages/core/src/marketplace/index.ts +105 -3 | @@ -1,6 +1,108 @@ | ||
| 1 | +import { mkdir, realpath, writeFile } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { MarketplaceSkill, MarketplaceSource } from '../catalog-model.ts' | |
| 1 | 4 | import type { CreateMarketplace } from '../contracts.ts' |
| 5 | +import { isDirectory, pathExists, readJson } from '../util.ts' | |
| 6 | +import { BUILTIN_SOURCES } from './builtin.ts' | |
| 7 | +import { runGit } from './git.ts' | |
| 8 | +import { findMarketplaceSkills, findSkills, inside } from './scan.ts' | |
| 2 | 9 | |
| 3 | -// Placeholder: replaced by the real implementation. | |
| 4 | -export const createMarketplace: CreateMarketplace = () => { | |
| 5 | - throw new Error('not implemented') | |
| 10 | +export const createMarketplace: CreateMarketplace = ({ cacheDir, userSources }) => { | |
| 11 | + const all: MarketplaceSource[] = BUILTIN_SOURCES.map((s) => ({ ...s })) | |
| 12 | + for (const s of userSources) if (!all.some((b) => b.id === s.id)) all.push({ ...s, builtIn: false }) | |
| 13 | + const fetchedAt = new Map<string, string>() | |
| 14 | + const loaded = new Set<string>() | |
| 15 | + | |
| 16 | + const checkoutDir = (id: string): string => path.join(cacheDir, id) | |
| 17 | + const metaFile = (id: string): string => path.join(cacheDir, `${id}.json`) | |
| 18 | + | |
| 19 | + function getSource(id: string): MarketplaceSource { | |
| 20 | + const s = all.find((x) => x.id === id) | |
| 21 | + if (!s) throw new Error(`Unknown marketplace source: ${id}`) | |
| 22 | + return s | |
| 23 | + } | |
| 24 | + | |
| 25 | + async function loadMeta(id: string): Promise<void> { | |
| 26 | + if (loaded.has(id)) return | |
| 27 | + loaded.add(id) | |
| 28 | + try { | |
| 29 | + const meta = await readJson<{ lastFetchedAt?: unknown }>(metaFile(id)) | |
| 30 | + if (typeof meta?.lastFetchedAt === 'string' && !fetchedAt.has(id)) fetchedAt.set(id, meta.lastFetchedAt) | |
| 31 | + } catch { | |
| 32 | + // Corrupt metadata: treated as never fetched. | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + async function isFetched(id: string): Promise<boolean> { | |
| 37 | + await loadMeta(id) | |
| 38 | + return fetchedAt.has(id) && (await pathExists(path.join(checkoutDir(id), '.git'))) | |
| 39 | + } | |
| 40 | + | |
| 41 | + const view = (s: MarketplaceSource): MarketplaceSource => ({ ...s, lastFetchedAt: fetchedAt.get(s.id) }) | |
| 42 | + | |
| 43 | + async function refresh(sourceId: string): Promise<MarketplaceSource> { | |
| 44 | + const s = getSource(sourceId) | |
| 45 | + const dir = checkoutDir(s.id) | |
| 46 | + await mkdir(cacheDir, { recursive: true }) | |
| 47 | + try { | |
| 48 | + if (await pathExists(path.join(dir, '.git'))) { | |
| 49 | + await runGit(['fetch', '--depth', '1', 'origin', s.ref ?? 'HEAD'], dir) | |
| 50 | + await runGit(['reset', '--hard', 'FETCH_HEAD'], dir) | |
| 51 | + } else { | |
| 52 | + const args = ['clone', '--depth', '1'] | |
| 53 | + if (s.ref) args.push('--branch', s.ref) | |
| 54 | + await runGit([...args, s.url, dir]) | |
| 55 | + } | |
| 56 | + } catch (e) { | |
| 57 | + throw new Error(`Cannot refresh "${s.name}" (${s.url}): ${(e as Error).message}`) | |
| 58 | + } | |
| 59 | + const now = new Date().toISOString() | |
| 60 | + fetchedAt.set(s.id, now) | |
| 61 | + loaded.add(s.id) | |
| 62 | + await writeFile(metaFile(s.id), JSON.stringify({ lastFetchedAt: now }, null, 2)) | |
| 63 | + return view(s) | |
| 64 | + } | |
| 65 | + | |
| 66 | + return { | |
| 67 | + sources: () => all.map(view), | |
| 68 | + refresh, | |
| 69 | + | |
| 70 | + async listSkills(sourceId, catalog) { | |
| 71 | + const s = getSource(sourceId) | |
| 72 | + if (!(await isFetched(s.id))) await refresh(s.id) | |
| 73 | + const dir = checkoutDir(s.id) | |
| 74 | + let skills: MarketplaceSkill[] | |
| 75 | + if (s.kind === 'claude-marketplace') { | |
| 76 | + const found = await findMarketplaceSkills(dir) | |
| 77 | + skills = found.map((f) => ({ sourceId: s.id, ...f })) | |
| 78 | + } else { | |
| 79 | + const found = await findSkills(dir) | |
| 80 | + skills = found.map((f) => ({ sourceId: s.id, ...f })) | |
| 81 | + } | |
| 82 | + if (catalog) { | |
| 83 | + const snap = await catalog.snapshot() | |
| 84 | + const byName = new Map(snap.items.filter((i) => i.type === 'skill').map((i) => [i.name, i])) | |
| 85 | + for (const sk of skills) { | |
| 86 | + const item = byName.get(sk.name) | |
| 87 | + if (item) sk.inCatalog = item.hash === sk.hash ? 'same' : 'different' | |
| 88 | + } | |
| 89 | + } | |
| 90 | + return skills | |
| 91 | + }, | |
| 92 | + | |
| 93 | + async skillDir(sourceId, skillPath) { | |
| 94 | + const s = getSource(sourceId) | |
| 95 | + const root = path.resolve(checkoutDir(s.id)) | |
| 96 | + if (!(await isDirectory(root))) throw new Error(`Source "${s.name}" has not been fetched yet`) | |
| 97 | + const rel = skillPath.replace(/\\/g, '/') | |
| 98 | + const abs = path.resolve(root, rel) | |
| 99 | + if (!rel || path.isAbsolute(rel) || !inside(root, abs) || abs.split(path.sep).includes('.git')) { | |
| 100 | + throw new Error(`Invalid skill path: ${skillPath}`) | |
| 101 | + } | |
| 102 | + if (!(await pathExists(path.join(abs, 'SKILL.md')))) throw new Error(`No skill found at "${skillPath}" in "${s.name}"`) | |
| 103 | + // Symlinks must not escape the checkout either. | |
| 104 | + if (!inside(await realpath(root), await realpath(abs))) throw new Error(`Invalid skill path: ${skillPath}`) | |
| 105 | + return abs | |
| 106 | + }, | |
| 107 | + } | |
| 6 | 108 | } |
| @@ -1,6 +1,108 @@ | |||
| 1 | +import { mkdir, realpath, writeFile } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import type { MarketplaceSkill, MarketplaceSource } from '../catalog-model.ts' | ||
| 1 | import type { CreateMarketplace } from '../contracts.ts' | 4 | import type { CreateMarketplace } from '../contracts.ts' |
| 5 | +import { isDirectory, pathExists, readJson } from '../util.ts' | ||
| 6 | +import { BUILTIN_SOURCES } from './builtin.ts' | ||
| 7 | +import { runGit } from './git.ts' | ||
| 8 | +import { findMarketplaceSkills, findSkills, inside } from './scan.ts' | ||
| 2 | 9 | ||
| 3 | -// Placeholder: replaced by the real implementation. | 10 | +export const createMarketplace: CreateMarketplace = ({ cacheDir, userSources }) => { |
| 4 | -export const createMarketplace: CreateMarketplace = () => { | 11 | + const all: MarketplaceSource[] = BUILTIN_SOURCES.map((s) => ({ ...s })) |
| 5 | - throw new Error('not implemented') | 12 | + for (const s of userSources) if (!all.some((b) => b.id === s.id)) all.push({ ...s, builtIn: false }) |
| 13 | + const fetchedAt = new Map<string, string>() | ||
| 14 | + const loaded = new Set<string>() | ||
| 15 | + | ||
| 16 | + const checkoutDir = (id: string): string => path.join(cacheDir, id) | ||
| 17 | + const metaFile = (id: string): string => path.join(cacheDir, `${id}.json`) | ||
| 18 | + | ||
| 19 | + function getSource(id: string): MarketplaceSource { | ||
| 20 | + const s = all.find((x) => x.id === id) | ||
| 21 | + if (!s) throw new Error(`Unknown marketplace source: ${id}`) | ||
| 22 | + return s | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + async function loadMeta(id: string): Promise<void> { | ||
| 26 | + if (loaded.has(id)) return | ||
| 27 | + loaded.add(id) | ||
| 28 | + try { | ||
| 29 | + const meta = await readJson<{ lastFetchedAt?: unknown }>(metaFile(id)) | ||
| 30 | + if (typeof meta?.lastFetchedAt === 'string' && !fetchedAt.has(id)) fetchedAt.set(id, meta.lastFetchedAt) | ||
| 31 | + } catch { | ||
| 32 | + // Corrupt metadata: treated as never fetched. | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + async function isFetched(id: string): Promise<boolean> { | ||
| 37 | + await loadMeta(id) | ||
| 38 | + return fetchedAt.has(id) && (await pathExists(path.join(checkoutDir(id), '.git'))) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + const view = (s: MarketplaceSource): MarketplaceSource => ({ ...s, lastFetchedAt: fetchedAt.get(s.id) }) | ||
| 42 | + | ||
| 43 | + async function refresh(sourceId: string): Promise<MarketplaceSource> { | ||
| 44 | + const s = getSource(sourceId) | ||
| 45 | + const dir = checkoutDir(s.id) | ||
| 46 | + await mkdir(cacheDir, { recursive: true }) | ||
| 47 | + try { | ||
| 48 | + if (await pathExists(path.join(dir, '.git'))) { | ||
| 49 | + await runGit(['fetch', '--depth', '1', 'origin', s.ref ?? 'HEAD'], dir) | ||
| 50 | + await runGit(['reset', '--hard', 'FETCH_HEAD'], dir) | ||
| 51 | + } else { | ||
| 52 | + const args = ['clone', '--depth', '1'] | ||
| 53 | + if (s.ref) args.push('--branch', s.ref) | ||
| 54 | + await runGit([...args, s.url, dir]) | ||
| 55 | + } | ||
| 56 | + } catch (e) { | ||
| 57 | + throw new Error(`Cannot refresh "${s.name}" (${s.url}): ${(e as Error).message}`) | ||
| 58 | + } | ||
| 59 | + const now = new Date().toISOString() | ||
| 60 | + fetchedAt.set(s.id, now) | ||
| 61 | + loaded.add(s.id) | ||
| 62 | + await writeFile(metaFile(s.id), JSON.stringify({ lastFetchedAt: now }, null, 2)) | ||
| 63 | + return view(s) | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + return { | ||
| 67 | + sources: () => all.map(view), | ||
| 68 | + refresh, | ||
| 69 | + | ||
| 70 | + async listSkills(sourceId, catalog) { | ||
| 71 | + const s = getSource(sourceId) | ||
| 72 | + if (!(await isFetched(s.id))) await refresh(s.id) | ||
| 73 | + const dir = checkoutDir(s.id) | ||
| 74 | + let skills: MarketplaceSkill[] | ||
| 75 | + if (s.kind === 'claude-marketplace') { | ||
| 76 | + const found = await findMarketplaceSkills(dir) | ||
| 77 | + skills = found.map((f) => ({ sourceId: s.id, ...f })) | ||
| 78 | + } else { | ||
| 79 | + const found = await findSkills(dir) | ||
| 80 | + skills = found.map((f) => ({ sourceId: s.id, ...f })) | ||
| 81 | + } | ||
| 82 | + if (catalog) { | ||
| 83 | + const snap = await catalog.snapshot() | ||
| 84 | + const byName = new Map(snap.items.filter((i) => i.type === 'skill').map((i) => [i.name, i])) | ||
| 85 | + for (const sk of skills) { | ||
| 86 | + const item = byName.get(sk.name) | ||
| 87 | + if (item) sk.inCatalog = item.hash === sk.hash ? 'same' : 'different' | ||
| 88 | + } | ||
| 89 | + } | ||
| 90 | + return skills | ||
| 91 | + }, | ||
| 92 | + | ||
| 93 | + async skillDir(sourceId, skillPath) { | ||
| 94 | + const s = getSource(sourceId) | ||
| 95 | + const root = path.resolve(checkoutDir(s.id)) | ||
| 96 | + if (!(await isDirectory(root))) throw new Error(`Source "${s.name}" has not been fetched yet`) | ||
| 97 | + const rel = skillPath.replace(/\\/g, '/') | ||
| 98 | + const abs = path.resolve(root, rel) | ||
| 99 | + if (!rel || path.isAbsolute(rel) || !inside(root, abs) || abs.split(path.sep).includes('.git')) { | ||
| 100 | + throw new Error(`Invalid skill path: ${skillPath}`) | ||
| 101 | + } | ||
| 102 | + if (!(await pathExists(path.join(abs, 'SKILL.md')))) throw new Error(`No skill found at "${skillPath}" in "${s.name}"`) | ||
| 103 | + // Symlinks must not escape the checkout either. | ||
| 104 | + if (!inside(await realpath(root), await realpath(abs))) throw new Error(`Invalid skill path: ${skillPath}`) | ||
| 105 | + return abs | ||
| 106 | + }, | ||
| 107 | + } | ||
| 6 | } | 108 | } |
added
packages/core/src/marketplace/scan.ts +114 -0 | new file mode 100644 | ||
| @@ -0,0 +1,114 @@ | ||
| 1 | +import { readdir } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import { hashPath, isDirectory, parseFrontmatter, pathExists, readJson, readText } from '../util.ts' | |
| 4 | + | |
| 5 | +export interface FoundSkill { | |
| 6 | + name: string | |
| 7 | + description?: string | |
| 8 | + /** Relative to the scan base, forward slashes. */ | |
| 9 | + path: string | |
| 10 | + hash: string | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface PluginSkill extends FoundSkill { | |
| 14 | + plugin: string | |
| 15 | +} | |
| 16 | + | |
| 17 | +const SKIP = new Set(['.git', 'node_modules']) | |
| 18 | +const MAX_DIRS = 5000 | |
| 19 | + | |
| 20 | +export function inside(root: string, target: string): boolean { | |
| 21 | + const rel = path.relative(root, target) | |
| 22 | + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Finds every folder containing SKILL.md under `base/startRel` (not descending into skill folders). */ | |
| 26 | +export async function findSkills(base: string, startRel = ''): Promise<FoundSkill[]> { | |
| 27 | + const out: FoundSkill[] = [] | |
| 28 | + let visited = 0 | |
| 29 | + async function walk(rel: string): Promise<void> { | |
| 30 | + if (visited++ >= MAX_DIRS) return | |
| 31 | + const abs = path.join(base, rel) | |
| 32 | + if (await pathExists(path.join(abs, 'SKILL.md'))) { | |
| 33 | + const skill = await readSkill(base, rel) | |
| 34 | + if (skill) out.push(skill) | |
| 35 | + return | |
| 36 | + } | |
| 37 | + let entries | |
| 38 | + try { | |
| 39 | + entries = await readdir(abs, { withFileTypes: true }) | |
| 40 | + } catch { | |
| 41 | + return | |
| 42 | + } | |
| 43 | + entries.sort((a, b) => a.name.localeCompare(b.name)) | |
| 44 | + for (const e of entries) { | |
| 45 | + if (SKIP.has(e.name)) continue | |
| 46 | + if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue | |
| 47 | + await walk(rel ? `${rel}/${e.name}` : e.name) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + await walk(startRel) | |
| 51 | + return out | |
| 52 | +} | |
| 53 | + | |
| 54 | +async function readSkill(base: string, rel: string): Promise<FoundSkill | undefined> { | |
| 55 | + const dir = path.join(base, rel) | |
| 56 | + const text = await readText(path.join(dir, 'SKILL.md')) | |
| 57 | + if (text === undefined) return undefined | |
| 58 | + const { frontmatter } = parseFrontmatter(text) | |
| 59 | + const fmName = typeof frontmatter.name === 'string' ? frontmatter.name.trim() : '' | |
| 60 | + const description = typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '' | |
| 61 | + const hash = await hashPath(dir) | |
| 62 | + if (!hash) return undefined | |
| 63 | + return { | |
| 64 | + name: fmName || path.basename(dir), | |
| 65 | + description: description || undefined, | |
| 66 | + path: rel, | |
| 67 | + hash, | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +interface MarketplaceJson { | |
| 72 | + plugins?: { name?: unknown; source?: unknown; skills?: unknown }[] | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * Skills of the plugins of a Claude marketplace whose source is a relative path inside the repo. | |
| 77 | + * Plugins with external sources (url, git-subdir, npm...) are skipped. A skill reachable from several | |
| 78 | + * plugins (e.g. `source: "./"` with explicit `skills` lists) is reported once, for the first plugin. | |
| 79 | + */ | |
| 80 | +export async function findMarketplaceSkills(base: string): Promise<PluginSkill[]> { | |
| 81 | + let json: MarketplaceJson | undefined | |
| 82 | + try { | |
| 83 | + json = await readJson<MarketplaceJson>(path.join(base, '.claude-plugin', 'marketplace.json')) | |
| 84 | + } catch (e) { | |
| 85 | + throw new Error(`Invalid .claude-plugin/marketplace.json: ${(e as Error).message}`) | |
| 86 | + } | |
| 87 | + if (!json) throw new Error('Missing .claude-plugin/marketplace.json in this source') | |
| 88 | + const out: PluginSkill[] = [] | |
| 89 | + const seen = new Set<string>() | |
| 90 | + const root = path.resolve(base) | |
| 91 | + for (const plugin of Array.isArray(json.plugins) ? json.plugins : []) { | |
| 92 | + if (typeof plugin.source !== 'string' || typeof plugin.name !== 'string') continue | |
| 93 | + const pluginAbs = path.resolve(root, plugin.source) | |
| 94 | + if (!inside(root, pluginAbs) || !(await isDirectory(pluginAbs))) continue | |
| 95 | + const prefix = path.relative(root, pluginAbs).replace(/\\/g, '/') | |
| 96 | + const listed = Array.isArray(plugin.skills) ? plugin.skills.filter((s): s is string => typeof s === 'string') : [] | |
| 97 | + let found: FoundSkill[] = [] | |
| 98 | + if (listed.length > 0) { | |
| 99 | + for (const s of listed) { | |
| 100 | + const abs = path.resolve(pluginAbs, s) | |
| 101 | + if (!inside(root, abs)) continue | |
| 102 | + found.push(...(await findSkills(root, path.relative(root, abs).replace(/\\/g, '/')))) | |
| 103 | + } | |
| 104 | + } else { | |
| 105 | + found = await findSkills(root, prefix) | |
| 106 | + } | |
| 107 | + for (const s of found) { | |
| 108 | + if (seen.has(s.path)) continue | |
| 109 | + seen.add(s.path) | |
| 110 | + out.push({ ...s, plugin: plugin.name }) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + return out | |
| 114 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,114 @@ | |||
| 1 | +import { readdir } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import { hashPath, isDirectory, parseFrontmatter, pathExists, readJson, readText } from '../util.ts' | ||
| 4 | + | ||
| 5 | +export interface FoundSkill { | ||
| 6 | + name: string | ||
| 7 | + description?: string | ||
| 8 | + /** Relative to the scan base, forward slashes. */ | ||
| 9 | + path: string | ||
| 10 | + hash: string | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +export interface PluginSkill extends FoundSkill { | ||
| 14 | + plugin: string | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +const SKIP = new Set(['.git', 'node_modules']) | ||
| 18 | +const MAX_DIRS = 5000 | ||
| 19 | + | ||
| 20 | +export function inside(root: string, target: string): boolean { | ||
| 21 | + const rel = path.relative(root, target) | ||
| 22 | + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** Finds every folder containing SKILL.md under `base/startRel` (not descending into skill folders). */ | ||
| 26 | +export async function findSkills(base: string, startRel = ''): Promise<FoundSkill[]> { | ||
| 27 | + const out: FoundSkill[] = [] | ||
| 28 | + let visited = 0 | ||
| 29 | + async function walk(rel: string): Promise<void> { | ||
| 30 | + if (visited++ >= MAX_DIRS) return | ||
| 31 | + const abs = path.join(base, rel) | ||
| 32 | + if (await pathExists(path.join(abs, 'SKILL.md'))) { | ||
| 33 | + const skill = await readSkill(base, rel) | ||
| 34 | + if (skill) out.push(skill) | ||
| 35 | + return | ||
| 36 | + } | ||
| 37 | + let entries | ||
| 38 | + try { | ||
| 39 | + entries = await readdir(abs, { withFileTypes: true }) | ||
| 40 | + } catch { | ||
| 41 | + return | ||
| 42 | + } | ||
| 43 | + entries.sort((a, b) => a.name.localeCompare(b.name)) | ||
| 44 | + for (const e of entries) { | ||
| 45 | + if (SKIP.has(e.name)) continue | ||
| 46 | + if (!e.isDirectory() && !(e.isSymbolicLink() && (await isDirectory(path.join(abs, e.name))))) continue | ||
| 47 | + await walk(rel ? `${rel}/${e.name}` : e.name) | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + await walk(startRel) | ||
| 51 | + return out | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +async function readSkill(base: string, rel: string): Promise<FoundSkill | undefined> { | ||
| 55 | + const dir = path.join(base, rel) | ||
| 56 | + const text = await readText(path.join(dir, 'SKILL.md')) | ||
| 57 | + if (text === undefined) return undefined | ||
| 58 | + const { frontmatter } = parseFrontmatter(text) | ||
| 59 | + const fmName = typeof frontmatter.name === 'string' ? frontmatter.name.trim() : '' | ||
| 60 | + const description = typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '' | ||
| 61 | + const hash = await hashPath(dir) | ||
| 62 | + if (!hash) return undefined | ||
| 63 | + return { | ||
| 64 | + name: fmName || path.basename(dir), | ||
| 65 | + description: description || undefined, | ||
| 66 | + path: rel, | ||
| 67 | + hash, | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +interface MarketplaceJson { | ||
| 72 | + plugins?: { name?: unknown; source?: unknown; skills?: unknown }[] | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +/** | ||
| 76 | + * Skills of the plugins of a Claude marketplace whose source is a relative path inside the repo. | ||
| 77 | + * Plugins with external sources (url, git-subdir, npm...) are skipped. A skill reachable from several | ||
| 78 | + * plugins (e.g. `source: "./"` with explicit `skills` lists) is reported once, for the first plugin. | ||
| 79 | + */ | ||
| 80 | +export async function findMarketplaceSkills(base: string): Promise<PluginSkill[]> { | ||
| 81 | + let json: MarketplaceJson | undefined | ||
| 82 | + try { | ||
| 83 | + json = await readJson<MarketplaceJson>(path.join(base, '.claude-plugin', 'marketplace.json')) | ||
| 84 | + } catch (e) { | ||
| 85 | + throw new Error(`Invalid .claude-plugin/marketplace.json: ${(e as Error).message}`) | ||
| 86 | + } | ||
| 87 | + if (!json) throw new Error('Missing .claude-plugin/marketplace.json in this source') | ||
| 88 | + const out: PluginSkill[] = [] | ||
| 89 | + const seen = new Set<string>() | ||
| 90 | + const root = path.resolve(base) | ||
| 91 | + for (const plugin of Array.isArray(json.plugins) ? json.plugins : []) { | ||
| 92 | + if (typeof plugin.source !== 'string' || typeof plugin.name !== 'string') continue | ||
| 93 | + const pluginAbs = path.resolve(root, plugin.source) | ||
| 94 | + if (!inside(root, pluginAbs) || !(await isDirectory(pluginAbs))) continue | ||
| 95 | + const prefix = path.relative(root, pluginAbs).replace(/\\/g, '/') | ||
| 96 | + const listed = Array.isArray(plugin.skills) ? plugin.skills.filter((s): s is string => typeof s === 'string') : [] | ||
| 97 | + let found: FoundSkill[] = [] | ||
| 98 | + if (listed.length > 0) { | ||
| 99 | + for (const s of listed) { | ||
| 100 | + const abs = path.resolve(pluginAbs, s) | ||
| 101 | + if (!inside(root, abs)) continue | ||
| 102 | + found.push(...(await findSkills(root, path.relative(root, abs).replace(/\\/g, '/')))) | ||
| 103 | + } | ||
| 104 | + } else { | ||
| 105 | + found = await findSkills(root, prefix) | ||
| 106 | + } | ||
| 107 | + for (const s of found) { | ||
| 108 | + if (seen.has(s.path)) continue | ||
| 109 | + seen.add(s.path) | ||
| 110 | + out.push({ ...s, plugin: plugin.name }) | ||
| 111 | + } | ||
| 112 | + } | ||
| 113 | + return out | ||
| 114 | +} | ||
added
packages/core/test/marketplace.test.ts +194 -0 | new file mode 100644 | ||
| @@ -0,0 +1,194 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | +import os from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | |
| 6 | +import type { CatalogItem, MarketplaceSource } from '../src/catalog-model.ts' | |
| 7 | +import type { CatalogStore } from '../src/contracts.ts' | |
| 8 | +import { createMarketplace } from '../src/marketplace/index.ts' | |
| 9 | + | |
| 10 | +let tmp: string | |
| 11 | + | |
| 12 | +async function put(file: string, content: string): Promise<void> { | |
| 13 | + await mkdir(path.dirname(file), { recursive: true }) | |
| 14 | + await writeFile(file, content) | |
| 15 | +} | |
| 16 | + | |
| 17 | +const skillMd = (name: string, description: string): string => `---\nname: ${name}\ndescription: ${description}\n---\nBody of ${name}\n` | |
| 18 | + | |
| 19 | +function git(cwd: string, ...args: string[]): void { | |
| 20 | + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], { | |
| 21 | + cwd, | |
| 22 | + stdio: 'ignore', | |
| 23 | + }) | |
| 24 | +} | |
| 25 | + | |
| 26 | +async function makeRepo(name: string, files: Record<string, string>): Promise<string> { | |
| 27 | + const dir = path.join(tmp, 'repos', name) | |
| 28 | + await mkdir(dir, { recursive: true }) | |
| 29 | + git(dir, 'init', '-q') | |
| 30 | + for (const [rel, content] of Object.entries(files)) await put(path.join(dir, rel), content) | |
| 31 | + git(dir, 'add', '-A') | |
| 32 | + git(dir, 'commit', '-q', '-m', 'init') | |
| 33 | + return dir | |
| 34 | +} | |
| 35 | + | |
| 36 | +function fakeCatalog(items: Partial<CatalogItem>[]): CatalogStore { | |
| 37 | + return { | |
| 38 | + snapshot: async () => ({ root: '', exists: true, items: items as CatalogItem[], presets: [], errors: [] }), | |
| 39 | + } as unknown as CatalogStore | |
| 40 | +} | |
| 41 | + | |
| 42 | +let plainRepo: string | |
| 43 | +let mktRepo: string | |
| 44 | +let cacheDir: string | |
| 45 | +let sources: MarketplaceSource[] | |
| 46 | + | |
| 47 | +beforeAll(async () => { | |
| 48 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-mkt-')) | |
| 49 | + cacheDir = path.join(tmp, 'cache') | |
| 50 | + plainRepo = await makeRepo('plain', { | |
| 51 | + 'skills/alpha/SKILL.md': skillMd('alpha', 'Alpha skill'), | |
| 52 | + 'skills/alpha/ref/SKILL.md': 'nested inside a skill: must be ignored', | |
| 53 | + 'skills/alpha/notes.md': 'notes', | |
| 54 | + 'deep/er/beta/SKILL.md': '# no frontmatter', | |
| 55 | + 'node_modules/pkg/SKILL.md': skillMd('ignored', 'ignored'), | |
| 56 | + }) | |
| 57 | + mktRepo = await makeRepo('mkt', { | |
| 58 | + '.claude-plugin/marketplace.json': JSON.stringify({ | |
| 59 | + name: 'fake', | |
| 60 | + plugins: [ | |
| 61 | + { name: 'p1', source: './plugins/p1' }, | |
| 62 | + { name: 'ext', source: { source: 'url', url: 'https://example.invalid/x.git' } }, | |
| 63 | + { name: 'escape', source: '../outside' }, | |
| 64 | + { name: 'listed', source: './', skills: ['./top/one'] }, | |
| 65 | + ], | |
| 66 | + }), | |
| 67 | + 'plugins/p1/skills/gamma/SKILL.md': skillMd('gamma', 'Gamma'), | |
| 68 | + 'plugins/p1/extra/delta/SKILL.md': skillMd('delta', 'Delta'), | |
| 69 | + 'plugins/p2/skills/unlisted/SKILL.md': skillMd('unlisted', 'Not in marketplace.json'), | |
| 70 | + 'top/one/SKILL.md': skillMd('one', 'One'), | |
| 71 | + 'top/two/SKILL.md': skillMd('two', 'Two (not listed)'), | |
| 72 | + }) | |
| 73 | + sources = [ | |
| 74 | + { id: 'plain', name: 'Plain', kind: 'git', url: pathToFileUrl(plainRepo) }, | |
| 75 | + { id: 'mkt', name: 'Mkt', kind: 'claude-marketplace', url: pathToFileUrl(mktRepo) }, | |
| 76 | + { id: 'broken', name: 'Broken', kind: 'git', url: pathToFileUrl(path.join(tmp, 'does-not-exist')) }, | |
| 77 | + ] | |
| 78 | +}) | |
| 79 | + | |
| 80 | +afterAll(async () => { | |
| 81 | + await rm(tmp, { recursive: true, force: true }) | |
| 82 | +}) | |
| 83 | + | |
| 84 | +function pathToFileUrl(p: string): string { | |
| 85 | + return `file:///${p.replace(/\\/g, '/').replace(/^\//, '')}` | |
| 86 | +} | |
| 87 | + | |
| 88 | +describe('marketplace', () => { | |
| 89 | + it('lists built-in and user sources', () => { | |
| 90 | + const list = createMarketplace({ cacheDir, userSources: sources }).sources() | |
| 91 | + const builtIn = list.filter((s) => s.builtIn) | |
| 92 | + expect(builtIn.length).toBeGreaterThanOrEqual(2) | |
| 93 | + expect(builtIn.map((s) => s.id)).toContain('anthropics-skills') | |
| 94 | + expect(list.find((s) => s.id === 'plain')?.builtIn).toBeFalsy() | |
| 95 | + }) | |
| 96 | + | |
| 97 | + it('rejects unknown sources', async () => { | |
| 98 | + const m = createMarketplace({ cacheDir, userSources: [] }) | |
| 99 | + await expect(m.refresh('nope')).rejects.toThrow(/Unknown/) | |
| 100 | + await expect(m.listSkills('nope')).rejects.toThrow(/Unknown/) | |
| 101 | + await expect(m.skillDir('nope', 'x')).rejects.toThrow(/Unknown/) | |
| 102 | + }) | |
| 103 | + | |
| 104 | + it('reports a clear error when the clone fails', async () => { | |
| 105 | + const m = createMarketplace({ cacheDir, userSources: sources }) | |
| 106 | + await expect(m.refresh('broken')).rejects.toThrow(/Cannot refresh "Broken"/) | |
| 107 | + }) | |
| 108 | + | |
| 109 | + it('refreshes: clone, then update after a new commit, and persists lastFetchedAt', async () => { | |
| 110 | + const m = createMarketplace({ cacheDir, userSources: sources }) | |
| 111 | + expect(m.sources().find((s) => s.id === 'plain')?.lastFetchedAt).toBeUndefined() | |
| 112 | + const first = await m.refresh('plain') | |
| 113 | + expect(first.lastFetchedAt).toBeTruthy() | |
| 114 | + expect(m.sources().find((s) => s.id === 'plain')?.lastFetchedAt).toBe(first.lastFetchedAt) | |
| 115 | + expect((await m.listSkills('plain')).map((s) => s.name)).not.toContain('late') | |
| 116 | + | |
| 117 | + await put(path.join(plainRepo, 'skills', 'late', 'SKILL.md'), skillMd('late', 'Added later')) | |
| 118 | + git(plainRepo, 'add', '-A') | |
| 119 | + git(plainRepo, 'commit', '-q', '-m', 'second') | |
| 120 | + await m.refresh('plain') | |
| 121 | + expect((await m.listSkills('plain')).map((s) => s.name)).toContain('late') | |
| 122 | + | |
| 123 | + // A new instance sees the persisted timestamp and does not re-clone. | |
| 124 | + const m2 = createMarketplace({ cacheDir, userSources: sources }) | |
| 125 | + await m2.listSkills('plain') | |
| 126 | + expect(m2.sources().find((s) => s.id === 'plain')?.lastFetchedAt).toBeTruthy() | |
| 127 | + expect(JSON.parse(await readFile(path.join(cacheDir, 'plain.json'), 'utf8')).lastFetchedAt).toBeTruthy() | |
| 128 | + }) | |
| 129 | + | |
| 130 | + it('lists skills of a git source', async () => { | |
| 131 | + const m = createMarketplace({ cacheDir, userSources: sources }) | |
| 132 | + const skills = await m.listSkills('plain') | |
| 133 | + const byName = Object.fromEntries(skills.map((s) => [s.name, s])) | |
| 134 | + expect(Object.keys(byName).sort()).toEqual(['alpha', 'beta', 'late']) | |
| 135 | + expect(byName.alpha).toMatchObject({ sourceId: 'plain', path: 'skills/alpha', description: 'Alpha skill' }) | |
| 136 | + expect(byName.alpha?.plugin).toBeUndefined() | |
| 137 | + expect(byName.alpha?.hash).toMatch(/^[0-9a-f]{64}$/) | |
| 138 | + expect(byName.beta?.path).toBe('deep/er/beta') | |
| 139 | + }) | |
| 140 | + | |
| 141 | + it('lists skills of a claude-marketplace source (relative plugins only)', async () => { | |
| 142 | + const m = createMarketplace({ cacheDir, userSources: sources }) | |
| 143 | + const skills = await m.listSkills('mkt') | |
| 144 | + const summary = skills.map((s) => `${s.plugin}:${s.name}:${s.path}`).sort() | |
| 145 | + expect(summary).toEqual(['listed:one:top/one', 'p1:delta:plugins/p1/extra/delta', 'p1:gamma:plugins/p1/skills/gamma']) | |
| 146 | + }) | |
| 147 | + | |
| 148 | + it('fails clearly when marketplace.json is missing', async () => { | |
| 149 | + const repo = await makeRepo('nomkt', { 'x/SKILL.md': skillMd('x', 'x') }) | |
| 150 | + const m = createMarketplace({ | |
| 151 | + cacheDir, | |
| 152 | + userSources: [{ id: 'nomkt', name: 'No mkt', kind: 'claude-marketplace', url: pathToFileUrl(repo) }], | |
| 153 | + }) | |
| 154 | + await expect(m.listSkills('nomkt')).rejects.toThrow(/marketplace\.json/) | |
| 155 | + }) | |
| 156 | + | |
| 157 | + it('fills inCatalog by name and hash', async () => { | |
| 158 | + const m = createMarketplace({ cacheDir, userSources: sources }) | |
| 159 | + const base = await m.listSkills('plain') | |
| 160 | + const alpha = base.find((s) => s.name === 'alpha')! | |
| 161 | + const catalog = fakeCatalog([ | |
| 162 | + { type: 'skill', name: 'alpha', hash: alpha.hash }, | |
| 163 | + { type: 'skill', name: 'beta', hash: 'other' }, | |
| 164 | + { type: 'rule', name: 'late', hash: 'x' }, | |
| 165 | + ]) | |
| 166 | + const skills = await m.listSkills('plain', catalog) | |
| 167 | + const get = (n: string) => skills.find((s) => s.name === n)?.inCatalog | |
| 168 | + expect(get('alpha')).toBe('same') | |
| 169 | + expect(get('beta')).toBe('different') | |
| 170 | + expect(get('late')).toBeUndefined() | |
| 171 | + }) | |
| 172 | + | |
| 173 | + it('resolves skill dirs and rejects traversal', async () => { | |
| 174 | + const m = createMarketplace({ cacheDir, userSources: sources }) | |
| 175 | + await m.refresh('plain') | |
| 176 | + const dir = await m.skillDir('plain', 'skills/alpha') | |
| 177 | + expect(dir).toBe(path.join(cacheDir, 'plain', 'skills', 'alpha')) | |
| 178 | + await expect(m.skillDir('plain', '../mkt')).rejects.toThrow(/Invalid skill path/) | |
| 179 | + await expect(m.skillDir('plain', 'skills/../../plain.json')).rejects.toThrow(/Invalid skill path/) | |
| 180 | + await expect(m.skillDir('plain', path.resolve(tmp))).rejects.toThrow(/Invalid skill path/) | |
| 181 | + await expect(m.skillDir('plain', '')).rejects.toThrow(/Invalid skill path/) | |
| 182 | + await expect(m.skillDir('plain', 'skills')).rejects.toThrow(/No skill found/) | |
| 183 | + }) | |
| 184 | + | |
| 185 | + it.skipIf(!process.env.SKILLY_NET_TESTS)('clones the real built-in sources', { timeout: 300_000 }, async () => { | |
| 186 | + const netCache = path.join(tmp, 'net-cache') | |
| 187 | + const m = createMarketplace({ cacheDir: netCache, userSources: [] }) | |
| 188 | + for (const s of m.sources().filter((x) => x.builtIn)) { | |
| 189 | + const skills = await m.listSkills(s.id) | |
| 190 | + console.log(`${s.id} (${s.kind}): ${skills.length} skills`) | |
| 191 | + expect(skills.length).toBeGreaterThan(0) | |
| 192 | + } | |
| 193 | + }) | |
| 194 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,194 @@ | |||
| 1 | +import { execFileSync } from 'node:child_process' | ||
| 2 | +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | ||
| 3 | +import os from 'node:os' | ||
| 4 | +import path from 'node:path' | ||
| 5 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
| 6 | +import type { CatalogItem, MarketplaceSource } from '../src/catalog-model.ts' | ||
| 7 | +import type { CatalogStore } from '../src/contracts.ts' | ||
| 8 | +import { createMarketplace } from '../src/marketplace/index.ts' | ||
| 9 | + | ||
| 10 | +let tmp: string | ||
| 11 | + | ||
| 12 | +async function put(file: string, content: string): Promise<void> { | ||
| 13 | + await mkdir(path.dirname(file), { recursive: true }) | ||
| 14 | + await writeFile(file, content) | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +const skillMd = (name: string, description: string): string => `---\nname: ${name}\ndescription: ${description}\n---\nBody of ${name}\n` | ||
| 18 | + | ||
| 19 | +function git(cwd: string, ...args: string[]): void { | ||
| 20 | + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], { | ||
| 21 | + cwd, | ||
| 22 | + stdio: 'ignore', | ||
| 23 | + }) | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +async function makeRepo(name: string, files: Record<string, string>): Promise<string> { | ||
| 27 | + const dir = path.join(tmp, 'repos', name) | ||
| 28 | + await mkdir(dir, { recursive: true }) | ||
| 29 | + git(dir, 'init', '-q') | ||
| 30 | + for (const [rel, content] of Object.entries(files)) await put(path.join(dir, rel), content) | ||
| 31 | + git(dir, 'add', '-A') | ||
| 32 | + git(dir, 'commit', '-q', '-m', 'init') | ||
| 33 | + return dir | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function fakeCatalog(items: Partial<CatalogItem>[]): CatalogStore { | ||
| 37 | + return { | ||
| 38 | + snapshot: async () => ({ root: '', exists: true, items: items as CatalogItem[], presets: [], errors: [] }), | ||
| 39 | + } as unknown as CatalogStore | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +let plainRepo: string | ||
| 43 | +let mktRepo: string | ||
| 44 | +let cacheDir: string | ||
| 45 | +let sources: MarketplaceSource[] | ||
| 46 | + | ||
| 47 | +beforeAll(async () => { | ||
| 48 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-mkt-')) | ||
| 49 | + cacheDir = path.join(tmp, 'cache') | ||
| 50 | + plainRepo = await makeRepo('plain', { | ||
| 51 | + 'skills/alpha/SKILL.md': skillMd('alpha', 'Alpha skill'), | ||
| 52 | + 'skills/alpha/ref/SKILL.md': 'nested inside a skill: must be ignored', | ||
| 53 | + 'skills/alpha/notes.md': 'notes', | ||
| 54 | + 'deep/er/beta/SKILL.md': '# no frontmatter', | ||
| 55 | + 'node_modules/pkg/SKILL.md': skillMd('ignored', 'ignored'), | ||
| 56 | + }) | ||
| 57 | + mktRepo = await makeRepo('mkt', { | ||
| 58 | + '.claude-plugin/marketplace.json': JSON.stringify({ | ||
| 59 | + name: 'fake', | ||
| 60 | + plugins: [ | ||
| 61 | + { name: 'p1', source: './plugins/p1' }, | ||
| 62 | + { name: 'ext', source: { source: 'url', url: 'https://example.invalid/x.git' } }, | ||
| 63 | + { name: 'escape', source: '../outside' }, | ||
| 64 | + { name: 'listed', source: './', skills: ['./top/one'] }, | ||
| 65 | + ], | ||
| 66 | + }), | ||
| 67 | + 'plugins/p1/skills/gamma/SKILL.md': skillMd('gamma', 'Gamma'), | ||
| 68 | + 'plugins/p1/extra/delta/SKILL.md': skillMd('delta', 'Delta'), | ||
| 69 | + 'plugins/p2/skills/unlisted/SKILL.md': skillMd('unlisted', 'Not in marketplace.json'), | ||
| 70 | + 'top/one/SKILL.md': skillMd('one', 'One'), | ||
| 71 | + 'top/two/SKILL.md': skillMd('two', 'Two (not listed)'), | ||
| 72 | + }) | ||
| 73 | + sources = [ | ||
| 74 | + { id: 'plain', name: 'Plain', kind: 'git', url: pathToFileUrl(plainRepo) }, | ||
| 75 | + { id: 'mkt', name: 'Mkt', kind: 'claude-marketplace', url: pathToFileUrl(mktRepo) }, | ||
| 76 | + { id: 'broken', name: 'Broken', kind: 'git', url: pathToFileUrl(path.join(tmp, 'does-not-exist')) }, | ||
| 77 | + ] | ||
| 78 | +}) | ||
| 79 | + | ||
| 80 | +afterAll(async () => { | ||
| 81 | + await rm(tmp, { recursive: true, force: true }) | ||
| 82 | +}) | ||
| 83 | + | ||
| 84 | +function pathToFileUrl(p: string): string { | ||
| 85 | + return `file:///${p.replace(/\\/g, '/').replace(/^\//, '')}` | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +describe('marketplace', () => { | ||
| 89 | + it('lists built-in and user sources', () => { | ||
| 90 | + const list = createMarketplace({ cacheDir, userSources: sources }).sources() | ||
| 91 | + const builtIn = list.filter((s) => s.builtIn) | ||
| 92 | + expect(builtIn.length).toBeGreaterThanOrEqual(2) | ||
| 93 | + expect(builtIn.map((s) => s.id)).toContain('anthropics-skills') | ||
| 94 | + expect(list.find((s) => s.id === 'plain')?.builtIn).toBeFalsy() | ||
| 95 | + }) | ||
| 96 | + | ||
| 97 | + it('rejects unknown sources', async () => { | ||
| 98 | + const m = createMarketplace({ cacheDir, userSources: [] }) | ||
| 99 | + await expect(m.refresh('nope')).rejects.toThrow(/Unknown/) | ||
| 100 | + await expect(m.listSkills('nope')).rejects.toThrow(/Unknown/) | ||
| 101 | + await expect(m.skillDir('nope', 'x')).rejects.toThrow(/Unknown/) | ||
| 102 | + }) | ||
| 103 | + | ||
| 104 | + it('reports a clear error when the clone fails', async () => { | ||
| 105 | + const m = createMarketplace({ cacheDir, userSources: sources }) | ||
| 106 | + await expect(m.refresh('broken')).rejects.toThrow(/Cannot refresh "Broken"/) | ||
| 107 | + }) | ||
| 108 | + | ||
| 109 | + it('refreshes: clone, then update after a new commit, and persists lastFetchedAt', async () => { | ||
| 110 | + const m = createMarketplace({ cacheDir, userSources: sources }) | ||
| 111 | + expect(m.sources().find((s) => s.id === 'plain')?.lastFetchedAt).toBeUndefined() | ||
| 112 | + const first = await m.refresh('plain') | ||
| 113 | + expect(first.lastFetchedAt).toBeTruthy() | ||
| 114 | + expect(m.sources().find((s) => s.id === 'plain')?.lastFetchedAt).toBe(first.lastFetchedAt) | ||
| 115 | + expect((await m.listSkills('plain')).map((s) => s.name)).not.toContain('late') | ||
| 116 | + | ||
| 117 | + await put(path.join(plainRepo, 'skills', 'late', 'SKILL.md'), skillMd('late', 'Added later')) | ||
| 118 | + git(plainRepo, 'add', '-A') | ||
| 119 | + git(plainRepo, 'commit', '-q', '-m', 'second') | ||
| 120 | + await m.refresh('plain') | ||
| 121 | + expect((await m.listSkills('plain')).map((s) => s.name)).toContain('late') | ||
| 122 | + | ||
| 123 | + // A new instance sees the persisted timestamp and does not re-clone. | ||
| 124 | + const m2 = createMarketplace({ cacheDir, userSources: sources }) | ||
| 125 | + await m2.listSkills('plain') | ||
| 126 | + expect(m2.sources().find((s) => s.id === 'plain')?.lastFetchedAt).toBeTruthy() | ||
| 127 | + expect(JSON.parse(await readFile(path.join(cacheDir, 'plain.json'), 'utf8')).lastFetchedAt).toBeTruthy() | ||
| 128 | + }) | ||
| 129 | + | ||
| 130 | + it('lists skills of a git source', async () => { | ||
| 131 | + const m = createMarketplace({ cacheDir, userSources: sources }) | ||
| 132 | + const skills = await m.listSkills('plain') | ||
| 133 | + const byName = Object.fromEntries(skills.map((s) => [s.name, s])) | ||
| 134 | + expect(Object.keys(byName).sort()).toEqual(['alpha', 'beta', 'late']) | ||
| 135 | + expect(byName.alpha).toMatchObject({ sourceId: 'plain', path: 'skills/alpha', description: 'Alpha skill' }) | ||
| 136 | + expect(byName.alpha?.plugin).toBeUndefined() | ||
| 137 | + expect(byName.alpha?.hash).toMatch(/^[0-9a-f]{64}$/) | ||
| 138 | + expect(byName.beta?.path).toBe('deep/er/beta') | ||
| 139 | + }) | ||
| 140 | + | ||
| 141 | + it('lists skills of a claude-marketplace source (relative plugins only)', async () => { | ||
| 142 | + const m = createMarketplace({ cacheDir, userSources: sources }) | ||
| 143 | + const skills = await m.listSkills('mkt') | ||
| 144 | + const summary = skills.map((s) => `${s.plugin}:${s.name}:${s.path}`).sort() | ||
| 145 | + expect(summary).toEqual(['listed:one:top/one', 'p1:delta:plugins/p1/extra/delta', 'p1:gamma:plugins/p1/skills/gamma']) | ||
| 146 | + }) | ||
| 147 | + | ||
| 148 | + it('fails clearly when marketplace.json is missing', async () => { | ||
| 149 | + const repo = await makeRepo('nomkt', { 'x/SKILL.md': skillMd('x', 'x') }) | ||
| 150 | + const m = createMarketplace({ | ||
| 151 | + cacheDir, | ||
| 152 | + userSources: [{ id: 'nomkt', name: 'No mkt', kind: 'claude-marketplace', url: pathToFileUrl(repo) }], | ||
| 153 | + }) | ||
| 154 | + await expect(m.listSkills('nomkt')).rejects.toThrow(/marketplace\.json/) | ||
| 155 | + }) | ||
| 156 | + | ||
| 157 | + it('fills inCatalog by name and hash', async () => { | ||
| 158 | + const m = createMarketplace({ cacheDir, userSources: sources }) | ||
| 159 | + const base = await m.listSkills('plain') | ||
| 160 | + const alpha = base.find((s) => s.name === 'alpha')! | ||
| 161 | + const catalog = fakeCatalog([ | ||
| 162 | + { type: 'skill', name: 'alpha', hash: alpha.hash }, | ||
| 163 | + { type: 'skill', name: 'beta', hash: 'other' }, | ||
| 164 | + { type: 'rule', name: 'late', hash: 'x' }, | ||
| 165 | + ]) | ||
| 166 | + const skills = await m.listSkills('plain', catalog) | ||
| 167 | + const get = (n: string) => skills.find((s) => s.name === n)?.inCatalog | ||
| 168 | + expect(get('alpha')).toBe('same') | ||
| 169 | + expect(get('beta')).toBe('different') | ||
| 170 | + expect(get('late')).toBeUndefined() | ||
| 171 | + }) | ||
| 172 | + | ||
| 173 | + it('resolves skill dirs and rejects traversal', async () => { | ||
| 174 | + const m = createMarketplace({ cacheDir, userSources: sources }) | ||
| 175 | + await m.refresh('plain') | ||
| 176 | + const dir = await m.skillDir('plain', 'skills/alpha') | ||
| 177 | + expect(dir).toBe(path.join(cacheDir, 'plain', 'skills', 'alpha')) | ||
| 178 | + await expect(m.skillDir('plain', '../mkt')).rejects.toThrow(/Invalid skill path/) | ||
| 179 | + await expect(m.skillDir('plain', 'skills/../../plain.json')).rejects.toThrow(/Invalid skill path/) | ||
| 180 | + await expect(m.skillDir('plain', path.resolve(tmp))).rejects.toThrow(/Invalid skill path/) | ||
| 181 | + await expect(m.skillDir('plain', '')).rejects.toThrow(/Invalid skill path/) | ||
| 182 | + await expect(m.skillDir('plain', 'skills')).rejects.toThrow(/No skill found/) | ||
| 183 | + }) | ||
| 184 | + | ||
| 185 | + it.skipIf(!process.env.SKILLY_NET_TESTS)('clones the real built-in sources', { timeout: 300_000 }, async () => { | ||
| 186 | + const netCache = path.join(tmp, 'net-cache') | ||
| 187 | + const m = createMarketplace({ cacheDir: netCache, userSources: [] }) | ||
| 188 | + for (const s of m.sources().filter((x) => x.builtIn)) { | ||
| 189 | + const skills = await m.listSkills(s.id) | ||
| 190 | + console.log(`${s.id} (${s.kind}): ${skills.length} skills`) | ||
| 191 | + expect(skills.length).toBeGreaterThan(0) | ||
| 192 | + } | ||
| 193 | + }) | ||
| 194 | +}) | ||