Skip to content

Commit 9cb2bb1

Browse files
feat: add skill installer with install/uninstall and settings UI (#277)
* feat: add skill installer with install/uninstall and settings UI * feat: refactor skill installer with extracted SkillLibraryList component * feat: simplify SkillsEditor, extract SkillLibraryList
1 parent 3e617c2 commit 9cb2bb1

19 files changed

Lines changed: 2340 additions & 474 deletions

backend/src/routes/settings.ts

Lines changed: 202 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,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

4349
function 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+
95132
function 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+
110160
function 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

Comments
 (0)