hlassiege/panopliepublic Fork
⑂ efb1294
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(core): marketplace sources (git and Claude plugin marketplaces) with local cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T12:02:04+02:00 Browse files
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'
14 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'
29
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+ }
6108 }
@@ -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+})