Skip to content
Open
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
14 changes: 5 additions & 9 deletions apps/desktop/src/main/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1571,24 +1571,20 @@ export function registerIPCHandlers(): void {
return skillsManager.getUserSkillsPath();
});

handle('skills:pick-file', async () => {
handle('skills:pick-folder', async () => {
const mainWindow = BrowserWindow.getAllWindows()[0];
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Select a SKILL.md file',
filters: [
{ name: 'Skill Files', extensions: ['md'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
title: 'Select a skill folder',
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
});

handle('skills:add-from-file', async (_event, filePath: string) => {
return skillsManager.addFromFile(filePath);
handle('skills:add-from-folder', async (_event, folderPath: string) => {
return skillsManager.addFromFile(folderPath);
});

handle('skills:add-from-github', async (_event, rawUrl: string) => {
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,9 @@ const accomplishAPI = {
getSkillContent: (id: string): Promise<string | null> =>
ipcRenderer.invoke('skills:get-content', id),
getUserSkillsPath: (): Promise<string> => ipcRenderer.invoke('skills:get-user-skills-path'),
pickSkillFile: (): Promise<string | null> => ipcRenderer.invoke('skills:pick-file'),
addSkillFromFile: (filePath: string): Promise<Skill> =>
ipcRenderer.invoke('skills:add-from-file', filePath),
pickSkillFolder: (): Promise<string | null> => ipcRenderer.invoke('skills:pick-folder'),
addSkillFromFolder: (folderPath: string): Promise<Skill> =>
ipcRenderer.invoke('skills:add-from-folder', folderPath),
addSkillFromGitHub: (rawUrl: string): Promise<Skill> =>
ipcRenderer.invoke('skills:add-from-github', rawUrl),
deleteSkill: (id: string): Promise<void> => ipcRenderer.invoke('skills:delete', id),
Expand Down
6 changes: 3 additions & 3 deletions apps/web/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,16 @@
"buildWithAccomplish": "Build with Accomplish",
"buildDescription": "Create skills through conversation",
"uploadSkill": "Upload a skill",
"uploadDescription": "Upload a SKILL.md file",
"uploadDescription": "Select a skill folder to import",
"importFromGitHub": "Import from GitHub",
"importDescription": "Paste a repository link",
"importDialogDescription": "Enter the URL of a GitHub repository containing a SKILL.md file.",
"importing": "Importing...",
"import": "Import",
"uploadFailedTitle": "Upload Failed",
"uploadFailedDescription": "The skill file could not be uploaded.",
"uploadFailedDescription": "The skill could not be uploaded.",
"uploadErrorHelp": "Make sure your SKILL.md file has valid YAML frontmatter with at least a",
"selectFile": "Select a SKILL.md file",
"selectFile": "Select a skill folder",
"uploadErrorField": "name",
"uploadErrorFieldSuffix": "field:",
"ok": "OK",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,16 @@
"buildWithAccomplish": "Créer avec Accomplish",
"buildDescription": "Créez des compétences par conversation",
"uploadSkill": "Importer une compétence",
"uploadDescription": "Importez un fichier SKILL.md",
"uploadDescription": "Sélectionnez un dossier de compétences à importer",
"importFromGitHub": "Importer depuis GitHub",
"importDescription": "Collez un lien de dépôt",
"importDialogDescription": "Saisissez l'URL d'un dépôt GitHub contenant un fichier SKILL.md.",
"importing": "Importation...",
"import": "Importer",
"uploadFailedTitle": "Échec de l'importation",
"uploadFailedDescription": "Le fichier de compétence n'a pas pu être importé.",
"uploadFailedDescription": "Le dossier de compétence n'a pas pu être importé.",
"uploadErrorHelp": "Assurez-vous que votre fichier SKILL.md contient un frontmatter YAML valide avec au moins un champ",
"selectFile": "Sélectionnez un fichier SKILL.md",
"selectFile": "Sélectionnez un dossier de compétences",
"uploadErrorField": "name",
"uploadErrorFieldSuffix": " :",
"ok": "OK",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/locales/ru/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,16 @@
"buildWithAccomplish": "Создать с Accomplish",
"buildDescription": "Создавайте навыки через диалог",
"uploadSkill": "Загрузить навык",
"uploadDescription": "Загрузите файл SKILL.md",
"uploadDescription": "Выберите папку с навыком для импорта",
"importFromGitHub": "Импорт из GitHub",
"importDescription": "Вставьте ссылку на репозиторий",
"importDialogDescription": "Введите URL репозитория GitHub, содержащего файл SKILL.md.",
"importing": "Импорт...",
"import": "Импортировать",
"uploadFailedTitle": "Ошибка загрузки",
"uploadFailedDescription": "Не удалось загрузить файл навыка.",
"uploadFailedDescription": "Не удалось импортировать навык.",
"uploadErrorHelp": "Убедитесь, что ваш файл SKILL.md содержит корректный YAML-заголовок с полем",
"selectFile": "Выберите файл SKILL.md",
"selectFile": "Выберите папку с навыком",
"uploadErrorField": "name",
"uploadErrorFieldSuffix": ":",
"ok": "OK",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,16 @@
"buildWithAccomplish": "使用 Accomplish 构建",
"buildDescription": "通过对话创建技能",
"uploadSkill": "上传技能",
"uploadDescription": "上传 SKILL.md 文件",
"uploadDescription": "选择技能文件夹以导入",
"importFromGitHub": "从 GitHub 导入",
"importDescription": "粘贴仓库链接",
"importDialogDescription": "输入包含 SKILL.md 文件的 GitHub 仓库 URL。",
"importing": "导入中...",
"import": "导入",
"uploadFailedTitle": "上传失败",
"uploadFailedDescription": "无法上传技能文件。",
"uploadFailedDescription": "无法上传技能文件夹。",
"uploadErrorHelp": "确保您的 SKILL.md 文件包含有效的 YAML frontmatter,至少包含一个",
"selectFile": "选择 SKILL.md 文件",
"selectFile": "选择技能文件夹",
"uploadErrorField": "name",
"uploadErrorFieldSuffix": "字段:",
"ok": "确定",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ export function AddSkillDropdown({ onSkillAdded, onClose }: AddSkillDropdownProp
try {
setIsLoading(true);
setUploadError(null);
const filePath = await window.accomplish.pickSkillFile();
if (!filePath) {
const folderPath = await window.accomplish.pickSkillFolder();
if (!folderPath) {
setIsLoading(false);
return; // User cancelled
}
await window.accomplish.addSkillFromFile(filePath);
await window.accomplish.addSkillFromFolder(folderPath);
onSkillAdded?.();
} catch (err) {
console.error('Failed to upload skill:', err);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/client/lib/accomplish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,8 @@ interface AccomplishAPI {
setSkillEnabled(id: string, enabled: boolean): Promise<void>;
getSkillContent(id: string): Promise<string | null>;
getUserSkillsPath(): Promise<string>;
pickSkillFile(): Promise<string | null>;
addSkillFromFile(filePath: string): Promise<Skill>;
pickSkillFolder(): Promise<string | null>;
addSkillFromFolder(folderPath: string): Promise<Skill>;
addSkillFromGitHub(rawUrl: string): Promise<Skill>;
deleteSkill(id: string): Promise<void>;
resyncSkills(): Promise<Skill[]>;
Expand Down
41 changes: 41 additions & 0 deletions packages/agent-core/src/internal/classes/SkillsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ export class SkillsManager {
return this.addFromUrl(sourcePath);
}

const stat = fs.statSync(sourcePath);
if (stat.isDirectory()) {
return this.addFromFolder(sourcePath);
}

return this.addFromFile(sourcePath);
}

Expand Down Expand Up @@ -238,6 +243,42 @@ export class SkillsManager {
return this.persistSkill(frontmatter, destPath, 'custom');
}

private addFromFolder(folderPath: string): Skill {
const skillMdPath = path.join(folderPath, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
throw new Error(`Selected folder does not contain a SKILL.md file: ${folderPath}`);
}

const content = fs.readFileSync(skillMdPath, 'utf-8');
const frontmatter = this.validateSkillFrontmatter(content);
const destSkillMdPath = this.prepareSkillDir(frontmatter);
const destDir = path.dirname(destSkillMdPath);

// Remove existing top-level files so re-imports don't leave stale companions
if (fs.existsSync(destDir)) {
const existing = fs.readdirSync(destDir, { withFileTypes: true });
for (const entry of existing) {
if (!entry.isFile()) {
continue;
}
fs.unlinkSync(path.join(destDir, entry.name));
}
}

// Copy all top-level files from the source folder into the destination
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const srcFile = path.join(folderPath, entry.name);
const destFile = path.join(destDir, entry.name);
fs.copyFileSync(srcFile, destFile);
}

return this.persistSkill(frontmatter, destSkillMdPath, 'custom');
}

private async addFromUrl(rawUrl: string): Promise<Skill> {
const fetchUrl = this.resolveGithubRawUrl(rawUrl);

Expand Down
45 changes: 45 additions & 0 deletions packages/agent-core/tests/unit/skills/skills-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,51 @@ Content.
});
});

describe('addSkill from folder', () => {
it('should import SKILL.md and all companion files from a folder', async () => {
if (!moduleAvailable || !manager) return;

await manager.initialize();

const sourceDir = path.join(testDir, 'my-skill-folder');
fs.mkdirSync(sourceDir, { recursive: true });

const skillContent = `---
name: Folder Skill
description: A skill with companion files
---

Uses template_layouts.md and data.json for reference.
`;
fs.writeFileSync(path.join(sourceDir, 'SKILL.md'), skillContent);
fs.writeFileSync(path.join(sourceDir, 'template_layouts.md'), '# Template');
fs.writeFileSync(path.join(sourceDir, 'data.json'), '{"key": "value"}');

const skill = await manager.addSkill(sourceDir);

expect(skill).not.toBeNull();
expect(skill?.name).toBe('Folder Skill');
expect(skill?.source).toBe('custom');

const destDir = path.join(userSkillsPath, 'Folder-Skill');
expect(fs.existsSync(path.join(destDir, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(destDir, 'template_layouts.md'))).toBe(true);
expect(fs.existsSync(path.join(destDir, 'data.json'))).toBe(true);
});

it('should throw when selected folder has no SKILL.md', async () => {
if (!moduleAvailable || !manager) return;

await manager.initialize();

const emptyDir = path.join(testDir, 'empty-folder');
fs.mkdirSync(emptyDir, { recursive: true });
fs.writeFileSync(path.join(emptyDir, 'notes.txt'), 'no skill here');

await expect(manager.addSkill(emptyDir)).rejects.toThrow('does not contain a SKILL.md');
});
});

describe('deleteSkill', () => {
it('should delete custom skills', async () => {
if (!moduleAvailable || !manager) return;
Expand Down
Loading