@@ -29,6 +29,8 @@ const stat = promisify(fs.stat);
2929const lstat = promisify ( fs . lstat ) ;
3030const unlink = promisify ( fs . unlink ) ;
3131const 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