Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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