Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/help-knowledge/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -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): `<working-dir>/.agents/skills/<name>/` or `<working-dir>/.claude/skills/<name>/`.
- 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<name>', () => {
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);
});
});
119 changes: 119 additions & 0 deletions apps/desktop/src/main/skillhub/__tests__/importLocalSkill.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | Buffer>): Promise<string> {
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/);
}
});
});
Loading