1+ import { access } from 'node:fs/promises'
12import path from 'node:path'
23import { isLockInSync } from '../config/compareSkillsLock'
34import { readSkillsLock } from '../config/readSkillsLock'
45import { readSkillsManifest } from '../config/readSkillsManifest'
56import { syncSkillsLock } from '../config/syncSkillsLock'
67import type { InstallProgressListener , SkillsLock , SkillsManifest } from '../config/types'
78import { writeSkillsLock } from '../config/writeSkillsLock'
8- import { cleanupPackedNpmPackage , packNpmPackage } from '../npm/packPackage'
9- import { normalizeSpecifier } from '../specifiers/normalizeSpecifier'
9+ import { cleanupPackedNpmPackage , downloadNpmPackageTarball } from '../npm/packPackage'
1010import { sha256 } from '../utils/hash'
1111import { readInstallState , writeInstallState } from './installState'
1212import { linkSkill } from './links'
@@ -19,19 +19,20 @@ export const installStageHooks = {
1919 beforeFetch : async ( _rootDir : string , _manifest : SkillsManifest , _lockfile : SkillsLock ) => { } ,
2020}
2121
22- function resolveNpmPackSource ( specifier : string , packageName : string , version : string ) : string {
23- const normalized = normalizeSpecifier ( specifier )
24- const source = normalized . source . slice ( 'npm:' . length )
25-
26- if ( source . startsWith ( '.' ) || source . startsWith ( '/' ) || source . startsWith ( '~' ) ) {
27- return source
28- }
29-
30- if ( source . includes ( ':' ) ) {
31- return source
22+ async function areManagedSkillsInstalled (
23+ rootDir : string ,
24+ installDir : string ,
25+ skillNames : string [ ] ,
26+ ) : Promise < boolean > {
27+ for ( const skillName of skillNames ) {
28+ try {
29+ await access ( path . join ( rootDir , installDir , skillName , 'SKILL.md' ) )
30+ } catch {
31+ return false
32+ }
3233 }
3334
34- return ` ${ packageName } @ ${ version } `
35+ return true
3536}
3637
3738export async function fetchSkillsFromLock (
@@ -44,87 +45,107 @@ export async function fetchSkillsFromLock(
4445) {
4546 await installStageHooks . beforeFetch ( rootDir , manifest , lockfile )
4647
47- const lockDigest = sha256 ( JSON . stringify ( lockfile ) )
48- const state = await readInstallState ( rootDir )
49- if ( state ?. lockDigest === lockDigest ) {
50- return { status : 'skipped' , reason : 'up-to-date' } as const
51- }
52-
5348 const installDir = manifest . installDir ?? '.agents/skills'
5449 const linkTargets = manifest . linkTargets ?? [ ]
5550
5651 await pruneManagedSkills ( rootDir , installDir , linkTargets , Object . keys ( lockfile . skills ) )
5752
58- for ( const [ skillName , entry ] of Object . entries ( lockfile . skills ) ) {
59- if ( entry . resolution . type === 'link' ) {
60- await materializeLocalSkill (
61- rootDir ,
62- skillName ,
63- path . resolve ( rootDir , entry . resolution . path ) ,
64- '/' ,
65- installDir ,
66- )
67- continue
68- }
53+ const lockDigest = sha256 ( JSON . stringify ( lockfile ) )
54+ const state = await readInstallState ( rootDir , installDir )
55+ if (
56+ state ?. lockDigest === lockDigest &&
57+ ( await areManagedSkillsInstalled ( rootDir , installDir , Object . keys ( lockfile . skills ) ) )
58+ ) {
59+ return { status : 'skipped' , reason : 'up-to-date' } as const
60+ }
6961
70- if ( entry . resolution . type === 'file' ) {
71- await materializePackedSkill (
72- rootDir ,
73- skillName ,
74- path . resolve ( rootDir , entry . resolution . tarball ) ,
75- entry . resolution . path ,
76- installDir ,
77- )
78- options ?. onProgress ?.( { type : 'added' , skillName } )
79- continue
80- }
62+ const downloadedTarballs = new Map < string , Promise < string > > ( )
8163
82- if ( entry . resolution . type === 'git' ) {
83- await materializeGitSkill (
84- rootDir ,
85- skillName ,
86- entry . resolution . url ,
87- entry . resolution . commit ,
88- entry . resolution . path ,
89- installDir ,
90- )
91- options ?. onProgress ?.( { type : 'added' , skillName } )
92- continue
93- }
64+ try {
65+ for ( const [ skillName , entry ] of Object . entries ( lockfile . skills ) ) {
66+ if ( entry . resolution . type === 'link' ) {
67+ await materializeLocalSkill (
68+ rootDir ,
69+ skillName ,
70+ path . resolve ( rootDir , entry . resolution . path ) ,
71+ '/' ,
72+ installDir ,
73+ )
74+ options ?. onProgress ?.( { type : 'added' , skillName } )
75+ continue
76+ }
9477
95- if ( entry . resolution . type === 'npm' ) {
96- const packed = await packNpmPackage (
97- resolveNpmPackSource (
98- entry . specifier ,
99- entry . resolution . packageName ,
100- entry . resolution . version ,
101- ) ,
102- )
78+ if ( entry . resolution . type === 'file' ) {
79+ await materializePackedSkill (
80+ rootDir ,
81+ skillName ,
82+ path . resolve ( rootDir , entry . resolution . tarball ) ,
83+ entry . resolution . path ,
84+ installDir ,
85+ )
86+ options ?. onProgress ?.( { type : 'added' , skillName } )
87+ continue
88+ }
10389
104- try {
90+ if ( entry . resolution . type === 'git' ) {
91+ await materializeGitSkill (
92+ rootDir ,
93+ skillName ,
94+ entry . resolution . url ,
95+ entry . resolution . commit ,
96+ entry . resolution . path ,
97+ installDir ,
98+ )
99+ options ?. onProgress ?.( { type : 'added' , skillName } )
100+ continue
101+ }
102+
103+ if ( entry . resolution . type === 'npm' ) {
104+ const cacheKey = `${ entry . resolution . tarball } \0${ entry . resolution . integrity ?? '' } `
105+ let tarballPathPromise = downloadedTarballs . get ( cacheKey )
106+ if ( ! tarballPathPromise ) {
107+ tarballPathPromise = downloadNpmPackageTarball (
108+ rootDir ,
109+ entry . resolution . tarball ,
110+ entry . resolution . integrity ,
111+ )
112+ downloadedTarballs . set ( cacheKey , tarballPathPromise )
113+ }
114+
115+ const tarballPath = await tarballPathPromise
105116 await materializePackedSkill (
106117 rootDir ,
107118 skillName ,
108- packed . tarballPath ,
119+ tarballPath ,
109120 entry . resolution . path ,
110121 installDir ,
111122 )
112- } finally {
113- await cleanupPackedNpmPackage ( packed . tarballPath )
123+ options ?. onProgress ?. ( { type : 'added' , skillName } )
124+ continue
114125 }
115- continue
126+
127+ throw new Error ( `Unsupported resolution type in 0.1.0 core flow: ${ entry . resolution . type } ` )
116128 }
117129
118- throw new Error ( `Unsupported resolution type in 0.1.0 core flow: ${ entry . resolution . type } ` )
119- }
130+ await writeInstallState ( rootDir , installDir , {
131+ lockDigest,
132+ installDir,
133+ linkTargets,
134+ installerVersion : '0.1.0' ,
135+ installedAt : new Date ( ) . toISOString ( ) ,
136+ } )
137+ } finally {
138+ const settledTarballs = await Promise . allSettled ( downloadedTarballs . values ( ) )
139+ const downloadedPaths = new Set (
140+ settledTarballs
141+ . filter (
142+ ( result ) : result is PromiseFulfilledResult < string > => result . status === 'fulfilled' ,
143+ )
144+ . map ( ( result ) => result . value ) ,
145+ )
120146
121- await writeInstallState ( rootDir , {
122- lockDigest,
123- installDir,
124- linkTargets,
125- installerVersion : '0.1.0' ,
126- installedAt : new Date ( ) . toISOString ( ) ,
127- } )
147+ await Promise . all ( [ ...downloadedPaths ] . map ( ( tarballPath ) => cleanupPackedNpmPackage ( tarballPath ) ) )
148+ }
128149
129150 return { status : 'fetched' , fetched : Object . keys ( lockfile . skills ) } as const
130151}
@@ -164,7 +185,6 @@ export async function installSkills(
164185 let lockfile : SkillsLock
165186
166187 if ( options ?. frozenLockfile ) {
167- // Frozen mode: lock must exist and be in sync
168188 if ( ! currentLock ) {
169189 throw new Error ( 'Lockfile is required in frozen mode but none was found' )
170190 }
@@ -178,7 +198,6 @@ export async function installSkills(
178198 options ?. onProgress ?.( { type : 'resolved' , skillName } )
179199 }
180200 } else {
181- // Normal mode: sync lock with manifest (may trigger network requests)
182201 lockfile = await syncSkillsLock ( rootDir , manifest , currentLock , {
183202 onProgress : options ?. onProgress ,
184203 } )
@@ -187,7 +206,6 @@ export async function installSkills(
187206 await fetchSkillsFromLock ( rootDir , manifest , lockfile , { onProgress : options ?. onProgress } )
188207 await linkSkillsFromLock ( rootDir , manifest , lockfile , { onProgress : options ?. onProgress } )
189208
190- // Write lockfile only after all operations succeed (atomicity)
191209 if ( ! options ?. frozenLockfile ) {
192210 await writeSkillsLock ( rootDir , lockfile )
193211 }
0 commit comments