Skip to content

Commit db0d45a

Browse files
feat: add skill installer with install/uninstall and settings UI
1 parent cb7c707 commit db0d45a

12 files changed

Lines changed: 1889 additions & 22 deletions

File tree

backend/src/routes/settings.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import {
1818
CreateSkillRequestSchema,
1919
UpdateSkillRequestSchema,
2020
SkillScopeSchema,
21+
InstallSkillFromGithubRequestSchema,
22+
InstallSkillUploadRequestSchema,
23+
InstallSkillUploadManifestEntrySchema,
2124
} from '@opencode-manager/shared'
2225
import { logger } from '../utils/logger'
2326
import { 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

4348
function 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+
110128
function 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

Comments
 (0)