@@ -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,7 +41,10 @@ import {
3841 createSkill ,
3942 updateSkill ,
4043 deleteSkill ,
44+ installSkillFromGithubTree ,
45+ installSkillFromUploadedFiles ,
4146} from '../services/skills'
47+ import { getRepoById } from '../db/queries'
4248
4349function getOpenCodeInstallMethod ( ) : string {
4450 const homePath = process . env . HOME || ''
@@ -92,6 +98,37 @@ async function restartOpenCode(openCodeSupervisor?: OpenCodeSupervisor): Promise
9298 await opencodeServerManager . restart ( )
9399}
94100
101+ async function dispatchSkillReload (
102+ db : Database ,
103+ openCodeClient : OpenCodeClient ,
104+ openCodeSupervisor : OpenCodeSupervisor | undefined ,
105+ repoId : number ,
106+ ) : Promise < void > {
107+ const repo = getRepoById ( db , repoId )
108+ if ( ! repo ) {
109+ logger . warn ( `Cannot dispatch skill reload: repo ${ repoId } not found` )
110+ return
111+ }
112+
113+ try {
114+ await restartOpenCode ( openCodeSupervisor )
115+ logger . info ( 'Restarted OpenCode server after skill install' )
116+ } catch ( restartError ) {
117+ logger . warn ( 'Failed to restart OpenCode server after skill install:' , restartError )
118+ }
119+
120+ try {
121+ await openCodeClient . forward ( {
122+ method : 'GET' ,
123+ path : '/skill' ,
124+ directory : repo . fullPath ,
125+ } )
126+ logger . info ( `Dispatched skill reload for project ${ repo . fullPath } ` )
127+ } catch ( dispatchError ) {
128+ logger . warn ( 'Failed to dispatch skill reload:' , dispatchError )
129+ }
130+ }
131+
95132function didConfigFieldChange (
96133 previous : Record < string , unknown > | undefined ,
97134 next : Record < string , unknown > | undefined ,
@@ -107,6 +144,19 @@ function needsOpenCodeRestart(
107144 return [ 'agent' , 'plugin' , 'skills' , 'provider' ] . some ( ( field ) => didConfigFieldChange ( previous , next , field ) )
108145}
109146
147+ function parseOptionalRepoId ( value : string | undefined ) : number | undefined {
148+ if ( value === undefined ) return undefined
149+ const parsed = parseInt ( value , 10 )
150+ if ( isNaN ( parsed ) ) throw new Error ( 'Invalid repoId' )
151+ return parsed
152+ }
153+
154+ function parseBooleanFormValue ( value : unknown ) : boolean | undefined {
155+ if ( value === true || value === 'true' ) return true
156+ if ( value === false || value === 'false' ) return false
157+ return undefined
158+ }
159+
110160function hasConfiguredPlugins ( config : Record < string , unknown > | undefined ) : boolean {
111161 return Array . isArray ( config ?. plugin ) && config . plugin . length > 0
112162}
@@ -1137,6 +1187,158 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
11371187 }
11381188 } )
11391189
1190+ app . post ( '/skills/install' , async ( c ) => {
1191+ try {
1192+ const contentType = c . req . header ( 'content-type' ) || ''
1193+
1194+ if ( contentType . includes ( 'application/json' ) ) {
1195+ const body = await c . req . json ( )
1196+ const validated = InstallSkillFromGithubRequestSchema . parse ( body )
1197+
1198+ if ( validated . scope === 'project' && validated . repoId === undefined ) {
1199+ return c . json ( { error : 'repoId is required for project scope' } , 400 )
1200+ }
1201+
1202+ const result = await installSkillFromGithubTree ( db , validated )
1203+
1204+ if ( validated . scope === 'project' && validated . repoId ) {
1205+ await dispatchSkillReload ( db , openCodeClient , openCodeSupervisor , validated . repoId )
1206+ } else {
1207+ try {
1208+ await restartOpenCode ( openCodeSupervisor )
1209+ logger . info ( 'Restarted OpenCode server after skill install' )
1210+ } catch ( restartError ) {
1211+ logger . warn ( 'Failed to restart OpenCode server after skill install:' , restartError )
1212+ }
1213+ }
1214+
1215+ return c . json ( result )
1216+ }
1217+
1218+ if ( contentType . includes ( 'multipart/form-data' ) ) {
1219+ const formData = await c . req . parseBody ( { all : true } )
1220+
1221+ const scope = formData [ 'scope' ]
1222+ const repoIdValue = formData [ 'repoId' ]
1223+ const overwriteValue = formData [ 'overwrite' ]
1224+ const fileManifestRaw = formData [ 'fileManifest' ]
1225+
1226+ if ( typeof fileManifestRaw !== 'string' ) {
1227+ return c . json ( { error : 'fileManifest is required as a JSON string' } , 400 )
1228+ }
1229+
1230+ let manifestEntries : unknown
1231+ try {
1232+ manifestEntries = JSON . parse ( fileManifestRaw )
1233+ } catch {
1234+ return c . json ( { error : 'fileManifest must be valid JSON' } , 400 )
1235+ }
1236+
1237+ const manifest = InstallSkillUploadManifestEntrySchema . array ( ) . parse ( manifestEntries )
1238+
1239+ const repoId = parseOptionalRepoId ( repoIdValue as string | undefined )
1240+ const overwrite = parseBooleanFormValue ( overwriteValue )
1241+
1242+ const uploadRequest = InstallSkillUploadRequestSchema . parse ( {
1243+ sourceType : 'upload' ,
1244+ scope,
1245+ repoId,
1246+ overwrite,
1247+ } )
1248+
1249+ if ( scope === 'project' && repoId === undefined ) {
1250+ return c . json ( { error : 'repoId is required for project scope' } , 400 )
1251+ }
1252+
1253+ if ( manifest . length === 0 ) {
1254+ return c . json ( { error : 'fileManifest must contain at least one entry' } , 400 )
1255+ }
1256+
1257+ const missingFields = manifest . filter ( ( entry ) => ! formData [ entry . fieldName ] )
1258+ if ( missingFields . length > 0 ) {
1259+ return c . json ( {
1260+ error : `Missing upload file(s): ${ missingFields . map ( ( e ) => e . fieldName ) . join ( ', ' ) } ` ,
1261+ } , 400 )
1262+ }
1263+
1264+ const files = await Promise . all (
1265+ manifest . map ( async ( entry ) => {
1266+ const file = formData [ entry . fieldName ]
1267+ if ( ! file || ! ( file instanceof File ) ) {
1268+ throw new Error ( `Field "${ entry . fieldName } " is not a valid file` )
1269+ }
1270+ const content = Buffer . from ( await file . arrayBuffer ( ) )
1271+ return { relativePath : entry . relativePath , content }
1272+ } ) ,
1273+ )
1274+
1275+ const result = await installSkillFromUploadedFiles ( db , uploadRequest , files )
1276+
1277+ if ( uploadRequest . scope === 'project' && uploadRequest . repoId ) {
1278+ await dispatchSkillReload ( db , openCodeClient , openCodeSupervisor , uploadRequest . repoId )
1279+ } else {
1280+ try {
1281+ await restartOpenCode ( openCodeSupervisor )
1282+ logger . info ( 'Restarted OpenCode server after skill install' )
1283+ } catch ( restartError ) {
1284+ logger . warn ( 'Failed to restart OpenCode server after skill install:' , restartError )
1285+ }
1286+ }
1287+
1288+ return c . json ( result )
1289+ }
1290+
1291+ return c . json ( { error : 'Unsupported content type. Use application/json or multipart/form-data' } , 400 )
1292+ } catch ( error ) {
1293+ logger . error ( 'Failed to install skill:' , error )
1294+
1295+ if ( error instanceof z . ZodError ) {
1296+ return c . json ( { error : 'Invalid skill install data' , details : error . issues } , 400 )
1297+ }
1298+
1299+ if ( error instanceof Error ) {
1300+ if ( error . message . includes ( 'already exists' ) ) {
1301+ return c . json ( { error : error . message } , 409 )
1302+ }
1303+ if ( error . message . includes ( 'Invalid GitHub tree URL' ) ) {
1304+ return c . json ( { error : error . message } , 400 )
1305+ }
1306+ if ( error . message . includes ( 'Invalid skill name' ) ) {
1307+ return c . json ( { error : error . message } , 400 )
1308+ }
1309+ if ( error . message . includes ( 'Only one skill' ) ) {
1310+ return c . json ( { error : error . message } , 400 )
1311+ }
1312+ if ( error . message . includes ( 'Skill source must contain' ) ) {
1313+ return c . json ( { error : error . message } , 400 )
1314+ }
1315+ if ( error . message . includes ( 'Path must be relative' ) || error . message . includes ( 'Path must not contain' ) || error . message . includes ( 'escapes' ) ) {
1316+ return c . json ( { error : error . message } , 400 )
1317+ }
1318+ if ( error . message . includes ( 'no downloadable files' ) ) {
1319+ return c . json ( { error : error . message } , 400 )
1320+ }
1321+ if ( error . message . includes ( '404' ) ) {
1322+ return c . json ( { error : error . message } , 404 )
1323+ }
1324+ if ( error . message . includes ( 'repoId is required' ) ) {
1325+ return c . json ( { error : error . message } , 400 )
1326+ }
1327+ if ( error . message . includes ( 'Missing upload file' ) ) {
1328+ return c . json ( { error : error . message } , 400 )
1329+ }
1330+ if ( error . message . includes ( 'Invalid repoId' ) ) {
1331+ return c . json ( { error : error . message } , 400 )
1332+ }
1333+ if ( error . message . includes ( 'not a valid file' ) ) {
1334+ return c . json ( { error : error . message } , 400 )
1335+ }
1336+ }
1337+
1338+ return c . json ( { error : 'Failed to install skill' } , 500 )
1339+ }
1340+ } )
1341+
11401342 app . get ( '/skills/:name' , async ( c ) => {
11411343 try {
11421344 const name = c . req . param ( 'name' )
0 commit comments