diff --git a/apps/desktop/help-knowledge/skills.md b/apps/desktop/help-knowledge/skills.md index 2d99da20561..db982b0ab4c 100644 --- a/apps/desktop/help-knowledge/skills.md +++ b/apps/desktop/help-knowledge/skills.md @@ -15,6 +15,13 @@ Skills are reusable agent capabilities you package as a folder and load into you - Project-scoped (only inside one working directory): `/.agents/skills//` or `/.claude/skills//`. - Each skill is its own folder with a required `SKILL.md` at the root (the prompt / spec the agent reads). Sibling files and subfolders in that folder are also visible to the agent. +**Importing a local skill:** + +- On the Skills page, use **Import skill** (top-right) to pick a `.zip` package or a standalone `SKILL.md` file. +- A zip must contain a `SKILL.md` (at the package root, or inside a single top-level folder). The YAML frontmatter must include non-empty `name` and `description` fields; `name` must match `^[a-z0-9-]{1,200}$`. +- You then choose where to install: global, a known project, or another directory. Cindy extracts metadata from the file automatically and lists the skill with that name and description. +- Imported skills can be uninstalled from the detail page, and you can publish them to SkillHub later if you want. + **Using an installed skill:** - Type `/` in the composer to open the slash-command palette; your installed skills show up there alongside built-in and agent commands. Pick one to run it. diff --git a/apps/desktop/src/main/skillhub/__tests__/importLocalSkill.pure.test.ts b/apps/desktop/src/main/skillhub/__tests__/importLocalSkill.pure.test.ts new file mode 100644 index 00000000000..46f48928af7 --- /dev/null +++ b/apps/desktop/src/main/skillhub/__tests__/importLocalSkill.pure.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyImportSourcePath, + extractSkillMetadataFromMd, + findZipSkillPackageRoot, + fitsUncompressedBudget, + isValidImportSkillName, + relativizeZipEntry, + resolveImportInstallPath, +} from '../importLocalSkill.pure'; + +describe('isValidImportSkillName', () => { + it('accepts registry-safe names', () => { + expect(isValidImportSkillName('my-skill')).toBe(true); + expect(isValidImportSkillName('a')).toBe(true); + }); + + it('rejects uppercase, underscores, empty', () => { + expect(isValidImportSkillName('My-Skill')).toBe(false); + expect(isValidImportSkillName('my_skill')).toBe(false); + expect(isValidImportSkillName('')).toBe(false); + }); +}); + +describe('classifyImportSourcePath', () => { + it('accepts zip and SKILL.md', () => { + expect(classifyImportSourcePath('/tmp/pkg.zip')).toEqual({ kind: 'zip' }); + expect(classifyImportSourcePath('/tmp/SKILL.md')).toEqual({ kind: 'md' }); + expect(classifyImportSourcePath('/tmp/skill.md')).toEqual({ kind: 'md' }); + }); + + it('rejects other md names and unknown extensions', () => { + expect(classifyImportSourcePath('/tmp/README.md')).toMatchObject({ error: expect.any(String) }); + expect(classifyImportSourcePath('/tmp/foo.tar')).toMatchObject({ error: expect.any(String) }); + }); +}); + +describe('findZipSkillPackageRoot', () => { + it('finds SKILL.md at zip root', () => { + expect(findZipSkillPackageRoot(['SKILL.md', 'refs/a.md'])).toEqual({ packageRoot: '' }); + }); + + it('finds SKILL.md under a single top-level folder', () => { + expect(findZipSkillPackageRoot(['my-skill/SKILL.md', 'my-skill/refs/x.md'])).toEqual({ + packageRoot: 'my-skill/', + }); + }); + + it('errors when missing or ambiguous', () => { + expect(findZipSkillPackageRoot(['readme.txt'])).toMatchObject({ error: expect.any(String) }); + expect( + findZipSkillPackageRoot(['a/SKILL.md', 'b/SKILL.md']), + ).toMatchObject({ error: expect.any(String) }); + }); + + it('prefers root SKILL.md when nested copies also exist', () => { + expect(findZipSkillPackageRoot(['SKILL.md', 'vendor/SKILL.md'])).toEqual({ packageRoot: '' }); + }); +}); + +describe('relativizeZipEntry', () => { + it('strips package root and skips __MACOSX', () => { + expect(relativizeZipEntry('my-skill/SKILL.md', 'my-skill/')).toBe('SKILL.md'); + expect(relativizeZipEntry('__MACOSX/._x', '')).toBeNull(); + expect(relativizeZipEntry('other/SKILL.md', 'my-skill/')).toBeNull(); + }); +}); + +describe('extractSkillMetadataFromMd', () => { + it('extracts name, description, and version', () => { + const result = extractSkillMetadataFromMd(`--- +name: demo-skill +description: Does useful things +version: 1.2.3 +--- + +# Body +`); + expect(result).toEqual({ + ok: true, + metadata: { + name: 'demo-skill', + description: 'Does useful things', + version: '1.2.3', + }, + }); + }); + + it('defaults version to 0.1.0', () => { + const result = extractSkillMetadataFromMd(`--- +name: demo-skill +description: Does useful things +--- +`); + expect(result.ok && result.metadata.version).toBe('0.1.0'); + }); + + it('rejects missing name/description and invalid name', () => { + expect( + extractSkillMetadataFromMd(`--- +description: only desc +--- +`), + ).toMatchObject({ ok: false, errorCode: 'INVALID_FRONTMATTER' }); + + expect( + extractSkillMetadataFromMd(`--- +name: Bad_Name +description: x +--- +`), + ).toMatchObject({ ok: false, errorCode: 'INVALID_NAME' }); + }); +}); + +describe('resolveImportInstallPath', () => { + const home = '/Users/sam'; + + it('defaults to global ~/.agents/skills/', () => { + expect(resolveImportInstallPath('demo', undefined, home)).toEqual({ + finalDir: '/Users/sam/.agents/skills/demo', + }); + expect(resolveImportInstallPath('demo', ' ', home)).toEqual({ + finalDir: '/Users/sam/.agents/skills/demo', + }); + }); + + it('accepts absolute project .agents/skills paths', () => { + expect( + resolveImportInstallPath('demo', '/repo/.agents/skills/demo', home), + ).toEqual({ finalDir: '/repo/.agents/skills/demo' }); + }); + + it('rejects relative paths, basename mismatch, and non-skill roots', () => { + expect(resolveImportInstallPath('demo', 'relative/demo', home)).toMatchObject({ + errorCode: 'INTERNAL', + message: expect.stringContaining('绝对路径'), + }); + expect( + resolveImportInstallPath('demo', '/repo/.agents/skills/other', home), + ).toMatchObject({ errorCode: 'INTERNAL' }); + expect( + resolveImportInstallPath('demo', '/tmp/demo', home), + ).toMatchObject({ + errorCode: 'INTERNAL', + message: expect.stringContaining('.agents/skills'), + }); + }); +}); + +describe('fitsUncompressedBudget', () => { + it('accepts totals within the budget and rejects overflow / invalid sizes', () => { + expect(fitsUncompressedBudget([10, 20, 30], 100)).toBe(true); + expect(fitsUncompressedBudget([60, 50], 100)).toBe(false); + expect(fitsUncompressedBudget([-1], 100)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/skillhub/__tests__/importLocalSkill.test.ts b/apps/desktop/src/main/skillhub/__tests__/importLocalSkill.test.ts new file mode 100644 index 00000000000..834f5731433 --- /dev/null +++ b/apps/desktop/src/main/skillhub/__tests__/importLocalSkill.test.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import JSZip from 'jszip'; + +const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'xdt-import-local-skill-test-')); + +function removeTestRoot(): void { + fs.rmSync(TEST_ROOT, { + recursive: true, + force: true, + maxRetries: process.platform === 'win32' ? 5 : 0, + retryDelay: 20, + }); +} + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => path.join(TEST_ROOT, 'userData')), + }, +})); + +vi.mock('../../logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../../authManager', () => ({ + getCurrentUserId: vi.fn(() => 'user-1'), +})); + +vi.mock('../registry', () => ({ + registryService: { + addInstall: vi.fn(), + }, +})); + +vi.mock('../folderHash', () => ({ + computeFolderHash: vi.fn(async () => 'folder-hash'), +})); + +vi.mock('../../maker-host/shared-global-skills.js', () => ({ + prepareSharedGlobalSkillLinks: vi.fn(async () => ({ warnings: [] })), + prepareSharedProjectSkillLinks: vi.fn(async () => ({ warnings: [] })), + projectWorkingDirFromSkillPath: vi.fn(() => null), +})); + +vi.mock('../installService', () => ({ + ensureSymlinkToShared: vi.fn(async () => undefined), +})); + +afterAll(() => { + removeTestRoot(); +}); + +async function writeZip(files: Record): Promise { + const zip = new JSZip(); + for (const [name, content] of Object.entries(files)) { + zip.file(name, content); + } + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + const filePath = path.join(TEST_ROOT, `pkg-${Date.now()}-${Math.random().toString(16).slice(2)}.zip`); + await fs.promises.writeFile(filePath, buf); + return filePath; +} + +describe('importLocalSkill zip / installPath guards', () => { + it('inspect rejects an oversized SKILL.md before full-budget inflate', async () => { + const { inspectLocalSkill } = await import('../importLocalSkill'); + const huge = `--- +name: huge-skill +description: too large +--- + +${'x'.repeat(2 * 1024 * 1024 + 100)} +`; + const zipPath = await writeZip({ 'SKILL.md': huge }); + const result = await inspectLocalSkill({ filePath: zipPath }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errorCode).toBe('EXTRACT_FAILED'); + expect(result.message).toMatch(/SKILL\.md|上限/); + } + }); + + it('import rejects relative installPath and non-skill roots', async () => { + const { importLocalSkill } = await import('../importLocalSkill'); + const zipPath = await writeZip({ + 'SKILL.md': `--- +name: demo-skill +description: A demo skill +--- +`, + }); + + const relative = await importLocalSkill({ + filePath: zipPath, + installPath: 'relative/demo-skill', + }); + expect(relative.success).toBe(false); + if (!relative.success) { + expect(relative.message).toMatch(/绝对路径/); + } + + const outside = await importLocalSkill({ + filePath: zipPath, + installPath: path.join(TEST_ROOT, 'demo-skill'), + }); + expect(outside.success).toBe(false); + if (!outside.success) { + expect(outside.message).toMatch(/\.agents\/skills|\.claude\/skills/); + } + }); +}); diff --git a/apps/desktop/src/main/skillhub/__tests__/installService.test.ts b/apps/desktop/src/main/skillhub/__tests__/installService.test.ts index a16727432c1..ecdc165e494 100644 --- a/apps/desktop/src/main/skillhub/__tests__/installService.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/installService.test.ts @@ -204,13 +204,23 @@ describe('skillhub/installService', () => { expect(fs.readFileSync(path.join(finalDir, 'SKILL.md'), 'utf-8')).toBe('old content'); }); - it('rejects uninstall outside a cloud capability boundary', async () => { - const finalDir = path.join(TEST_ROOT, 'skills', 'local-only'); + it('rejects uninstall outside a cloud capability boundary for market installs', async () => { + const finalDir = path.join(TEST_ROOT, '.agents', 'skills', 'market-only'); fs.mkdirSync(finalDir, { recursive: true }); + fs.writeFileSync(path.join(finalDir, 'SKILL.md'), 'content', 'utf-8'); const { getAppCapabilities } = await import('../../appCapabilities.js'); + const { registryService } = await import('../registry'); const { uninstall } = await import('../installService'); - vi.mocked(getAppCapabilities).mockReturnValue({ + vi.mocked(registryService.getInstall).mockResolvedValueOnce({ + version: '1.0.0', + authorId: 'owner', + folderHash: 'hash', + installedAt: 1, + updatedAt: 1, + origin: 'installed', + }); + vi.mocked(getAppCapabilities).mockReturnValueOnce({ canUseCindyAccountServices: false, canUseCindyGateway: false, canUseDeviceLink: false, @@ -225,14 +235,57 @@ describe('skillhub/installService', () => { message: 'SkillHub 卸载需要 Cindy 云端账号', }); expect(fs.existsSync(finalDir)).toBe(true); - vi.mocked(getAppCapabilities).mockImplementation(() => ({ - canUseCindyAccountServices: true, - canUseCindyGateway: true, - canUseDeviceLink: true, - canUseSkillHubCloud: true, - canUseCindyOAuthBroker: true, - canUseCindyHeartbeat: true, - })); + }); + + it('allows uninstalling an imported skill without cloud login', async () => { + const finalDir = path.join(TEST_ROOT, '.agents', 'skills', 'imported-offline'); + fs.mkdirSync(finalDir, { recursive: true }); + fs.writeFileSync(path.join(finalDir, 'SKILL.md'), 'content', 'utf-8'); + + const { getCurrentUserId } = await import('../../authManager'); + const { getAppCapabilities } = await import('../../appCapabilities.js'); + const { registryService } = await import('../registry'); + const { uninstall } = await import('../installService'); + + vi.mocked(getCurrentUserId).mockReturnValueOnce(null); + vi.mocked(registryService.getInstall).mockResolvedValueOnce({ + version: '0.1.0', + authorId: '', + folderHash: 'hash', + installedAt: 1, + updatedAt: 1, + origin: 'imported', + }); + vi.mocked(registryService.removeInstall).mockResolvedValue(undefined); + // imported 路径不读 canUseSkillHubCloud;这里刻意关掉云能力,确认仍可卸载。 + vi.mocked(getAppCapabilities).mockReturnValue({ + canUseCindyAccountServices: false, + canUseCindyGateway: false, + canUseDeviceLink: false, + canUseSkillHubCloud: false, + canUseCindyOAuthBroker: false, + canUseCindyHeartbeat: false, + }); + + try { + const result = await uninstall(finalDir); + + expect(result).toEqual({ success: true }); + expect(fs.existsSync(finalDir)).toBe(false); + expect(registryService.removeInstall).toHaveBeenCalledWith( + 'imported-offline', + expect.stringMatching(/[/\\]imported-offline$/), + ); + } finally { + vi.mocked(getAppCapabilities).mockImplementation(() => ({ + canUseCindyAccountServices: true, + canUseCindyGateway: true, + canUseDeviceLink: true, + canUseSkillHubCloud: true, + canUseCindyOAuthBroker: true, + canUseCindyHeartbeat: true, + })); + } }); it('rejects archives that exceed the entry count limit before replacing the target', async () => { @@ -904,17 +957,26 @@ describe('skillhub/installService', () => { }); }); - it('rejects local mode uninstall while the registry is shared', async () => { + it('rejects local mode uninstall of a market-installed skill while cloud is unavailable', async () => { const finalDir = path.join(TEST_ROOT, 'local-project', '.agents', 'skills', 'local-skill'); fs.mkdirSync(finalDir, { recursive: true }); fs.writeFileSync(path.join(finalDir, 'SKILL.md'), 'content', 'utf-8'); const { getCurrentDataOwnerId, getCurrentUserId } = await import('../../authManager'); const { getAppCapabilities } = await import('../../appCapabilities.js'); + const { registryService } = await import('../registry'); const { uninstall } = await import('../installService'); - vi.mocked(getCurrentUserId).mockReturnValue(null); - vi.mocked(getCurrentDataOwnerId).mockReturnValueOnce('local-v1'); - vi.mocked(getAppCapabilities).mockReturnValue({ + vi.mocked(getCurrentUserId).mockReturnValueOnce(null); + vi.mocked(getCurrentDataOwnerId).mockReturnValue('local-v1'); + vi.mocked(registryService.getInstall).mockResolvedValueOnce({ + version: '1.0.0', + authorId: 'owner', + folderHash: 'hash', + installedAt: 1, + updatedAt: 1, + origin: 'installed', + }); + vi.mocked(getAppCapabilities).mockReturnValueOnce({ canUseCindyAccountServices: false, canUseCindyGateway: false, canUseDeviceLink: false, @@ -930,14 +992,7 @@ describe('skillhub/installService', () => { errorCode: 'AUTH_REQUIRED', }); expect(fs.existsSync(finalDir)).toBe(true); - vi.mocked(getAppCapabilities).mockImplementation(() => ({ - canUseCindyAccountServices: true, - canUseCindyGateway: true, - canUseDeviceLink: true, - canUseSkillHubCloud: true, - canUseCindyOAuthBroker: true, - canUseCindyHeartbeat: true, - })); + vi.mocked(getCurrentDataOwnerId).mockReturnValue('user-1'); }); it('uninstalls a linked install when the scanner passes its physical path', async () => { diff --git a/apps/desktop/src/main/skillhub/__tests__/registerIpcUsage.test.ts b/apps/desktop/src/main/skillhub/__tests__/registerIpcUsage.test.ts index bf185bd766c..d43d88f0c43 100644 --- a/apps/desktop/src/main/skillhub/__tests__/registerIpcUsage.test.ts +++ b/apps/desktop/src/main/skillhub/__tests__/registerIpcUsage.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const handlers = new Map unknown>(); +const showOpenDialog = vi.fn(); +const assertTrustedAppRendererEvent = vi.fn(); +const importLocalSkillMocks = vi.hoisted(() => ({ + inspectLocalSkill: vi.fn(), + importLocalSkill: vi.fn(), +})); const installServiceMocks = vi.hoisted(() => ({ install: vi.fn(), cancelInstall: vi.fn(), @@ -9,8 +15,12 @@ const installServiceMocks = vi.hoisted(() => ({ vi.mock('electron', () => ({ BrowserWindow: { + fromWebContents: vi.fn(() => ({ isDestroyed: () => false })), getAllWindows: vi.fn(() => []), }, + dialog: { + showOpenDialog, + }, ipcMain: { handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { handlers.set(channel, handler); @@ -18,6 +28,10 @@ vi.mock('electron', () => ({ }, })); +vi.mock('../../security/trustedAppRenderer.js', () => ({ + assertTrustedAppRendererEvent, +})); + vi.mock('../../authManager', () => ({ getCurrentDataOwnerId: vi.fn(() => 'local-v1'), })); @@ -59,6 +73,7 @@ vi.mock('../usageIndexer', () => ({ })); vi.mock('../installService', () => installServiceMocks); +vi.mock('../importLocalSkill', () => importLocalSkillMocks); const publish = vi.fn(); const cancel = vi.fn(); @@ -79,6 +94,7 @@ describe('registerSkillhubIpc usage handlers', () => { vi.clearAllMocks(); ensureReady.mockResolvedValue({ ready: true }); requestLocalSkillUsageAnalyticsRefresh.mockReturnValue(null); + showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }); const { registerSkillhubIpc } = await import('../registerIpc'); registerSkillhubIpc({ getMaker: () => ({ listAgentSkills }) as never, @@ -87,6 +103,110 @@ describe('registerSkillhubIpc usage handlers', () => { }); }); + it('issues a sender-bound grant for the file selected and inspected in main', async () => { + showOpenDialog.mockResolvedValueOnce({ + canceled: false, + filePaths: ['/selected/demo-skill.zip'], + }); + importLocalSkillMocks.inspectLocalSkill.mockResolvedValueOnce({ + success: true, + name: 'demo-skill', + description: 'Demo', + version: '1.0.0', + }); + const sender = { id: 11 }; + const handler = handlers.get('skillhub:pick-local'); + + const result = await handler?.({ sender }); + + expect(assertTrustedAppRendererEvent).toHaveBeenCalledWith({ sender }); + expect(importLocalSkillMocks.inspectLocalSkill).toHaveBeenCalledWith({ + filePath: '/selected/demo-skill.zip', + }); + expect(result).toMatchObject({ + success: true, + canceled: false, + grantToken: expect.any(String), + name: 'demo-skill', + description: 'Demo', + version: '1.0.0', + }); + }); + + it('imports only the selected path for the grant owner and consumes a successful grant', async () => { + showOpenDialog.mockResolvedValueOnce({ + canceled: false, + filePaths: ['/selected/demo-skill.zip'], + }); + importLocalSkillMocks.inspectLocalSkill.mockResolvedValueOnce({ + success: true, + name: 'demo-skill', + description: 'Demo', + version: '1.0.0', + }); + importLocalSkillMocks.importLocalSkill.mockResolvedValueOnce({ + success: true, + name: 'demo-skill', + description: 'Demo', + version: '1.0.0', + absolutePath: '/home/.agents/skills/demo-skill', + }); + const sender = { id: 11 }; + const picked = (await handlers.get('skillhub:pick-local')?.({ sender })) as { + grantToken: string; + }; + const handler = handlers.get('skillhub:import-local'); + + const result = await handler?.( + { sender }, + { + grantToken: picked.grantToken, + filePath: '/not-authorized/other.zip', + force: true, + }, + ); + + expect(importLocalSkillMocks.importLocalSkill).toHaveBeenCalledWith({ + filePath: '/selected/demo-skill.zip', + force: true, + }); + expect(result).toMatchObject({ success: true, name: 'demo-skill' }); + + const replay = await handler?.({ sender }, { grantToken: picked.grantToken }); + expect(replay).toMatchObject({ success: false, errorCode: 'PERMISSION_DENIED' }); + expect(importLocalSkillMocks.importLocalSkill).toHaveBeenCalledTimes(1); + }); + + it('rejects missing grants and grants issued to another renderer', async () => { + const importHandler = handlers.get('skillhub:import-local'); + const missing = await importHandler?.( + { sender: { id: 11 } }, + { filePath: '/not-authorized/demo.zip' }, + ); + expect(missing).toMatchObject({ success: false, errorCode: 'PERMISSION_DENIED' }); + + showOpenDialog.mockResolvedValueOnce({ + canceled: false, + filePaths: ['/selected/demo-skill.zip'], + }); + importLocalSkillMocks.inspectLocalSkill.mockResolvedValueOnce({ + success: true, + name: 'demo-skill', + description: 'Demo', + version: '1.0.0', + }); + const picked = (await handlers.get('skillhub:pick-local')?.({ + sender: { id: 11 }, + })) as { grantToken: string }; + const wrongSender = await importHandler?.( + { sender: { id: 22 } }, + { grantToken: picked.grantToken }, + ); + + expect(wrongSender).toMatchObject({ success: false, errorCode: 'PERMISSION_DENIED' }); + expect(importLocalSkillMocks.importLocalSkill).not.toHaveBeenCalled(); + }); + it('retries usage summary after local DB becomes ready', async () => { getLocalSkillUsageSummary .mockRejectedValueOnce(new Error('localDb not ready: pending')) diff --git a/apps/desktop/src/main/skillhub/importLocalSkill.pure.ts b/apps/desktop/src/main/skillhub/importLocalSkill.pure.ts new file mode 100644 index 00000000000..8b6d678b058 --- /dev/null +++ b/apps/desktop/src/main/skillhub/importLocalSkill.pure.ts @@ -0,0 +1,240 @@ +/** + * Pure helpers for local skill import (zip / SKILL.md inspection). + * No fs / Electron — unit-testable without I/O. + */ + +import path from 'node:path'; + +import matter from 'gray-matter'; + +import { parseAndValidateFrontmatter } from './frontmatterValidation.js'; + +export type ImportLocalErrorCode = + | 'INVALID_FILE' + | 'MISSING_SKILL_MD' + | 'INVALID_FRONTMATTER' + | 'INVALID_NAME' + | 'CONFLICT_USER_OWNED' + | 'EXTRACT_FAILED' + | 'WRITE_FAILED' + | 'BUSY' + | 'INTERNAL'; + +export interface SkillImportMetadata { + name: string; + description: string; + version: string; +} + +const DEFAULT_VERSION = '0.1.0'; +const SKILL_MD_NAMES = new Set(['SKILL.md', 'skill.md']); + +/** Registry / folder name rule (same as sanitizeSkillName). */ +export function isValidImportSkillName(name: string): boolean { + return /^[a-z0-9-]{1,200}$/.test(name); +} + +/** + * Locate the package root inside a zip entry list. + * Accepts SKILL.md at zip root, or under a single top-level directory. + */ +export function findZipSkillPackageRoot( + entryNames: ReadonlyArray, +): { packageRoot: string } | { error: string } { + const skillMdPaths: string[] = []; + for (const raw of entryNames) { + const name = raw.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!name || name.endsWith('/')) continue; + if (name.startsWith('__MACOSX/')) continue; + const base = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name; + if (SKILL_MD_NAMES.has(base)) { + skillMdPaths.push(name); + } + } + + if (skillMdPaths.length === 0) { + return { error: '压缩包中未找到 SKILL.md' }; + } + + const rootLevel = skillMdPaths.filter((p) => !p.includes('/')); + if (rootLevel.length === 1 && skillMdPaths.length === 1) { + return { packageRoot: '' }; + } + if (rootLevel.length > 0) { + // Root has SKILL.md; ignore nested copies only when exactly one root hit. + if (rootLevel.length === 1) { + return { packageRoot: '' }; + } + return { error: '压缩包根目录存在多个 SKILL.md,无法确定技能包' }; + } + + const topDirs = new Set(skillMdPaths.map((p) => p.split('/')[0]!)); + if (topDirs.size !== 1) { + return { error: '压缩包中存在多个技能目录,请只包含一个含 SKILL.md 的包' }; + } + const top = [...topDirs][0]!; + const underTop = skillMdPaths.filter((p) => p.startsWith(`${top}/`)); + const depthOne = underTop.filter((p) => p.split('/').length === 2); + if (depthOne.length !== 1) { + return { error: `压缩包 ${top}/ 下未找到唯一的 SKILL.md` }; + } + return { packageRoot: `${top}/` }; +} + +/** Strip packageRoot prefix from a zip entry path; null if outside package. */ +export function relativizeZipEntry(entryName: string, packageRoot: string): string | null { + const name = entryName.replace(/\\/g, '/').replace(/^\/+/, ''); + if (name.startsWith('__MACOSX/')) return null; + if (!packageRoot) return name; + if (!name.startsWith(packageRoot)) return null; + return name.slice(packageRoot.length); +} + +export function extractSkillMetadataFromMd( + content: string, +): { ok: true; metadata: SkillImportMetadata } | { ok: false; errorCode: ImportLocalErrorCode; message: string } { + let data: Record; + try { + data = (matter(content).data as Record) ?? {}; + } catch (err) { + return { + ok: false, + errorCode: 'INVALID_FRONTMATTER', + message: `YAML frontmatter 解析失败:${err instanceof Error ? err.message : String(err)}`, + }; + } + + const { issues } = parseAndValidateFrontmatter(content, 'skill'); + if (issues.length > 0) { + return { + ok: false, + errorCode: 'INVALID_FRONTMATTER', + message: issues.map((i) => i.message).join(';'), + }; + } + + const name = typeof data.name === 'string' ? data.name.trim() : ''; + const description = typeof data.description === 'string' ? data.description.trim() : ''; + if (!name || !description) { + return { + ok: false, + errorCode: 'INVALID_FRONTMATTER', + message: 'SKILL.md frontmatter 必须包含非空的 name 与 description', + }; + } + if (!isValidImportSkillName(name)) { + return { + ok: false, + errorCode: 'INVALID_NAME', + message: `skill name "${name}" 不符合 ^[a-z0-9-]{1,200}$ 格式`, + }; + } + + let version = DEFAULT_VERSION; + if (data.version != null) { + if (typeof data.version === 'string' && data.version.trim()) { + version = data.version.trim(); + } else if (typeof data.version === 'number' && Number.isFinite(data.version)) { + version = String(data.version); + } + } + + return { ok: true, metadata: { name, description, version } }; +} + +export function isSkillMdFileName(fileName: string): boolean { + return SKILL_MD_NAMES.has(fileName); +} + +export function classifyImportSourcePath( + filePath: string, +): { kind: 'md' | 'zip' } | { error: string } { + const base = filePath.replace(/\\/g, '/').split('/').pop() ?? ''; + const lower = base.toLowerCase(); + if (lower.endsWith('.zip')) { + return { kind: 'zip' }; + } + if (lower === 'skill.md') { + return { kind: 'md' }; + } + if (lower.endsWith('.md')) { + return { error: '单独导入时文件名须为 SKILL.md' }; + } + return { error: '仅支持 .zip 压缩包或 SKILL.md 文件' }; +} + +function pathNameEquals(actual: string, expected: string): boolean { + return process.platform === 'win32' + ? actual.toLowerCase() === expected.toLowerCase() + : actual === expected; +} + +/** + * Resolve the final install directory for a local import. + * - omit installPath → `~/.agents/skills/` + * - otherwise require an absolute path whose basename is `name`, under + * `/.agents/skills/` or `/.claude/skills/` + */ +export function resolveImportInstallPath( + name: string, + installPath: string | undefined, + homeDir: string, +): { finalDir: string } | { errorCode: ImportLocalErrorCode; message: string } { + if (installPath == null || !installPath.trim()) { + return { finalDir: path.join(homeDir, '.agents', 'skills', name) }; + } + + const trimmed = installPath.trim(); + if (!path.isAbsolute(trimmed)) { + return { + errorCode: 'INTERNAL', + message: 'installPath 必须是绝对路径', + }; + } + + const finalDir = path.normalize(trimmed); + if (!pathNameEquals(path.basename(finalDir), name)) { + return { + errorCode: 'INTERNAL', + message: `installPath 的 basename "${path.basename(finalDir)}" 与 name "${name}" 不符`, + }; + } + + const skillsDir = path.dirname(finalDir); + const discoveryDir = path.dirname(skillsDir); + if (!pathNameEquals(path.basename(skillsDir), 'skills')) { + return { + errorCode: 'INTERNAL', + message: 'installPath 必须位于 .agents/skills 或 .claude/skills 目录下', + }; + } + const discoveryRoot = path.basename(discoveryDir); + if ( + !pathNameEquals(discoveryRoot, '.agents') && + !pathNameEquals(discoveryRoot, '.claude') + ) { + return { + errorCode: 'INTERNAL', + message: 'installPath 必须位于 .agents/skills 或 .claude/skills 目录下', + }; + } + + return { finalDir }; +} + +/** + * Running total of declared uncompressed sizes. Returns false as soon as the + * budget is exceeded (used to reject zip bombs before inflate). + */ +export function fitsUncompressedBudget( + sizes: ReadonlyArray, + maxTotal: number, +): boolean { + let total = 0; + for (const size of sizes) { + if (!Number.isFinite(size) || size < 0) return false; + total += size; + if (total > maxTotal) return false; + } + return true; +} diff --git a/apps/desktop/src/main/skillhub/importLocalSkill.ts b/apps/desktop/src/main/skillhub/importLocalSkill.ts new file mode 100644 index 00000000000..787fc853c9e --- /dev/null +++ b/apps/desktop/src/main/skillhub/importLocalSkill.ts @@ -0,0 +1,574 @@ +/** + * Local skill import — zip or standalone SKILL.md → ~/.agents/skills// (or custom installPath). + * + * Flow mirrors market install final-switch + learn apply registry write, without Hub download. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { app } from 'electron'; +import JSZip from 'jszip'; + +import { getCurrentUserId } from '../authManager'; +import { createLogger, maskPath } from '../logger'; +import { + prepareSharedGlobalSkillLinks, + prepareSharedProjectSkillLinks, + projectWorkingDirFromSkillPath, +} from '../maker-host/shared-global-skills.js'; +import { computeFolderHash } from './folderHash'; +import { + classifyImportSourcePath, + extractSkillMetadataFromMd, + findZipSkillPackageRoot, + fitsUncompressedBudget, + relativizeZipEntry, + resolveImportInstallPath, + type ImportLocalErrorCode, + type SkillImportMetadata, +} from './importLocalSkill.pure.js'; +import { getSkillInstallLockOwner, tryAcquireSkillInstallLock } from './installLock'; +import { ensureSymlinkToShared } from './installService'; +import { registryService } from './registry'; + +const log = createLogger('skillhub:importLocal'); + +const MAX_SKILL_ZIP = 200 * 1024 * 1024; +const MAX_SKILL_UNCOMPRESSED = 500 * 1024 * 1024; +/** Cap a single SKILL.md inflate/read so inspect cannot OOM on one entry. */ +const MAX_SKILL_MD = 2 * 1024 * 1024; +const MAX_SKILL_ZIP_ENTRIES = 10_000; + +export interface InspectLocalParams { + filePath: string; +} + +export interface ImportLocalParams { + filePath: string; + installPath?: string; + force?: boolean; +} + +export type InspectLocalResult = + | { success: true; name: string; description: string; version: string } + | { success: false; errorCode: ImportLocalErrorCode; message: string }; + +export type ImportLocalResult = + | { + success: true; + name: string; + description: string; + version: string; + absolutePath: string; + projectWorkingDir?: string; + } + | { success: false; errorCode: ImportLocalErrorCode; message: string }; + +function rand(): string { + return crypto.randomBytes(4).toString('hex'); +} + +function backupsRoot(): string { + return path.join(app.getPath('userData'), 'skillhub', 'backups'); +} + +async function pathExists(p: string): Promise { + try { + await fs.promises.access(p); + return true; + } catch { + return false; + } +} + +function isSubPathOrSame(parent: string, child: string): boolean { + const rel = path.relative(path.resolve(parent), path.resolve(child)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function safeJoin(dest: string, relPath: string): string | null { + const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, ''); + const resolved = path.resolve(dest, normalized); + return isSubPathOrSame(dest, resolved) ? resolved : null; +} + +function busyMessage(skillName: string): string { + const owner = getSkillInstallLockOwner(skillName); + if (owner === 'market-install') return `${skillName} 正在从市场安装中,请稍后再导入`; + if (owner === 'market-uninstall') return `${skillName} 正在卸载中,请稍后再导入`; + if (owner === 'learn-apply') return `${skillName} 正在应用学习产物,请稍后再导入`; + if (owner === 'local-import') return `${skillName} 正在导入中`; + return `${skillName} 正在被其它安装任务占用`; +} + +function getDeclaredUncompressedSize(entry: JSZip.JSZipObject): number | null { + const raw = (entry as { _data?: { uncompressedSize?: unknown } })._data?.uncompressedSize; + return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? raw : null; +} + +/** + * Inflate a zip entry with a hard byte ceiling so a zip bomb cannot fully + * materialize into memory before the budget check runs. + */ +async function readZipEntryLimited( + entry: JSZip.JSZipObject, + maxBytes: number, +): Promise { + const declared = getDeclaredUncompressedSize(entry); + if (declared != null && declared > maxBytes) { + throw new Error(`zip entry 解压后大小超过上限:${maxBytes} bytes`); + } + + // JSZip typings expose a DOM ReadableStream; runtime returns a Node stream. + const stream = entry.nodeStream('nodebuffer') as unknown as NodeJS.ReadableStream & { + destroy?: (error?: Error) => void; + }; + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + const fail = (err: Error) => { + if (settled) return; + settled = true; + stream.destroy?.(err); + reject(err); + }; + stream.on('data', (chunk: Buffer | string) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.byteLength; + if (total > maxBytes) { + fail(new Error(`zip entry 解压后大小超过上限:${maxBytes} bytes`)); + return; + } + chunks.push(buf); + }); + stream.on('error', (err: Error) => fail(err)); + stream.on('end', () => { + if (settled) return; + settled = true; + resolve(Buffer.concat(chunks)); + }); + }); +} + +async function moveDir(src: string, dst: string): Promise { + try { + await fs.promises.rename(src, dst); + return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + } + await fs.promises.cp(src, dst, { recursive: true, verbatimSymlinks: true }); + await fs.promises.rm(src, { recursive: true, force: true }); +} + +async function movePersistentBackup(tempDir: string, skillName: string): Promise { + const root = path.join(backupsRoot(), skillName); + await fs.promises.mkdir(root, { recursive: true }); + const dest = path.join(root, `${Date.now()}-${rand()}`); + await moveDir(tempDir, dest); +} + +interface LoadedPackage { + metadata: SkillImportMetadata; + /** Write skill contents into stagingDir (must create SKILL.md at staging root). */ + materialize: (stagingDir: string) => Promise; +} + +async function loadPackageFromPath(filePath: string): Promise< + | { ok: true; pkg: LoadedPackage } + | { ok: false; errorCode: ImportLocalErrorCode; message: string } +> { + const abs = path.resolve(filePath); + if (!(await pathExists(abs))) { + return { ok: false, errorCode: 'INVALID_FILE', message: '文件不存在' }; + } + + const kindResult = classifyImportSourcePath(abs); + if ('error' in kindResult) { + return { ok: false, errorCode: 'INVALID_FILE', message: kindResult.error }; + } + + if (kindResult.kind === 'md') { + let content: string; + try { + const st = await fs.promises.stat(abs); + if (st.size > MAX_SKILL_MD) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `SKILL.md 过大:${st.size} bytes(上限 ${MAX_SKILL_MD})`, + }; + } + content = await fs.promises.readFile(abs, 'utf-8'); + } catch (err) { + return { + ok: false, + errorCode: 'INVALID_FILE', + message: `读取文件失败:${err instanceof Error ? err.message : String(err)}`, + }; + } + if (Buffer.byteLength(content, 'utf-8') > MAX_SKILL_MD) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `SKILL.md 过大(上限 ${MAX_SKILL_MD} bytes)`, + }; + } + const meta = extractSkillMetadataFromMd(content); + if (!meta.ok) return meta; + return { + ok: true, + pkg: { + metadata: meta.metadata, + materialize: async (stagingDir) => { + await fs.promises.mkdir(stagingDir, { recursive: true }); + await fs.promises.writeFile(path.join(stagingDir, 'SKILL.md'), content, 'utf-8'); + }, + }, + }; + } + + // zip + let zipBuf: Buffer; + try { + const st = await fs.promises.stat(abs); + if (st.size > MAX_SKILL_ZIP) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `文件过大:${st.size} bytes(上限 ${MAX_SKILL_ZIP})`, + }; + } + zipBuf = await fs.promises.readFile(abs); + } catch (err) { + return { + ok: false, + errorCode: 'INVALID_FILE', + message: `读取压缩包失败:${err instanceof Error ? err.message : String(err)}`, + }; + } + + let zip: JSZip; + try { + zip = await JSZip.loadAsync(zipBuf); + } catch (err) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `无法解析压缩包:${err instanceof Error ? err.message : String(err)}`, + }; + } + + const entries = Object.values(zip.files); + if (entries.length > MAX_SKILL_ZIP_ENTRIES) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `zip entry 数量超过上限:${entries.length}/${MAX_SKILL_ZIP_ENTRIES}`, + }; + } + + const rootResult = findZipSkillPackageRoot(entries.map((e) => e.name)); + if ('error' in rootResult) { + return { ok: false, errorCode: 'MISSING_SKILL_MD', message: rootResult.error }; + } + const { packageRoot } = rootResult; + + const skillMdEntryName = packageRoot ? `${packageRoot}SKILL.md` : 'SKILL.md'; + const skillMdAlt = packageRoot ? `${packageRoot}skill.md` : 'skill.md'; + const skillEntry = + entries.find((e) => e.name.replace(/\\/g, '/') === skillMdEntryName) ?? + entries.find((e) => e.name.replace(/\\/g, '/') === skillMdAlt) ?? + entries.find((e) => { + const rel = relativizeZipEntry(e.name, packageRoot); + return rel === 'SKILL.md' || rel === 'skill.md'; + }); + + if (!skillEntry || skillEntry.dir) { + return { ok: false, errorCode: 'MISSING_SKILL_MD', message: '压缩包中未找到 SKILL.md' }; + } + + // Reject zip bombs before any inflate: sum declared uncompressed sizes for + // package file entries (directories ignored). Unknown sizes are skipped here + // and enforced by the streaming reader below. + const declaredSizes: number[] = []; + for (const entry of entries) { + if (entry.dir) continue; + const rel = relativizeZipEntry(entry.name, packageRoot); + if (rel == null || rel === '') continue; + const declared = getDeclaredUncompressedSize(entry); + if (declared == null) continue; + declaredSizes.push(declared); + } + if (!fitsUncompressedBudget(declaredSizes, MAX_SKILL_UNCOMPRESSED)) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `zip 解压后大小超过上限:${MAX_SKILL_UNCOMPRESSED} bytes`, + }; + } + + const skillMdDeclared = getDeclaredUncompressedSize(skillEntry); + if (skillMdDeclared != null && skillMdDeclared > MAX_SKILL_MD) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `SKILL.md 过大:${skillMdDeclared} bytes(上限 ${MAX_SKILL_MD})`, + }; + } + + let skillMdContent: string; + try { + const skillMdBuf = await readZipEntryLimited(skillEntry, MAX_SKILL_MD); + skillMdContent = skillMdBuf.toString('utf8'); + } catch (err) { + return { + ok: false, + errorCode: 'EXTRACT_FAILED', + message: `读取 SKILL.md 失败:${err instanceof Error ? err.message : String(err)}`, + }; + } + + const meta = extractSkillMetadataFromMd(skillMdContent); + if (!meta.ok) return meta; + + return { + ok: true, + pkg: { + metadata: meta.metadata, + materialize: async (stagingDir) => { + await fs.promises.mkdir(stagingDir, { recursive: true }); + let totalUncompressedBytes = 0; + for (const entry of entries) { + const rel = relativizeZipEntry(entry.name, packageRoot); + if (rel == null || rel === '') continue; + const dest = safeJoin(stagingDir, rel); + if (!dest) { + throw new Error(`非法 zip entry 路径:${entry.name}`); + } + if (entry.dir) { + await fs.promises.mkdir(dest, { recursive: true }); + continue; + } + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + const remaining = MAX_SKILL_UNCOMPRESSED - totalUncompressedBytes; + if (remaining <= 0) { + throw new Error(`zip 解压后大小超过上限:${MAX_SKILL_UNCOMPRESSED} bytes`); + } + const buf = await readZipEntryLimited(entry, remaining); + totalUncompressedBytes += buf.byteLength; + if (totalUncompressedBytes > MAX_SKILL_UNCOMPRESSED) { + throw new Error(`zip 解压后大小超过上限:${MAX_SKILL_UNCOMPRESSED} bytes`); + } + await fs.promises.writeFile(dest, buf); + } + // Normalize entry file name to SKILL.md when source was skill.md + const lowerMd = path.join(stagingDir, 'skill.md'); + const canonicalMd = path.join(stagingDir, 'SKILL.md'); + if ((await pathExists(lowerMd)) && !(await pathExists(canonicalMd))) { + await fs.promises.rename(lowerMd, canonicalMd); + } + if (!(await pathExists(canonicalMd))) { + throw new Error('解压后未找到 SKILL.md'); + } + }, + }, + }; +} + +function resolveFinalDir( + name: string, + installPath?: string, +): { finalDir: string } | { errorCode: ImportLocalErrorCode; message: string } { + return resolveImportInstallPath(name, installPath, os.homedir()); +} + +async function reconcileProjectLinks(...skillPaths: string[]): Promise { + const projectWorkingDir = skillPaths + .map((skillPath) => projectWorkingDirFromSkillPath(skillPath)) + .find((workingDir): workingDir is string => Boolean(workingDir)); + if (!projectWorkingDir || path.resolve(projectWorkingDir) === path.resolve(os.homedir())) { + return undefined; + } + try { + const linkResult = await prepareSharedProjectSkillLinks({ workingDir: projectWorkingDir }); + for (const warning of linkResult.warnings) { + log.warn('[importLocal] shared project skill link warning:', warning); + } + } catch (err) { + log.warn('[importLocal] prepare shared project skill links failed:', err); + } + return projectWorkingDir; +} + +export async function inspectLocalSkill(params: InspectLocalParams): Promise { + if (typeof params?.filePath !== 'string' || !params.filePath.trim()) { + return { success: false, errorCode: 'INVALID_FILE', message: '缺少 filePath' }; + } + const loaded = await loadPackageFromPath(params.filePath.trim()); + if (!loaded.ok) { + return { success: false, errorCode: loaded.errorCode, message: loaded.message }; + } + const { name, description, version } = loaded.pkg.metadata; + return { success: true, name, description, version }; +} + +export async function importLocalSkill(params: ImportLocalParams): Promise { + if (typeof params?.filePath !== 'string' || !params.filePath.trim()) { + return { success: false, errorCode: 'INVALID_FILE', message: '缺少 filePath' }; + } + + const loaded = await loadPackageFromPath(params.filePath.trim()); + if (!loaded.ok) { + return { success: false, errorCode: loaded.errorCode, message: loaded.message }; + } + const { metadata, materialize } = loaded.pkg; + const { name, description, version } = metadata; + + const resolved = resolveFinalDir(name, params.installPath); + if ('errorCode' in resolved) { + return { success: false, errorCode: resolved.errorCode, message: resolved.message }; + } + const finalDir = resolved.finalDir; + + if (await pathExists(finalDir)) { + if (!params.force) { + return { + success: false, + errorCode: 'CONFLICT_USER_OWNED', + message: `目标位置已存在 ${name}/`, + }; + } + } + + const releaseLock = tryAcquireSkillInstallLock(name, 'local-import'); + if (!releaseLock) { + return { success: false, errorCode: 'BUSY', message: busyMessage(name) }; + } + + const stagingDir = path.join(path.dirname(finalDir), `.xdt-importing-${name}-${rand()}`); + let replaceDir: string | null = null; + let finalDirCreated = false; + + const rollback = async () => { + if (finalDirCreated) { + await fs.promises.rm(finalDir, { recursive: true, force: true }).catch(() => undefined); + } + if (replaceDir) { + await fs.promises.rename(replaceDir, finalDir).catch(async (err) => { + log.error('[importLocal] restore replaced dir failed:', err); + try { + await moveDir(replaceDir!, finalDir); + } catch (restoreErr) { + log.error('[importLocal] EXDEV restore failed:', restoreErr); + } + }); + } + await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + }; + + try { + try { + await materialize(stagingDir); + } catch (err) { + await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + return { + success: false, + errorCode: 'EXTRACT_FAILED', + message: err instanceof Error ? err.message : String(err), + }; + } + + try { + if (await pathExists(finalDir)) { + replaceDir = path.join(path.dirname(finalDir), `.xdt-replacing-${name}-${rand()}`); + await moveDir(finalDir, replaceDir); + await moveDir(stagingDir, finalDir); + finalDirCreated = true; + } else { + await fs.promises.mkdir(path.dirname(finalDir), { recursive: true }); + await moveDir(stagingDir, finalDir); + finalDirCreated = true; + } + } catch (err) { + await rollback(); + return { + success: false, + errorCode: 'WRITE_FAILED', + message: `写入失败:${err instanceof Error ? err.message : String(err)}`, + }; + } + + const folderHash = (await computeFolderHash(finalDir).catch(() => null)) ?? ''; + const nowSec = Math.floor(Date.now() / 1000); + // 与 scanner join 一致:registry key 用 realpath,避免 symlink / 规范化差异导致 + // 详情页挂上了 registryEntry,但卸载按路径查不到 origin=imported。 + let registryPath = path.normalize(finalDir); + try { + registryPath = path.normalize(await fs.promises.realpath(finalDir)); + } catch { + // keep normalized finalDir + } + try { + await registryService.addInstall(name, registryPath, { + version, + authorId: getCurrentUserId() ?? '', + folderHash, + installedAt: nowSec, + updatedAt: nowSec, + origin: 'imported', + }); + } catch (err) { + log.error('[importLocal] registry.addInstall failed, rolling back:', err); + await rollback(); + return { + success: false, + errorCode: 'WRITE_FAILED', + message: `注册失败:${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (replaceDir) { + try { + await movePersistentBackup(replaceDir, name); + } catch (err) { + log.warn(`[importLocal] persist backup failed, left at ${maskPath(replaceDir)}:`, err); + } + replaceDir = null; + } + + // Global default path: Claude discovery symlink (same as market install). + if (!params.installPath) { + const claudeLink = path.join(os.homedir(), '.claude', 'skills', name); + try { + await ensureSymlinkToShared(claudeLink, finalDir); + } catch (err) { + log.warn('[importLocal] claude symlink failed (non-fatal):', claudeLink, err); + } + } + + const projectWorkingDir = await reconcileProjectLinks(finalDir); + try { + const linkResult = await prepareSharedGlobalSkillLinks(); + for (const warning of linkResult.warnings) { + log.warn('[importLocal] shared global skill link warning:', warning); + } + } catch (err) { + log.warn('[importLocal] prepare shared global skill links failed:', err); + } + + return { + success: true, + name, + description, + version, + absolutePath: finalDir, + ...(projectWorkingDir ? { projectWorkingDir } : {}), + }; + } finally { + releaseLock(); + } +} diff --git a/apps/desktop/src/main/skillhub/installLock.ts b/apps/desktop/src/main/skillhub/installLock.ts index 4bee89b5729..a4ff86b1229 100644 --- a/apps/desktop/src/main/skillhub/installLock.ts +++ b/apps/desktop/src/main/skillhub/installLock.ts @@ -18,7 +18,11 @@ */ /** 锁持有方标识 —— 对端获取失败时据此生成可理解的错误文案。 */ -export type SkillInstallLockOwner = 'market-install' | 'market-uninstall' | 'learn-apply'; +export type SkillInstallLockOwner = + | 'market-install' + | 'market-uninstall' + | 'learn-apply' + | 'local-import'; interface LockHolder { owner: SkillInstallLockOwner; diff --git a/apps/desktop/src/main/skillhub/installService.ts b/apps/desktop/src/main/skillhub/installService.ts index c3555efb59d..4c56255ed26 100644 --- a/apps/desktop/src/main/skillhub/installService.ts +++ b/apps/desktop/src/main/skillhub/installService.ts @@ -398,6 +398,7 @@ function skillLockBusyMessage(skillName: string): string { const owner = getSkillInstallLockOwner(skillName); if (owner === 'learn-apply') return `${skillName} 正在被 learn 提案应用,请等待当前任务完成`; if (owner === 'market-uninstall') return `${skillName} 正在卸载中,请等待当前任务完成`; + if (owner === 'local-import') return `${skillName} 正在导入中,请等待当前任务完成`; return `${skillName} 正在安装中,请等待当前任务完成`; } @@ -904,23 +905,14 @@ export async function install( * * 防御:absolutePath 必须落在受支持的 skill discovery root 下 —— 拒绝删除任意路径。 * UI 层(F-UI-4)在按钮分流时已确保"未注册的本地技能"不显示卸载按钮,这层是双保险。 + * + * 鉴权: + * - origin=imported / learned → 本地产物(导入 / 蒸馏),不要求登录 / SkillHub 云能力 + * - 其它(市场 installed 等)→ 仍要求数据空间 + canUseSkillHubCloud */ export async function uninstall( absolutePath: string, ): Promise { - const ownerId = getCurrentDataOwnerId(); - if (!ownerId) { - return { success: false, errorCode: 'AUTH_REQUIRED', message: '无可用数据空间' }; - } - if (!getAppCapabilities().canUseSkillHubCloud) { - return { - success: false, - errorCode: 'AUTH_REQUIRED', - message: 'SkillHub 卸载需要 Cindy 云端账号', - }; - } - const cloudUserId = getCurrentUserId(); - // 防御:resolve 后验证路径是精确的 skill 根目录(只允许一层 slug,防 traversal) let resolved: string; try { @@ -944,7 +936,36 @@ export async function uninstall( return { success: false, errorCode: 'INTERNAL', message: skillLockBusyMessage(skillName) }; } try { - return await uninstallLocked(absolutePath, resolved, skillName, cloudUserId); + // 先读 registry 再判鉴权:本地导入 / 蒸馏产物允许离线卸载。 + const registryMatch = await findRegistryInstallForPath(skillName, absolutePath, resolved); + if (!registryMatch) { + return { success: false, errorCode: 'INTERNAL', message: '该 skill 无安装记录,拒绝删除' }; + } + + const origin = registryMatch.entry.origin; + const isOfflineLocalOrigin = origin === 'imported' || origin === 'learned'; + if (!isOfflineLocalOrigin) { + const ownerId = getCurrentDataOwnerId(); + if (!ownerId) { + return { success: false, errorCode: 'AUTH_REQUIRED', message: '无可用数据空间' }; + } + if (!getAppCapabilities().canUseSkillHubCloud) { + return { + success: false, + errorCode: 'AUTH_REQUIRED', + message: 'SkillHub 卸载需要 Cindy 云端账号', + }; + } + } + + const cloudUserId = getCurrentUserId(); + return await uninstallLocked( + absolutePath, + resolved, + skillName, + cloudUserId, + registryMatch, + ); } finally { releaseLock(); } @@ -966,8 +987,18 @@ async function findRegistryInstallForPath( absolutePath: string, resolved: string, ): Promise { - const directPaths = Array.from(new Set([resolved, absolutePath].map((candidate) => path.normalize(candidate)))); - for (const installPath of directPaths) { + const candidatePaths = uniqueNormalizedPaths([ + resolved, + absolutePath, + ...[resolved, absolutePath].flatMap((candidate) => { + try { + return [fs.realpathSync(candidate)]; + } catch { + return []; + } + }), + ]); + for (const installPath of candidatePaths) { const entry = await registryService.getInstall(skillName, installPath).catch(() => null); if (entry) return { installPath, entry }; } @@ -979,9 +1010,13 @@ async function findRegistryInstallForPath( try { realInstallPath = fs.realpathSync(installPath); } catch { + // 目录已删时仍允许用规范化路径与候选路径直接比对 + if (candidatePaths.some((candidate) => pathTextEquals(path.normalize(installPath), candidate))) { + return { installPath, entry }; + } continue; } - if (resolvedPathEquals(realInstallPath, resolved)) { + if (candidatePaths.some((candidate) => resolvedPathEquals(realInstallPath, candidate))) { return { installPath, entry }; } } @@ -994,12 +1029,8 @@ async function uninstallLocked( resolved: string, skillName: string, cloudUserId: string | null, + registryMatch: RegistryInstallMatch, ): Promise { - // 额外校验:registry 中必须有匹配记录,防止删除未注册的用户手写目录 - const registryMatch = await findRegistryInstallForPath(skillName, absolutePath, resolved); - if (!registryMatch) { - return { success: false, errorCode: 'INTERNAL', message: '该 skill 无安装记录,拒绝删除' }; - } const { installPath: registryInstallPath, entry: registryEntry } = registryMatch; if (!(await pathExists(resolved))) { diff --git a/apps/desktop/src/main/skillhub/registerIpc.ts b/apps/desktop/src/main/skillhub/registerIpc.ts index 5d7a60dd946..539add20071 100644 --- a/apps/desktop/src/main/skillhub/registerIpc.ts +++ b/apps/desktop/src/main/skillhub/registerIpc.ts @@ -1,11 +1,14 @@ +import { randomUUID } from 'node:crypto'; import type { Maker } from '@cindy/maker-core'; -import { BrowserWindow, ipcMain } from 'electron'; +import { BrowserWindow, dialog, ipcMain } from 'electron'; import { getCurrentDataOwnerId } from '../authManager'; import { isAppSessionBoundaryPending } from '../appSessionState'; import { ensureReady as ensureLocalDbReady, getRawDb } from '../localDb'; import { createLogger } from '../logger'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; import { computeFolderHashDetailed } from './folderHash'; import { type MdKind, parseAndValidateFrontmatter } from './frontmatterValidation'; +import * as importLocalSkill from './importLocalSkill'; import * as installService from './installService'; import { SkillhubMarketService, skillhubIpcError } from './marketService'; import type { PublishParams } from './publishService'; @@ -21,6 +24,14 @@ import { } from './usageIndexer'; const log = createLogger('skillhub'); +const LOCAL_IMPORT_GRANT_TTL_MS = 10 * 60 * 1_000; +const MAX_LOCAL_IMPORT_GRANTS = 32; + +interface LocalImportGrant { + filePath: string; + senderId: number; + expiresAt: number; +} export interface RegisterSkillhubIpcOptions { getMaker: () => Maker; @@ -36,6 +47,21 @@ export interface RegisterSkillhubIpcOptions { */ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { const marketService = options.marketService ?? new SkillhubMarketService(); + const localImportGrants = new Map(); + + const sweepLocalImportGrants = () => { + const now = Date.now(); + for (const [token, grant] of localImportGrants) { + if (grant.expiresAt <= now) localImportGrants.delete(token); + } + }; + const makeRoomForLocalImportGrant = () => { + while (localImportGrants.size >= MAX_LOCAL_IMPORT_GRANTS) { + const oldestToken = localImportGrants.keys().next().value as string | undefined; + if (!oldestToken) break; + localImportGrants.delete(oldestToken); + } + }; const refreshCodexProjectSkillCache = async (workingDir?: string): Promise => { if (!workingDir) return; @@ -515,6 +541,89 @@ export function registerSkillhubIpc(options: RegisterSkillhubIpcOptions): void { return { success: true }; }); + // ── Local import (zip / SKILL.md) ──────────────────────────────────────── + ipcMain.handle( + 'skillhub:pick-local', + async (event) => { + assertTrustedAppRendererEvent(event); + const owner = BrowserWindow.fromWebContents(event.sender); + if (!owner || owner.isDestroyed()) { + return { success: false, errorCode: 'INTERNAL', message: '无法打开文件选择器' }; + } + const picked = await dialog.showOpenDialog(owner, { + properties: ['openFile'], + filters: [{ name: 'Skill package', extensions: ['zip', 'md'] }], + }); + const filePath = picked.filePaths[0]; + if (picked.canceled || !filePath) { + return { success: true, canceled: true }; + } + + const inspected = await importLocalSkill.inspectLocalSkill({ filePath }); + if (!inspected.success) return inspected; + + sweepLocalImportGrants(); + makeRoomForLocalImportGrant(); + const grantToken = randomUUID(); + localImportGrants.set(grantToken, { + filePath, + senderId: event.sender.id, + expiresAt: Date.now() + LOCAL_IMPORT_GRANT_TTL_MS, + }); + return { + success: true, + canceled: false, + grantToken, + name: inspected.name, + description: inspected.description, + version: inspected.version, + }; + }, + ); + + ipcMain.handle( + 'skillhub:import-local', + async ( + event, + params: { grantToken?: unknown; installPath?: unknown; force?: unknown }, + ) => { + assertTrustedAppRendererEvent(event); + sweepLocalImportGrants(); + if ( + typeof params?.grantToken !== 'string' || + !params.grantToken || + params.grantToken.length > 128 + ) { + return { success: false, errorCode: 'PERMISSION_DENIED', message: '本地导入授权无效' }; + } + const grant = localImportGrants.get(params.grantToken); + if (!grant || grant.senderId !== event.sender.id) { + return { + success: false, + errorCode: 'PERMISSION_DENIED', + message: '本地导入授权不存在、已过期或不属于当前窗口', + }; + } + const result = await importLocalSkill.importLocalSkill({ + filePath: grant.filePath, + ...(typeof params.installPath === 'string' && params.installPath + ? { installPath: params.installPath } + : {}), + ...(params.force === true ? { force: true } : {}), + }); + if (!result.success) return result; + localImportGrants.delete(params.grantToken); + await refreshCodexProjectSkillCache(result.projectWorkingDir); + return { + success: true, + name: result.name, + description: result.description, + version: result.version, + absolutePath: result.absolutePath, + }; + }, + ); + // ── Market install / uninstall / cancel ────────────────────────────────── // install:异步流程,进度通过 skillhub:install-progress 推。返回值是终态。 ipcMain.handle( diff --git a/apps/desktop/src/main/skillhub/registry/types.ts b/apps/desktop/src/main/skillhub/registry/types.ts index e4129114d66..ccbe8f2314d 100644 --- a/apps/desktop/src/main/skillhub/registry/types.ts +++ b/apps/desktop/src/main/skillhub/registry/types.ts @@ -17,13 +17,14 @@ export interface StoredInstall { /** unix seconds。update / publish 同步时刷新。 */ updatedAt: number; /** 本地来源:'installed' = 从市场安装,'published' = 本地创建后发布, - * 'learned' = /learn 蒸馏产物(经 diff 审查确认后落盘)。 - * 影响 UI 是否显示"卸载"按钮(只有 installed 才有卸载概念)。 + * 'learned' = /learn 蒸馏产物(经 diff 审查确认后落盘), + * 'imported' = 用户从本地 zip / SKILL.md 导入。 + * 影响 UI 是否显示"卸载"按钮(installed / imported 可卸载)。 * 历史遗留数据可能缺此字段;读取层不强制补默认值。 * renderer 会结合 server isMine 做保守推断:明确是别人的历史 registry 才视作 * installed,自己的会通过 reconcile 回填为 published,本地手写 / 市场不存在的 skill * 不因此显示卸载。 */ - origin?: 'installed' | 'published' | 'learned'; + origin?: 'installed' | 'published' | 'learned' | 'imported'; /** 是否由产品自动同步流程安装。用于区分普通市场安装与用户可 opt-out 的自动同步安装。 */ autoSynced?: boolean; /** /learn 蒸馏产物的溯源(仅 origin='learned' 时存在)。 diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7ab00a1a22d..bb82eb3d64e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2388,6 +2388,36 @@ contextBridge.exposeInMainWorld('electronAPI', { | { success: false; errorCode: string; message: string } > => ipcRenderer.invoke('skillhub:uninstall', { absolutePath }), + /** 在 main 内选择并检查本地包,成功时签发绑定当前 renderer 的短期导入授权。 */ + pickLocal: (): Promise< + | { success: true; canceled: true } + | { + success: true; + canceled: false; + grantToken: string; + name: string; + description: string; + version: string; + } + | { success: false; errorCode: string; message: string } + > => ipcRenderer.invoke('skillhub:pick-local'), + + /** 使用 main 签发的文件授权导入到全局或指定 installPath;registry origin=imported。 */ + importLocal: (params: { + grantToken: string; + installPath?: string; + force?: boolean; + }): Promise< + | { + success: true; + name: string; + description: string; + version: string; + absolutePath: string; + } + | { success: false; errorCode: string; message: string } + > => ipcRenderer.invoke('skillhub:import-local', params), + // 订阅 install 进度事件 onInstallProgress: ( cb: (event: { diff --git a/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx b/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx index 586fd1e5d1e..234a579bb58 100644 --- a/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx +++ b/apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx @@ -2008,7 +2008,7 @@ export function SkillhubDetailView() { {/* skill 按钮组 — detailAction.status 保证市场状态/动作互斥 */} {isSkill && detailState && ( <> - {/* D1: 卸载 — origin='installed' 才显示 */} + {/* D1: 卸载 — origin=installed(市场)或 imported(本地导入) */} {showUninstall && ( {/* ① Skill Hub 入口 → 完整 Market 浏览页(仅市场可见账号) */} @@ -353,6 +413,44 @@ export function SkillhubHomeView() { setPreviewSkill(null); }} /> + { + if (!importGrantToken) { + return { success: false, errorCode: 'INVALID_FILE', message: t('skillhub.home.importFailed') }; + } + return window.electronAPI.skillhub.importLocal({ + grantToken: importGrantToken, + installPath, + force, + }); + }} + onInstallComplete={(result) => { + void refreshSkillhub(); + closeImportPicker(); + if (!result?.name) return; + const name = encodeURIComponent(result.name); + const projectRoot = result.absolutePath + ? deriveProjectWorkingDir(result.absolutePath) + : null; + if (projectRoot) { + navigate( + `/skillhub/local/skill/project/${projectHash(projectRoot)}/${name}`, + { state: { from: '/skillhub/local', resetHistory: true } }, + ); + return; + } + navigate(`/skillhub/local/skill/global/${name}`, { + state: { from: '/skillhub/local', resetHistory: true }, + }); + }} + /> ); diff --git a/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx b/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx index 135e1cf081e..40e2273df06 100644 --- a/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx +++ b/apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx @@ -1,7 +1,7 @@ /** * InstallTargetPicker — F-UI-2 * - * 选择安装位置的弹窗。用户点击"Clone"后触发。 + * 选择安装位置的弹窗。市场 Clone 与本地导入共用。 * * 布局:固定全屏蒙版 + 居中弹窗(width=480, cornerRadius=12)。 * 行高:全局行 + 项目行均为 h-12 (48px),超出 4 行时容器内可滚动。 @@ -27,13 +27,41 @@ import { joinSkillInstallPath, normalizeInstallPathKey, } from '../lib/installTargetPaths'; -import type { MarketSkill } from '../hooks/useMarketList'; + +/** Minimal skill identity for the picker (market Clone or local import). */ +export interface InstallTargetSkill { + name: string; + /** Shown in import / custom subtitle. */ + versionLabel?: string; + /** Market Clone passes latestVersion; used when versionLabel is absent. */ + latestVersion?: string | number; + description?: string; +} + +export type InstallTargetActionResult = + | { success: true; absolutePath?: string } + | { success: false; errorCode?: string; message?: string }; interface InstallTargetPickerProps { open: boolean; - skill: MarketSkill | null; + skill: InstallTargetSkill | null; onClose: () => void; - onInstallComplete: () => void; + onInstallComplete: (result?: { absolutePath?: string; name: string }) => void; + /** + * Install / import executor. Defaults to market `skillhub.install`. + * Import mode passes a closure that calls `skillhub.importLocal`. + */ + runAction?: (params: { + name: string; + installPath?: string; + force?: boolean; + }) => Promise; + /** i18n key override for dialog title (default installPicker.title). */ + titleKey?: string; + /** When set, subtitle uses this key with { name, version, description }. */ + subtitleKey?: string; + successToastKey?: string; + failedToastKey?: string; } // 最多可见的项目行数(超出滚动) @@ -41,11 +69,11 @@ const MAX_VISIBLE_PROJECTS = 4; const PROJECT_ROW_H = 48; const INSTALL_PICKER_TITLE_ID = 'skillhub-install-picker-title'; -async function runInstall(params: { +async function runMarketInstall(params: { name: string; installPath?: string; force?: boolean; -}): Promise<{ success: boolean; errorCode?: string; message?: string; absolutePath?: string }> { +}): Promise { return window.electronAPI.skillhub.install({ name: params.name, installPath: params.installPath, @@ -53,7 +81,17 @@ async function runInstall(params: { }); } -export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: InstallTargetPickerProps) { +export function InstallTargetPicker({ + open, + skill, + onClose, + onInstallComplete, + runAction = runMarketInstall, + titleKey = 'skillhub.installPicker.title', + subtitleKey = 'skillhub.installPicker.subtitle', + successToastKey = 'skillhub.installPicker.installSuccess', + failedToastKey = 'skillhub.installPicker.installFailed', +}: InstallTargetPickerProps) { const { t } = useTranslation(); const { projects, loading: projectsLoading } = useProjectsForPicker(); const { confirm } = useConfirmDialog(); @@ -75,9 +113,18 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: setInstalledPaths(paths); }) .catch(() => undefined); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [open, skill?.name]); + useEffect(() => { + if (!open) { + setBannerError(null); + setInstalling(false); + } + }, [open]); + if (!open || !skill) return null; const getInstalledVersion = (targetPath: string): string | null => { @@ -98,13 +145,15 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: setBannerError(null); setInstalling(true); try { - const res = await runInstall({ name: skill.name, installPath }); + const res = await runAction({ name: skill.name, installPath }); if (res.success) { - toast.success(t('skillhub.installPicker.installSuccess', { - name: skill.name, - suffix: res.absolutePath ? ` → ${res.absolutePath}` : '', - })); - onInstallComplete(); + toast.success( + t(successToastKey, { + name: skill.name, + suffix: res.absolutePath ? ` → ${res.absolutePath}` : '', + }), + ); + onInstallComplete({ name: skill.name, absolutePath: res.absolutePath }); return; } if (res.errorCode === 'CONFLICT_USER_OWNED') { @@ -119,20 +168,22 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: cancelText: t('skillhub.installPicker.conflictDialog.cancel'), }); if (!ok) return; - const forced = await runInstall({ name: skill.name, installPath, force: true }); + const forced = await runAction({ name: skill.name, installPath, force: true }); if (forced.success) { - toast.success(t('skillhub.installPicker.installSuccess', { - name: skill.name, - suffix: forced.absolutePath ? ` → ${forced.absolutePath}` : '', - })); - onInstallComplete(); + toast.success( + t(successToastKey, { + name: skill.name, + suffix: forced.absolutePath ? ` → ${forced.absolutePath}` : '', + }), + ); + onInstallComplete({ name: skill.name, absolutePath: forced.absolutePath }); } else if (forced.errorCode !== 'CANCELLED') { - setBannerError(forced.message ?? t('skillhub.installPicker.installFailed')); + setBannerError(forced.message ?? t(failedToastKey)); } return; } if (res.errorCode !== 'CANCELLED') { - setBannerError(res.message ?? t('skillhub.installPicker.installFailed')); + setBannerError(res.message ?? t(failedToastKey)); } } catch (err) { setBannerError(err instanceof Error ? err.message : String(err)); @@ -148,6 +199,8 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: await handleInstall(installPath); }; + const versionForSubtitle = String(skill.versionLabel ?? skill.latestVersion ?? ''); + const dialog = (
- {/* 错误提示 */} {bannerError && (
{bannerError}
)} - {/* 安装目标列表 */}
- {/* 全局行 */}
{globalInstalledVersion ? ( - + v{globalInstalledVersion} ) : ( @@ -244,11 +307,7 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: )} - {/* 项目分隔符 */} -
+
- {/* 项目列表(最多可见 4 行,超出滚动) */} {projectsLoading ? (
{ void handleInstall(installPath); }} + onClick={() => { + void handleInstall(installPath); + }} className={cn( 'flex w-full items-center gap-3 rounded-xl text-left transition-colors', 'border border-[var(--cmd-palette-border)] bg-[hsl(var(--content-area))]', @@ -307,7 +367,10 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }:
- + {p.displayName} @@ -315,7 +378,10 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }:
{isRowInstalled ? ( - + v{rowVersion} ) : ( @@ -326,7 +392,6 @@ export function InstallTargetPicker({ open, skill, onClose, onInstallComplete }: })}
)} - diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts index 4afc7486868..71e2b20ab45 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/detailButtons.test.ts @@ -277,6 +277,61 @@ describe('deriveDetailActionState', () => { showForeignDirtyBanner: false, }, }, + { + name: 'imported skill can uninstall and never offers market update', + detail: { + origin: 'imported', + isMine: false, + localVersion: '0.1.0', + latestVersion: '2.0.0', + }, + registryEntry: makeRegistryEntry({ origin: 'imported', version: '0.1.0', folderHash: 'same' }), + localFolderHash: 'same', + expected: { + showUninstall: true, + status: { kind: 'none' }, + isOutdated: true, + isMineDirty: false, + showForeignDirtyBanner: false, + }, + }, + { + name: 'own imported skill sharing a market name prompts publish-new-version', + detail: { + origin: 'imported', + isMine: true, + localVersion: '0.1.0', + latestVersion: '1.0.0', + }, + registryEntry: makeRegistryEntry({ origin: 'imported', version: '0.1.0', folderHash: 'same' }), + localFolderHash: 'same', + expected: { + showUninstall: true, + status: { kind: 'publish-new-version' }, + isOutdated: true, + isMineDirty: false, + showForeignDirtyBanner: false, + }, + }, + { + name: 'imported skill with no market record can publish-to-market', + detail: { + origin: 'imported', + isMine: false, + localVersion: '0.1.0', + latestVersion: null, + marketDeleted: true, + }, + registryEntry: makeRegistryEntry({ origin: 'imported', version: '0.1.0', folderHash: 'same' }), + localFolderHash: 'same', + expected: { + showUninstall: true, + status: { kind: 'publish-to-market' }, + isOutdated: false, + isMineDirty: false, + showForeignDirtyBanner: false, + }, + }, { name: 'own published clean skill shows published tag without uninstall', detail: { diff --git a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/skillSource.test.ts b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/skillSource.test.ts index 8cdc55b6bb7..a55d763aac6 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/__tests__/skillSource.test.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/__tests__/skillSource.test.ts @@ -18,6 +18,11 @@ describe('deriveSkillSource', () => { expect(deriveSkillSource('learned', true, false)).toBe('local'); }); + it('treats explicit imported origin as local', () => { + expect(deriveSkillSource('imported', true, false)).toBe('local'); + expect(deriveSkillSource('imported', true, true)).toBe('local'); + }); + it('treats pre-origin foreign registry records as skillhub', () => { // 历史遗留:有 registry 记录、origin 缺失,server 明确判定不是我的 → 他人历史安装 expect(deriveSkillSource(undefined, true, false)).toBe('skillhub'); diff --git a/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts b/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts index 3c509c60aa8..29509fb4e92 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/detailButtons.ts @@ -4,7 +4,7 @@ import { semverCompare } from '../versionUtils'; * deriveDetailState — 从 entry + server info 派生三个独立维度的 UI 状态。 * * 三维度模型: - * D1 origin — 本地来源(installed/published/learned),决定是否显示卸载 + * D1 origin — 本地来源(installed/published/learned/imported),决定是否显示卸载 * D2 isMine — 管理权(server 权威),决定发布相关操作 * D3 version — 远程版本状态,决定已安装/更新按钮 * @@ -13,8 +13,8 @@ import { semverCompare } from '../versionUtils'; export interface DetailState { /** null = 无 registryEntry(纯本地手写 skill)。'learned' = /learn 蒸馏产物, - * 按钮语义同本地创建(无卸载概念,不可发布由 provenance.personal 另行拦截)。 */ - origin: 'installed' | 'published' | 'learned' | null; + * 'imported' = 本地 zip/SKILL.md 导入。按钮语义见 deriveDetailActionState。 */ + origin: 'installed' | 'published' | 'learned' | 'imported' | null; /** server 确认的管理权。null = server 不可用(404/error/loading) */ isMine: boolean | null; /** registryEntry 里记录的本地版本。null = 无 registryEntry */ @@ -154,18 +154,18 @@ export function deriveDetailActionState( localChanged ) { status = { kind: 'publish-new-version' }; - } else if (isOutdated && detailState.latestVersion !== null && detailState.origin !== 'learned') { - // learned 不进市场更新路径(Codex review):/learn hub: 的产物与市场 - // skill 同名,「更新到 v…」会用市场包覆盖掉用户的个人蒸馏版 —— learned 是 - // 本地创作,版本号与市场无对应关系,isOutdated 对它无意义。 + } else if (isOutdated && detailState.latestVersion !== null && detailState.origin !== 'learned' && detailState.origin !== 'imported') { + // learned / imported 不进市场更新路径:用市场包覆盖会丢掉本地创作 / 导入内容。 status = { kind: 'update', latestVersion: detailState.latestVersion }; } else if (detailState.latestVersion !== null) { // Server confirms the skill exists; never offer first-publish in this branch. - if (detailState.origin === 'learned' && detailState.isMine === true) { - // learned 的 registry hash 对应本地蒸馏结果,不是 server 已发布版本。 + if ( + (detailState.origin === 'learned' || detailState.origin === 'imported') && + detailState.isMine === true + ) { + // learned / imported 的 registry hash 对应本地内容,不是 server 已发布版本。 // 即使 localChanged=false 也不能显示 published-tag;若用户确实拥有同名 - // 市场 skill,应走发布新版本路径。(个人上下文产物的发布语义 —— 发布前 - // 泛化 —— 按产品 review 方向另行独立 PR,不在本系列做硬拦截。) + // 市场 skill,应走发布新版本路径。 status = { kind: 'publish-new-version' }; } else if (detailState.isMine === true) { status = isMineDirty @@ -183,7 +183,7 @@ export function deriveDetailActionState( } return { - showUninstall: detailState.origin === 'installed', + showUninstall: detailState.origin === 'installed' || detailState.origin === 'imported', status, isOutdated, isMineDirty, diff --git a/apps/desktop/src/renderer/features/skillhub/lib/skillSource.ts b/apps/desktop/src/renderer/features/skillhub/lib/skillSource.ts index fd4a0099586..0b1a515e10c 100644 --- a/apps/desktop/src/renderer/features/skillhub/lib/skillSource.ts +++ b/apps/desktop/src/renderer/features/skillhub/lib/skillSource.ts @@ -3,7 +3,7 @@ * * 用户视角只关心一份本地 skill 是「从 SkillHub 安装下来的副本」还是「自己的」: * - 'installed' → 走 SkillHub 市场安装的版本 → 'skillhub' - * - 'published' / 'learned' / 无 origin → 自己开发/发布/学习得到的本地版本 → 'local' + * - 'published' / 'learned' / 'imported' / 无 origin → 自己开发/发布/学习/导入的本地版本 → 'local' * ('published' 的本地目录是作者自己的 dev 副本,不是从 hub 拉下来的,故归 'local'。) * * 历史遗留数据(v0.6 引入 origin 之前的 registry 记录)可能有 registry 记录但 @@ -23,13 +23,13 @@ export type SkillSource = 'skillhub' | 'local'; * @param isMine server 权威归属:true=我的 / false=他人 / null|undefined=未知 */ export function deriveSkillSource( - origin: 'installed' | 'published' | 'learned' | null | undefined, + origin: 'installed' | 'published' | 'learned' | 'imported' | null | undefined, hasRegistryEntry: boolean, isMine: boolean | null | undefined, ): SkillSource { if (origin === 'installed') return 'skillhub'; - // published / learned 都是本地创作(learned = /learn 蒸馏产物) - if (origin === 'published' || origin === 'learned') return 'local'; + // published / learned / imported 都是本地侧产物 + if (origin === 'published' || origin === 'learned' || origin === 'imported') return 'local'; // origin 缺失:仅当有 registry 记录且 server 明确说不是我的(他人历史安装)才算 skillhub。 if (hasRegistryEntry && isMine === false) return 'skillhub'; return 'local'; diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 5d8cd730b56..fb64ebc7792 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -6991,7 +6991,13 @@ "installed": "Installed", "globalScope": "Global", "sourceSkillhub": "SkillHub", - "sourceLocal": "Local" + "sourceLocal": "Local", + "import": "Import skill", + "importAria": "Import a skill from a local zip or SKILL.md", + "importPickerTitle": "Choose import location", + "importPickerSubtitle": "{{name}} · {{description}}", + "importSuccess": "{{name}} imported{{suffix}}", + "importFailed": "Failed to import skill" }, "common": { "openFolderFailed": "Failed to open folder", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 65bf1ff7593..7a8df7ccc5a 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -6980,7 +6980,13 @@ "installed": "インストール済み", "globalScope": "グローバル", "sourceSkillhub": "SkillHub", - "sourceLocal": "ローカル" + "sourceLocal": "ローカル", + "import": "スキルをインポート", + "importAria": "ローカルの zip または SKILL.md からスキルをインポート", + "importPickerTitle": "インポート先を選択", + "importPickerSubtitle": "{{name}} · {{description}}", + "importSuccess": "{{name}} をインポートしました{{suffix}}", + "importFailed": "スキルのインポートに失敗しました" }, "common": { "openFolderFailed": "フォルダを開けませんでした", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 13f41644d87..25cc98431d9 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -6980,7 +6980,13 @@ "installed": "설치됨", "globalScope": "전역", "sourceSkillhub": "SkillHub", - "sourceLocal": "로컬" + "sourceLocal": "로컬", + "import": "스킬 가져오기", + "importAria": "로컬 zip 또는 SKILL.md에서 스킬 가져오기", + "importPickerTitle": "가져오기 위치 선택", + "importPickerSubtitle": "{{name}} · {{description}}", + "importSuccess": "{{name}} 가져오기 완료{{suffix}}", + "importFailed": "스킬 가져오기 실패" }, "common": { "openFolderFailed": "폴더 열기 실패", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index ee56927b229..21e54a51cb6 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -6980,7 +6980,13 @@ "installed": "已安装", "globalScope": "全局", "sourceSkillhub": "SkillHub", - "sourceLocal": "本地" + "sourceLocal": "本地", + "import": "导入技能", + "importAria": "从本地压缩包或 SKILL.md 导入技能", + "importPickerTitle": "选择导入位置", + "importPickerSubtitle": "{{name}} · {{description}}", + "importSuccess": "{{name}} 已导入{{suffix}}", + "importFailed": "导入技能失败" }, "common": { "openFolderFailed": "打开文件夹失败", diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 27d9864f02c..6e7959dc39b 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -2661,6 +2661,34 @@ interface ElectronAPI { | { success: true } | { success: false; errorCode: string; message: string } >; + /** 在 main 内选择并检查本地包,成功时签发绑定当前 renderer 的短期导入授权。 */ + pickLocal: () => Promise< + | { success: true; canceled: true } + | { + success: true; + canceled: false; + grantToken: string; + name: string; + description: string; + version: string; + } + | { success: false; errorCode: string; message: string } + >; + /** 使用 main 签发的文件授权导入;registry origin=imported。 */ + importLocal: (params: { + grantToken: string; + installPath?: string; + force?: boolean; + }) => Promise< + | { + success: true; + name: string; + description: string; + version: string; + absolutePath: string; + } + | { success: false; errorCode: string; message: string } + >; onInstallProgress: ( callback: (event: { phase: @@ -4853,8 +4881,8 @@ interface StoredInstall { installedAt: number; /** unix seconds。update / publish 同步时刷新。 */ updatedAt: number; - /** 本地来源:installed=从市场安装,published=本地创建后发布,learned=/learn 蒸馏产物。历史数据无此字段。 */ - origin?: 'installed' | 'published' | 'learned'; + /** 本地来源:installed=从市场安装,published=本地创建后发布,learned=/learn 蒸馏产物,imported=本地 zip/SKILL.md 导入。历史数据无此字段。 */ + origin?: 'installed' | 'published' | 'learned' | 'imported'; /** 是否由产品自动同步流程安装。用于区分普通市场安装与用户可 opt-out 的自动同步安装。 */ autoSynced?: boolean; /** /learn 蒸馏产物的溯源(仅 origin='learned')。personal=true ⇒ publish 拦截。 */