Skip to content

Commit d93325b

Browse files
authored
Merge pull request #83 from dpomian/feat-skills
feat: Support for copilot skills datasource
2 parents 40c43bb + 2642435 commit d93325b

File tree

10 files changed

+2604
-24
lines changed

10 files changed

+2604
-24
lines changed

src/adapters/LocalSkillsAdapter.ts

Lines changed: 590 additions & 0 deletions
Large diffs are not rendered by default.

src/adapters/SkillsAdapter.ts

Lines changed: 706 additions & 0 deletions
Large diffs are not rendered by default.

src/commands/SourceCommands.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ export class SourceCommands {
7777
description: 'Local filesystem directory with OLAF skills organized in bundle-based structure',
7878
value: 'local-olaf' as SourceType
7979
},
80+
{
81+
label: '$(sparkle) Skills Repository',
82+
description: 'GitHub repository with skills in skills/ folder (Anthropic-style SKILL.md files)',
83+
value: 'skills' as SourceType
84+
},
85+
{
86+
label: '$(folder-library) Local Skills',
87+
description: 'Local filesystem directory with skills in skills/ folder (SKILL.md files)',
88+
value: 'local-skills' as SourceType
89+
},
8090
],
8191
{
8292
placeHolder: 'Select source type',
@@ -747,6 +757,32 @@ export class SourceCommands {
747757
return uris && uris.length > 0 ? uris[0].fsPath : undefined;
748758
}
749759

760+
case 'skills':
761+
return await vscode.window.showInputBox({
762+
prompt: 'Enter GitHub repository URL containing skills (e.g., anthropics/skills)',
763+
placeHolder: 'https://github.com/anthropics/skills',
764+
value: 'https://github.com/anthropics/skills',
765+
validateInput: (value) => {
766+
if (!value || !value.match(/github\.com/)) {
767+
return 'Please enter a valid GitHub URL';
768+
}
769+
return undefined;
770+
},
771+
ignoreFocusOut: true
772+
});
773+
774+
case 'local-skills': {
775+
const uris = await vscode.window.showOpenDialog({
776+
canSelectFolders: true,
777+
canSelectFiles: false,
778+
canSelectMany: false,
779+
title: 'Select local skills directory (must contain skills/ folder)',
780+
openLabel: 'Select Directory'
781+
});
782+
783+
return uris && uris.length > 0 ? uris[0].fsPath : undefined;
784+
}
785+
750786
default:
751787
return undefined;
752788
}

src/services/BundleInstaller.ts

Lines changed: 267 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const stat = promisify(fs.stat);
2929
const lstat = promisify(fs.lstat);
3030
const unlink = promisify(fs.unlink);
3131
const rmdir = promisify(fs.rmdir);
32+
const symlink = promisify(fs.symlink);
33+
const readlink = promisify(fs.readlink);
3234

