This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 16:57:58 +04:00
parent b0ba137551
commit fe0e725997
11 changed files with 62 additions and 33 deletions

View file

@ -176,6 +176,8 @@ async def export_skills(
raise HTTPException(403, 'Export permission required')
if version_id and (not ids or len(ids) != 1):
raise HTTPException(400, 'Select one skill to export a historical version')
if format == 'skill' and (not ids or len(ids) != 1):
raise HTTPException(400, 'Select one skill to export as .skill')
skills = (
[await authorized_skill(id, user, db=db) for id in ids]
if ids is not None
@ -195,14 +197,15 @@ async def export_skills(
}
)
try:
if format == 'zip':
if format in ('zip', 'skill'):
filename = f'{skills[0].id}.skill' if format == 'skill' else 'skills.zip'
return Response(
zip_export(packages),
media_type='application/zip',
headers={'Content-Disposition': 'attachment; filename="skills.zip"'},
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
if format != 'json':
raise ValueError('Export format must be json or zip')
raise ValueError('Export format must be json, zip, or skill')
return packages[0] if ids and len(ids) == 1 else packages
except ValueError as error:
raise HTTPException(400, str(error))

View file

@ -363,7 +363,7 @@ def parse_import(
if filename.lower().endswith('.json'):
value = json.loads(data)
packages = value if isinstance(value, list) else [value]
elif filename.lower().endswith('.zip'):
elif filename.lower().endswith(('.zip', '.skill')):
entries = {}
links = []
total = 0
@ -388,7 +388,12 @@ def parse_import(
total += info.file_size
if info.file_size > MAX_FILE_BYTES or total > MAX_IMPORT_BYTES or len(entries) >= 10000:
raise ValueError('Archive exceeds import limits')
entries[path] = archive.read(info)
if info.flag_bits & 1:
raise ValueError('Encrypted skill archives are not supported')
try:
entries[path] = archive.read(info)
except NotImplementedError as error:
raise ValueError('Skill archive uses an unsupported compression method') from error
roots = (
[''] if 'SKILL.md' in entries else sorted(p[: -len('SKILL.md')] for p in entries if p.endswith('/SKILL.md'))
)
@ -406,7 +411,7 @@ def parse_import(
elif filename.lower().endswith('.md'):
packages = [{'files': [encode_file('SKILL.md', data)]}]
else:
raise ValueError('Use JSON, ZIP, or Markdown')
raise ValueError('Use JSON, ZIP, .skill, or Markdown')
total = 0
result = []
for package in packages:
@ -500,7 +505,7 @@ async def load_skill_from_url(url: str) -> list[dict]:
raise ValueError('Could not download the skill. Check that the URL is accessible.')
content_type = response.content_type
if content_type == 'text/html':
raise ValueError('The URL returned a web page. Use a raw skill file or a ZIP download link.')
raise ValueError('The URL returned a web page. Use a raw skill file or a ZIP/.skill download link.')
data = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
data.extend(chunk)

View file

@ -399,7 +399,7 @@ export const cloneSkill = async (
).json();
export const exportSkillBundle = async (
token: string,
format: 'json' | 'zip',
format: 'json' | 'zip' | 'skill',
ids: string[] = [],
versionId?: string
) => {

View file

@ -14,6 +14,8 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount, onDestroy, tick } from 'svelte';
import type { Readable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
import {
terminalServers,
settings,
@ -62,7 +64,7 @@
import PortPreview from './FileNav/PortPreview.svelte';
import TerminalDock from './TerminalDock.svelte';
const i18n = getContext('i18n');
const i18n = getContext<Readable<I18n>>('i18n');
export let overlay = false;
export let chatId: string | null = null;
@ -1441,6 +1443,11 @@
// Click outside panel to clear selection
const handleWindowClick = (e: MouseEvent) => {
// A close click may reach the window after its modal has already been destroyed.
if (
e.composedPath().some((node) => node instanceof Element && node.classList.contains('modal'))
)
return;
if (directoryMenu) directoryMenu = null;
if (
!comparison &&
@ -1821,22 +1828,6 @@
</Tooltip>
</FileNavToolbar>
{#if pinnedComparison}
<div class="flex shrink-0 items-center gap-2 px-3 py-1.5 text-xs text-gray-500">
<Tooltip content={pinnedComparison.path}>
<span class="block max-w-[16rem] truncate"
>{$i18n.t('Comparing with {{name}}', { name: pinnedComparison.name })}</span
>
</Tooltip>
<button
type="button"
class="shrink-0 hover:text-gray-900 dark:hover:text-white"
aria-label={$i18n.t('Clear comparison selection')}
on:click={() => (pinnedComparison = null)}><Icon name="xmark" size={12} /></button
>
</div>
{/if}
{#if !selectedFile}
<div
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-gray-50 px-3 dark:border-gray-850/30"
@ -1860,6 +1851,22 @@
</div>
{/if}
{#if pinnedComparison}
<div class="flex shrink-0 items-center gap-2 px-3 py-1.5 text-xs text-gray-500">
<Tooltip content={pinnedComparison.path}>
<span class="block max-w-[16rem] truncate"
>{$i18n.t('Comparing with {{name}}', { name: pinnedComparison.name })}</span
>
</Tooltip>
<button
type="button"
class="shrink-0 hover:text-gray-900 dark:hover:text-white"
aria-label={$i18n.t('Clear comparison selection')}
on:click={() => (pinnedComparison = null)}><Icon name="xmark" size={12} /></button
>
</div>
{/if}
<!-- Bulk action bar -->
{#if selectedCount > 0 && !isSearching}
<BulkActionBar

View file

@ -203,7 +203,7 @@
<button
type="button"
class="shrink-0 text-gray-500 hover:text-gray-900 dark:hover:text-white"
on:click={onClose}
on:click|stopPropagation={onClose}
aria-label={$i18n.t('Close')}><Icon name="xmark" size={16} /></button
>
</div>
@ -245,7 +245,7 @@
...new Set([...(result.original.notices ?? []), ...(result.revised.notices ?? [])])
].join('\n')}
>
<span tabindex="0">{$i18n.t('Text only')}</span>
<button type="button" class="cursor-help">{$i18n.t('Text only')}</button>
</Tooltip>
</span>
</div>

View file

@ -147,7 +147,7 @@
type="button"
class="shrink-0 text-gray-500 hover:text-gray-900 dark:hover:text-white"
aria-label={$i18n.t('Close')}
on:click={close}><XMark className="size-4" /></button
on:click|stopPropagation={close}><XMark className="size-4" /></button
>
</header>
<div class="grid grid-cols-2 gap-4 px-4 py-2">

View file

@ -492,7 +492,7 @@
type="button"
class="comparison-close shrink-0 text-gray-500 hover:text-gray-900 dark:hover:text-white"
aria-label={$i18n.t('Close')}
on:click={requestClose}><XMark className="size-4" /></button
on:click|stopPropagation={requestClose}><XMark className="size-4" /></button
>
</header>
{#if error}<p class="px-4 py-2 text-red-600" role="alert">{error}</p>{/if}
@ -643,7 +643,7 @@
{applyDescription ??
$i18n.t('Apply uses the proposed revision on the right. Save your draft afterward.')}
</p>
<button type="button" class="secondary" on:click={requestClose}
<button type="button" class="secondary" on:click|stopPropagation={requestClose}
>{$i18n.t('Cancel')}</button
>
<button type="button" class="primary" disabled={loading} on:click={apply}

View file

@ -265,7 +265,7 @@
}
};
const exportHandler = async (skill, format: 'json' | 'zip' = 'json') => {
const exportHandler = async (skill, format: 'json' | 'zip' | 'skill' = 'json') => {
try {
saveAs(
await exportSkillBundle(localStorage.token, format, [skill.id]),
@ -367,7 +367,7 @@
<input
bind:this={importInputElement}
type="file"
accept=".md,.json,.zip"
accept=".md,.json,.zip,.skill"
multiple
hidden
on:change={() => {

View file

@ -193,7 +193,7 @@
loading = false;
}
};
const exportVersion = async (format: 'json' | 'zip') => {
const exportVersion = async (format: 'json' | 'zip' | 'skill') => {
try {
saveAs(
await exportSkillBundle(localStorage.token, format, [id], versionId!),
@ -569,6 +569,9 @@
{/if}
{/snippet}
{#snippet exports()}
<button type="button" on:click={() => exportVersion('skill')}
><Download className="size-3.5" />{$i18n.t('Export .skill')}</button
>
<button type="button" on:click={() => exportVersion('json')}
><Download className="size-3.5" />{$i18n.t('Export JSON')}</button
>

View file

@ -108,6 +108,16 @@
</button>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills_export}
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
exportHandler('skill');
closeMenu();
}}
>
<Download className="size-3.5" />
<div class="flex items-center">{$i18n.t('Export .skill')}</div>
</button>
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {

View file

@ -1072,6 +1072,7 @@
"Explored": "",
"Exploring": "",
"Export": "",
"Export .skill": "",
"Export as CSV": "",
"Export as JSON": "",
"Export chat (.json)": "",