Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions transports/bifrost-http/handlers/skills_serving.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,11 @@ func (h *SkillsServingHandler) RegisterRoutes(r *router.Router, middlewares ...s
// Git-based marketplace routes only registered when git binary is available,
// since Claude Code and Codex require git clone support.
if h.gitAvailable {
// Claude Code marketplace
// Claude Code and Claude Desktop/Cowork marketplace
claudeMarketplaceBase := "/api/skills/serve/claude-code.git"
r.GET("/api/skills/serve/claude-code/.claude-plugin/marketplace.json", h.claudeCodeMarketplace)
r.GET(claudeMarketplaceBase+"/info/refs", h.claudeCodeMarketplaceGit())
r.POST(claudeMarketplaceBase+"/git-upload-pack", h.claudeCodeMarketplaceGit())

// Codex marketplace — Codex expects .agents/plugins/marketplace.json
r.GET("/api/skills/serve/codex/.agents/plugins/marketplace.json", h.codexMarketplace)
Expand Down Expand Up @@ -169,9 +172,20 @@ const allSkillsPluginName = pluginNamePrefix + "all-skills"

// claudeCodeMarketplace generates GET /api/skills/serve/claude-code/.claude-plugin/marketplace.json
func (h *SkillsServingHandler) claudeCodeMarketplace(ctx *fasthttp.RequestCtx) {
marketplaceJSON, err := h.buildClaudeCodeMarketplaceJSON(ctx)
if err != nil {
return // error already sent
}
ctx.SetContentType("application/json")
ctx.SetStatusCode(fasthttp.StatusOK)
ctx.SetBody(marketplaceJSON)
}

// buildClaudeCodeMarketplaceJSON builds the Claude marketplace JSON bytes.
func (h *SkillsServingHandler) buildClaudeCodeMarketplaceJSON(ctx *fasthttp.RequestCtx) ([]byte, error) {
skills, err := h.listAllSkills(ctx)
if err != nil {
return
return nil, err
}

allSkillsVersion := "0.0.0"
Expand All @@ -180,7 +194,7 @@ func (h *SkillsServingHandler) claudeCodeMarketplace(ctx *fasthttp.RequestCtx) {
if err != nil {
logger.Error("all-skills: failed to get version: %v", err)
SendError(ctx, fasthttp.StatusInternalServerError, "failed to get all-skills version")
return
return nil, err
}
}

Expand Down Expand Up @@ -218,7 +232,7 @@ func (h *SkillsServingHandler) claudeCodeMarketplace(ctx *fasthttp.RequestCtx) {
"plugins": plugins,
}

SendJSON(ctx, result)
return json.MarshalIndent(result, "", " ")
}

// codexMarketplace generates GET /api/skills/serve/codex/.codex-plugin/marketplace.json
Expand Down Expand Up @@ -575,9 +589,23 @@ func (h *SkillsServingHandler) servePluginGit(harness string) fasthttp.RequestHa
}
}

// claudeCodeMarketplaceGit serves the Claude marketplace as a git repository.
// Claude Desktop and Cowork clone this URL and read .claude-plugin/marketplace.json.
func (h *SkillsServingHandler) claudeCodeMarketplaceGit() fasthttp.RequestHandler {
repoBase := "/api/skills/serve/claude-code.git"
return func(ctx *fasthttp.RequestCtx) {
marketplaceJSON, err := h.buildClaudeCodeMarketplaceJSON(ctx)
if err != nil {
return // error already sent
}

spec := assembleMarketplaceRepoSpec(marketplaceJSON, "claude-code")
serveGitRepo(ctx, spec, repoBase)
}
}

