fix: import github skill directories

This commit is contained in:
Junyan Qin
2026-05-18 17:26:35 +08:00
parent bf8b51569f
commit 651904a5d4
6 changed files with 144 additions and 100 deletions
+93 -31
View File
@@ -8,7 +8,7 @@ import shutil
import tempfile import tempfile
import zipfile import zipfile
from typing import Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import quote, unquote, urlparse
import httpx import httpx
import yaml import yaml
@@ -429,7 +429,7 @@ class SkillService:
shutil.rmtree(tmp_dir, ignore_errors=True) shutil.rmtree(tmp_dir, ignore_errors=True)
async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]: async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]:
zip_bytes, filename, package_name = await self._download_github_skill_md_as_zip( zip_bytes, filename, package_name = await self._download_github_skill_directory_as_zip(
asset_url, asset_url,
owner=owner, owner=owner,
repo=repo, repo=repo,
@@ -464,7 +464,7 @@ class SkillService:
return await self._resolve_installed_skills(scanned) return await self._resolve_installed_skills(scanned)
async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]: async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]:
zip_bytes, _filename, package_name = await self._download_github_skill_md_as_zip( zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
asset_url, asset_url,
owner=owner, owner=owner,
repo=repo, repo=repo,
@@ -572,30 +572,85 @@ class SkillService:
resp.raise_for_status() resp.raise_for_status()
return resp.content return resp.content
async def _download_github_skill_md_as_zip( async def _download_github_skill_directory_as_zip(
self, asset_url: str, *, owner: str, repo: str self, asset_url: str, *, owner: str, repo: str
) -> tuple[bytes, str, str]: ) -> tuple[bytes, str, str]:
info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo) info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo)
content = await self._download_github_skill_md(info['raw_url']) archive_url = f'https://codeload.github.com/{owner}/{repo}/zip/{quote(info["ref"], safe="/")}'
package_name = self._resolve_github_skill_md_package_name(content, info['package_name']) archive_bytes = await self._download_github_asset(archive_url)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f'{package_name}/SKILL.md', content)
return buffer.getvalue(), f'{package_name}.zip', package_name
async def _download_github_skill_md(self, raw_url: str) -> str:
async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
try:
resp = await client.get(raw_url)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise ValueError(f'Failed to download SKILL.md from GitHub: {exc}') from exc
try: try:
return resp.content.decode('utf-8') source_archive = zipfile.ZipFile(io.BytesIO(archive_bytes), 'r')
except UnicodeDecodeError as exc: except zipfile.BadZipFile as exc:
raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc raise ValueError('GitHub repository archive must be a valid .zip archive') from exc
with source_archive as source_zip:
skill_entry = self._find_github_skill_archive_entry(source_zip, info['file_path'])
try:
skill_md_content = source_zip.read(skill_entry).decode('utf-8')
except UnicodeDecodeError as exc:
raise ValueError('GitHub SKILL.md must be valid UTF-8 text') from exc
package_name = self._resolve_github_skill_md_package_name(skill_md_content, info['package_name'])
source_skill_dir = posixpath.dirname(posixpath.normpath(skill_entry.filename))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip:
self._copy_github_skill_directory_to_zip(source_zip, target_zip, source_skill_dir, package_name)
return buffer.getvalue(), f'{package_name}.zip', package_name
def _find_github_skill_archive_entry(self, archive: zipfile.ZipFile, file_path: str) -> zipfile.ZipInfo:
normalized_file_path = posixpath.normpath(file_path).lower()
for member in archive.infolist():
if member.is_dir():
continue
normalized_member = posixpath.normpath(member.filename)
path_parts = normalized_member.split('/', 1)
if len(path_parts) != 2:
continue
archive_relative_path = path_parts[1].lower()
if archive_relative_path == normalized_file_path:
return member
raise ValueError(f'GitHub archive does not contain requested SKILL.md: {file_path}')
def _copy_github_skill_directory_to_zip(
self,
source_zip: zipfile.ZipFile,
target_zip: zipfile.ZipFile,
source_skill_dir: str,
package_name: str,
) -> None:
normalized_source_dir = posixpath.normpath(source_skill_dir)
source_prefix = f'{normalized_source_dir}/'
copied_files = 0
for member in source_zip.infolist():
normalized_member = posixpath.normpath(member.filename)
if normalized_member != normalized_source_dir and not normalized_member.startswith(source_prefix):
continue
relative_path = posixpath.relpath(normalized_member, normalized_source_dir)
if relative_path in ('', '.'):
continue
if relative_path.startswith('../') or relative_path == '..' or posixpath.isabs(relative_path):
raise ValueError(f'GitHub archive contains an unsafe skill path: {member.filename}')
target_name = f'{package_name}/{relative_path}'
if member.is_dir() and not target_name.endswith('/'):
target_name = f'{target_name}/'
target_info = zipfile.ZipInfo(target_name, date_time=member.date_time)
target_info.external_attr = member.external_attr
target_info.compress_type = zipfile.ZIP_DEFLATED
if member.is_dir():
target_zip.writestr(target_info, b'')
continue
target_zip.writestr(target_info, source_zip.read(member))
copied_files += 1
if copied_files == 0:
raise ValueError('GitHub skill directory is empty')
def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str: def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str:
extract_dir = os.path.join(tmp_dir, 'extracted') extract_dir = os.path.join(tmp_dir, 'extracted')
@@ -758,31 +813,38 @@ class SkillService:
raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL') raise ValueError('asset_url must be a valid HTTPS GitHub SKILL.md URL')
host = parsed.netloc.lower() host = parsed.netloc.lower()
path_parts = [part for part in (parsed.path or '').split('/') if part] path_parts = [unquote(part) for part in (parsed.path or '').split('/') if part]
if host == 'github.com': if host == 'github.com':
if len(path_parts) < 5 or path_parts[0] != owner or path_parts[1] != repo or path_parts[2] != 'blob': if (
len(path_parts) < 5
or path_parts[0] != owner
or path_parts[1] != repo
or path_parts[2]
not in (
'blob',
'raw',
)
):
raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path') raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo blob path')
ref = path_parts[3] ref = path_parts[3]
file_path = '/'.join(path_parts[4:]) file_path = '/'.join(path_parts[4:])
raw_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{file_path}'
elif host == 'raw.githubusercontent.com': elif host == 'raw.githubusercontent.com':
if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo: if len(path_parts) < 4 or path_parts[0] != owner or path_parts[1] != repo:
raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path') raise ValueError('GitHub SKILL.md URL must point to the requested owner/repo raw path')
ref = path_parts[2] ref = path_parts[2]
file_path = '/'.join(path_parts[3:]) file_path = '/'.join(path_parts[3:])
raw_url = parsed.geturl()
else: else:
raise ValueError('asset_url must point to a GitHub SKILL.md file') raise ValueError('asset_url must point to a GitHub SKILL.md file')
normalized_file_path = posixpath.normpath(file_path).lower() normalized_file_path = posixpath.normpath(file_path)
if normalized_file_path != 'skill.md' and not normalized_file_path.endswith('/skill.md'): normalized_file_path_lower = normalized_file_path.lower()
if normalized_file_path_lower != 'skill.md' and not normalized_file_path_lower.endswith('/skill.md'):
raise ValueError('GitHub skill import requires a URL ending with SKILL.md') raise ValueError('GitHub skill import requires a URL ending with SKILL.md')
parent_dir = posixpath.basename(posixpath.dirname(file_path)) or repo parent_dir = posixpath.basename(posixpath.dirname(normalized_file_path)) or repo
return { return {
'raw_url': raw_url,
'ref': ref, 'ref': ref,
'file_path': file_path, 'file_path': normalized_file_path,
'package_name': self._uploaded_skill_target_stem(parent_dir), 'package_name': self._uploaded_skill_target_stem(parent_dir),
} }
+23 -6
View File
@@ -24,8 +24,14 @@ import {
Loader2, Loader2,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
CircleHelp,
} from 'lucide-react'; } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -855,9 +861,23 @@ function AddExtensionContent() {
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4"> <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{githubInstallStatus === GithubInstallStatus.WAIT_INPUT && ( {githubInstallStatus === GithubInstallStatus.WAIT_INPUT && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t('addExtension.githubUrlHelp')} <span>{t('addExtension.githubUrlHelp')}</span>
</p> <Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-4 items-center justify-center rounded-full transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label={t('addExtension.githubUrlTooltip')}
>
<CircleHelp className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[280px]">
{t('addExtension.githubUrlTooltip')}
</TooltipContent>
</Tooltip>
</div>
<Input <Input
placeholder={t('addExtension.githubUrlPlaceholder')} placeholder={t('addExtension.githubUrlPlaceholder')}
value={githubURL} value={githubURL}
@@ -866,9 +886,6 @@ function AddExtensionContent() {
if (e.key === 'Enter') handleGithubAddressSubmit(); if (e.key === 'Enter') handleGithubAddressSubmit();
}} }}
/> />
<p className="text-[11px] leading-relaxed text-muted-foreground">
{t('addExtension.skillMdUrlHelp')}
</p>
<Button <Button
className="w-full" className="w-full"
onClick={handleGithubAddressSubmit} onClick={handleGithubAddressSubmit}
@@ -214,7 +214,7 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="max-h-48 space-y-1 overflow-y-auto"> <div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1">
{rootEntries.length === 0 && !loading && ( {rootEntries.length === 0 && !loading && (
<div className="text-sm text-muted-foreground py-2"> <div className="text-sm text-muted-foreground py-2">
{t('skills.noFiles')} {t('skills.noFiles')}
@@ -330,24 +330,9 @@ export default function SkillForm({
} }
}; };
const handleContentChange = (content: string) => { const handleInstructionDraftChange = (content: string) => {
setFileContent(content); setFileContent(content);
// If editing SKILL.md, sync to skill.instructions setSkill((prev) => ({ ...prev, instructions: content }));
if (selectedFile === 'SKILL.md' || selectedFile?.endsWith('/SKILL.md')) {
setSkill((prev) => ({ ...prev, instructions: content }));
}
};
const handleSaveFile = async () => {
if (!initSkillName || !selectedFile) return;
try {
await httpClient.writeSkillFile(initSkillName, selectedFile, fileContent);
toast.success(t('skills.saveFileSuccess'));
} catch (error) {
console.error('Failed to save file:', error);
toast.error(t('skills.saveFileError') + String(error));
}
}; };
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
@@ -462,23 +447,12 @@ export default function SkillForm({
<Textarea <Textarea
id="instructions" id="instructions"
value={fileContent} value={fileContent}
onChange={(e) => handleContentChange(e.target.value)} onChange={(e) => handleInstructionDraftChange(e.target.value)}
readOnly={Boolean(initSkillName)}
placeholder={t('skills.instructionsPlaceholder')} placeholder={t('skills.instructionsPlaceholder')}
rows={16} rows={16}
className="min-h-[360px] resize-y font-mono text-sm lg:min-h-[calc(100vh-220px)]" className="min-h-[360px] resize-y font-mono text-sm read-only:cursor-default read-only:bg-muted/30 lg:min-h-[calc(100vh-220px)]"
/> />
{selectedFile &&
selectedFile !== 'SKILL.md' &&
!selectedFile.endsWith('/SKILL.md') && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSaveFile}
>
{t('skills.saveFile')}
</Button>
)}
</div> </div>
); );
+6 -9
View File
@@ -1487,16 +1487,13 @@ const enUS = {
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files', uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
orContinueWith: 'or choose an action below', orContinueWith: 'or choose an action below',
addMCPServerHint: 'Connect an MCP tool server extension', addMCPServerHint: 'Connect an MCP tool server extension',
installFromGithub: 'Install Plugin or Skill from GitHub', installFromGithub: 'Install from GitHub',
installFromGithubHint: installFromGithubHint: 'Plugin package or Skill (SKILL.md)',
'Supports GitHub Release plugin packages and direct GitHub SKILL.md imports', githubUrlHelp: 'Paste a GitHub URL',
githubUrlHelp: githubUrlTooltip:
'Paste a GitHub repository URL to install a plugin. To install a Skill, paste the GitHub SKILL.md file URL.', 'Plugin: paste a repository, Release, or Tag URL. Skill: paste the SKILL.md page URL inside the skill directory.',
githubUrlPlaceholder: githubUrlPlaceholder: 'GitHub repository, Release, or SKILL.md link',
'e.g. https://github.com/owner/repo or https://github.com/owner/repo/blob/main/path/SKILL.md',
githubUrlRequired: 'Enter a GitHub URL', githubUrlRequired: 'Enter a GitHub URL',
skillMdUrlHelp:
'For Skills, copy the exact SKILL.md file link from the skill directory in GitHub.',
previewSkill: 'Preview Skill', previewSkill: 'Preview Skill',
noSkillPreviewFound: 'No importable Skill found', noSkillPreviewFound: 'No importable Skill found',
createSkill: 'Create New Skill', createSkill: 'Create New Skill',
+8 -11
View File
@@ -1426,18 +1426,15 @@ const zhHans = {
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件', uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
orContinueWith: '或选择以下操作', orContinueWith: '或选择以下操作',
addMCPServerHint: '连接一个 MCP 工具服务器扩展', addMCPServerHint: '连接一个 MCP 工具服务器扩展',
installFromGithub: '从 GitHub 安装插件或 Skill', installFromGithub: '从 GitHub 安装',
installFromGithubHint: installFromGithubHint: '插件包或技能(SKILL.md',
'支持 GitHub Release 插件包,也支持直接导入 GitHub 上的 SKILL.md', githubUrlHelp: '粘贴 GitHub 地址',
githubUrlHelp: githubUrlTooltip:
'粘贴 GitHub 仓库地址安装插件;如果要安装 Skill,请粘贴 GitHub 上的 SKILL.md 文件地址。', '插件:粘贴仓库、Release 或 Tag 地址。技能:粘贴技能目录里的 SKILL.md 页面地址。',
githubUrlPlaceholder: githubUrlPlaceholder: 'GitHub 仓库、Release 或 SKILL.md 链接',
'例如 https://github.com/owner/repo 或 https://github.com/owner/repo/blob/main/path/SKILL.md',
githubUrlRequired: '请输入 GitHub 地址', githubUrlRequired: '请输入 GitHub 地址',
skillMdUrlHelp: previewSkill: '预览技能',
'Skill 需要复制具体文件链接,例如仓库中某个技能目录下的 SKILL.md 页面地址。', noSkillPreviewFound: '未找到可导入的技能',
previewSkill: '预览 Skill',
noSkillPreviewFound: '未找到可导入的 Skill',
createSkill: '创建新的技能', createSkill: '创建新的技能',
createSkillHint: '手动创建一个新的技能扩展', createSkillHint: '手动创建一个新的技能扩展',
unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件', unsupportedFileType: '不支持的文件类型,仅支持 .zip 和 .lbpkg 文件',
+8 -11
View File
@@ -1332,18 +1332,15 @@ const zhHant = {
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案', uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
orContinueWith: '或選擇以下操作', orContinueWith: '或選擇以下操作',
addMCPServerHint: '連接一個 MCP 工具伺服器擴充', addMCPServerHint: '連接一個 MCP 工具伺服器擴充',
installFromGithub: '從 GitHub 安裝插件或 Skill', installFromGithub: '從 GitHub 安裝',
installFromGithubHint: installFromGithubHint: '插件包或技能(SKILL.md',
'支援 GitHub Release 插件包,也支援直接匯入 GitHub 上的 SKILL.md', githubUrlHelp: '貼上 GitHub 地址',
githubUrlHelp: githubUrlTooltip:
'貼上 GitHub 倉庫地址安裝插件;如果要安裝 Skill,請貼上 GitHub 上的 SKILL.md 檔案地址。', '插件:貼上倉庫、Release 或 Tag 地址。技能:貼上技能目錄裡的 SKILL.md 頁面地址。',
githubUrlPlaceholder: githubUrlPlaceholder: 'GitHub 倉庫、Release 或 SKILL.md 連結',
'例如 https://github.com/owner/repo 或 https://github.com/owner/repo/blob/main/path/SKILL.md',
githubUrlRequired: '請輸入 GitHub 地址', githubUrlRequired: '請輸入 GitHub 地址',
skillMdUrlHelp: previewSkill: '預覽技能',
'Skill 需要複製具體檔案連結,例如倉庫中某個技能目錄下的 SKILL.md 頁面地址。', noSkillPreviewFound: '未找到可匯入的技能',
previewSkill: '預覽 Skill',
noSkillPreviewFound: '未找到可匯入的 Skill',
createSkill: '建立新的技能', createSkill: '建立新的技能',
createSkillHint: '手動建立一個新的技能擴充', createSkillHint: '手動建立一個新的技能擴充',
unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案', unsupportedFileType: '不支援的檔案類型,僅支援 .zip 和 .lbpkg 檔案',