Skip to content

Commit c22149e

Browse files
authored
feat: implement multi-marketplace support for plugin distribution (#11)
* feat: implement multi-marketplace support for plugin distribution Add support for loading plugins from multiple external marketplace sources with dynamic runtime fetching and server-side caching. Key changes: - Create marketplace-sources.json config for managing marketplace URLs - Implement /api/marketplaces endpoint with 5-minute cache - Add marketplace filtering UI with USelectMenu component - Support marketplace-specific installation commands - Use marketplace.json name field for plugin installation - Add marketplace badges to distinguish plugin sources - Remove dependency on Nuxt Content for marketplace data Technical details: - Server API uses defineCachedFunction for performance - Zod schemas for runtime validation - Support for both local and GitHub-sourced plugins - Graceful handling of individual marketplace failures - TypeScript types for multi-marketplace aggregation Installation format: - Marketplace: /plugin marketplace add org/repo - Plugin: /plugin install pluginName@marketplaceName * fix: resolve eslint errors - Remove console.log statement from PluginCard - Remove unused MarketplaceData interface from index.vue - Import process from node:process in marketplaces API
1 parent 03a8dd0 commit c22149e

39 files changed

Lines changed: 2606 additions & 74 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"description": "A Model Context Protocol (MCP) server for Grafana providing access to dashboards, datasources, and querying capabilities",
6565
"source": {
6666
"source": "github",
67-
"repo": "amondnet/grafana-plugin"
67+
"repo": "amondnet/mcp-grafana"
6868
}
6969
}
7070
]

.idea/vcs.xml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CLAUDE.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,19 @@ The marketplace website is built with:
8383
cd apps/web
8484

8585
# Install dependencies
86-
npm install
86+
bun install
8787

8888
# Development server
89-
npm run dev
89+
bun run dev
9090

9191
# Build for production
92-
npm run build
92+
bun run build
9393

9494
# Preview production build
95-
npm run preview
95+
bun run preview
9696

9797
# Generate static site
98-
npm run generate
98+
bun run generate
9999
```
100100

101101
### Plugin Development
@@ -134,10 +134,10 @@ Users can add this marketplace and install plugins:
134134
```bash
135135
# For plugins with tests
136136
cd plugins/<plugin-name>
137-
npm run test
137+
bun run test
138138

