@@ -18,6 +18,9 @@ import {
1818 CreateSkillRequestSchema ,
1919 UpdateSkillRequestSchema ,
2020 SkillScopeSchema ,
21+ InstallSkillFromGithubRequestSchema ,
22+ InstallSkillUploadRequestSchema ,
23+ InstallSkillUploadManifestEntrySchema ,
2124} from '@opencode-manager/shared'
2225import { logger } from '../utils/logger'
2326import { opencodeServerManager , ConfigReloadError } from '../services/opencode-single-server'
@@ -38,6 +41,8 @@ import {
3841 createSkill ,
3942 updateSkill ,
4043 deleteSkill ,
44+ installSkillFromGithubTree ,
45+ installSkillFromUploadedFiles ,
4146} from '../services/skills'
4247
4348function getOpenCodeInstallMethod ( ) : string {
@@ -107,6 +112,19 @@ function needsOpenCodeRestart(
107112 return [ 'agent' , 'plugin' , 'skills' , 'provider' ] . some ( ( field ) => didConfigFieldChange ( previous , next , field ) )
108113}
109114
115+ function parseOptionalRepoId ( value : string | undefined ) : number | undefined {
116+ if ( value === undefined ) return undefined
117+ const parsed = parseInt ( value , 10 )
118+ if ( isNaN ( parsed ) ) throw new Error ( 'Invalid repoId' )
119+ return parsed
120+ }
121+
122+ function parseBooleanFormValue ( value : unknown ) : boolean | undefined {
123+ if ( value === true || value === 'true' ) return true
124+ if ( value === false || value === 'false' ) return false
125+ return undefined
126+ }
127+
110128function hasConfiguredPlugins ( config : Record < string , unknown > | undefined ) : boolean {
111129 return Array . isArray ( config ?. plugin ) && config . plugin . length > 0
112130}
@@ -1137,6 +1155,150 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
11371155 }
11381156 } )
11391157
1158+ app . post ( '/skills/install' , async ( c ) => {
1159+ try {
1160+ const contentType = c . req . header ( 'content-type' ) || ''
1161+
1162+ if ( contentType . includes ( 'application/json' ) ) {
1163+ const body = await c . req . json ( )
1164+ const validated = InstallSkillFromGithubRequestSchema . parse ( body )
1165+
1166+ if ( validated . scope === 'project' && validated . repoId === undefined ) {
1167+ return c . json ( { error : 'repoId is required for project scope' } , 400 )
1168+ }
1169+
1170+ const result = await installSkillFromGithubTree ( db , validated )
1171+
1172+ try {
1173+ await restartOpenCode ( openCodeSupervisor )
1174+ logger . info ( 'Restarted OpenCode server after skill install' )
1175+ } catch ( restartError ) {
1176+ logger . warn ( 'Failed to restart OpenCode server after skill install:' , restartError )
1177+ }
1178+
1179+ return c . json ( result )
1180+ }
1181+
1182+ if ( contentType . includes ( 'multipart/form-data' ) ) {
1183+ const formData = await c . req . parseBody ( { all : true } )
1184+
1185+ const scope = formData [ 'scope' ]
1186+ const repoIdValue = formData [ 'repoId' ]
1187+ const overwriteValue = formData [ 'overwrite' ]
1188+ const fileManifestRaw = formData [ 'fileManifest' ]
1189+
1190+ if ( typeof fileManifestRaw !== 'string' ) {
1191+ return c . json ( { error : 'fileManifest is required as a JSON string' } , 400 )
1192+ }
1193+
1194+ let manifestEntries : unknown
1195+ try {
1196+ manifestEntries = JSON . parse ( fileManifestRaw )
1197+ } catch {
1198+ return c . json ( { error : 'fileManifest must be valid JSON' } , 400 )
1199+ }
1200+
1201+ const manifest = InstallSkillUploadManifestEntrySchema . array ( ) . parse ( manifestEntries )
1202+
1203+ const repoId = parseOptionalRepoId ( repoIdValue as string | undefined )
1204+ const overwrite = parseBooleanFormValue ( overwriteValue )
1205+
1206+ const uploadRequest = InstallSkillUploadRequestSchema . parse ( {
1207+ sourceType : 'upload' ,
1208+ scope,
1209+ repoId,
1210+ overwrite,
1211+ } )
1212+
1213+ if ( scope === 'project' && repoId === undefined ) {
1214+ return c . json ( { error : 'repoId is required for project scope' } , 400 )
1215+ }
1216+
1217+ if ( manifest . length === 0 ) {
1218+ return c . json ( { error : 'fileManifest must contain at least one entry' } , 400 )
1219+ }
1220+
1221+ const missingFields = manifest . filter ( ( entry ) => ! formData [ entry . fieldName ] )
1222+ if ( missingFields . length > 0 ) {
1223+ return c . json ( {
1224+ error : `Missing upload file(s): ${ missingFields . map ( ( e ) => e . fieldName ) . join ( ', ' ) } ` ,
1225+ } , 400 )
1226+ }
1227+
1228+ const files = await Promise . all (
1229+ manifest . map ( async ( entry ) => {
1230+ const file = formData [ entry . fieldName ]
1231+ if ( ! file || ! ( file instanceof File ) ) {
1232+ throw new Error ( `Field "${ entry . fieldName } " is not a valid file` )
1233+ }
1234+ const content = Buffer . from ( await file . arrayBuffer ( ) )
1235+ return { relativePath : entry . relativePath , content }
1236+ } ) ,
1237+ )
1238+
1239+ const result = await installSkillFromUploadedFiles ( db , uploadRequest , files )
1240+
1241+ try {
1242+ await restartOpenCode ( openCodeSupervisor )
1243+ logger . info ( 'Restarted OpenCode server after skill install' )
1244+ } catch ( restartError ) {
1245+ logger . warn ( 'Failed to restart OpenCode server after skill install:' , restartError )
1246+ }
1247+
1248+ return c . json ( result )
1249+ }
1250+
1251+ return c . json ( { error : 'Unsupported content type. Use application/json or multipart/form-data' } , 400 )
1252+ } catch ( error ) {
1253+ logger . error ( 'Failed to install skill:' , error )
1254+
1255+ if ( error instanceof z . ZodError ) {
1256+ return c . json ( { error : 'Invalid skill install data' , details : error . issues } , 400 )
1257+ }
1258+
1259+ if ( error instanceof Error ) {
1260+ if ( error . message . includes ( 'already exists' ) ) {
1261+ return c . json ( { error : error . message } , 409 )
1262+ }
1263+ if ( error . message . includes ( 'Invalid GitHub tree URL' ) ) {
1264+ return c . json ( { error : error . message } , 400 )
1265+ }
1266+ if ( error . message . includes ( 'Invalid skill name' ) ) {
1267+ return c . json ( { error : error . message } , 400 )
1268+ }
1269+ if ( error . message . includes ( 'Only one skill' ) ) {
1270+ return c . json ( { error : error . message } , 400 )
1271+ }
1272+ if ( error . message . includes ( 'Skill source must contain' ) ) {
1273+ return c . json ( { error : error . message } , 400 )
1274+ }
1275+ if ( error . message . includes ( 'Path must be relative' ) || error . message . includes ( 'Path must not contain' ) || error . message . includes ( 'escapes' ) ) {
1276+ return c . json ( { error : error . message } , 400 )
1277+ }
1278+ if ( error . message . includes ( 'no downloadable files' ) ) {
1279+ return c . json ( { error : error . message } , 400 )
1280+ }
1281+ if ( error . message . includes ( '404' ) ) {
1282+ return c . json ( { error : error . message } , 404 )
1283+ }
1284+ if ( error . message . includes ( 'repoId is required' ) ) {
1285+ return c . json ( { error : error . message } , 400 )
1286+ }
1287+ if ( error . message . includes ( 'Missing upload file' ) ) {
1288+ return c . json ( { error : error . message } , 400 )
1289+ }
1290+ if ( error . message . includes ( 'Invalid repoId' ) ) {
1291+ return c . json ( { error : error . message } , 400 )
1292+ }
1293+ if ( error . message . includes ( 'not a valid file' ) ) {
1294+ return c . json ( { error : error . message } , 400 )
1295+ }
1296+ }
1297+
1298+ return c . json ( { error : 'Failed to install skill' } , 500 )
1299+ }
1300+ } )
1301+
11401302 app . get ( '/skills/:name' , async ( c ) => {
11411303 try {
11421304 const name = c . req . param ( 'name' )
0 commit comments