3335
/**
3436
* Bundle Installer
@@ -141,21 +143,55 @@ export class BundleInstaller {
141143
const manifest = await this.validateBundle(extractDir, bundle);
142144
this.logger.debug('Bundle validation passed');
143145

144-
// Step 5: Get installation directory
145-
const installDir = this.getInstallDirectory(bundle.id, options.scope, sourceType, sourceName, bundle.name);
146-
await this.ensureDirectory(installDir);
147-
this.logger.debug(`Installation directory: ${installDir}`);
148-
149-
// Step 6: Copy files to installation directory
150-
// For OLAF bundles, copy all skill folders directly (skip deployment-manifest.yml)
151-
const isOlafBundle = sourceType === 'olaf' || sourceType === 'local-olaf' || bundle.id.startsWith('olaf-');
152-
if (isOlafBundle) {
153-
// Copy all directories (skill folders) from the extracted bundle
154-
// Skip deployment-manifest.yml as it's only needed for validation
155-
this.logger.debug(`[BundleInstaller] OLAF bundle detected, copying skill folders to: ${installDir}`);
156-
await this.copyOlafSkillFolders(extractDir, installDir);
146+
// Check if this is a skills bundle (installs directly to ~/.copilot/skills/)
147+
const isSkillsBundle = sourceType === 'skills' || sourceType === 'local-skills';
148+
149+
let installDir: string;
150+
151+
if (isSkillsBundle) {
152+
// Skills bundles install directly to ~/.copilot/skills/{skill-name}
153+
// Extract skill name from the bundle - look in skills/ directory
154+
const skillName = await this.extractSkillNameFromBundle(extractDir);
155+
installDir = this.copilotSync.getCopilotSkillsDirectory('user');
156+
await this.ensureDirectory(installDir);
157+
installDir = path.join(installDir, skillName);
158+
159+
this.logger.debug(`[BundleInstaller] Skills bundle detected, installing to: ${installDir}`);
160+
161+
// Copy skill files directly to ~/.copilot/skills/{skill-name}
162+
const skillSourceDir = path.join(extractDir, 'skills', skillName);
163+
if (fs.existsSync(skillSourceDir)) {
164+
// Check for existing skill and warn user
165+
if (fs.existsSync(installDir)) {
166+
const existingIsSymlink = await this.isSymlink(installDir);
167+
const shouldOverwrite = await this.promptOverwriteSkill(skillName, installDir, existingIsSymlink);
168+
if (!shouldOverwrite) {
169+
await this.cleanupTempDir(tempDir);
170+
throw new Error(`Installation cancelled: skill '${skillName}' already exists`);
171+
}
172+
await this.removeDirectory(installDir);
173+
}
174+
await this.copyDirectory(skillSourceDir, installDir);
175+
} else {
176+
throw new Error(`Skill directory not found in bundle: skills/${skillName}`);
177+
}
157178
} else {
158-
await this.copyBundleFiles(extractDir, installDir);
179+
// Step 5: Get installation directory (standard bundles)
180+
installDir = this.getInstallDirectory(bundle.id, options.scope, sourceType, sourceName, bundle.name);
181+
await this.ensureDirectory(installDir);
182+
this.logger.debug(`Installation directory: ${installDir}`);
183+
184+
// Step 6: Copy files to installation directory
185+
// For OLAF bundles, copy all skill folders directly (skip deployment-manifest.yml)
186+
const isOlafBundle = sourceType === 'olaf' || sourceType === 'local-olaf' || bundle.id.startsWith('olaf-');
187+
if (isOlafBundle) {
188+
// Copy all directories (skill folders) from the extracted bundle
189+
// Skip deployment-manifest.yml as it's only needed for validation
190+
this.logger.debug(`[BundleInstaller] OLAF bundle detected, copying skill folders to: ${installDir}`);
191+
await this.copyOlafSkillFolders(extractDir, installDir);
192+
} else {
193+
await this.copyBundleFiles(extractDir, installDir);
194+
}
159195
}
160196
this.logger.debug('Files copied to installation directory');
161197

@@ -176,13 +212,17 @@ export class BundleInstaller {
176212
sourceType: undefined, // Will be set by RegistryManager
177213
};
178214

179-
// Step 9: Install MCP servers if defined
180-
await this.installMcpServers(bundle.id, bundle.version, installDir, manifest, options.scope);
181-
this.logger.debug('MCP servers installation completed');
182-
183-
// Step 10: Sync to GitHub Copilot native directory
184-
await this.copilotSync.syncBundle(bundle.id, installDir);
185-
this.logger.debug('Synced to GitHub Copilot');
215+
// Step 9: Install MCP servers if defined (skip for skills bundles)
216+
if (!isSkillsBundle) {
217+
await this.installMcpServers(bundle.id, bundle.version, installDir, manifest, options.scope);
218+
this.logger.debug('MCP servers installation completed');
219+
220+
// Step 10: Sync to GitHub Copilot native directory (skip for skills - already installed there)
221+
await this.copilotSync.syncBundle(bundle.id, installDir);
222+
this.logger.debug('Synced to GitHub Copilot');
223+
} else {
224+
this.logger.debug('Skills bundle - skipping MCP servers and Copilot sync (already installed to ~/.copilot/skills/)');
225+
}
186226

187227
this.logger.info(`Bundle installed successfully from buffer: ${bundle.name}`);
188228
return installed;
@@ -475,6 +515,49 @@ export class BundleInstaller {
475515
await rmdir(dir);
476516
}
477517

518+
/**
519+
* Extract skill name from a skills bundle
520+
* Looks for the first directory under skills/ in the extracted bundle
521+
*/
522+
private async extractSkillNameFromBundle(extractDir: string): Promise<string> {
523+
const skillsDir = path.join(extractDir, 'skills');
524+
525+
if (!fs.existsSync(skillsDir)) {
526+
throw new Error('Skills directory not found in bundle');
527+
}
528+
529+
const entries = await readdir(skillsDir, { withFileTypes: true });
530+
const skillDirs = entries.filter(e => e.isDirectory());
531+
532+
if (skillDirs.length === 0) {
533+
throw new Error('No skill directories found in bundle');
534+
}
535+
536+
// Return the first skill directory name
537+
return skillDirs[0].name;
538+
}
539+
540+
/**
541+
* Copy directory recursively
542+
*/
543+
private async copyDirectory(sourceDir: string, targetDir: string): Promise<void> {
544+
await this.ensureDirectory(targetDir);
545+
546+
const entries = await readdir(sourceDir, { withFileTypes: true });
547+
548+
for (const entry of entries) {
549+
const sourcePath = path.join(sourceDir, entry.name);
550+
const targetPath = path.join(targetDir, entry.name);
551+
552+
if (entry.isDirectory()) {
553+
await this.copyDirectory(sourcePath, targetPath);
554+
} else if (entry.isFile()) {
555+
const content = await readFile(sourcePath);
556+
await writeFile(targetPath, content);
557+
}
558+
}
559+
}
560+
478561
/**
479562
* Clean up temporary directory
480563
*/
@@ -554,4 +637,167 @@ export class BundleInstaller {
554637
// Don't fail the entire bundle uninstallation if MCP uninstallation fails
555638
}
556639
}
640+
641+
/**
642+
* Check if a path is a symbolic link
643+
*/
644+
private async isSymlink(targetPath: string): Promise<boolean> {
645+
try {
646+
const stats = await lstat(targetPath);
647+
return stats.isSymbolicLink();
648+
} catch {
649+
return false;
650+
}
651+
}
652+
653+
/**
654+
* Prompt user to confirm overwriting an existing skill
655+
* @param skillName Name of the skill
656+
* @param existingPath Path to the existing skill
657+
* @param isSymlink Whether the existing skill is a symlink
658+
* @returns True if user confirms overwrite, false otherwise
659+
*/
660+
private async promptOverwriteSkill(skillName: string, existingPath: string, isSymlink: boolean): Promise<boolean> {
661+
const symlinkInfo = isSymlink ? ' (symlink)' : '';
662+
const message = `A skill named '${skillName}' already exists${symlinkInfo}. Do you want to overwrite it?`;
663+
664+
const result = await vscode.window.showWarningMessage(
665+
message,
666+
{ modal: true },
667+
'Overwrite',
668+
'Cancel'
669+
);
670+
671+
return result === 'Overwrite';
672+
}
673+
674+
/**
675+
* Install a local skill using a symlink instead of copying
676+
* This is used for local-skills sources to maintain a live link to the source directory
677+
* @param skillName Name of the skill
678+
* @param sourcePath Path to the source skill directory
679+
* @param options Installation options
680+
* @returns The installed bundle record
681+
*/
682+
async installLocalSkillAsSymlink(
683+
bundle: Bundle,
684+
skillName: string,
685+
sourcePath: string,
686+
options: InstallOptions
687+
): Promise<InstalledBundle> {
688+
this.logger.info(`Installing local skill as symlink: ${skillName}`);
689+
690+
try {
691+
// Get the skills directory
692+
const skillsDir = this.copilotSync.getCopilotSkillsDirectory('user');
693+
await this.ensureDirectory(skillsDir);
694+
695+
const installDir = path.join(skillsDir, skillName);
696+
697+
// Check for existing skill and warn user
698+
if (fs.existsSync(installDir)) {
699+
const existingIsSymlink = await this.isSymlink(installDir);
700+
const shouldOverwrite = await this.promptOverwriteSkill(skillName, installDir, existingIsSymlink);
701+
if (!shouldOverwrite) {
702+
throw new Error(`Installation cancelled: skill '${skillName}' already exists`);
703+
}
704+
705+
// Remove existing (symlink or directory)
706+
if (existingIsSymlink) {
707+
await unlink(installDir);
708+
this.logger.debug(`Removed existing symlink: ${installDir}`);
709+
} else {
710+
await this.removeDirectory(installDir);
711+
this.logger.debug(`Removed existing directory: ${installDir}`);
712+
}
713+
}
714+
715+
// Create symlink to the source directory
716+
try {
717+
await symlink(sourcePath, installDir, 'dir');
718+
this.logger.info(`Created symlink: ${installDir} -> ${sourcePath}`);
719+
} catch (symlinkError) {
720+
// Symlink failed (maybe Windows or permissions), fall back to copy
721+
this.logger.warn(`Symlink creation failed, falling back to copy: ${symlinkError}`);
722+
await this.copyDirectory(sourcePath, installDir);
723+
this.logger.info(`Copied directory: ${sourcePath} -> ${installDir}`);
724+
}
725+
726+
// Create a minimal manifest for the installation record
727+
const manifest: DeploymentManifest = {
728+
common: {
729+
directories: [`skills/${skillName}`],
730+
files: [],
731+
include_patterns: ['**/*'],
732+
exclude_patterns: []
733+
},
734+
bundle_settings: {
735+
include_common_in_environment_bundles: true,
736+
create_common_bundle: true,
737+
compression: 'none' as any,
738+
naming: {
739+
environment_bundle: bundle.id
740+
}
741+
},
742+
metadata: {
743+
manifest_version: '1.0',
744+
description: bundle.description || bundle.name || bundle.id,
745+
author: 'local-skills',
746+
last_updated: new Date().toISOString()
747+
}
748+
};
749+
750+
// Create installation record
751+
const installed: InstalledBundle = {
752+
bundleId: bundle.id,
753+
version: bundle.version,
754+
installedAt: new Date().toISOString(),
755+
scope: options.scope,
756+
profileId: options.profileId,
757+
installPath: installDir,
758+
manifest: manifest,
759+
sourceId: bundle.sourceId,
760+
sourceType: 'local-skills',
761+
};
762+
763+
this.logger.info(`Local skill installed successfully as symlink: ${skillName}`);
764+
return installed;
765+
766+
} catch (error) {
767+
this.logger.error(`Failed to install local skill as symlink: ${skillName}`, error as Error);
768+
throw error;
769+
}
770+
}
771+
772+
/**
773+
* Uninstall a skill that was installed as a symlink
774+
* Only removes the symlink, not the original source directory
775+
* @param installed The installed bundle record
776+
*/
777+
async uninstallSkillSymlink(installed: InstalledBundle): Promise<void> {
778+
this.logger.info(`Uninstalling skill symlink: ${installed.bundleId}`);
779+
780+
try {
781+
if (!installed.installPath || !fs.existsSync(installed.installPath)) {
782+
this.logger.debug(`Skill path does not exist: ${installed.installPath}`);
783+
return;
784+
}
785+
786+
const isLink = await this.isSymlink(installed.installPath);
787+
788+
if (isLink) {
789+
// Remove only the symlink, not the target
790+
await unlink(installed.installPath);
791+
this.logger.info(`Removed symlink: ${installed.installPath}`);
792+
} else {
793+
// It's a regular directory (fallback from failed symlink), remove it
794+
await this.removeDirectory(installed.installPath);
795+
this.logger.info(`Removed directory: ${installed.installPath}`);
796+
}
797+
798+
} catch (error) {
799+
this.logger.error(`Failed to uninstall skill symlink: ${installed.bundleId}`, error as Error);
800+
throw error;
801+
}
802+
}
557803
}

0 commit comments

Comments
 (0)