139139
# Type checking across plugins
140-
npm run typecheck
140+
bun run typecheck
141141
```
142142

143143
## Key Files

apps/web/app/components/InstallModal.vue

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script setup lang="ts">
22
interface Props {
33
pluginName: string
4+
marketplaceJsonName?: string
5+
marketplaceRepo?: string
46
open?: boolean
57
}
68
@@ -19,8 +21,19 @@ const isOpen = computed({
1921
set: value => emit('update:open', value),
2022
})
2123
22-
const marketplaceCommand = '/plugin marketplace add pleaseai/claude-code-plugins'
23-
const installCommand = computed(() => `/plugin install ${props.pluginName}@pleaseai`)
24+
// Determine marketplace command and install command based on marketplace info
25+
const marketplaceCommand = computed(() => {
26+
// Use org/repo format for marketplace add
27+
const repo = props.marketplaceRepo || 'pleaseai/claude-code-plugins'
28+
return `/plugin marketplace add ${repo}`
29+
})
30+
31+
const installCommand = computed(() => {
32+
// Use pluginName@marketplaceJsonName format for plugin install
33+
// marketplaceJsonName comes from the "name" field in marketplace.json
34+
const marketplaceJsonName = props.marketplaceJsonName || 'claude-code-plugins'
35+
return `/plugin install ${props.pluginName}@${marketplaceJsonName}`
36+
})
2437
2538
const copiedStates = reactive({
2639
marketplace: false,
@@ -74,7 +87,7 @@ async function copyCommand(command: string, type: keyof typeof copiedStates) {
7487
}
7588
7689
async function copyAllCommands() {
77-
const allCommands = `${marketplaceCommand}\n${installCommand.value}`
90+
const allCommands = `${marketplaceCommand.value}\n${installCommand.value}`
7891
7992
try {
8093
// Check if clipboard API is available
@@ -92,7 +105,7 @@ async function copyAllCommands() {
92105
catch (err) {
93106
// Log error with full context for debugging
94107
console.error('Failed to copy all commands to clipboard:', {
95-
marketplaceCommand,
108+
marketplaceCommand: marketplaceCommand.value,
96109
installCommand: installCommand.value,
97110
error: err instanceof Error ? err.message : String(err),
98111
clipboardAvailable: isClipboardSupported.value,

apps/web/app/components/PluginCard.vue

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ interface Plugin {
1010
name: string
1111
description: string
1212
version?: string
13-
author?: string
14-
source: PluginSource
13+
author?: string | { name?: string, email?: string }
14+
source: PluginSource | string
15+
marketplaceRepo?: string
16+
marketplaceJsonName?: string
1517
}
1618
1719
interface PluginMetadata {
@@ -35,13 +37,19 @@ const isModalOpen = ref(false)
3537
const pluginMetadata = ref<PluginMetadata | null>(null)
3638
const loading = ref(false)
3739
38-
// Fetch plugin metadata from GitHub
40+
// Fetch plugin metadata from GitHub (only for plugins without version in marketplace.json)
3941
async function fetchPluginMetadata() {
42+
// If version already exists in marketplace.json, don't fetch
4043
if (props.plugin.version) {
41-
// If version already exists in marketplace.json, don't fetch
4244
return
4345
}
4446
47+
// Local plugins should have metadata in marketplace.json - skip fetch
48+
if (typeof props.plugin.source === 'string') {
49+
return
50+
}
51+
52+
// GitHub plugin - fetch from GitHub
4553
loading.value = true
4654
try {
4755
const url = `https://raw.githubusercontent.com/${props.plugin.source.repo}/main/.claude-plugin/plugin.json`
@@ -104,8 +112,22 @@ const displayDescription = computed(() => {
104112
105113
// Extract author name from either marketplace.json or fetched metadata
106114
// Handles both string and object formats
107-
function getAuthorName(author: string | { name?: string } | undefined): string | undefined {
108-
return typeof author === 'string' ? author : author?.name
115+
// If author email is @anthropic.com, display "Anthropic" instead of individual name
116+
function getAuthorName(author: string | { name?: string, email?: string } | undefined): string | undefined {
117+
if (!author)
118+
return undefined
119+
120+
// Handle string format
121+
if (typeof author === 'string') {
122+
return author
123+
}
124+
125+
// Handle object format - check email domain
126+
if (author.email?.endsWith('@anthropic.com')) {
127+
return 'Anthropic'
128+
}
129+
130+
return author.name
109131
}
110132
111133
// Computed author - prefer marketplace.json, fallback to metadata
@@ -129,6 +151,27 @@ const hasContext = computed(() => {
129151
return !!pluginMetadata.value?.contextFileName
130152
})
131153
154+
// Computed GitHub source URL
155+
const githubSourceUrl = computed(() => {
156+
if (typeof props.plugin.source === 'string') {
157+
// Local plugin - construct GitHub URL from marketplace repository
158+
if (!props.plugin.marketplaceRepo) {
159+
console.warn(`[PluginCard] No marketplace repository for local plugin: ${props.plugin.name}`)
160+
return undefined
161+
}
162+
163+
// marketplaceRepo is already in "org/repo" format
164+
const repoPath = props.plugin.marketplaceRepo
165+
// Convert local path to GitHub tree path (e.g., "./plugins/agent-sdk-dev" -> "tree/main/plugins/agent-sdk-dev")
166+
const treePath = props.plugin.source.replace(/^\.\//, 'tree/main/')
167+
const url = `https://github.com/${repoPath}/${treePath}`
168+
169+
return url
170+
}
171+
// GitHub plugin
172+
return `https://github.com/${props.plugin.source.repo}`
173+
})
174+
132175
// Consolidated badges configuration
133176
interface Badge {
134177
key: string
@@ -141,6 +184,18 @@ interface Badge {
141184
const badges = computed<Badge[]>(() => {
142185
const badgeList: Badge[] = []
143186
187+
// Marketplace badge (shown first)
188+
if (props.plugin.marketplaceJsonName) {
189+
const isOfficial = props.plugin.marketplaceJsonName === 'anthropic'
190+
badgeList.push({
191+
key: 'marketplace',
192+
icon: isOfficial ? 'i-heroicons-shield-check' : 'i-heroicons-building-storefront',
193+
label: props.plugin.marketplaceJsonName,
194+
color: isOfficial ? 'primary' : 'success',
195+
title: `From ${props.plugin.marketplaceJsonName} marketplace`,
196+
})
197+
}
198+
144199
// Author badges
145200
if (displayAuthor.value === 'Google') {
146201
badgeList.push({
@@ -232,7 +287,7 @@ watch(() => props.autoOpenModal, (shouldOpen) => {
232287
</div>
233288
<div class="flex items-center gap-2 text-xs text-muted">
234289
<UIcon name="i-heroicons-code-bracket" class="shrink-0" />
235-
<span class="truncate">{{ plugin.source.repo }}</span>
290+
<span class="truncate">{{ typeof plugin.source === 'string' ? plugin.source : plugin.source.repo }}</span>
236291
</div>
237292
</div>
238293
<div class="shrink-0">
@@ -277,7 +332,7 @@ watch(() => props.autoOpenModal, (shouldOpen) => {
277332

278333
<!-- Metadata -->
279334
<div v-if="displayAuthor || displayLicense" class="flex flex-wrap gap-2 text-xs">
280-
<div v-if="displayAuthor" class="flex items-center gap-1 text-muted">
335+
<div v-if="displayAuthor && displayAuthor !== 'Anthropic' && displayAuthor !== 'Google'" class="flex items-center gap-1 text-muted">
281336
<UIcon name="i-heroicons-user-circle" class="shrink-0" />
282337
<span>{{ displayAuthor }}</span>
283338
</div>
@@ -291,7 +346,8 @@ watch(() => props.autoOpenModal, (shouldOpen) => {
291346
<template #footer>
292347
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
293348
<UButton
294-
:to="`https://github.com/${plugin.source.repo}`"
349+
v-if="githubSourceUrl"
350+
:to="githubSourceUrl"
295351
target="_blank"
296352
external
297353
variant="outline"
@@ -307,7 +363,7 @@ watch(() => props.autoOpenModal, (shouldOpen) => {
307363
color="primary"
308364
size="sm"
309365
icon="i-heroicons-arrow-down-tray"
310-
class="flex-1 justify-center"
366+
:class="githubSourceUrl ? 'flex-1 justify-center' : 'w-full justify-center'"
311367
title="View installation instructions"
312368
@click="openInstallModal"
313369
>
@@ -321,5 +377,7 @@ watch(() => props.autoOpenModal, (shouldOpen) => {
321377
<InstallModal
322378
v-model:open="isModalOpen"
323379
:plugin-name="plugin.name"
380+
:marketplace-json-name="plugin.marketplaceJsonName"
381+
:marketplace-repo="plugin.marketplaceRepo"
324382
/>
325383
</template>

0 commit comments

Comments
 (0)