feat: add GitHub skill installation support

- Implemented functionality to install skills from GitHub URLs.
- Updated API to handle new installation requests from GitHub.
- Enhanced UI to allow users to input GitHub skill URLs for installation.
- Added translations for new GitHub installation features in English, Thai, and Chinese.
- Created tests for the new skill installation service and API routes to ensure proper functionality.
This commit is contained in:
DEV_DSW
2026-04-23 11:41:52 +08:00
parent f80bdc7f11
commit 655e7c51d2
16 changed files with 1818 additions and 51 deletions

View File

@@ -90,6 +90,11 @@
"installDialogSubtitle": "Browse Explore by default, or enter keywords to search.",
"sourceLabel": "Source",
"sourceClawHub": "ClawHub",
"githubInstallLabel": "Install From GitHub",
"githubUrlPlaceholder": "Paste a GitHub /blob/.../SKILL.md or /tree/... skill URL",
"githubInstallAction": "Install URL",
"githubInstallHint": "Supports public github.com skill directories and installs them into \"{{path}}\".",
"githubUrlRequired": "Enter a GitHub skill URL first.",
"securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.",
"manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{{path}}\".",
"searching": "Searching ClawHub...",

View File

@@ -90,6 +90,11 @@
"installDialogSubtitle": "ค่าเริ่มต้นจะแสดง Explore และจะค้นหาเมื่อมีการกรอกคำสำคัญ",
"sourceLabel": "แหล่งที่มา",
"sourceClawHub": "ClawHub",
"githubInstallLabel": "ติดตั้งจาก GitHub",
"githubUrlPlaceholder": "วางลิงก์สกิล GitHub แบบ /blob/.../SKILL.md หรือ /tree/...",
"githubInstallAction": "ติดตั้งลิงก์",
"githubInstallHint": "รองรับ skill directory บน public github.com และจะติดตั้งไปที่ \"{{path}}\"",
"githubUrlRequired": "กรุณาใส่ลิงก์สกิล GitHub ก่อน",
"securityNote": "ก่อนติดตั้ง ให้คลิกการ์ดสกิลเพื่อดูเอกสารและข้อมูลความปลอดภัยบน ClawHub",
"manualInstallHint": "มีปัญหาเครือข่ายหรือไม่? คุณสามารถดาวน์โหลด ZIP ของสกิลจาก ClawHub.ai และแตกไฟล์ไปที่ \"{{path}}\" เพื่อติดตั้งด้วยตนเองได้ทุกเมื่อ",
"searching": "กำลังค้นหา ClawHub...",

View File

@@ -90,6 +90,11 @@
"installDialogSubtitle": "默认展示 Explore也可以输入关键词搜索。",
"sourceLabel": "来源",
"sourceClawHub": "ClawHub",
"githubInstallLabel": "从 GitHub 安装",
"githubUrlPlaceholder": "粘贴 GitHub /blob/.../SKILL.md 或 /tree/... 技能链接",
"githubInstallAction": "安装链接",
"githubInstallHint": "支持 public github.com 的技能目录,并安装到 \"{{path}}\"。",
"githubUrlRequired": "请先输入 GitHub 技能链接。",
"securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。",
"manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。",
"searching": "正在搜索 ClawHub...",

View File

