Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 Skill Hub later if you want.
Comment thread
codeingforcoffee marked this conversation as resolved.
Outdated

**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,113 @@
import { describe, expect, it } from 'vitest';

import {
classifyImportSourcePath,
extractSkillMetadataFromMd,
findZipSkillPackageRoot,
isValidImportSkillName,
relativizeZipEntry,
} 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' });
});
});
101 changes: 78 additions & 23 deletions apps/desktop/src/main/skillhub/__tests__/installService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
Loading