Skip to content

Commit c4d03c4

Browse files
7418claude
andcommitted
feat: Skills.sh marketplace integration + navigation restructure
Skills.sh Marketplace: - Add marketplace browser with search, detail panel, and install/uninstall - Search API proxying skills.sh with lock file integration for installed state - Install/remove APIs using `npx skills` CLI with SSE streaming progress - SKILL.md content fetching via GitHub Git Trees API with in-memory caching - MarketplaceSkillCard, MarketplaceSkillDetail, MarketplaceBrowser components - InstallProgressDialog with real-time SSE log output - SkillsManager gains "My Skills" / "Marketplace" segmented control - Type definitions: MarketplaceSkill, SkillLockFile, SkillLockEntry Navigation restructure: - Split Extensions page into standalone /skills and /mcp top-level pages - NavRail: replace single Extensions icon with separate Skills (ZapIcon) and MCP (Plug01Icon) entries - /extensions page now redirects to /skills or /mcp based on ?tab param - /plugins redirects updated to point to new routes - Remove redundant source badges from SkillListItem (section headers suffice) Other: - Add WeChat user group QR code to README.md and README_CN.md - i18n: add 19 marketplace-related keys in en.ts and zh.ts - Bump version to 0.18.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 495319f commit c4d03c4

File tree

25 files changed

+1065
-83
lines changed

25 files changed

+1065
-83
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
[中文文档](./README_CN.md) | [日本語](./README_JA.md)
1111

12+
### Join the Community / 加入用户群
13+
14+
<img src="docs/wechat-group-qr.png" width="240" alt="WeChat Group QR Code" />
15+
16+
Scan the QR code to join the WeChat user group for discussions, feedback, and updates.
17+
1218
---
1319

1420
## Features