@@ -48,6 +48,18 @@ type GatewaySkillsStatusResult = {
skills?: GatewaySkillStatus[];
};
export type SkillInstallRequest =
| { kind: 'marketplace'; slug: string; version?: string; force?: boolean }
| { kind: 'github-url'; url: string; force?: boolean };
export type SkillInstallResult = {
success: true;
slug: string;
baseDir: string;
source: 'marketplace' | 'github-url';
enabled: true;
};
function mapErrorCodeToSkillErrorKey(
message: string,
operation: 'fetch' | 'search' | 'install',
@@ -245,16 +257,18 @@ export async function apiSearchSkills(query: string): Promise<MarketplaceSkill[]
}
}
export async function apiInstallSkill(slug: string, version?: string): Promise<void> {
export async function apiInstallSkill(request: SkillInstallRequest): Promise<SkillInstallResult> {
try {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', {
const result = await hostApiFetch<SkillInstallResult>('/api/skills/install', {
method: 'POST',
body: JSON.stringify({ slug, version }),
body: JSON.stringify(request),
});
if (!result?.success) {
throw new Error(result?.error || 'Install failed');
throw new Error('Install failed');
}
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(mapErrorCodeToSkillErrorKey(message, 'install'));
@@ -320,11 +334,11 @@ export async function apiOpenSkillReadme(skillKey: string, slug?: string, baseDi
export async function apiGetSkillsDir(): Promise<string> {
try {
const result = await hostApiFetch<{ success: boolean; dir?: string; path?: string; error?: string }>('/api/clawhub/skills-dir');
if (result?.success && (result.dir || result.path)) return result.dir || result.path || '~/.zn-ai/skills';
if (result?.success && (result.dir || result.path)) return result.dir || result.path || '~/.openclaw/skills';
} catch {
// Fallback to the default local path.
}
return '~/.zn-ai/skills';
return '~/.openclaw/skills';
}
export async function apiGetGatewayStatus(): Promise<{

View File

@@ -18,9 +18,12 @@ type MarketplaceDrawerProps = {
searchErrorLabel: string | null;
installedSkills: Skill[];
installing: Record<string, boolean>;
githubSkillUrl: string;
skillsDirPath: string;
t: SkillsTranslate;
onClose: () => void;
onGithubUrlChange: (value: string) => void;
onInstallFromGithub: (url: string) => void | Promise<void>;
onQueryChange: (value: string) => void;
onInstall: (slug: string) => void | Promise<void>;
onUninstall: (slug: string) => void | Promise<void>;
@@ -39,14 +42,19 @@ export default function MarketplaceDrawer({
searchErrorLabel,
installedSkills,
installing,
githubSkillUrl,
skillsDirPath,
t,
onClose,
onGithubUrlChange,
onInstallFromGithub,
onQueryChange,
onInstall,
onUninstall,
onOpenExternal,
}: MarketplaceDrawerProps) {
const githubInstalling = Boolean(installing['__github-url__']);
useEffect(() => {
if (!open) return undefined;
@@ -119,6 +127,52 @@ export default function MarketplaceDrawer({
{t('skills.marketplace.sourceLabel')}: {t('skills.marketplace.sourceClawHub')}
</div>
</div>
<div className="mt-4 rounded-2xl border border-black/10 bg-black/[0.03] p-4 dark:border-gray-700 dark:bg-white/[0.03]">
<div className="mb-2 text-[12px] font-medium uppercase tracking-[0.18em] text-[#525866] dark:text-gray-400">
{t('skills.marketplace.githubInstallLabel', undefined, 'Install From GitHub')}
</div>
<div className="flex flex-col gap-2 md:flex-row">
<input
value={githubSkillUrl}
onChange={(event) => onGithubUrlChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && githubSkillUrl.trim() && !githubInstalling) {
event.preventDefault();
void onInstallFromGithub(githubSkillUrl);
}
}}
placeholder={t(
'skills.marketplace.githubUrlPlaceholder',
undefined,
'Paste a GitHub /blob/.../SKILL.md or /tree/... skill URL',
)}
className="h-10 flex-1 rounded-xl border border-black/10 bg-white px-3 text-[13px] text-[#171717] outline-none placeholder:text-[#171717]/45 dark:border-gray-700 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500"
/>
<button
type="button"
className="inline-flex h-10 items-center justify-center rounded-xl bg-[#171717] px-4 text-[13px] font-medium text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#f3f4f6] dark:text-[#171717] dark:hover:bg-white"
onClick={() => {
void onInstallFromGithub(githubSkillUrl);
}}
disabled={!githubSkillUrl.trim() || githubInstalling}
>
{githubInstalling
? <RefreshIcon className="h-3.5 w-3.5 animate-spin" />
: t('skills.marketplace.githubInstallAction', undefined, 'Install URL')}
</button>
</div>
<p className="mt-2 text-[12px] leading-relaxed text-[#525866] dark:text-gray-400">
{t(
'skills.marketplace.githubInstallHint',
{ path: skillsDirPath },
'Supports public github.com skill directories and installs them into "{{path}}".',
)}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">

View File

@@ -34,6 +34,8 @@ type FeedbackTone = 'info' | 'success' | 'error';
const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitError']);
const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']);
const SEARCH_ERROR_CODES = new Set(['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError']);
const DEFAULT_SKILLS_DIR = '~/.openclaw/skills';
const GITHUB_INSTALL_KEY = '__github-url__';
type FeedbackState = {
id: number;
@@ -56,8 +58,9 @@ export default function SkillsPage() {
const [marketplaceResults, setMarketplaceResults] = useState<MarketplaceSkill[]>([]);
const [marketplaceSearching, setMarketplaceSearching] = useState(false);
const [marketplaceSearchError, setMarketplaceSearchError] = useState<string | null>(null);
const [githubSkillUrl, setGithubSkillUrl] = useState('');
const [installing, setInstalling] = useState<Record<string, boolean>>({});
const [skillsDirPath, setSkillsDirPath] = useState('~/.zn-ai/skills');
const [skillsDirPath, setSkillsDirPath] = useState(DEFAULT_SKILLS_DIR);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isGatewayRunning, setIsGatewayRunning] = useState(false);
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
@@ -196,9 +199,9 @@ export default function SkillsPage() {
async function loadSkillsDir() {
try {
const dir = await apiGetSkillsDir();
setSkillsDirPath(dir || '~/.zn-ai/skills');
setSkillsDirPath(dir || DEFAULT_SKILLS_DIR);
} catch {
setSkillsDirPath('~/.zn-ai/skills');
setSkillsDirPath(DEFAULT_SKILLS_DIR);
}
}
@@ -310,16 +313,44 @@ export default function SkillsPage() {
return;
}
setInstalling((currentInstalling) => ({
...currentInstalling,
[slug]: true,
}));
try {
const result = await runInstall(
slug,
{ kind: 'marketplace', slug, version: marketplaceSkill.version },
);
if (result) {
setSelectedSkillId(result.slug);
}
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
pushFeedback(
INSTALL_ERROR_CODES.has(message)
? t(`skills.toast.${message}`, { path: skillsDirPath }, message)
: `${t('skills.toast.failedInstall')}: ${message}`,
'error',
);
}
}
async function handleInstallFromGitHub(url: string) {
const trimmedUrl = url.trim();
if (!trimmedUrl) {
pushFeedback(
t('skills.marketplace.githubUrlRequired', undefined, 'Enter a GitHub skill URL first.'),
'error',
);
return;
}
try {
await apiInstallSkill(slug, marketplaceSkill.version);
await apiSetSkillEnabled(slug, true);
await loadSkills();
pushFeedback(t('skills.toast.installed'), 'success');
const result = await runInstall(
GITHUB_INSTALL_KEY,
{ kind: 'github-url', url: trimmedUrl },
);
if (result) {
setGithubSkillUrl('');
setSelectedSkillId(result.slug);
}
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
pushFeedback(
@@ -328,12 +359,6 @@ export default function SkillsPage() {
: `${t('skills.toast.failedInstall')}: ${message}`,
'error',
);
} finally {
setInstalling((currentInstalling) => {
const nextInstalling = { ...currentInstalling };
delete nextInstalling[slug];
return nextInstalling;
});
}
}
@@ -434,9 +459,34 @@ export default function SkillsPage() {
setMarketplaceQuery('');
setMarketplaceResults([]);
setMarketplaceSearchError(null);
setGithubSkillUrl('');
setMarketplaceOpen(true);
}
async function runInstall(
installKey: string,
request: Parameters<typeof apiInstallSkill>[0],
) {
setInstalling((currentInstalling) => ({
...currentInstalling,
[installKey]: true,
}));
try {
const result = await apiInstallSkill(request);
await apiSetSkillEnabled(result.slug, true);
await loadSkills();
pushFeedback(t('skills.toast.installed'), 'success');
return result;
} finally {
setInstalling((currentInstalling) => {
const nextInstalling = { ...currentInstalling };
delete nextInstalling[installKey];
return nextInstalling;
});
}
}
return (
<section className="h-full w-full min-h-0">
<div className="relative flex h-full w-full overflow-hidden rounded-[16px] bg-white dark:bg-[#1b1b1d]">
@@ -640,9 +690,12 @@ export default function SkillsPage() {
: null}
installedSkills={skills}
installing={installing}
githubSkillUrl={githubSkillUrl}
skillsDirPath={skillsDirPath}
t={t}
onClose={() => setMarketplaceOpen(false)}
onGithubUrlChange={setGithubSkillUrl}
onInstallFromGithub={handleInstallFromGitHub}
onQueryChange={setMarketplaceQuery}
onInstall={handleInstall}
onUninstall={handleUninstall}