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:
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user