README_CN.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey)](https://github.com/op7418/CodePilot/releases)
1010
[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
1111

12+
### 加入用户群
13+
14+
<img src="docs/wechat-group-qr.png" width="240" alt="微信用户群二维码" />
15+
16+
扫描二维码加入微信用户群,交流使用心得、反馈问题和获取最新动态。
17+
1218
---
1319

1420
## 功能特性

docs/wechat-group-qr.png

4.85 KB
Loading

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.17.8",
3+
"version": "0.18.0",
44
"private": true,
55
"author": {
66
"name": "op7418",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { NextResponse } from "next/server";
2+
import { spawn } from "child_process";
3+
4+
export async function POST(request: Request) {
5+
try {
6+
const body = await request.json();
7+
const { source, global: isGlobal } = body as { source: string; global?: boolean };
8+
9+
if (!source || typeof source !== "string") {
10+
return NextResponse.json(
11+
{ error: "source is required" },
12+
{ status: 400 }
13+
);
14+
}
15+
16+
const args = ["skills", "add", source, "-y", "--agent", "claude-code"];
17+
if (isGlobal !== false) {
18+
args.splice(3, 0, "-g");
19+
}
20+
21+
const child = spawn("npx", args, {
22+
env: { ...process.env },
23+
shell: true,
24+
});
25+
26+
const encoder = new TextEncoder();
27+
const stream = new ReadableStream({
28+
start(controller) {
29+
const send = (event: string, data: string) => {
30+
controller.enqueue(
31+
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
32+
);
33+
};
34+
35+
child.stdout?.on("data", (chunk: Buffer) => {
36+
send("output", chunk.toString());
37+
});
38+
39+
child.stderr?.on("data", (chunk: Buffer) => {
40+
send("output", chunk.toString());
41+
});
42+
43+
child.on("close", (code) => {
44+
if (code === 0) {
45+
send("done", "Install completed successfully");
46+
} else {
47+
send("error", `Process exited with code ${code}`);
48+
}
49+
controller.close();
50+
});
51+
52+
child.on("error", (err) => {
53+
send("error", err.message);
54+
controller.close();
55+
});
56+
},
57+
cancel() {
58+
child.kill();
59+
},
60+
});
61+
62+
return new Response(stream, {
63+
headers: {
64+
"Content-Type": "text/event-stream",
65+
"Cache-Control": "no-cache",
66+
Connection: "keep-alive",
67+
},
68+
});
69+
} catch (error) {
70+
console.error("[marketplace/install] Error:", error);
71+
return NextResponse.json(
72+
{ error: error instanceof Error ? error.message : "Install failed" },
73+
{ status: 500 }
74+
);
75+
}
76+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
// In-memory cache: repo source → Map<skillId, raw-content path>
4+
const treeCache = new Map<string, { paths: Map<string, string>; ts: number }>();
5+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
6+
7+
/**
8+
* Use GitHub Git Trees API to find the actual path of a SKILL.md for a given skillId.
9+
* Repos have widely varying structures, so we can't guess the path — we scan the tree.
10+
*/
11+
async function findSkillPath(
12+
source: string,
13+
skillId: string,
14+
signal: AbortSignal
15+
): Promise<string | null> {
16+
const cached = treeCache.get(source);
17+
if (cached && Date.now() - cached.ts < CACHE_TTL) {
18+
return cached.paths.get(skillId) ?? null;
19+
}
20+
21+
// Fetch the full recursive tree for the repo's default branch
22+
const treeUrl = `https://api.github.com/repos/${source}/git/trees/HEAD?recursive=1`;
23+
const res = await fetch(treeUrl, {
24+
signal,
25+
headers: { Accept: "application/vnd.github+json" },
26+
});
27+
28+
if (!res.ok) return null;
29+
30+
const data = await res.json();
31+
const tree: Array<{ path: string; type: string }> = data.tree || [];
32+
33+
// Index all SKILL.md files by their parent directory name (= skillId)
34+
const paths = new Map<string, string>();
35+
for (const item of tree) {
36+
if (item.type !== "blob") continue;
37+
if (!item.path.endsWith("/SKILL.md")) continue;
38+
// Extract the folder name right before SKILL.md
39+
const parts = item.path.split("/");
40+
const folder = parts[parts.length - 2];
41+
if (folder) {
42+
// If there are duplicates, prefer shorter paths (closer to root)
43+
if (!paths.has(folder) || item.path.length < (paths.get(folder)?.length ?? Infinity)) {
44+
paths.set(folder, item.path);
45+
}
46+
}
47+
}
48+
49+
treeCache.set(source, { paths, ts: Date.now() });
50+
return paths.get(skillId) ?? null;
51+
}
52+
53+
export async function GET(request: NextRequest) {
54+
try {
55+
const source = request.nextUrl.searchParams.get("source") || "";
56+
const skillId = request.nextUrl.searchParams.get("skillId") || "";
57+
58+
if (!source || !skillId) {
59+
return NextResponse.json(
60+
{ error: "source and skillId are required" },
61+
{ status: 400 }
62+
);
63+
}
64+
65+
const controller = new AbortController();
66+
const timeout = setTimeout(() => controller.abort(), 15000);
67+
68+
try {
69+
// 1. Find the actual path via tree API (cached per repo)
70+
const skillPath = await findSkillPath(source, skillId, controller.signal);
71+
72+
if (!skillPath) {
73+
return NextResponse.json({ content: null }, { status: 200 });
74+
}
75+
76+
// 2. Fetch the raw SKILL.md content
77+
const rawUrl = `https://raw.githubusercontent.com/${source}/HEAD/${skillPath}`;
78+
const res = await fetch(rawUrl, { signal: controller.signal });
79+
80+
if (!res.ok) {
81+
return NextResponse.json({ content: null }, { status: 200 });
82+
}
83+
84+
const content = await res.text();
85+
return NextResponse.json({ content });
86+
} finally {
87+
clearTimeout(timeout);
88+
}
89+
} catch (error) {
90+
if (error instanceof Error && error.name === "AbortError") {
91+
return NextResponse.json({ content: null }, { status: 200 });
92+
}
93+
return NextResponse.json({ content: null }, { status: 200 });
94+
}
95+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { NextResponse } from "next/server";
2+
import { spawn } from "child_process";
3+
4+
export async function POST(request: Request) {
5+
try {
6+
const body = await request.json();
7+
const { skill, global: isGlobal } = body as { skill: string; global?: boolean };
8+
9+
if (!skill || typeof skill !== "string") {
10+
return NextResponse.json(
11+
{ error: "skill name is required" },
12+
{ status: 400 }
13+
);
14+
}
15+
16+
const args = ["skills", "remove", skill, "-y", "--agent", "claude-code"];
17+
if (isGlobal !== false) {
18+
args.splice(3, 0, "-g");
19+
}
20+
21+
const child = spawn("npx", args, {
22+
env: { ...process.env },
23+
shell: true,
24+
});
25+
26+
const encoder = new TextEncoder();
27+
const stream = new ReadableStream({
28+
start(controller) {
29+
const send = (event: string, data: string) => {
30+
controller.enqueue(
31+
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
32+
);
33+
};
34+
35+
child.stdout?.on("data", (chunk: Buffer) => {
36+
send("output", chunk.toString());
37+
});
38+
39+
child.stderr?.on("data", (chunk: Buffer) => {
40+
send("output", chunk.toString());
41+
});
42+
43+
child.on("close", (code) => {
44+
if (code === 0) {
45+
send("done", "Uninstall completed successfully");
46+
} else {
47+
send("error", `Process exited with code ${code}`);
48+
}
49+
controller.close();
50+
});
51+
52+
child.on("error", (err) => {
53+
send("error", err.message);
54+
controller.close();
55+
});
56+
},
57+
cancel() {
58+
child.kill();
59+
},
60+
});
61+
62+
return new Response(stream, {
63+
headers: {
64+
"Content-Type": "text/event-stream",
65+
"Cache-Control": "no-cache",
66+
Connection: "keep-alive",
67+
},
68+
});
69+
} catch (error) {
70+
console.error("[marketplace/remove] Error:", error);
71+
return NextResponse.json(
72+
{ error: error instanceof Error ? error.message : "Remove failed" },
73+
{ status: 500 }
74+
);
75+
}
76+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { readLockFile } from "@/lib/skills-lock";
3+
import type { MarketplaceSkill } from "@/types";
4+
5+
export async function GET(request: NextRequest) {
6+
try {
7+
const q = request.nextUrl.searchParams.get("q") || "";
8+
const limit = request.nextUrl.searchParams.get("limit") || "20";
9+
10+
// Skills.sh requires query >= 2 chars; use a popular fallback for empty/short queries
11+
const query = q.length >= 2 ? q : "claude";
12+
13+
const url = new URL("https://skills.sh/api/search");
14+
url.searchParams.set("q", query);
15+
url.searchParams.set("limit", limit);
16+
17+
const controller = new AbortController();
18+
const timeout = setTimeout(() => controller.abort(), 10000);
19+
20+
let response: Response;
21+
try {
22+
response = await fetch(url.toString(), { signal: controller.signal });
23+
} finally {
24+
clearTimeout(timeout);
25+
}
26+
27+
if (!response.ok) {
28+
return NextResponse.json(
29+
{ error: `Skills.sh API returned ${response.status}` },
30+
{ status: 502 }
31+
);
32+
}
33+
34+
const data = await response.json();
35+
const results: unknown[] = Array.isArray(data) ? data : (data.results || data.skills || []);
36+
37+
// Read lock file to mark installed skills
38+
const lockFile = readLockFile();
39+
const installedSources = new Set(
40+
Object.values(lockFile.skills).map((entry) => entry.source)
41+
);
42+
43+
const skills: MarketplaceSkill[] = results.map((item: unknown) => {
44+
const r = item as Record<string, unknown>;
45+
const source = String(r.source || r.slug || r.name || "");
46+
const installedEntry = Object.values(lockFile.skills).find(
47+
(entry) => entry.source === source
48+
);
49+
return {
50+
id: String(r.id || r.slug || r.name || ""),
51+
skillId: String(r.skillId || r.name || r.slug || ""),
52+
name: String(r.name || r.slug || ""),
53+
installs: Number(r.installs || r.downloads || 0),
54+
source,
55+
isInstalled: installedSources.has(source),
56+
installedAt: installedEntry?.installedAt,
57+
};
58+
});
59+
60+
return NextResponse.json({ skills });
61+
} catch (error) {
62+
if (error instanceof Error && error.name === "AbortError") {
63+
return NextResponse.json(
64+
{ error: "Skills.sh API request timed out" },
65+
{ status: 504 }
66+
);
67+
}
68+
console.error("[marketplace/search] Error:", error);
69+
return NextResponse.json(
70+
{ error: error instanceof Error ? error.message : "Search failed" },
71+
{ status: 502 }
72+
);
73+
}
74+
}

0 commit comments

Comments
 (0)