// codexMarketplaceGit returns a handler that serves the Codex marketplace as a
// git repo. Codex clones the marketplace URL itself (unlike Claude Code which
// fetches marketplace.json as plain HTTP).
// git repo. Codex clones the marketplace URL itself.
func (h *SkillsServingHandler) codexMarketplaceGit() fasthttp.RequestHandler {
repoBase := "/api/skills/serve/codex"
return func(ctx *fasthttp.RequestCtx) {
Expand Down
99 changes: 99 additions & 0 deletions transports/bifrost-http/handlers/skills_serving_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ package handlers

import (
"context"
"encoding/json"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -73,3 +78,97 @@ func TestSkillsServingGenericFileDownloadDecodesEncodedPathParams(t *testing.T)
t.Fatalf("body got %q, want %q", got, string(content))
}
}

func TestClaudeMarketplaceGitRepoContainsMarketplaceAndCloneablePlugin(t *testing.T) {
if !CheckGitAvailability() {
t.Skip("git binary is unavailable")
}

ctx := context.Background()
store := newTestConfigStore(t)
if err := store.CreateSkill(ctx, &tables.TableSkill{
Name: "desktop-skill",
Description: "skill for testing Claude Desktop marketplace installation",
SkillMDBody: "Use this skill from Claude Desktop.",
}, "1.0.0", nil); err != nil {
t.Fatalf("create skill: %v", err)
}

handler := NewSkillsServingHandler(store, nil)
r := router.New()
handler.RegisterRoutes(r)

server := &fasthttp.Server{Handler: r.Handler}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go server.Serve(ln) //nolint:errcheck
defer server.Shutdown()
defer ln.Close()

baseURL := "http://" + ln.Addr().String()
marketplaceURL := baseURL + "/api/skills/serve/claude-code.git"
cloneDir := filepath.Join(t.TempDir(), "marketplace")
cloneGitRepo(t, marketplaceURL, cloneDir)

marketplaceBytes, err := os.ReadFile(filepath.Join(cloneDir, ".claude-plugin", "marketplace.json"))
if err != nil {
t.Fatalf("read marketplace manifest: %v", err)
}
var marketplace struct {
Plugins []struct {
Name string `json:"name"`
Source struct {
Source string `json:"source"`
URL string `json:"url"`
} `json:"source"`
} `json:"plugins"`
}
if err := json.Unmarshal(marketplaceBytes, &marketplace); err != nil {
t.Fatalf("decode marketplace manifest: %v", err)
}

var pluginURL string
for _, plugin := range marketplace.Plugins {
if plugin.Name == "bifrost-desktop-skill" {
if plugin.Source.Source != "url" {
t.Fatalf("plugin source type got %q, want url", plugin.Source.Source)
}
pluginURL = plugin.Source.URL
break
}
}
if pluginURL != baseURL+"/api/skills/serve/claude-code/plugins/bifrost-desktop-skill" {
t.Fatalf("plugin URL got %q; marketplace=%s", pluginURL, marketplaceBytes)
}

pluginCloneDir := filepath.Join(t.TempDir(), "plugin")
cloneGitRepo(t, pluginURL, pluginCloneDir)
for _, relativePath := range []string{
filepath.Join(".claude-plugin", "plugin.json"),
filepath.Join("skills", "desktop-skill", "SKILL.md"),
} {
if _, err := os.Stat(filepath.Join(pluginCloneDir, relativePath)); err != nil {
t.Errorf("expected plugin file %s: %v", relativePath, err)
}
}

statusCode, _, err := fasthttp.Get(nil, baseURL+"/api/skills/serve/claude-code/.claude-plugin/marketplace.json")
if err != nil {
t.Fatalf("get raw marketplace: %v", err)
}
if statusCode != fasthttp.StatusOK {
t.Fatalf("raw marketplace status got %d, want %d", statusCode, fasthttp.StatusOK)
}
}

func cloneGitRepo(t *testing.T, repoURL, destination string) {
t.Helper()
cmd := exec.Command(gitBinaryPath, "clone", "--quiet", repoURL, destination) //nolint:gosec // gitBinaryPath is resolved from PATH
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git clone %s: %v: %s", repoURL, err, strings.TrimSpace(string(output)))
}
}
20 changes: 14 additions & 6 deletions ui/app/workspace/skills-repo/components/skillListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,23 @@ function MarketplacePopover() {
const marketplaceBaseUrl = `${getExampleBaseUrl()}/api`;

const items = [
{
key: "claude-desktop",
label: "Claude Desktop / Cowork",
value: `${marketplaceBaseUrl}/skills/serve/claude-code.git`,
ariaLabel: "Copy Claude Desktop and Cowork marketplace Git URL",
},
{
key: "claude",
label: "Claude Code",
command: `claude plugin marketplace add ${marketplaceBaseUrl}/skills/serve/claude-code/.claude-plugin/marketplace.json`,
value: `claude plugin marketplace add ${marketplaceBaseUrl}/skills/serve/claude-code/.claude-plugin/marketplace.json`,
ariaLabel: "Copy Claude Code command",
},
{
key: "codex",
label: "Codex",
command: `codex plugin marketplace add ${marketplaceBaseUrl}/skills/serve/codex`,
value: `codex plugin marketplace add ${marketplaceBaseUrl}/skills/serve/codex`,
ariaLabel: "Copy Codex command",
},
];

Expand Down Expand Up @@ -103,20 +111,20 @@ function MarketplacePopover() {
</PopoverTrigger>
<PopoverContent align="end" className="w-[calc(100vw-2rem)] max-w-md p-0 md:w-auto">
<div className="border-b px-3 py-2">
<p className="text-muted-foreground text-xs font-medium">Copy CLI command to register this repository</p>
<p className="text-muted-foreground text-xs font-medium">Copy a marketplace URL or CLI command</p>
</div>
<div className="py-1">
{items.map((item) => (
<button
key={item.key}
data-testid={`skill-copy-marketplace-${item.key}`}
className="hover:bg-muted/50 flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left transition-colors"
aria-label={`Copy ${item.label} command`}
onClick={() => handleCopy(item.key, item.command)}
aria-label={item.ariaLabel}
onClick={() => handleCopy(item.key, item.value)}
>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{item.label}</p>
<p className="text-muted-foreground mt-0.5 truncate font-mono text-xs">{item.command}</p>
<p className="text-muted-foreground mt-0.5 truncate font-mono text-xs">{item.value}</p>
</div>
{copiedKey === item.key ? (
<Check className="h-3.5 w-3.5 shrink-0 text-green-500" />
Expand Down
Loading