hlassiege/panopliepublic Fork
⑂ 19957db
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(server): routes for settings, catalog, git, scope sync, home cleanup and marketplaces; stable port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T12:17:52+02:00 Browse files
19957db parent: 8804311
modified packages/cli/src/main.ts +21 -10
@@ -8,10 +8,13 @@ import { startServer } from '@skilly/server'
88 import { openWindow } from './window.ts'
99
1010 const IDLE_EXIT_MS = 20_000
11+/** Tried first so the origin (and thus localStorage, e.g. the theme) stays the same across launches. */
12+const PREFERRED_PORT = 47821
1113
1214 interface Args {
1315 open: boolean
14- port: number
16+ /** Undefined: try the preferred port, then fall back to a free one. */
17+ port: number | undefined
1518 help: boolean
1619 }
1720
@@ -23,7 +26,7 @@ interface InstanceInfo {
2326 }
2427
2528 function parseArgs(argv: string[]): Args {
26- const args: Args = { open: true, port: 0, help: false }
29+ const args: Args = { open: true, port: undefined, help: false }
2730 for (let i = 0; i < argv.length; i++) {
2831 const a = argv[i]
2932 if (a === '--no-open') args.open = false
@@ -70,6 +73,20 @@ async function launch(url: string, profileDir: string): Promise<void> {
7073 }
7174 }
7275
76+type ServerOptions = Parameters<typeof startServer>[0]
77+
78+/** An explicit port is used as is; otherwise the preferred port is tried first, then any free port. */
79+async function startOnPreferredPort(options: Omit<ServerOptions, 'port'>, explicit: number | undefined) {
80+ if (explicit !== undefined) return startServer({ ...options, port: explicit })
81+ try {
82+ return await startServer({ ...options, port: PREFERRED_PORT })
83+ } catch (e) {
84+ const code = (e as NodeJS.ErrnoException).code
85+ if (code !== 'EADDRINUSE' && code !== 'EACCES') throw e
86+ return startServer({ ...options, port: 0 })
87+ }
88+}
89+
7390 async function main(): Promise<void> {
7491 const args = parseArgs(process.argv.slice(2))
7592 if (args.help) {
@@ -109,14 +126,8 @@ async function main(): Promise<void> {
109126 process.exit(0)
110127 }
111128
112- const server = await startServer({
113- token,
114- port: args.port,
115- configDir,
116- staticDir,
117- exitOnIdleMs: IDLE_EXIT_MS,
118- onIdle: () => void cleanup(),
119- })
129+ const serverOptions = { token, configDir, staticDir, exitOnIdleMs: IDLE_EXIT_MS, onIdle: () => void cleanup() }
130+ const server = await startOnPreferredPort(serverOptions, args.port)
120131 const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() }
121132 await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8')
122133 process.on('SIGINT', () => void cleanup())
@@ -8,10 +8,13 @@ import { startServer } from '@skilly/server'
8 import { openWindow } from './window.ts'8 import { openWindow } from './window.ts'
9 9
10 const IDLE_EXIT_MS = 20_00010 const IDLE_EXIT_MS = 20_000
11+/** Tried first so the origin (and thus localStorage, e.g. the theme) stays the same across launches. */
12+const PREFERRED_PORT = 47821
11 13
12 interface Args {14 interface Args {
13 open: boolean15 open: boolean
14- port: number16+ /** Undefined: try the preferred port, then fall back to a free one. */
17+ port: number | undefined
15 help: boolean18 help: boolean
16 }19 }
17 20
@@ -23,7 +26,7 @@ interface InstanceInfo {
23 }26 }
24 27
25 function parseArgs(argv: string[]): Args {28 function parseArgs(argv: string[]): Args {
26- const args: Args = { open: true, port: 0, help: false }29+ const args: Args = { open: true, port: undefined, help: false }
27 for (let i = 0; i < argv.length; i++) {30 for (let i = 0; i < argv.length; i++) {
28 const a = argv[i]31 const a = argv[i]
29 if (a === '--no-open') args.open = false32 if (a === '--no-open') args.open = false
@@ -70,6 +73,20 @@ async function launch(url: string, profileDir: string): Promise<void> {
70 }73 }
71 }74 }
72 75
76+type ServerOptions = Parameters<typeof startServer>[0]
77+
78+/** An explicit port is used as is; otherwise the preferred port is tried first, then any free port. */
79+async function startOnPreferredPort(options: Omit<ServerOptions, 'port'>, explicit: number | undefined) {
80+ if (explicit !== undefined) return startServer({ ...options, port: explicit })
81+ try {
82+ return await startServer({ ...options, port: PREFERRED_PORT })
83+ } catch (e) {
84+ const code = (e as NodeJS.ErrnoException).code
85+ if (code !== 'EADDRINUSE' && code !== 'EACCES') throw e
86+ return startServer({ ...options, port: 0 })
87+ }
88+}
89+
73 async function main(): Promise<void> {90 async function main(): Promise<void> {
74 const args = parseArgs(process.argv.slice(2))91 const args = parseArgs(process.argv.slice(2))
75 if (args.help) {92 if (args.help) {
@@ -109,14 +126,8 @@ async function main(): Promise<void> {
109 process.exit(0)126 process.exit(0)
110 }127 }
111 128
112- const server = await startServer({129+ const serverOptions = { token, configDir, staticDir, exitOnIdleMs: IDLE_EXIT_MS, onIdle: () => void cleanup() }
113- token,130+ const server = await startOnPreferredPort(serverOptions, args.port)
114- port: args.port,
115- configDir,
116- staticDir,
117- exitOnIdleMs: IDLE_EXIT_MS,
118- onIdle: () => void cleanup(),
119- })
120 const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() }131 const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() }
121 await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8')132 await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8')
122 process.on('SIGINT', () => void cleanup())133 process.on('SIGINT', () => void cleanup())
added packages/server/src/catalog-routes.ts +291 -0
new file mode 100644
@@ -0,0 +1,291 @@
1+import {
2+ CATALOG_ITEM_TYPES,
3+ type CatalogItemType,
4+ type CatalogRef,
5+ type CatalogResponse,
6+ type GitStatus,
7+ type ImportBody,
8+ type ImportResult,
9+ type Preset,
10+ type SaveItemInput,
11+ type ScopeConfig,
12+ type Settings,
13+} from '@skilly/core'
14+import type { Hono } from 'hono'
15+import path from 'node:path'
16+import { HttpError, isDirectory, isStringArray, readJsonBody, type AppContext } from './context.ts'
17+
18+const EMPTY_GIT: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] }
19+
20+function parseType(raw: string): CatalogItemType {
21+ if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(raw)) throw new HttpError(400, `Unknown item type "${raw}"`)
22+ return raw as CatalogItemType
23+}
24+
25+function requireUrlLike(v: unknown, label: string): string {
26+ if (typeof v !== 'string' || !v.trim()) throw new HttpError(400, `"${label}" is required`)
27+ if (v.trim().startsWith('-')) throw new HttpError(400, `Invalid ${label}`)
28+ return v.trim()
29+}
30+
31+function normalizePreset(body: Record<string, unknown>, name: string): Preset {
32+ if (body.includes !== undefined && !isStringArray(body.includes)) throw new HttpError(400, '"includes" must be a string array')
33+ if (body.description !== undefined && typeof body.description !== 'string') throw new HttpError(400, '"description" must be a string')
34+ const items: Preset['items'] = {}
35+ const rawItems = body.items ?? {}
36+ if (typeof rawItems !== 'object' || rawItems === null || Array.isArray(rawItems)) throw new HttpError(400, '"items" must be an object')
37+ for (const [type, list] of Object.entries(rawItems)) {
38+ parseType(type)
39+ if (!isStringArray(list)) throw new HttpError(400, `"items.${type}" must be a string array`)
40+ items[type as CatalogItemType] = list
41+ }
42+ const preset: Preset = { name, includes: (body.includes as string[] | undefined) ?? [], items }
43+ if (body.description) preset.description = body.description as string
44+ return preset
45+}
46+
47+/** Scopes (project ids or `home`) whose config references each catalog item / preset. */
48+async function computeUsage(ctx: AppContext): Promise<Record<string, string[]>> {
49+ const usage: Record<string, string[]> = {}
50+ const add = (key: string, scope: string): void => {
51+ const list = (usage[key] ??= [])
52+ if (!list.includes(scope)) list.push(scope)
53+ }
54+ const snapshot = await (await ctx.catalog()).snapshot()
55+ const presets = new Map(snapshot.presets.map((p) => [p.name, p]))
56+ const scopeIds = ['home', ...(await ctx.store.list()).map((p) => p.id)]
57+ for (const id of scopeIds) {
58+ let config: ScopeConfig | undefined
59+ try {
60+ const scope = await ctx.scope(id)
61+ config = await ctx.syncEngine.readConfig(scope.target)
62+ } catch {
63+ continue
64+ }
65+ if (!config) continue
66+ for (const [type, names] of Object.entries(config.add ?? {})) for (const n of names ?? []) add(`${type}/${n}`, id)
67+ const seen = new Set<string>()
68+ const visit = (name: string): void => {
69+ if (seen.has(name)) return
70+ seen.add(name)
71+ add(`preset/${name}`, id)
72+ const preset = presets.get(name)
73+ if (!preset) return
74+ for (const [type, names] of Object.entries(preset.items)) for (const n of names ?? []) add(`${type}/${n}`, id)
75+ for (const inc of preset.includes) visit(inc)
76+ }
77+ for (const name of config.presets ?? []) visit(name)
78+ }
79+ return usage
80+}
81+
82+function usedMessage(key: string, scopes: string[]): string {
83+ return `${key} is used by ${scopes.length} scope${scopes.length === 1 ? '' : 's'} (${scopes.join(', ')}); use ?force=1 to delete anyway`
84+}
85+
86+export function registerCatalogRoutes(app: Hono, ctx: AppContext): void {
87+ // ---- settings ----
88+ app.get('/api/settings', async (c) => c.json(await ctx.settings()))
89+
90+ app.put('/api/settings', async (c) => {
91+ const body = await readJsonBody(c)
92+ const current = await ctx.settings()
93+ const next: Settings = { ...current }
94+ if (body.catalogPath !== undefined) {
95+ if (typeof body.catalogPath !== 'string' || !path.isAbsolute(body.catalogPath) || body.catalogPath.includes('\0')) {
96+ throw new HttpError(400, '"catalogPath" must be an absolute path')
97+ }
98+ next.catalogPath = path.resolve(body.catalogPath)
99+ }
100+ if (body.autoSync !== undefined) {
101+ if (typeof body.autoSync !== 'boolean') throw new HttpError(400, '"autoSync" must be a boolean')
102+ next.autoSync = body.autoSync
103+ }
104+ await ctx.store.setSettings(next)
105+ return c.json(await ctx.settings())
106+ })
107+
108+ // ---- catalog ----
109+ async function catalogResponse(): Promise<CatalogResponse> {
110+ const catalog = await ctx.catalog()
111+ const git = await ctx.git()
112+ const exists = await catalog.exists()
113+ const [snapshot, gitStatus, usage] = await Promise.all([
114+ catalog.snapshot(),
115+ exists ? git.status() : Promise.resolve(EMPTY_GIT),
116+ computeUsage(ctx),
117+ ])
118+ return { snapshot, git: gitStatus, usage }
119+ }
120+
121+ app.get('/api/catalog', async (c) => c.json(await catalogResponse()))
122+
123+ app.post('/api/catalog/init', async (c) => {
124+ const catalog = await ctx.catalog()
125+ await catalog.init()
126+ const git = await ctx.git()
127+ if (!(await git.status()).isRepo) await git.init()
128+ return c.json(await catalogResponse())
129+ })
130+
131+ app.get('/api/catalog/items/:type/:name', async (c) => {
132+ const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
133+ const detail = await (await ctx.catalog()).getItem(ref)
134+ if (!detail) throw new HttpError(404, `${ref.type} "${ref.name}" not found in the catalog`)
135+ return c.json(detail)
136+ })
137+
138+ app.put('/api/catalog/items/:type/:name', async (c) => {
139+ const type = parseType(c.req.param('type'))
140+ const name = c.req.param('name')
141+ const body = await readJsonBody(c)
142+ if (typeof body.content !== 'string') throw new HttpError(400, '"content" must be a string')
143+ if (body.files !== undefined) {
144+ const f = body.files
145+ if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.values(f).every((v) => typeof v === 'string')) {
146+ throw new HttpError(400, '"files" must map relative paths to strings')
147+ }
148+ }
149+ if (body.deleteFiles !== undefined && !isStringArray(body.deleteFiles)) throw new HttpError(400, '"deleteFiles" must be a string array')
150+ if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array')
151+ if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string')
152+ const input: SaveItemInput = {
153+ type,
154+ name,
155+ content: body.content,
156+ files: body.files as Record<string, string> | undefined,
157+ deleteFiles: body.deleteFiles as string[] | undefined,
158+ tags: body.tags as string[] | undefined,
159+ previousName: body.previousName as string | undefined,
160+ }
161+ const catalog = await ctx.catalog()
162+ if (!(await catalog.exists())) await catalog.init()
163+ const item = await catalog.saveItem(input)
164+ ctx.catalogChanged()
165+ return c.json(item)
166+ })
167+
168+ app.delete('/api/catalog/items/:type/:name', async (c) => {
169+ const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
170+ if (c.req.query('force') !== '1') {
171+ const key = `${ref.type}/${ref.name}`
172+ const scopes = (await computeUsage(ctx))[key]
173+ if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes))
174+ }
175+ await (await ctx.catalog()).deleteItem(ref)
176+ ctx.catalogChanged()
177+ return c.body(null, 204)
178+ })
179+
180+ app.put('/api/catalog/items/:type/:name/tags', async (c) => {
181+ const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
182+ const body = await readJsonBody(c)
183+ if (!isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array')
184+ await (await ctx.catalog()).setTags(ref, body.tags)
185+ ctx.catalogChanged()
186+ return c.body(null, 204)
187+ })
188+
189+ app.post('/api/catalog/import', async (c) => {
190+ const body = (await readJsonBody(c)) as Partial<ImportBody>
191+ if (typeof body.scope !== 'string' || !body.scope) throw new HttpError(400, '"scope" is required')
192+ if (!isStringArray(body.itemIds)) throw new HttpError(400, '"itemIds" must be a string array')
193+ if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array')
194+ if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean')
195+ const scope = await ctx.scope(body.scope)
196+ const inventory = await scope.scan()
197+ const byId = new Map(inventory.items.map((i) => [i.id, i]))
198+ const catalog = await ctx.catalog()
199+ if (!(await catalog.exists())) await catalog.init()
200+ const result: ImportResult = { imported: [], skipped: [] }
201+ for (const id of body.itemIds) {
202+ const item = byId.get(id)
203+ if (!item) {
204+ result.skipped.push({ id, reason: 'Item not found in the scope inventory' })
205+ continue
206+ }
207+ if (item.type === 'instructions') {
208+ result.skipped.push({ id, reason: 'Instructions files (CLAUDE.md / AGENTS.md) cannot be imported' })
209+ continue
210+ }
211+ try {
212+ result.imported.push(await catalog.importFromInventory(item, { tags: body.tags, overwrite: body.overwrite }))
213+ } catch (e) {
214+ result.skipped.push({ id, reason: e instanceof Error ? e.message : String(e) })
215+ }
216+ }
217+ if (result.imported.length > 0) ctx.catalogChanged()
218+ return c.json(result)
219+ })
220+
221+ app.put('/api/catalog/presets/:name', async (c) => {
222+ const name = c.req.param('name')
223+ const body = await readJsonBody(c)
224+ if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string')
225+ const catalog = await ctx.catalog()
226+ if (!(await catalog.exists())) await catalog.init()
227+ const saved = await catalog.savePreset(normalizePreset(body, name), body.previousName as string | undefined)
228+ ctx.catalogChanged()
229+ return c.json(saved)
230+ })
231+
232+ app.delete('/api/catalog/presets/:name', async (c) => {
233+ const name = c.req.param('name')
234+ if (c.req.query('force') !== '1') {
235+ const key = `preset/${name}`
236+ const scopes = (await computeUsage(ctx))[key]
237+ if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes))
238+ }
239+ await (await ctx.catalog()).deletePreset(name)
240+ ctx.catalogChanged()
241+ return c.body(null, 204)
242+ })
243+
244+ // ---- catalog git ----
245+ app.get('/api/catalog/git', async (c) => {
246+ const catalog = await ctx.catalog()
247+ if (!(await catalog.exists())) return c.json(EMPTY_GIT)
248+ return c.json(await (await ctx.git()).status())
249+ })
250+
251+ async function gitOp<T>(run: (git: Awaited<ReturnType<AppContext['git']>>) => Promise<T>): Promise<GitStatus> {
252+ const catalog = await ctx.catalog()
253+ if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first')
254+ const git = await ctx.git()
255+ await run(git)
256+ return git.status()
257+ }
258+
259+ app.post('/api/catalog/git/remote', async (c) => {
260+ const url = requireUrlLike((await readJsonBody(c)).url, 'url')
261+ return c.json(await gitOp((g) => g.setRemote(url)))
262+ })
263+
264+ app.post('/api/catalog/git/commit', async (c) => {
265+ const message = (await readJsonBody(c)).message
266+ if (typeof message !== 'string' || !message.trim()) throw new HttpError(400, '"message" is required')
267+ return c.json(await gitOp((g) => g.commitAll(message.trim())))
268+ })
269+
270+ // pull / push / sync return their own status (conflicts are reported there).
271+ const returning = (run: (g: Awaited<ReturnType<AppContext['git']>>) => Promise<GitStatus>) => async (c: import('hono').Context) => {
272+ const catalog = await ctx.catalog()
273+ if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first')
274+ const status = await run(await ctx.git())
275+ return c.json(status)
276+ }
277+ app.post('/api/catalog/git/pull', returning((g) => g.pull()))
278+ app.post('/api/catalog/git/push', returning((g) => g.push()))
279+ app.post('/api/catalog/git/sync', returning((g) => g.sync()))
280+
281+ app.post('/api/catalog/git/resolve', async (c) => {
282+ const body = await readJsonBody(c)
283+ if (typeof body.path !== 'string' || !body.path) throw new HttpError(400, '"path" is required')
284+ if (body.side !== 'ours' && body.side !== 'theirs') throw new HttpError(400, '"side" must be "ours" or "theirs"')
285+ const file = body.path
286+ const side = body.side
287+ const catalog = await ctx.catalog()
288+ if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first')
289+ return c.json(await (await ctx.git()).resolve(file, side))
290+ })
291+}
new file mode 100644
@@ -0,0 +1,291 @@
1+import {
2+ CATALOG_ITEM_TYPES,
3+ type CatalogItemType,
4+ type CatalogRef,
5+ type CatalogResponse,
6+ type GitStatus,
7+ type ImportBody,
8+ type ImportResult,
9+ type Preset,
10+ type SaveItemInput,
11+ type ScopeConfig,
12+ type Settings,
13+} from '@skilly/core'
14+import type { Hono } from 'hono'
15+import path from 'node:path'
16+import { HttpError, isDirectory, isStringArray, readJsonBody, type AppContext } from './context.ts'
17+
18+const EMPTY_GIT: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] }
19+
20+function parseType(raw: string): CatalogItemType {
21+ if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(raw)) throw new HttpError(400, `Unknown item type "${raw}"`)
22+ return raw as CatalogItemType
23+}
24+
25+function requireUrlLike(v: unknown, label: string): string {
26+ if (typeof v !== 'string' || !v.trim()) throw new HttpError(400, `"${label}" is required`)
27+ if (v.trim().startsWith('-')) throw new HttpError(400, `Invalid ${label}`)
28+ return v.trim()
29+}
30+
31+function normalizePreset(body: Record<string, unknown>, name: string): Preset {
32+ if (body.includes !== undefined && !isStringArray(body.includes)) throw new HttpError(400, '"includes" must be a string array')
33+ if (body.description !== undefined && typeof body.description !== 'string') throw new HttpError(400, '"description" must be a string')
34+ const items: Preset['items'] = {}
35+ const rawItems = body.items ?? {}
36+ if (typeof rawItems !== 'object' || rawItems === null || Array.isArray(rawItems)) throw new HttpError(400, '"items" must be an object')
37+ for (const [type, list] of Object.entries(rawItems)) {
38+ parseType(type)
39+ if (!isStringArray(list)) throw new HttpError(400, `"items.${type}" must be a string array`)
40+ items[type as CatalogItemType] = list
41+ }
42+ const preset: Preset = { name, includes: (body.includes as string[] | undefined) ?? [], items }
43+ if (body.description) preset.description = body.description as string
44+ return preset
45+}
46+
47+/** Scopes (project ids or `home`) whose config references each catalog item / preset. */
48+async function computeUsage(ctx: AppContext): Promise<Record<string, string[]>> {
49+ const usage: Record<string, string[]> = {}
50+ const add = (key: string, scope: string): void => {
51+ const list = (usage[key] ??= [])
52+ if (!list.includes(scope)) list.push(scope)
53+ }
54+ const snapshot = await (await ctx.catalog()).snapshot()
55+ const presets = new Map(snapshot.presets.map((p) => [p.name, p]))
56+ const scopeIds = ['home', ...(await ctx.store.list()).map((p) => p.id)]
57+ for (const id of scopeIds) {
58+ let config: ScopeConfig | undefined
59+ try {
60+ const scope = await ctx.scope(id)
61+ config = await ctx.syncEngine.readConfig(scope.target)
62+ } catch {
63+ continue
64+ }
65+ if (!config) continue
66+ for (const [type, names] of Object.entries(config.add ?? {})) for (const n of names ?? []) add(`${type}/${n}`, id)
67+ const seen = new Set<string>()
68+ const visit = (name: string): void => {
69+ if (seen.has(name)) return
70+ seen.add(name)
71+ add(`preset/${name}`, id)
72+ const preset = presets.get(name)
73+ if (!preset) return
74+ for (const [type, names] of Object.entries(preset.items)) for (const n of names ?? []) add(`${type}/${n}`, id)
75+ for (const inc of preset.includes) visit(inc)
76+ }
77+ for (const name of config.presets ?? []) visit(name)
78+ }
79+ return usage
80+}
81+
82+function usedMessage(key: string, scopes: string[]): string {
83+ return `${key} is used by ${scopes.length} scope${scopes.length === 1 ? '' : 's'} (${scopes.join(', ')}); use ?force=1 to delete anyway`
84+}
85+
86+export function registerCatalogRoutes(app: Hono, ctx: AppContext): void {
87+ // ---- settings ----
88+ app.get('/api/settings', async (c) => c.json(await ctx.settings()))
89+
90+ app.put('/api/settings', async (c) => {
91+ const body = await readJsonBody(c)
92+ const current = await ctx.settings()
93+ const next: Settings = { ...current }
94+ if (body.catalogPath !== undefined) {
95+ if (typeof body.catalogPath !== 'string' || !path.isAbsolute(body.catalogPath) || body.catalogPath.includes('\0')) {
96+ throw new HttpError(400, '"catalogPath" must be an absolute path')
97+ }
98+ next.catalogPath = path.resolve(body.catalogPath)
99+ }
100+ if (body.autoSync !== undefined) {
101+ if (typeof body.autoSync !== 'boolean') throw new HttpError(400, '"autoSync" must be a boolean')
102+ next.autoSync = body.autoSync
103+ }
104+ await ctx.store.setSettings(next)
105+ return c.json(await ctx.settings())
106+ })
107+
108+ // ---- catalog ----
109+ async function catalogResponse(): Promise<CatalogResponse> {
110+ const catalog = await ctx.catalog()
111+ const git = await ctx.git()
112+ const exists = await catalog.exists()
113+ const [snapshot, gitStatus, usage] = await Promise.all([
114+ catalog.snapshot(),
115+ exists ? git.status() : Promise.resolve(EMPTY_GIT),
116+ computeUsage(ctx),
117+ ])
118+ return { snapshot, git: gitStatus, usage }
119+ }
120+
121+ app.get('/api/catalog', async (c) => c.json(await catalogResponse()))
122+
123+ app.post('/api/catalog/init', async (c) => {
124+ const catalog = await ctx.catalog()
125+ await catalog.init()
126+ const git = await ctx.git()
127+ if (!(await git.status()).isRepo) await git.init()
128+ return c.json(await catalogResponse())
129+ })
130+
131+ app.get('/api/catalog/items/:type/:name', async (c) => {
132+ const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
133+ const detail = await (await ctx.catalog()).getItem(ref)
134+ if (!detail) throw new HttpError(404, `${ref.type} "${ref.name}" not found in the catalog`)
135+ return c.json(detail)
136+ })
137+
138+ app.put('/api/catalog/items/:type/:name', async (c) => {
139+ const type = parseType(c.req.param('type'))
140+ const name = c.req.param('name')
141+ const body = await readJsonBody(c)
142+ if (typeof body.content !== 'string') throw new HttpError(400, '"content" must be a string')
143+ if (body.files !== undefined) {
144+ const f = body.files
145+ if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.values(f).every((v) => typeof v === 'string')) {
146+ throw new HttpError(400, '"files" must map relative paths to strings')
147+ }
148+ }
149+ if (body.deleteFiles !== undefined && !isStringArray(body.deleteFiles)) throw new HttpError(400, '"deleteFiles" must be a string array')
150+ if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array')
151+ if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string')
152+ const input: SaveItemInput = {
153+ type,
154+ name,
155+ content: body.content,
156+ files: body.files as Record<string, string> | undefined,
157+ deleteFiles: body.deleteFiles as string[] | undefined,
158+ tags: body.tags as string[] | undefined,
159+ previousName: body.previousName as string | undefined,
160+ }
161+ const catalog = await ctx.catalog()
162+ if (!(await catalog.exists())) await catalog.init()
163+ const item = await catalog.saveItem(input)
164+ ctx.catalogChanged()
165+ return c.json(item)
166+ })
167+
168+ app.delete('/api/catalog/items/:type/:name', async (c) => {
169+ const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
170+ if (c.req.query('force') !== '1') {
171+ const key = `${ref.type}/${ref.name}`
172+ const scopes = (await computeUsage(ctx))[key]
173+ if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes))
174+ }
175+ await (await ctx.catalog()).deleteItem(ref)
176+ ctx.catalogChanged()
177+ return c.body(null, 204)
178+ })
179+
180+ app.put('/api/catalog/items/:type/:name/tags', async (c) => {
181+ const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
182+ const body = await readJsonBody(c)
183+ if (!isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array')
184+ await (await ctx.catalog()).setTags(ref, body.tags)
185+ ctx.catalogChanged()
186+ return c.body(null, 204)
187+ })
188+
189+ app.post('/api/catalog/import', async (c) => {
190+ const body = (await readJsonBody(c)) as Partial<ImportBody>
191+ if (typeof body.scope !== 'string' || !body.scope) throw new HttpError(400, '"scope" is required')
192+ if (!isStringArray(body.itemIds)) throw new HttpError(400, '"itemIds" must be a string array')
193+ if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array')
194+ if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean')
195+ const scope = await ctx.scope(body.scope)
196+ const inventory = await scope.scan()
197+ const byId = new Map(inventory.items.map((i) => [i.id, i]))
198+ const catalog = await ctx.catalog()
199+ if (!(await catalog.exists())) await catalog.init()
200+ const result: ImportResult = { imported: [], skipped: [] }
201+ for (const id of body.itemIds) {
202+ const item = byId.get(id)
203+ if (!item) {
204+ result.skipped.push({ id, reason: 'Item not found in the scope inventory' })
205+ continue
206+ }
207+ if (item.type === 'instructions') {
208+ result.skipped.push({ id, reason: 'Instructions files (CLAUDE.md / AGENTS.md) cannot be imported' })
209+ continue
210+ }
211+ try {
212+ result.imported.push(await catalog.importFromInventory(item, { tags: body.tags, overwrite: body.overwrite }))
213+ } catch (e) {
214+ result.skipped.push({ id, reason: e instanceof Error ? e.message : String(e) })
215+ }
216+ }
217+ if (result.imported.length > 0) ctx.catalogChanged()
218+ return c.json(result)
219+ })
220+
221+ app.put('/api/catalog/presets/:name', async (c) => {
222+ const name = c.req.param('name')
223+ const body = await readJsonBody(c)
224+ if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string')
225+ const catalog = await ctx.catalog()
226+ if (!(await catalog.exists())) await catalog.init()
227+ const saved = await catalog.savePreset(normalizePreset(body, name), body.previousName as string | undefined)
228+ ctx.catalogChanged()
229+ return c.json(saved)
230+ })
231+
232+ app.delete('/api/catalog/presets/:name', async (c) => {
233+ const name = c.req.param('name')
234+ if (c.req.query('force') !== '1') {
235+ const key = `preset/${name}`
236+ const scopes = (await computeUsage(ctx))[key]
237+ if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes))
238+ }
239+ await (await ctx.catalog()).deletePreset(name)
240+ ctx.catalogChanged()
241+ return c.body(null, 204)
242+ })
243+
244+ // ---- catalog git ----
245+ app.get('/api/catalog/git', async (c) => {
246+ const catalog = await ctx.catalog()
247+ if (!(await catalog.exists())) return c.json(EMPTY_GIT)
248+ return c.json(await (await ctx.git()).status())
249+ })
250+
251+ async function gitOp<T>(run: (git: Awaited<ReturnType<AppContext['git']>>) => Promise<T>): Promise<GitStatus> {
252+ const catalog = await ctx.catalog()
253+ if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first')
254+ const git = await ctx.git()
255+ await run(git)
256+ return git.status()
257+ }
258+
259+ app.post('/api/catalog/git/remote', async (c) => {
260+ const url = requireUrlLike((await readJsonBody(c)).url, 'url')
261+ return c.json(await gitOp((g) => g.setRemote(url)))
262+ })
263+
264+ app.post('/api/catalog/git/commit', async (c) => {
265+ const message = (await readJsonBody(c)).message
266+ if (typeof message !== 'string' || !message.trim()) throw new HttpError(400, '"message" is required')
267+ return c.json(await gitOp((g) => g.commitAll(message.trim())))
268+ })
269+
270+ // pull / push / sync return their own status (conflicts are reported there).
271+ const returning = (run: (g: Awaited<ReturnType<AppContext['git']>>) => Promise<GitStatus>) => async (c: import('hono').Context) => {
272+ const catalog = await ctx.catalog()
273+ if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first')
274+ const status = await run(await ctx.git())
275+ return c.json(status)
276+ }
277+ app.post('/api/catalog/git/pull', returning((g) => g.pull()))
278+ app.post('/api/catalog/git/push', returning((g) => g.push()))
279+ app.post('/api/catalog/git/sync', returning((g) => g.sync()))
280+
281+ app.post('/api/catalog/git/resolve', async (c) => {
282+ const body = await readJsonBody(c)
283+ if (typeof body.path !== 'string' || !body.path) throw new HttpError(400, '"path" is required')
284+ if (body.side !== 'ours' && body.side !== 'theirs') throw new HttpError(400, '"side" must be "ours" or "theirs"')
285+ const file = body.path
286+ const side = body.side
287+ const catalog = await ctx.catalog()
288+ if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first')
289+ return c.json(await (await ctx.git()).resolve(file, side))
290+ })
291+}
added packages/server/src/context.ts +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+import { stat } from 'node:fs/promises'
2+import path from 'node:path'
3+import {
4+ scanHome,
5+ scanProject,
6+ type CatalogGit,
7+ type CatalogStore,
8+ type CreateMarketplace,
9+ type OpenCatalog,
10+ type OpenCatalogGit,
11+ type ScanContext,
12+ type ScopeInventory,
13+ type Settings,
14+ type SyncEngine,
15+ type SyncTarget,
16+} from '@skilly/core'
17+import type { Context } from 'hono'
18+import type { ProjectStore } from './store.ts'
19+
20+/** Error carrying the HTTP status to answer with. */
21+export class HttpError extends Error {
22+ constructor(
23+ readonly status: 400 | 404 | 409,
24+ message: string,
25+ ) {
26+ super(message)
27+ }
28+}
29+
30+export interface ScopeRef {
31+ id: string
32+ kind: 'home' | 'project'
33+ target: SyncTarget
34+ scan(): Promise<ScopeInventory>
35+}
36+
37+export interface AppContext {
38+ configDir: string
39+ scanContext: ScanContext
40+ store: ProjectStore
41+ syncEngine: SyncEngine
42+ openCatalog: OpenCatalog
43+ openCatalogGit: OpenCatalogGit
44+ createMarketplace: CreateMarketplace
45+ marketplaceCacheDir: string
46+ settings(): Promise<Settings>
47+ catalog(): Promise<CatalogStore>
48+ git(): Promise<CatalogGit>
49+ /** Throws HttpError 404 for an unknown scope. */
50+ scope(id: string): Promise<ScopeRef>
51+ /** Drops cached project scans after a write. */
52+ invalidateScan(id?: string): void
53+ /** Call after any catalog write; commits / syncs the catalog later when `autoSync` is on. */
54+ catalogChanged(): void
55+}
56+
57+export async function isDirectory(p: string): Promise<boolean> {
58+ try {
59+ return (await stat(p)).isDirectory()
60+ } catch {
61+ return false
62+ }
63+}
64+
65+export async function readJsonBody(c: Context): Promise<Record<string, unknown>> {
66+ let body: unknown
67+ try {
68+ body = await c.req.json()
69+ } catch {
70+ throw new HttpError(400, 'Invalid JSON body')
71+ }
72+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'A JSON object body is required')
73+ return body as Record<string, unknown>
74+}
75+
76+export function isStringArray(v: unknown): v is string[] {
77+ return Array.isArray(v) && v.every((x) => typeof x === 'string')
78+}
79+
80+export function makeScopeRef(
81+ id: string,
82+ kind: 'home' | 'project',
83+ root: string,
84+ scanContext: ScanContext,
85+ configDir: string,
86+): ScopeRef {
87+ const base = { homeDir: scanContext.homeDir, codexHome: scanContext.codexHome }
88+ if (kind === 'home') {
89+ return {
90+ id,
91+ kind,
92+ target: { kind: 'home', root: scanContext.homeDir, ...base, configDir },
93+ scan: () => scanHome(scanContext),
94+ }
95+ }
96+ const abs = path.resolve(root)
97+ return {
98+ id,
99+ kind,
100+ target: { kind: 'project', root: abs, ...base, configDir: abs },
101+ scan: () => scanProject(abs, scanContext),
102+ }
103+}
new file mode 100644
@@ -0,0 +1,103 @@
1+import { stat } from 'node:fs/promises'
2+import path from 'node:path'
3+import {
4+ scanHome,
5+ scanProject,
6+ type CatalogGit,
7+ type CatalogStore,
8+ type CreateMarketplace,
9+ type OpenCatalog,
10+ type OpenCatalogGit,
11+ type ScanContext,
12+ type ScopeInventory,
13+ type Settings,
14+ type SyncEngine,
15+ type SyncTarget,
16+} from '@skilly/core'
17+import type { Context } from 'hono'
18+import type { ProjectStore } from './store.ts'
19+
20+/** Error carrying the HTTP status to answer with. */
21+export class HttpError extends Error {
22+ constructor(
23+ readonly status: 400 | 404 | 409,
24+ message: string,
25+ ) {
26+ super(message)
27+ }
28+}
29+
30+export interface ScopeRef {
31+ id: string
32+ kind: 'home' | 'project'
33+ target: SyncTarget
34+ scan(): Promise<ScopeInventory>
35+}
36+
37+export interface AppContext {
38+ configDir: string
39+ scanContext: ScanContext
40+ store: ProjectStore
41+ syncEngine: SyncEngine
42+ openCatalog: OpenCatalog
43+ openCatalogGit: OpenCatalogGit
44+ createMarketplace: CreateMarketplace
45+ marketplaceCacheDir: string
46+ settings(): Promise<Settings>
47+ catalog(): Promise<CatalogStore>
48+ git(): Promise<CatalogGit>
49+ /** Throws HttpError 404 for an unknown scope. */
50+ scope(id: string): Promise<ScopeRef>
51+ /** Drops cached project scans after a write. */
52+ invalidateScan(id?: string): void
53+ /** Call after any catalog write; commits / syncs the catalog later when `autoSync` is on. */
54+ catalogChanged(): void
55+}
56+
57+export async function isDirectory(p: string): Promise<boolean> {
58+ try {
59+ return (await stat(p)).isDirectory()
60+ } catch {
61+ return false
62+ }
63+}
64+
65+export async function readJsonBody(c: Context): Promise<Record<string, unknown>> {
66+ let body: unknown
67+ try {
68+ body = await c.req.json()
69+ } catch {
70+ throw new HttpError(400, 'Invalid JSON body')
71+ }
72+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'A JSON object body is required')
73+ return body as Record<string, unknown>
74+}
75+
76+export function isStringArray(v: unknown): v is string[] {
77+ return Array.isArray(v) && v.every((x) => typeof x === 'string')
78+}
79+
80+export function makeScopeRef(
81+ id: string,
82+ kind: 'home' | 'project',
83+ root: string,
84+ scanContext: ScanContext,
85+ configDir: string,
86+): ScopeRef {
87+ const base = { homeDir: scanContext.homeDir, codexHome: scanContext.codexHome }
88+ if (kind === 'home') {
89+ return {
90+ id,
91+ kind,
92+ target: { kind: 'home', root: scanContext.homeDir, ...base, configDir },
93+ scan: () => scanHome(scanContext),
94+ }
95+ }
96+ const abs = path.resolve(root)
97+ return {
98+ id,
99+ kind,
100+ target: { kind: 'project', root: abs, ...base, configDir: abs },
101+ scan: () => scanProject(abs, scanContext),
102+ }
103+}
modified packages/server/src/index.ts +110 -10
@@ -8,16 +8,28 @@ import { serve } from '@hono/node-server'
88 import {
99 ITEM_TYPES,
1010 defaultScanContext,
11+ createMarketplace as realCreateMarketplace,
12+ openCatalog as realOpenCatalog,
13+ openCatalogGit as realOpenCatalogGit,
14+ syncEngine as realSyncEngine,
1115 scanHome,
1216 scanProject,
1317 type AddProjectBody,
1418 type ApiError,
19+ type CreateMarketplace,
1520 type ItemType,
21+ type OpenCatalog,
22+ type OpenCatalogGit,
23+ type SyncEngine,
1624 type ProjectSummary,
1725 type ScanContext,
1826 type ScopeInventory,
1927 } from '@skilly/core'
2028 import { Hono } from 'hono'
29+import { registerCatalogRoutes } from './catalog-routes.ts'
30+import { HttpError, isDirectory, makeScopeRef, type AppContext } from './context.ts'
31+import { registerMarketplaceRoutes } from './marketplace-routes.ts'
32+import { registerScopeRoutes } from './scope-routes.ts'
2133 import { FileAccessError, isInside, readAllowedFile } from './files.ts'
2234 import { listDirectory, listRoots } from './fslist.ts'
2335 import { ProjectStore, type StoredProject } from './store.ts'
@@ -32,6 +44,16 @@ export interface AppOptions {
3244 /** Defaults to `~/.skilly`. */
3345 configDir?: string
3446 scanContext?: ScanContext
47+ /** Sync engine (defaults to the real one); injectable for tests. */
48+ syncEngine?: SyncEngine
49+ /** Catalog factory (defaults to the real one). */
50+ catalog?: OpenCatalog
51+ /** Catalog git factory (defaults to the real one). */
52+ catalogGit?: OpenCatalogGit
53+ /** Marketplace factory (defaults to the real one). */
54+ marketplace?: CreateMarketplace
55+ /** Debounce before the catalog auto-sync runs after a write (default 3000 ms). */
56+ autoSyncDelayMs?: number
3557 /** Built UI folder served at `/`. */
3658 staticDir?: string
3759 /** Called on every `POST /api/heartbeat`. */
@@ -98,13 +120,7 @@ async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> {
98120 }
99121 }
100122
101-async function isDir(p: string): Promise<boolean> {
102- try {
103- return (await stat(p)).isDirectory()
104- } catch {
105- return false
106- }
107-}
123+const isDir = isDirectory
108124
109125 async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> {
110126 let rel: string
@@ -125,7 +141,17 @@ async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer
125141 }
126142 }
127143
144+export interface AppHandle {
145+ app: Hono
146+ /** Cancels pending timers (auto-sync). */
147+ dispose(): void
148+}
149+
128150 export function createApp(options: AppOptions): Hono {
151+ return createAppHandle(options).app
152+}
153+
154+export function createAppHandle(options: AppOptions): AppHandle {
129155 const { token } = options
130156 const configDir = options.configDir ?? path.join(os.homedir(), '.skilly')
131157 const ctx = options.scanContext ?? defaultScanContext()
@@ -133,6 +159,63 @@ export function createApp(options: AppOptions): Hono {
133159 const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>()
134160 const app = new Hono()
135161
162+ const syncEngine = options.syncEngine ?? realSyncEngine
163+ const openCatalog = options.catalog ?? realOpenCatalog
164+ const openCatalogGit = options.catalogGit ?? realOpenCatalogGit
165+ const createMarketplace = options.marketplace ?? realCreateMarketplace
166+ const marketplaceCacheDir = path.join(configDir, 'cache', 'marketplaces')
167+
168+ let syncTimer: NodeJS.Timeout | undefined
169+ let syncChain: Promise<void> = Promise.resolve()
170+
171+ const runAutoSync = async (): Promise<void> => {
172+ const settings = await store.getSettings()
173+ if (!settings.autoSync) return
174+ const git = openCatalogGit(settings.catalogPath)
175+ const status = await git.status()
176+ if (!status.isRepo) return
177+ if (status.remote) await git.sync()
178+ else await git.commitAll('skilly: update catalog')
179+ }
180+
181+ const appCtx: AppContext = {
182+ configDir,
183+ scanContext: ctx,
184+ store,
185+ syncEngine,
186+ openCatalog,
187+ openCatalogGit,
188+ createMarketplace,
189+ marketplaceCacheDir,
190+ settings: () => store.getSettings(),
191+ catalog: async () => openCatalog((await store.getSettings()).catalogPath),
192+ git: async () => openCatalogGit((await store.getSettings()).catalogPath),
193+ async scope(id) {
194+ if (id === 'home') return makeScopeRef('home', 'home', ctx.homeDir, ctx, configDir)
195+ const project = (await store.list()).find((p) => p.id === id)
196+ if (!project) throw new HttpError(404, 'Unknown scope')
197+ if (!(await isDirectory(project.path))) throw new HttpError(404, 'Project folder no longer exists')
198+ return makeScopeRef(id, 'project', project.path, ctx, configDir)
199+ },
200+ invalidateScan(id) {
201+ if (id) scanCache.delete(id)
202+ else scanCache.clear()
203+ },
204+ catalogChanged() {
205+ void store.getSettings().then((s) => {
206+ if (!s.autoSync) return
207+ if (syncTimer) clearTimeout(syncTimer)
208+ syncTimer = setTimeout(() => {
209+ syncTimer = undefined
210+ syncChain = syncChain
211+ .then(runAutoSync)
212+ .catch((e: unknown) => console.error('skilly: catalog auto-sync failed:', e instanceof Error ? e.message : e))
213+ }, options.autoSyncDelayMs ?? 3000)
214+ syncTimer.unref()
215+ })
216+ },
217+ }
218+
136219 const err = (message: string): ApiError => ({ error: message })
137220
138221 // DNS rebinding protection: only loopback host names are served.
@@ -147,6 +230,12 @@ export function createApp(options: AppOptions): Hono {
147230 await next()
148231 })
149232
233+ // Validation errors and core errors carry user-facing messages; stack traces are never sent.
234+ app.onError((e, c) => {
235+ if (e instanceof HttpError) return c.json(err(e.message), e.status)
236+ return c.json(err(e instanceof Error && e.message ? e.message : 'Request failed'), 400)
237+ })
238+
150239 async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> {
151240 const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt }
152241 if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() }
@@ -229,7 +318,7 @@ export function createApp(options: AppOptions): Hono {
229318 const file = await readAllowedFile(p, {
230319 homeDir: ctx.homeDir,
231320 codexHome: ctx.codexHome,
232- projectRoots: projects.map((x) => x.path),
321+ projectRoots: [...projects.map((x) => x.path), (await store.getSettings()).catalogPath, marketplaceCacheDir],
233322 })
234323 return c.json(file)
235324 } catch (e) {
@@ -238,6 +327,10 @@ export function createApp(options: AppOptions): Hono {
238327 }
239328 })
240329
330+ registerCatalogRoutes(app, appCtx)
331+ registerScopeRoutes(app, appCtx)
332+ registerMarketplaceRoutes(app, appCtx)
333+
241334 app.all('/api/*', (c) => c.json(err('Not found'), 404))
242335
243336 const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined
@@ -254,7 +347,13 @@ export function createApp(options: AppOptions): Hono {
254347 })
255348 }
256349
257- return app
350+ return {
351+ app,
352+ dispose() {
353+ if (syncTimer) clearTimeout(syncTimer)
354+ syncTimer = undefined
355+ },
356+ }
258357 }
259358
260359 export async function startServer(options: ServerOptions): Promise<RunningServer> {
@@ -269,7 +368,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer
269368 idleTimer.unref()
270369 }
271370
272- const app = createApp({
371+ const { app, dispose } = createAppHandle({
273372 ...options,
274373 onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0),
275374 })
@@ -288,6 +387,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer
288387 close: () =>
289388 new Promise<void>((resolve) => {
290389 closed = true
390+ dispose()
291391 if (idleTimer) clearTimeout(idleTimer)
292392 server.close(() => resolve())
293393 ;(server as unknown as Server).closeAllConnections?.()
@@ -8,16 +8,28 @@ import { serve } from '@hono/node-server'
8 import {8 import {
9 ITEM_TYPES,9 ITEM_TYPES,
10 defaultScanContext,10 defaultScanContext,
11+ createMarketplace as realCreateMarketplace,
12+ openCatalog as realOpenCatalog,
13+ openCatalogGit as realOpenCatalogGit,
14+ syncEngine as realSyncEngine,
11 scanHome,15 scanHome,
12 scanProject,16 scanProject,
13 type AddProjectBody,17 type AddProjectBody,
14 type ApiError,18 type ApiError,
19+ type CreateMarketplace,
15 type ItemType,20 type ItemType,
21+ type OpenCatalog,
22+ type OpenCatalogGit,
23+ type SyncEngine,
16 type ProjectSummary,24 type ProjectSummary,
17 type ScanContext,25 type ScanContext,
18 type ScopeInventory,26 type ScopeInventory,
19 } from '@skilly/core'27 } from '@skilly/core'
20 import { Hono } from 'hono'28 import { Hono } from 'hono'
29+import { registerCatalogRoutes } from './catalog-routes.ts'
30+import { HttpError, isDirectory, makeScopeRef, type AppContext } from './context.ts'
31+import { registerMarketplaceRoutes } from './marketplace-routes.ts'
32+import { registerScopeRoutes } from './scope-routes.ts'
21 import { FileAccessError, isInside, readAllowedFile } from './files.ts'33 import { FileAccessError, isInside, readAllowedFile } from './files.ts'
22 import { listDirectory, listRoots } from './fslist.ts'34 import { listDirectory, listRoots } from './fslist.ts'
23 import { ProjectStore, type StoredProject } from './store.ts'35 import { ProjectStore, type StoredProject } from './store.ts'
@@ -32,6 +44,16 @@ export interface AppOptions {
32 /** Defaults to `~/.skilly`. */44 /** Defaults to `~/.skilly`. */
33 configDir?: string45 configDir?: string
34 scanContext?: ScanContext46 scanContext?: ScanContext
47+ /** Sync engine (defaults to the real one); injectable for tests. */
48+ syncEngine?: SyncEngine
49+ /** Catalog factory (defaults to the real one). */
50+ catalog?: OpenCatalog
51+ /** Catalog git factory (defaults to the real one). */
52+ catalogGit?: OpenCatalogGit
53+ /** Marketplace factory (defaults to the real one). */
54+ marketplace?: CreateMarketplace
55+ /** Debounce before the catalog auto-sync runs after a write (default 3000 ms). */
56+ autoSyncDelayMs?: number
35 /** Built UI folder served at `/`. */57 /** Built UI folder served at `/`. */
36 staticDir?: string58 staticDir?: string
37 /** Called on every `POST /api/heartbeat`. */59 /** Called on every `POST /api/heartbeat`. */
@@ -98,13 +120,7 @@ async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> {
98 }120 }
99 }121 }
100 122
101-async function isDir(p: string): Promise<boolean> {123+const isDir = isDirectory
102- try {
103- return (await stat(p)).isDirectory()
104- } catch {
105- return false
106- }
107-}
108 124
109 async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> {125 async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> {
110 let rel: string126 let rel: string
@@ -125,7 +141,17 @@ async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer
125 }141 }
126 }142 }
127 143
144+export interface AppHandle {
145+ app: Hono
146+ /** Cancels pending timers (auto-sync). */
147+ dispose(): void
148+}
149+
128 export function createApp(options: AppOptions): Hono {150 export function createApp(options: AppOptions): Hono {
151+ return createAppHandle(options).app
152+}
153+
154+export function createAppHandle(options: AppOptions): AppHandle {
129 const { token } = options155 const { token } = options
130 const configDir = options.configDir ?? path.join(os.homedir(), '.skilly')156 const configDir = options.configDir ?? path.join(os.homedir(), '.skilly')
131 const ctx = options.scanContext ?? defaultScanContext()157 const ctx = options.scanContext ?? defaultScanContext()
@@ -133,6 +159,63 @@ export function createApp(options: AppOptions): Hono {
133 const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>()159 const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>()
134 const app = new Hono()160 const app = new Hono()
135 161
162+ const syncEngine = options.syncEngine ?? realSyncEngine
163+ const openCatalog = options.catalog ?? realOpenCatalog
164+ const openCatalogGit = options.catalogGit ?? realOpenCatalogGit
165+ const createMarketplace = options.marketplace ?? realCreateMarketplace
166+ const marketplaceCacheDir = path.join(configDir, 'cache', 'marketplaces')
167+
168+ let syncTimer: NodeJS.Timeout | undefined
169+ let syncChain: Promise<void> = Promise.resolve()
170+
171+ const runAutoSync = async (): Promise<void> => {
172+ const settings = await store.getSettings()
173+ if (!settings.autoSync) return
174+ const git = openCatalogGit(settings.catalogPath)
175+ const status = await git.status()
176+ if (!status.isRepo) return
177+ if (status.remote) await git.sync()
178+ else await git.commitAll('skilly: update catalog')
179+ }
180+
181+ const appCtx: AppContext = {
182+ configDir,
183+ scanContext: ctx,
184+ store,
185+ syncEngine,
186+ openCatalog,
187+ openCatalogGit,
188+ createMarketplace,
189+ marketplaceCacheDir,
190+ settings: () => store.getSettings(),
191+ catalog: async () => openCatalog((await store.getSettings()).catalogPath),
192+ git: async () => openCatalogGit((await store.getSettings()).catalogPath),
193+ async scope(id) {
194+ if (id === 'home') return makeScopeRef('home', 'home', ctx.homeDir, ctx, configDir)
195+ const project = (await store.list()).find((p) => p.id === id)
196+ if (!project) throw new HttpError(404, 'Unknown scope')
197+ if (!(await isDirectory(project.path))) throw new HttpError(404, 'Project folder no longer exists')
198+ return makeScopeRef(id, 'project', project.path, ctx, configDir)
199+ },
200+ invalidateScan(id) {
201+ if (id) scanCache.delete(id)
202+ else scanCache.clear()
203+ },
204+ catalogChanged() {
205+ void store.getSettings().then((s) => {
206+ if (!s.autoSync) return
207+ if (syncTimer) clearTimeout(syncTimer)
208+ syncTimer = setTimeout(() => {
209+ syncTimer = undefined
210+ syncChain = syncChain
211+ .then(runAutoSync)
212+ .catch((e: unknown) => console.error('skilly: catalog auto-sync failed:', e instanceof Error ? e.message : e))
213+ }, options.autoSyncDelayMs ?? 3000)
214+ syncTimer.unref()
215+ })
216+ },
217+ }
218+
136 const err = (message: string): ApiError => ({ error: message })219 const err = (message: string): ApiError => ({ error: message })
137 220
138 // DNS rebinding protection: only loopback host names are served.221 // DNS rebinding protection: only loopback host names are served.
@@ -147,6 +230,12 @@ export function createApp(options: AppOptions): Hono {
147 await next()230 await next()
148 })231 })
149 232
233+ // Validation errors and core errors carry user-facing messages; stack traces are never sent.
234+ app.onError((e, c) => {
235+ if (e instanceof HttpError) return c.json(err(e.message), e.status)
236+ return c.json(err(e instanceof Error && e.message ? e.message : 'Request failed'), 400)
237+ })
238+
150 async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> {239 async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> {
151 const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt }240 const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt }
152 if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() }241 if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() }
@@ -229,7 +318,7 @@ export function createApp(options: AppOptions): Hono {
229 const file = await readAllowedFile(p, {318 const file = await readAllowedFile(p, {
230 homeDir: ctx.homeDir,319 homeDir: ctx.homeDir,
231 codexHome: ctx.codexHome,320 codexHome: ctx.codexHome,
232- projectRoots: projects.map((x) => x.path),321+ projectRoots: [...projects.map((x) => x.path), (await store.getSettings()).catalogPath, marketplaceCacheDir],
233 })322 })
234 return c.json(file)323 return c.json(file)
235 } catch (e) {324 } catch (e) {
@@ -238,6 +327,10 @@ export function createApp(options: AppOptions): Hono {
238 }327 }
239 })328 })
240 329
330+ registerCatalogRoutes(app, appCtx)
331+ registerScopeRoutes(app, appCtx)
332+ registerMarketplaceRoutes(app, appCtx)
333+
241 app.all('/api/*', (c) => c.json(err('Not found'), 404))334 app.all('/api/*', (c) => c.json(err('Not found'), 404))
242 335
243 const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined336 const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined
@@ -254,7 +347,13 @@ export function createApp(options: AppOptions): Hono {
254 })347 })
255 }348 }
256 349
257- return app350+ return {
351+ app,
352+ dispose() {
353+ if (syncTimer) clearTimeout(syncTimer)
354+ syncTimer = undefined
355+ },
356+ }
258 }357 }
259 358
260 export async function startServer(options: ServerOptions): Promise<RunningServer> {359 export async function startServer(options: ServerOptions): Promise<RunningServer> {
@@ -269,7 +368,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer
269 idleTimer.unref()368 idleTimer.unref()
270 }369 }
271 370
272- const app = createApp({371+ const { app, dispose } = createAppHandle({
273 ...options,372 ...options,
274 onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0),373 onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0),
275 })374 })
@@ -288,6 +387,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer
288 close: () =>387 close: () =>
289 new Promise<void>((resolve) => {388 new Promise<void>((resolve) => {
290 closed = true389 closed = true
390+ dispose()
291 if (idleTimer) clearTimeout(idleTimer)391 if (idleTimer) clearTimeout(idleTimer)
292 server.close(() => resolve())392 server.close(() => resolve())
293 ;(server as unknown as Server).closeAllConnections?.()393 ;(server as unknown as Server).closeAllConnections?.()
added packages/server/src/marketplace-routes.ts +150 -0
new file mode 100644
@@ -0,0 +1,150 @@
1+import { readFile, readdir, rm } from 'node:fs/promises'
2+import path from 'node:path'
3+import type { ImportResult, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
4+import type { Hono } from 'hono'
5+import { HttpError, isStringArray, readJsonBody, type AppContext } from './context.ts'
6+
7+const MAX_FILES = 200
8+
9+function slugify(name: string): string {
10+ return name
11+ .toLowerCase()
12+ .replace(/[^a-z0-9]+/g, '-')
13+ .replace(/^-+|-+$/g, '')
14+}
15+
16+function optionalArg(v: unknown, label: string): string | undefined {
17+ if (v === undefined || v === null || v === '') return undefined
18+ if (typeof v !== 'string' || v.startsWith('-') || v.includes('\0')) throw new HttpError(400, `Invalid "${label}"`)
19+ return v.trim()
20+}
21+
22+async function listFiles(dir: string, rel = ''): Promise<string[]> {
23+ const out: string[] = []
24+ const entries = await readdir(path.join(dir, rel), { withFileTypes: true })
25+ entries.sort((a, b) => a.name.localeCompare(b.name))
26+ for (const e of entries) {
27+ if (out.length >= MAX_FILES) break
28+ if (e.name === '.git') continue
29+ const childRel = rel ? `${rel}/${e.name}` : e.name
30+ if (e.isDirectory()) out.push(...(await listFiles(dir, childRel)))
31+ else if (e.isFile()) out.push(childRel)
32+ }
33+ return out.slice(0, MAX_FILES)
34+}
35+
36+function frontmatterField(md: string, key: string): string | undefined {
37+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md.replace(/^/, ''))
38+ if (!fm) return undefined
39+ const m = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm').exec(fm[1]!)
40+ const v = m?.[1]?.trim().replace(/^(['"])(.*)\1$/, '$2')
41+ return v || undefined
42+}
43+
44+export function registerMarketplaceRoutes(app: Hono, ctx: AppContext): void {
45+ async function marketplace() {
46+ return ctx.createMarketplace({ cacheDir: ctx.marketplaceCacheDir, userSources: await ctx.store.listMarketplaces() })
47+ }
48+
49+ async function requireSource(id: string) {
50+ const mkt = await marketplace()
51+ if (!mkt.sources().some((s) => s.id === id)) throw new HttpError(404, 'Unknown marketplace source')
52+ return mkt
53+ }
54+
55+ app.get('/api/marketplaces', async (c) => c.json((await marketplace()).sources()))
56+
57+ app.post('/api/marketplaces', async (c) => {
58+ const body = await readJsonBody(c)
59+ if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new HttpError(400, '"name" is required')
60+ if (body.kind !== 'git' && body.kind !== 'claude-marketplace') throw new HttpError(400, '"kind" must be "git" or "claude-marketplace"')
61+ if (typeof body.url !== 'string' || !body.url.trim() || body.url.trim().startsWith('-') || body.url.includes('\0')) {
62+ throw new HttpError(400, '"url" is required')
63+ }
64+ const ref = optionalArg(body.ref, 'ref')
65+ const id = slugify(body.name)
66+ if (!id) throw new HttpError(400, 'The name must contain letters or digits')
67+ if ((await marketplace()).sources().some((s) => s.id === id)) throw new HttpError(409, `A source with id "${id}" already exists`)
68+ const source: MarketplaceSource = { id, name: body.name.trim(), kind: body.kind, url: body.url.trim() }
69+ if (ref) source.ref = ref
70+ const added = await ctx.store.mutateMarketplaces((list) => {
71+ if (list.some((s) => s.id === id)) return false
72+ list.push(source)
73+ return true
74+ })
75+ if (!added) throw new HttpError(409, `A source with id "${id}" already exists`)
76+ const created = (await marketplace()).sources().find((s) => s.id === id)
77+ return c.json(created ?? source, 201)
78+ })
79+
80+ app.delete('/api/marketplaces/:id', async (c) => {
81+ const id = c.req.param('id')
82+ const source = (await marketplace()).sources().find((s) => s.id === id)
83+ if (!source) throw new HttpError(404, 'Unknown marketplace source')
84+ if (source.builtIn) throw new HttpError(400, 'Built-in sources cannot be removed')
85+ await ctx.store.mutateMarketplaces((list) => {
86+ const i = list.findIndex((s) => s.id === id)
87+ if (i >= 0) list.splice(i, 1)
88+ })
89+ await rm(path.join(ctx.marketplaceCacheDir, id), { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined)
90+ await rm(path.join(ctx.marketplaceCacheDir, `${id}.json`), { force: true }).catch(() => undefined)
91+ return c.body(null, 204)
92+ })
93+
94+ app.post('/api/marketplaces/:id/refresh', async (c) => {
95+ const mkt = await requireSource(c.req.param('id'))
96+ return c.json(await mkt.refresh(c.req.param('id')))
97+ })
98+
99+ app.get('/api/marketplaces/:id/skills', async (c) => {
100+ const mkt = await requireSource(c.req.param('id'))
101+ const catalog = await ctx.catalog()
102+ return c.json(await mkt.listSkills(c.req.param('id'), catalog))
103+ })
104+
105+ app.get('/api/marketplaces/:id/skill', async (c) => {
106+ const mkt = await requireSource(c.req.param('id'))
107+ const skillPath = c.req.query('path')
108+ if (!skillPath) throw new HttpError(400, 'Missing "path"')
109+ const dir = await mkt.skillDir(c.req.param('id'), skillPath)
110+ const content = await readFile(path.join(dir, 'SKILL.md'), 'utf8')
111+ const detail: MarketplaceSkillDetail = {
112+ path: skillPath,
113+ name: frontmatterField(content, 'name') ?? path.basename(dir),
114+ description: frontmatterField(content, 'description'),
115+ content,
116+ files: await listFiles(dir),
117+ }
118+ return c.json(detail)
119+ })
120+
121+ app.post('/api/marketplaces/:id/import', async (c) => {
122+ const id = c.req.param('id')
123+ const mkt = await requireSource(id)
124+ const body = await readJsonBody(c)
125+ if (!isStringArray(body.paths)) throw new HttpError(400, '"paths" must be a string array')
126+ if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean')
127+ const catalog = await ctx.catalog()
128+ if (!(await catalog.exists())) await catalog.init()
129+ const names = new Map((await mkt.listSkills(id)).map((s) => [s.path, s.name]))
130+ const result: ImportResult = { imported: [], skipped: [] }
131+ for (const p of body.paths) {
132+ try {
133+ const dir = await mkt.skillDir(id, p)
134+ const name = names.get(p) ?? path.basename(dir)
135+ result.imported.push(
136+ await catalog.importSkillDirectory(
137+ dir,
138+ name,
139+ { kind: 'marketplace', source: id, sourcePath: p, importedAt: new Date().toISOString() },
140+ { overwrite: body.overwrite },
141+ ),
142+ )
143+ } catch (e) {
144+ result.skipped.push({ id: p, reason: e instanceof Error ? e.message : String(e) })
145+ }
146+ }
147+ if (result.imported.length > 0) ctx.catalogChanged()
148+ return c.json(result)
149+ })
150+}
new file mode 100644
@@ -0,0 +1,150 @@
1+import { readFile, readdir, rm } from 'node:fs/promises'
2+import path from 'node:path'
3+import type { ImportResult, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
4+import type { Hono } from 'hono'
5+import { HttpError, isStringArray, readJsonBody, type AppContext } from './context.ts'
6+
7+const MAX_FILES = 200
8+
9+function slugify(name: string): string {
10+ return name
11+ .toLowerCase()
12+ .replace(/[^a-z0-9]+/g, '-')
13+ .replace(/^-+|-+$/g, '')
14+}
15+
16+function optionalArg(v: unknown, label: string): string | undefined {
17+ if (v === undefined || v === null || v === '') return undefined
18+ if (typeof v !== 'string' || v.startsWith('-') || v.includes('\0')) throw new HttpError(400, `Invalid "${label}"`)
19+ return v.trim()
20+}
21+
22+async function listFiles(dir: string, rel = ''): Promise<string[]> {
23+ const out: string[] = []
24+ const entries = await readdir(path.join(dir, rel), { withFileTypes: true })
25+ entries.sort((a, b) => a.name.localeCompare(b.name))
26+ for (const e of entries) {
27+ if (out.length >= MAX_FILES) break
28+ if (e.name === '.git') continue
29+ const childRel = rel ? `${rel}/${e.name}` : e.name
30+ if (e.isDirectory()) out.push(...(await listFiles(dir, childRel)))
31+ else if (e.isFile()) out.push(childRel)
32+ }
33+ return out.slice(0, MAX_FILES)
34+}
35+
36+function frontmatterField(md: string, key: string): string | undefined {
37+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md.replace(/^/, ''))
38+ if (!fm) return undefined
39+ const m = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm').exec(fm[1]!)
40+ const v = m?.[1]?.trim().replace(/^(['"])(.*)\1$/, '$2')
41+ return v || undefined
42+}
43+
44+export function registerMarketplaceRoutes(app: Hono, ctx: AppContext): void {
45+ async function marketplace() {
46+ return ctx.createMarketplace({ cacheDir: ctx.marketplaceCacheDir, userSources: await ctx.store.listMarketplaces() })
47+ }
48+
49+ async function requireSource(id: string) {
50+ const mkt = await marketplace()
51+ if (!mkt.sources().some((s) => s.id === id)) throw new HttpError(404, 'Unknown marketplace source')
52+ return mkt
53+ }
54+
55+ app.get('/api/marketplaces', async (c) => c.json((await marketplace()).sources()))
56+
57+ app.post('/api/marketplaces', async (c) => {
58+ const body = await readJsonBody(c)
59+ if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new HttpError(400, '"name" is required')
60+ if (body.kind !== 'git' && body.kind !== 'claude-marketplace') throw new HttpError(400, '"kind" must be "git" or "claude-marketplace"')
61+ if (typeof body.url !== 'string' || !body.url.trim() || body.url.trim().startsWith('-') || body.url.includes('\0')) {
62+ throw new HttpError(400, '"url" is required')
63+ }
64+ const ref = optionalArg(body.ref, 'ref')
65+ const id = slugify(body.name)
66+ if (!id) throw new HttpError(400, 'The name must contain letters or digits')
67+ if ((await marketplace()).sources().some((s) => s.id === id)) throw new HttpError(409, `A source with id "${id}" already exists`)
68+ const source: MarketplaceSource = { id, name: body.name.trim(), kind: body.kind, url: body.url.trim() }
69+ if (ref) source.ref = ref
70+ const added = await ctx.store.mutateMarketplaces((list) => {
71+ if (list.some((s) => s.id === id)) return false
72+ list.push(source)
73+ return true
74+ })
75+ if (!added) throw new HttpError(409, `A source with id "${id}" already exists`)
76+ const created = (await marketplace()).sources().find((s) => s.id === id)
77+ return c.json(created ?? source, 201)
78+ })
79+
80+ app.delete('/api/marketplaces/:id', async (c) => {
81+ const id = c.req.param('id')
82+ const source = (await marketplace()).sources().find((s) => s.id === id)
83+ if (!source) throw new HttpError(404, 'Unknown marketplace source')
84+ if (source.builtIn) throw new HttpError(400, 'Built-in sources cannot be removed')
85+ await ctx.store.mutateMarketplaces((list) => {
86+ const i = list.findIndex((s) => s.id === id)
87+ if (i >= 0) list.splice(i, 1)
88+ })
89+ await rm(path.join(ctx.marketplaceCacheDir, id), { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined)
90+ await rm(path.join(ctx.marketplaceCacheDir, `${id}.json`), { force: true }).catch(() => undefined)
91+ return c.body(null, 204)
92+ })
93+
94+ app.post('/api/marketplaces/:id/refresh', async (c) => {
95+ const mkt = await requireSource(c.req.param('id'))
96+ return c.json(await mkt.refresh(c.req.param('id')))
97+ })
98+
99+ app.get('/api/marketplaces/:id/skills', async (c) => {
100+ const mkt = await requireSource(c.req.param('id'))
101+ const catalog = await ctx.catalog()
102+ return c.json(await mkt.listSkills(c.req.param('id'), catalog))
103+ })
104+
105+ app.get('/api/marketplaces/:id/skill', async (c) => {
106+ const mkt = await requireSource(c.req.param('id'))
107+ const skillPath = c.req.query('path')
108+ if (!skillPath) throw new HttpError(400, 'Missing "path"')
109+ const dir = await mkt.skillDir(c.req.param('id'), skillPath)
110+ const content = await readFile(path.join(dir, 'SKILL.md'), 'utf8')
111+ const detail: MarketplaceSkillDetail = {
112+ path: skillPath,
113+ name: frontmatterField(content, 'name') ?? path.basename(dir),
114+ description: frontmatterField(content, 'description'),
115+ content,
116+ files: await listFiles(dir),
117+ }
118+ return c.json(detail)
119+ })
120+
121+ app.post('/api/marketplaces/:id/import', async (c) => {
122+ const id = c.req.param('id')
123+ const mkt = await requireSource(id)
124+ const body = await readJsonBody(c)
125+ if (!isStringArray(body.paths)) throw new HttpError(400, '"paths" must be a string array')
126+ if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean')
127+ const catalog = await ctx.catalog()
128+ if (!(await catalog.exists())) await catalog.init()
129+ const names = new Map((await mkt.listSkills(id)).map((s) => [s.path, s.name]))
130+ const result: ImportResult = { imported: [], skipped: [] }
131+ for (const p of body.paths) {
132+ try {
133+ const dir = await mkt.skillDir(id, p)
134+ const name = names.get(p) ?? path.basename(dir)
135+ result.imported.push(
136+ await catalog.importSkillDirectory(
137+ dir,
138+ name,
139+ { kind: 'marketplace', source: id, sourcePath: p, importedAt: new Date().toISOString() },
140+ { overwrite: body.overwrite },
141+ ),
142+ )
143+ } catch (e) {
144+ result.skipped.push({ id: p, reason: e instanceof Error ? e.message : String(e) })
145+ }
146+ }
147+ if (result.imported.length > 0) ctx.catalogChanged()
148+ return c.json(result)
149+ })
150+}
added packages/server/src/scope-routes.ts +250 -0
new file mode 100644
@@ -0,0 +1,250 @@
1+import {
2+ AGENTS,
3+ CATALOG_ITEM_TYPES,
4+ type AgentId,
5+ type ApplyBody,
6+ type ApplyResponse,
7+ type ApplyResult,
8+ type CatalogRef,
9+ type CatalogStore,
10+ type CatalogItemType,
11+ type ConflictResolution,
12+ type InventoryItem,
13+ type RemoveFromGlobalResult,
14+ type ScopeConfig,
15+ type ScopeInventory,
16+ type ScopeState,
17+ type SyncPlan,
18+} from '@skilly/core'
19+import type { Hono } from 'hono'
20+import { HttpError, isStringArray, readJsonBody, type AppContext, type ScopeRef } from './context.ts'
21+
22+const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog']
23+const HOME_TYPES = new Set(['skill', 'rule', 'agent'])
24+
25+function typedLists(v: unknown, label: string): Partial<Record<CatalogItemType, string[]>> {
26+ if (v === undefined) return {}
27+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`)
28+ const out: Partial<Record<CatalogItemType, string[]>> = {}
29+ for (const [type, list] of Object.entries(v)) {
30+ if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(type)) throw new HttpError(400, `"${label}": unknown item type "${type}"`)
31+ if (!isStringArray(list) || list.some((s) => !s.trim())) throw new HttpError(400, `"${label}.${type}" must be an array of non-empty strings`)
32+ if (list.length > 0) out[type as CatalogItemType] = list
33+ }
34+ return out
35+}
36+
37+function stringLists<K extends string>(v: unknown, label: string, keys: readonly K[]): Record<K, string[]> {
38+ const out = Object.fromEntries(keys.map((k) => [k, [] as string[]])) as Record<K, string[]>
39+ if (v === undefined) return out
40+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`)
41+ const rec = v as Record<string, unknown>
42+ for (const k of keys) {
43+ if (rec[k] === undefined) continue
44+ if (!isStringArray(rec[k])) throw new HttpError(400, `"${label}.${k}" must be a string array`)
45+ out[k] = rec[k] as string[]
46+ }
47+ return out
48+}
49+
50+/** Validates and normalizes a client-provided ScopeConfig. */
51+export function parseScopeConfig(body: Record<string, unknown>): ScopeConfig {
52+ if (body.version !== undefined && body.version !== 1) throw new HttpError(400, 'Unsupported config version')
53+ if (!isStringArray(body.targets)) throw new HttpError(400, '"targets" must be an array of agent ids')
54+ for (const t of body.targets) {
55+ if (!(AGENTS as readonly string[]).includes(t)) throw new HttpError(400, `Unknown agent "${t}"`)
56+ }
57+ if (body.presets !== undefined && !isStringArray(body.presets)) throw new HttpError(400, '"presets" must be a string array')
58+ return {
59+ version: 1,
60+ targets: [...new Set(body.targets as AgentId[])],
61+ presets: (body.presets as string[] | undefined) ?? [],
62+ add: typedLists(body.add, 'add'),
63+ exclude: typedLists(body.exclude, 'exclude'),
64+ onDemand: stringLists(body.onDemand, 'onDemand', ['skills'] as const),
65+ hideGlobal: stringLists(body.hideGlobal, 'hideGlobal', ['skills', 'mcp'] as const),
66+ }
67+}
68+
69+function defaultConfig(inventory: ScopeInventory): ScopeConfig {
70+ return {
71+ version: 1,
72+ targets: inventory.agentsDetected.length > 0 ? [...inventory.agentsDetected] : [...AGENTS],
73+ presets: [],
74+ add: {},
75+ exclude: {},
76+ onDemand: { skills: [] },
77+ hideGlobal: { skills: [], mcp: [] },
78+ }
79+}
80+
81+export function registerScopeRoutes(app: Hono, ctx: AppContext): void {
82+ const { syncEngine: engine } = ctx
83+
84+ async function buildState(scope: ScopeRef): Promise<ScopeState> {
85+ const catalog = await ctx.catalog()
86+ const catalogExists = await catalog.exists()
87+ const inventory = await scope.scan()
88+ const config = (await engine.readConfig(scope.target)) ?? null
89+ const lock = (await engine.readLock(scope.target)) ?? null
90+ let plan: SyncPlan | null = null
91+ if (config) {
92+ try {
93+ plan = await engine.plan(scope.target, config, catalog)
94+ } catch (e) {
95+ // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet.
96+ if (catalogExists) throw e
97+ }
98+ }
99+ let statuses: ScopeState['statuses'] = {}
100+ try {
101+ statuses = await engine.annotate(inventory, catalog, lock ?? undefined)
102+ } catch (e) {
103+ if (catalogExists) throw e
104+ }
105+ return { scope: scope.id, config, inventory, statuses, plan, lock }
106+ }
107+
108+ async function planAndApply(
109+ scope: ScopeRef,
110+ config: ScopeConfig,
111+ catalog: CatalogStore,
112+ resolutions: Record<string, ConflictResolution>,
113+ ): Promise<ApplyResult> {
114+ const plan = await engine.plan(scope.target, config, catalog)
115+ try {
116+ return await engine.apply(scope.target, config, catalog, plan, resolutions)
117+ } finally {
118+ ctx.invalidateScan(scope.id)
119+ }
120+ }
121+
122+ app.get('/api/scopes/:scope/state', async (c) => c.json(await buildState(await ctx.scope(c.req.param('scope')))))
123+
124+ app.put('/api/scopes/:scope/config', async (c) => {
125+ const scope = await ctx.scope(c.req.param('scope'))
126+ const config = parseScopeConfig(await readJsonBody(c))
127+ await engine.writeConfig(scope.target, config)
128+ return c.json(await buildState(scope))
129+ })
130+
131+ app.post('/api/scopes/:scope/apply', async (c) => {
132+ const scope = await ctx.scope(c.req.param('scope'))
133+ const body = (await readJsonBody(c)) as Partial<ApplyBody>
134+ const resolutions: Record<string, ConflictResolution> = {}
135+ if (body.resolutions !== undefined) {
136+ if (!body.resolutions || typeof body.resolutions !== 'object' || Array.isArray(body.resolutions)) {
137+ throw new HttpError(400, '"resolutions" must be an object')
138+ }
139+ for (const [id, r] of Object.entries(body.resolutions)) {
140+ if (typeof r !== 'string' || !RESOLUTIONS.includes(r)) throw new HttpError(400, `Invalid resolution for "${id}"`)
141+ resolutions[id] = r as ConflictResolution
142+ }
143+ }
144+ const config = await engine.readConfig(scope.target)
145+ if (!config) throw new HttpError(400, 'This scope has no configuration yet')
146+ const result = await planAndApply(scope, config, await ctx.catalog(), resolutions)
147+ if (Object.values(resolutions).includes('push-to-catalog')) ctx.catalogChanged()
148+ const response: ApplyResponse = { result, state: await buildState(scope) }
149+ return c.json(response)
150+ })
151+
152+ app.post('/api/home/remove-from-global', async (c) => {
153+ const body = await readJsonBody(c)
154+ if (!isStringArray(body.itemIds) || body.itemIds.length === 0) throw new HttpError(400, '"itemIds" must be a non-empty string array')
155+ if (!isStringArray(body.enableIn)) throw new HttpError(400, '"enableIn" must be a string array')
156+ const itemIds = [...new Set(body.itemIds)]
157+ const enableIn = [...new Set(body.enableIn)]
158+ const home = await ctx.scope('home')
159+ const projects: ScopeRef[] = []
160+ for (const id of enableIn) {
161+ if (id === 'home') throw new HttpError(400, 'Cannot enable items in Home while removing them from Home')
162+ projects.push(await ctx.scope(id))
163+ }
164+
165+ const result: RemoveFromGlobalResult = { removed: [], imported: [], errors: [] }
166+ const failed = new Map<string, string>()
167+ const inventory = await home.scan()
168+ const byId = new Map(inventory.items.map((i) => [i.id, i]))
169+ const catalog = await ctx.catalog()
170+ if (!(await catalog.exists())) await catalog.init()
171+ const snapshot = await catalog.snapshot()
172+
173+ // 1. Import into the catalog.
174+ const refs = new Map<string, CatalogRef>()
175+ const toRemove: InventoryItem[] = []
176+ for (const id of itemIds) {
177+ const item = byId.get(id)
178+ if (!item) {
179+ failed.set(id, 'Item not found in Home')
180+ continue
181+ }
182+ if (!HOME_TYPES.has(item.type)) {
183+ failed.set(id, `Items of type "${item.type}" cannot be removed from Home`)
184+ continue
185+ }
186+ try {
187+ const type = item.type as CatalogItemType
188+ const same = snapshot.items.find((i) => i.type === type && item.hash && i.hash === item.hash)
189+ const sameName = snapshot.items.find((i) => i.type === type && i.name === item.name)
190+ if (same) refs.set(id, { type, name: same.name })
191+ else if (sameName) throw new Error(`A different ${type} named "${item.name}" already exists in the catalog`)
192+ else {
193+ const imported = await catalog.importFromInventory(item)
194+ refs.set(id, { type: imported.type, name: imported.name })
195+ snapshot.items.push(imported)
196+ ctx.catalogChanged()
197+ }
198+ toRemove.push(item)
199+ } catch (e) {
200+ failed.set(id, e instanceof Error ? e.message : String(e))
201+ }
202+ }
203+
204+ for (const ref of refs.values()) {
205+ if (!result.imported.some((r) => r.type === ref.type && r.name === ref.name)) result.imported.push(ref)
206+ }
207+
208+ // 2. Enable in the target projects, then apply them.
209+ for (const project of projects) {
210+ const names = toRemove.map((i) => refs.get(i.id)).filter((r): r is CatalogRef => r !== undefined)
211+ if (names.length === 0) break
212+ try {
213+ const config = (await engine.readConfig(project.target)) ?? defaultConfig(await project.scan())
214+ for (const ref of names) {
215+ const list = (config.add[ref.type] ??= [])
216+ if (!list.includes(ref.name)) list.push(ref.name)
217+ }
218+ await engine.writeConfig(project.target, config)
219+ const applied = await planAndApply(project, config, catalog, {})
220+ for (const item of toRemove) {
221+ const ref = refs.get(item.id)
222+ if (!ref || failed.has(item.id)) continue
223+ const suffix = `:${ref.type}/${ref.name}`
224+ const err = applied.errors.find((e) => e.actionId.endsWith(suffix))
225+ if (err) failed.set(item.id, `Could not enable in project ${project.id}: ${err.message}`)
226+ else if (applied.skipped.some((s) => s.endsWith(suffix))) {
227+ failed.set(item.id, `Could not enable in project ${project.id}: a conflict needs a resolution`)
228+ }
229+ }
230+ } catch (e) {
231+ const msg = `Could not enable in project ${project.id}: ${e instanceof Error ? e.message : String(e)}`
232+ for (const item of toRemove) if (!failed.has(item.id)) failed.set(item.id, msg)
233+ }
234+ }
235+
236+ // 3. Remove from Home only the items that went through everything.
237+ for (const item of toRemove) {
238+ if (failed.has(item.id)) continue
239+ try {
240+ await engine.removeHomeItem(home.target, item)
241+ result.removed.push(item.id)
242+ } catch (e) {
243+ failed.set(item.id, e instanceof Error ? e.message : String(e))
244+ }
245+ }
246+ ctx.invalidateScan()
247+ for (const [id, message] of failed) result.errors.push({ id, message })
248+ return c.json(result)
249+ })
250+}
new file mode 100644
@@ -0,0 +1,250 @@
1+import {
2+ AGENTS,
3+ CATALOG_ITEM_TYPES,
4+ type AgentId,
5+ type ApplyBody,
6+ type ApplyResponse,
7+ type ApplyResult,
8+ type CatalogRef,
9+ type CatalogStore,
10+ type CatalogItemType,
11+ type ConflictResolution,
12+ type InventoryItem,
13+ type RemoveFromGlobalResult,
14+ type ScopeConfig,
15+ type ScopeInventory,
16+ type ScopeState,
17+ type SyncPlan,
18+} from '@skilly/core'
19+import type { Hono } from 'hono'
20+import { HttpError, isStringArray, readJsonBody, type AppContext, type ScopeRef } from './context.ts'
21+
22+const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog']
23+const HOME_TYPES = new Set(['skill', 'rule', 'agent'])
24+
25+function typedLists(v: unknown, label: string): Partial<Record<CatalogItemType, string[]>> {
26+ if (v === undefined) return {}
27+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`)
28+ const out: Partial<Record<CatalogItemType, string[]>> = {}
29+ for (const [type, list] of Object.entries(v)) {
30+ if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(type)) throw new HttpError(400, `"${label}": unknown item type "${type}"`)
31+ if (!isStringArray(list) || list.some((s) => !s.trim())) throw new HttpError(400, `"${label}.${type}" must be an array of non-empty strings`)
32+ if (list.length > 0) out[type as CatalogItemType] = list
33+ }
34+ return out
35+}
36+
37+function stringLists<K extends string>(v: unknown, label: string, keys: readonly K[]): Record<K, string[]> {
38+ const out = Object.fromEntries(keys.map((k) => [k, [] as string[]])) as Record<K, string[]>
39+ if (v === undefined) return out
40+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`)
41+ const rec = v as Record<string, unknown>
42+ for (const k of keys) {
43+ if (rec[k] === undefined) continue
44+ if (!isStringArray(rec[k])) throw new HttpError(400, `"${label}.${k}" must be a string array`)
45+ out[k] = rec[k] as string[]
46+ }
47+ return out
48+}
49+
50+/** Validates and normalizes a client-provided ScopeConfig. */
51+export function parseScopeConfig(body: Record<string, unknown>): ScopeConfig {
52+ if (body.version !== undefined && body.version !== 1) throw new HttpError(400, 'Unsupported config version')
53+ if (!isStringArray(body.targets)) throw new HttpError(400, '"targets" must be an array of agent ids')
54+ for (const t of body.targets) {
55+ if (!(AGENTS as readonly string[]).includes(t)) throw new HttpError(400, `Unknown agent "${t}"`)
56+ }
57+ if (body.presets !== undefined && !isStringArray(body.presets)) throw new HttpError(400, '"presets" must be a string array')
58+ return {
59+ version: 1,
60+ targets: [...new Set(body.targets as AgentId[])],
61+ presets: (body.presets as string[] | undefined) ?? [],
62+ add: typedLists(body.add, 'add'),
63+ exclude: typedLists(body.exclude, 'exclude'),
64+ onDemand: stringLists(body.onDemand, 'onDemand', ['skills'] as const),
65+ hideGlobal: stringLists(body.hideGlobal, 'hideGlobal', ['skills', 'mcp'] as const),
66+ }
67+}
68+
69+function defaultConfig(inventory: ScopeInventory): ScopeConfig {
70+ return {
71+ version: 1,
72+ targets: inventory.agentsDetected.length > 0 ? [...inventory.agentsDetected] : [...AGENTS],
73+ presets: [],
74+ add: {},
75+ exclude: {},
76+ onDemand: { skills: [] },
77+ hideGlobal: { skills: [], mcp: [] },
78+ }
79+}
80+
81+export function registerScopeRoutes(app: Hono, ctx: AppContext): void {
82+ const { syncEngine: engine } = ctx
83+
84+ async function buildState(scope: ScopeRef): Promise<ScopeState> {
85+ const catalog = await ctx.catalog()
86+ const catalogExists = await catalog.exists()
87+ const inventory = await scope.scan()
88+ const config = (await engine.readConfig(scope.target)) ?? null
89+ const lock = (await engine.readLock(scope.target)) ?? null
90+ let plan: SyncPlan | null = null
91+ if (config) {
92+ try {
93+ plan = await engine.plan(scope.target, config, catalog)
94+ } catch (e) {
95+ // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet.
96+ if (catalogExists) throw e
97+ }
98+ }
99+ let statuses: ScopeState['statuses'] = {}
100+ try {
101+ statuses = await engine.annotate(inventory, catalog, lock ?? undefined)
102+ } catch (e) {
103+ if (catalogExists) throw e
104+ }
105+ return { scope: scope.id, config, inventory, statuses, plan, lock }
106+ }
107+
108+ async function planAndApply(
109+ scope: ScopeRef,
110+ config: ScopeConfig,
111+ catalog: CatalogStore,
112+ resolutions: Record<string, ConflictResolution>,
113+ ): Promise<ApplyResult> {
114+ const plan = await engine.plan(scope.target, config, catalog)
115+ try {
116+ return await engine.apply(scope.target, config, catalog, plan, resolutions)
117+ } finally {
118+ ctx.invalidateScan(scope.id)
119+ }
120+ }
121+
122+ app.get('/api/scopes/:scope/state', async (c) => c.json(await buildState(await ctx.scope(c.req.param('scope')))))
123+
124+ app.put('/api/scopes/:scope/config', async (c) => {
125+ const scope = await ctx.scope(c.req.param('scope'))
126+ const config = parseScopeConfig(await readJsonBody(c))
127+ await engine.writeConfig(scope.target, config)
128+ return c.json(await buildState(scope))
129+ })
130+
131+ app.post('/api/scopes/:scope/apply', async (c) => {
132+ const scope = await ctx.scope(c.req.param('scope'))
133+ const body = (await readJsonBody(c)) as Partial<ApplyBody>
134+ const resolutions: Record<string, ConflictResolution> = {}
135+ if (body.resolutions !== undefined) {
136+ if (!body.resolutions || typeof body.resolutions !== 'object' || Array.isArray(body.resolutions)) {
137+ throw new HttpError(400, '"resolutions" must be an object')
138+ }
139+ for (const [id, r] of Object.entries(body.resolutions)) {
140+ if (typeof r !== 'string' || !RESOLUTIONS.includes(r)) throw new HttpError(400, `Invalid resolution for "${id}"`)
141+ resolutions[id] = r as ConflictResolution
142+ }
143+ }
144+ const config = await engine.readConfig(scope.target)
145+ if (!config) throw new HttpError(400, 'This scope has no configuration yet')
146+ const result = await planAndApply(scope, config, await ctx.catalog(), resolutions)
147+ if (Object.values(resolutions).includes('push-to-catalog')) ctx.catalogChanged()
148+ const response: ApplyResponse = { result, state: await buildState(scope) }
149+ return c.json(response)
150+ })
151+
152+ app.post('/api/home/remove-from-global', async (c) => {
153+ const body = await readJsonBody(c)
154+ if (!isStringArray(body.itemIds) || body.itemIds.length === 0) throw new HttpError(400, '"itemIds" must be a non-empty string array')
155+ if (!isStringArray(body.enableIn)) throw new HttpError(400, '"enableIn" must be a string array')
156+ const itemIds = [...new Set(body.itemIds)]
157+ const enableIn = [...new Set(body.enableIn)]
158+ const home = await ctx.scope('home')
159+ const projects: ScopeRef[] = []
160+ for (const id of enableIn) {
161+ if (id === 'home') throw new HttpError(400, 'Cannot enable items in Home while removing them from Home')
162+ projects.push(await ctx.scope(id))
163+ }
164+
165+ const result: RemoveFromGlobalResult = { removed: [], imported: [], errors: [] }
166+ const failed = new Map<string, string>()
167+ const inventory = await home.scan()
168+ const byId = new Map(inventory.items.map((i) => [i.id, i]))
169+ const catalog = await ctx.catalog()
170+ if (!(await catalog.exists())) await catalog.init()
171+ const snapshot = await catalog.snapshot()
172+
173+ // 1. Import into the catalog.
174+ const refs = new Map<string, CatalogRef>()
175+ const toRemove: InventoryItem[] = []
176+ for (const id of itemIds) {
177+ const item = byId.get(id)
178+ if (!item) {
179+ failed.set(id, 'Item not found in Home')
180+ continue
181+ }
182+ if (!HOME_TYPES.has(item.type)) {
183+ failed.set(id, `Items of type "${item.type}" cannot be removed from Home`)
184+ continue
185+ }
186+ try {
187+ const type = item.type as CatalogItemType
188+ const same = snapshot.items.find((i) => i.type === type && item.hash && i.hash === item.hash)
189+ const sameName = snapshot.items.find((i) => i.type === type && i.name === item.name)
190+ if (same) refs.set(id, { type, name: same.name })
191+ else if (sameName) throw new Error(`A different ${type} named "${item.name}" already exists in the catalog`)
192+ else {
193+ const imported = await catalog.importFromInventory(item)
194+ refs.set(id, { type: imported.type, name: imported.name })
195+ snapshot.items.push(imported)
196+ ctx.catalogChanged()
197+ }
198+ toRemove.push(item)
199+ } catch (e) {
200+ failed.set(id, e instanceof Error ? e.message : String(e))
201+ }
202+ }
203+
204+ for (const ref of refs.values()) {
205+ if (!result.imported.some((r) => r.type === ref.type && r.name === ref.name)) result.imported.push(ref)
206+ }
207+
208+ // 2. Enable in the target projects, then apply them.
209+ for (const project of projects) {
210+ const names = toRemove.map((i) => refs.get(i.id)).filter((r): r is CatalogRef => r !== undefined)
211+ if (names.length === 0) break
212+ try {
213+ const config = (await engine.readConfig(project.target)) ?? defaultConfig(await project.scan())
214+ for (const ref of names) {
215+ const list = (config.add[ref.type] ??= [])
216+ if (!list.includes(ref.name)) list.push(ref.name)
217+ }
218+ await engine.writeConfig(project.target, config)
219+ const applied = await planAndApply(project, config, catalog, {})
220+ for (const item of toRemove) {
221+ const ref = refs.get(item.id)
222+ if (!ref || failed.has(item.id)) continue
223+ const suffix = `:${ref.type}/${ref.name}`
224+ const err = applied.errors.find((e) => e.actionId.endsWith(suffix))
225+ if (err) failed.set(item.id, `Could not enable in project ${project.id}: ${err.message}`)
226+ else if (applied.skipped.some((s) => s.endsWith(suffix))) {
227+ failed.set(item.id, `Could not enable in project ${project.id}: a conflict needs a resolution`)
228+ }
229+ }
230+ } catch (e) {
231+ const msg = `Could not enable in project ${project.id}: ${e instanceof Error ? e.message : String(e)}`
232+ for (const item of toRemove) if (!failed.has(item.id)) failed.set(item.id, msg)
233+ }
234+ }
235+
236+ // 3. Remove from Home only the items that went through everything.
237+ for (const item of toRemove) {
238+ if (failed.has(item.id)) continue
239+ try {
240+ await engine.removeHomeItem(home.target, item)
241+ result.removed.push(item.id)
242+ } catch (e) {
243+ failed.set(item.id, e instanceof Error ? e.message : String(e))
244+ }
245+ }
246+ ctx.invalidateScan()
247+ for (const [id, message] of failed) result.errors.push({ id, message })
248+ return c.json(result)
249+ })
250+}
modified packages/server/src/store.ts +50 -11
@@ -1,7 +1,7 @@
11 import { createHash } from 'node:crypto'
22 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
33 import path from 'node:path'
4-import { normalizePath } from '@skilly/core'
4+import { normalizePath, type MarketplaceSource, type Settings } from '@skilly/core'
55
66 export interface StoredProject {
77 id: string
@@ -13,6 +13,8 @@ export interface StoredProject {
1313 interface ConfigFile {
1414 version: 1
1515 projects: StoredProject[]
16+ settings?: Partial<Settings>
17+ marketplaces?: MarketplaceSource[]
1618 }
1719
1820 export function projectId(p: string): string {
@@ -27,33 +29,70 @@ export class ProjectStore {
2729 this.file = path.join(configDir, 'config.json')
2830 }
2931
30- async list(): Promise<StoredProject[]> {
32+ private async read(): Promise<ConfigFile> {
3133 try {
3234 const text = (await readFile(this.file, 'utf8')).replace(/^/, '')
3335 const data = JSON.parse(text) as Partial<ConfigFile>
34- return Array.isArray(data.projects) ? data.projects : []
36+ return {
37+ ...data,
38+ version: 1,
39+ projects: Array.isArray(data.projects) ? data.projects : [],
40+ marketplaces: Array.isArray(data.marketplaces) ? data.marketplaces : [],
41+ settings: data.settings && typeof data.settings === 'object' ? data.settings : {},
42+ }
3543 } catch {
36- return []
44+ return { version: 1, projects: [], marketplaces: [], settings: {} }
45+ }
46+ }
47+
48+ async list(): Promise<StoredProject[]> {
49+ return (await this.read()).projects
50+ }
51+
52+ /** Effective settings: stored values over the defaults. */
53+ async getSettings(): Promise<Settings> {
54+ const s = (await this.read()).settings ?? {}
55+ return {
56+ catalogPath: typeof s.catalogPath === 'string' && s.catalogPath ? s.catalogPath : path.join(this.configDir, 'catalog'),
57+ autoSync: typeof s.autoSync === 'boolean' ? s.autoSync : false,
3758 }
3859 }
3960
61+ setSettings(settings: Settings): Promise<void> {
62+ return this.mutateConfig((cfg) => {
63+ cfg.settings = { ...settings }
64+ })
65+ }
66+
67+ /** User marketplace sources (built-in ones live in core). */
68+ async listMarketplaces(): Promise<MarketplaceSource[]> {
69+ return (await this.read()).marketplaces ?? []
70+ }
71+
72+ mutateMarketplaces<T>(fn: (sources: MarketplaceSource[]) => T): Promise<T> {
73+ return this.mutateConfig((cfg) => fn((cfg.marketplaces ??= [])))
74+ }
75+
4076 /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */
41- private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> {
77+ private mutateConfig<T>(fn: (cfg: ConfigFile) => T): Promise<T> {
4278 const run = this.queue.then(async () => {
43- const projects = await this.list()
44- const result = fn(projects)
45- await this.save(projects)
79+ const cfg = await this.read()
80+ const result = fn(cfg)
81+ await this.save(cfg)
4682 return result
4783 })
4884 this.queue = run.catch(() => undefined)
4985 return run
5086 }
5187
52- private async save(projects: StoredProject[]): Promise<void> {
88+ private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> {
89+ return this.mutateConfig((cfg) => fn(cfg.projects))
90+ }
91+
92+ private async save(cfg: ConfigFile): Promise<void> {
5393 await mkdir(this.configDir, { recursive: true })
5494 const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
55- const body: ConfigFile = { version: 1, projects }
56- await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8')
95+ await writeFile(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8')
5796 await rename(tmp, this.file)
5897 }
5998
@@ -1,7 +1,7 @@
1 import { createHash } from 'node:crypto'1 import { createHash } from 'node:crypto'
2 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'2 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3 import path from 'node:path'3 import path from 'node:path'
4-import { normalizePath } from '@skilly/core'4+import { normalizePath, type MarketplaceSource, type Settings } from '@skilly/core'
5 5
6 export interface StoredProject {6 export interface StoredProject {
7 id: string7 id: string
@@ -13,6 +13,8 @@ export interface StoredProject {
13 interface ConfigFile {13 interface ConfigFile {
14 version: 114 version: 1
15 projects: StoredProject[]15 projects: StoredProject[]
16+ settings?: Partial<Settings>
17+ marketplaces?: MarketplaceSource[]
16 }18 }
17 19
18 export function projectId(p: string): string {20 export function projectId(p: string): string {
@@ -27,33 +29,70 @@ export class ProjectStore {
27 this.file = path.join(configDir, 'config.json')29 this.file = path.join(configDir, 'config.json')
28 }30 }
29 31
30- async list(): Promise<StoredProject[]> {32+ private async read(): Promise<ConfigFile> {
31 try {33 try {
32 const text = (await readFile(this.file, 'utf8')).replace(/^/, '')34 const text = (await readFile(this.file, 'utf8')).replace(/^/, '')
33 const data = JSON.parse(text) as Partial<ConfigFile>35 const data = JSON.parse(text) as Partial<ConfigFile>
34- return Array.isArray(data.projects) ? data.projects : []36+ return {
37+ ...data,
38+ version: 1,
39+ projects: Array.isArray(data.projects) ? data.projects : [],
40+ marketplaces: Array.isArray(data.marketplaces) ? data.marketplaces : [],
41+ settings: data.settings && typeof data.settings === 'object' ? data.settings : {},
42+ }
35 } catch {43 } catch {
36- return []44+ return { version: 1, projects: [], marketplaces: [], settings: {} }
45+ }
46+ }
47+
48+ async list(): Promise<StoredProject[]> {
49+ return (await this.read()).projects
50+ }
51+
52+ /** Effective settings: stored values over the defaults. */
53+ async getSettings(): Promise<Settings> {
54+ const s = (await this.read()).settings ?? {}
55+ return {
56+ catalogPath: typeof s.catalogPath === 'string' && s.catalogPath ? s.catalogPath : path.join(this.configDir, 'catalog'),
57+ autoSync: typeof s.autoSync === 'boolean' ? s.autoSync : false,
37 }58 }
38 }59 }
39 60
61+ setSettings(settings: Settings): Promise<void> {
62+ return this.mutateConfig((cfg) => {
63+ cfg.settings = { ...settings }
64+ })
65+ }
66+
67+ /** User marketplace sources (built-in ones live in core). */
68+ async listMarketplaces(): Promise<MarketplaceSource[]> {
69+ return (await this.read()).marketplaces ?? []
70+ }
71+
72+ mutateMarketplaces<T>(fn: (sources: MarketplaceSource[]) => T): Promise<T> {
73+ return this.mutateConfig((cfg) => fn((cfg.marketplaces ??= [])))
74+ }
75+
40 /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */76 /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */
41- private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> {77+ private mutateConfig<T>(fn: (cfg: ConfigFile) => T): Promise<T> {
42 const run = this.queue.then(async () => {78 const run = this.queue.then(async () => {
43- const projects = await this.list()79+ const cfg = await this.read()
44- const result = fn(projects)80+ const result = fn(cfg)
45- await this.save(projects)81+ await this.save(cfg)
46 return result82 return result
47 })83 })
48 this.queue = run.catch(() => undefined)84 this.queue = run.catch(() => undefined)
49 return run85 return run
50 }86 }
51 87
52- private async save(projects: StoredProject[]): Promise<void> {88+ private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> {
89+ return this.mutateConfig((cfg) => fn(cfg.projects))
90+ }
91+
92+ private async save(cfg: ConfigFile): Promise<void> {
53 await mkdir(this.configDir, { recursive: true })93 await mkdir(this.configDir, { recursive: true })
54 const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`94 const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
55- const body: ConfigFile = { version: 1, projects }95+ await writeFile(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8')
56- await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8')
57 await rename(tmp, this.file)96 await rename(tmp, this.file)
58 }97 }
59 98
added packages/server/test/catalog.test.ts +213 -0
new file mode 100644
@@ -0,0 +1,213 @@
1+import { execFileSync } from 'node:child_process'
2+import { mkdir, readFile } from 'node:fs/promises'
3+import path from 'node:path'
4+import type { CatalogItem, CatalogItemDetail, CatalogResponse, FileContent, GitStatus, ImportResult, Settings } from '@skilly/core'
5+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts'
7+
8+let env: Env
9+
10+beforeEach(async () => {
11+ env = await makeEnv({ syncEngine: fakeEngine() })
12+})
13+
14+afterEach(() => env.cleanup())
15+
16+describe('settings', () => {
17+ it('defaults, validates and persists', async () => {
18+ const s = (await (await env.req('/api/settings')).json()) as Settings
19+ expect(s).toEqual({ catalogPath: env.catalogDir, autoSync: false })
20+ expect((await env.send('PUT', '/api/settings', { catalogPath: 'relative' })).status).toBe(400)
21+ expect((await env.send('PUT', '/api/settings', { autoSync: 'yes' })).status).toBe(400)
22+ const other = path.join(env.tmp, 'other-catalog')
23+ const res = await env.send('PUT', '/api/settings', { catalogPath: other, autoSync: true })
24+ expect(await res.json()).toEqual({ catalogPath: other, autoSync: true })
25+ expect(JSON.parse(await readFile(path.join(env.configDir, 'config.json'), 'utf8')).settings.catalogPath).toBe(other)
26+ })
27+
28+ it('keeps projects when settings are written (and vice versa)', async () => {
29+ await env.send('POST', '/api/projects', { path: env.project })
30+ await env.send('PUT', '/api/settings', { autoSync: true })
31+ expect(((await (await env.req('/api/projects')).json()) as unknown[]).length).toBe(1)
32+ })
33+
34+ it('re-opens the catalog at the new path', async () => {
35+ const other = path.join(env.tmp, 'other-catalog')
36+ await env.send('PUT', '/api/settings', { catalogPath: other })
37+ await env.send('POST', '/api/catalog/init')
38+ const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse
39+ expect(cat.snapshot.root).toBe(other)
40+ expect(cat.snapshot.exists).toBe(true)
41+ })
42+})
43+
44+describe('catalog', () => {
45+ it('reports a missing catalog without failing', async () => {
46+ const res = await env.req('/api/catalog')
47+ expect(res.status).toBe(200)
48+ const cat = (await res.json()) as CatalogResponse
49+ expect(cat.snapshot.exists).toBe(false)
50+ expect(cat.git.isRepo).toBe(false)
51+ })
52+
53+ it('init creates the layout and a git repo', async () => {
54+ const cat = (await (await env.send('POST', '/api/catalog/init')).json()) as CatalogResponse
55+ expect(cat.snapshot.exists).toBe(true)
56+ expect(cat.git.isRepo).toBe(true)
57+ // idempotent
58+ expect((await env.send('POST', '/api/catalog/init')).status).toBe(200)
59+ })
60+
61+ it('saves, reads, tags, renames and deletes items', async () => {
62+ await env.send('POST', '/api/catalog/init')
63+ const saved = await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha'), tags: ['a'] })
64+ expect(saved.status).toBe(200)
65+ expect(((await saved.json()) as CatalogItem).tags).toEqual(['a'])
66+ const detail = (await (await env.req('/api/catalog/items/skill/alpha')).json()) as CatalogItemDetail
67+ expect(detail.content).toContain('Body of alpha')
68+ expect((await env.send('PUT', '/api/catalog/items/skill/alpha/tags', { tags: ['x', 'y'] })).status).toBe(204)
69+ const renamed = await env.send('PUT', '/api/catalog/items/skill/beta', { content: skillMd('beta'), previousName: 'alpha' })
70+ expect(renamed.status).toBe(200)
71+ expect((await env.req('/api/catalog/items/skill/alpha')).status).toBe(404)
72+ expect((await env.send('DELETE', '/api/catalog/items/skill/beta')).status).toBe(204)
73+ })
74+
75+ it('answers 400 with the core message on invalid input, never a stack', async () => {
76+ await env.send('POST', '/api/catalog/init')
77+ const bad = await env.send('PUT', '/api/catalog/items/skill/Bad_Name', { content: 'x' })
78+ expect(bad.status).toBe(400)
79+ const body = (await bad.json()) as { error: string }
80+ expect(body.error).toBeTruthy()
81+ expect(body.error).not.toContain(' at ')
82+ expect((await env.send('PUT', '/api/catalog/items/widget/x', { content: 'x' })).status).toBe(400)
83+ expect((await env.send('PUT', '/api/catalog/items/skill/x', { content: 5 })).status).toBe(400)
84+ expect((await env.send('PUT', '/api/catalog/items/mcp/x', { content: 'transport: [' })).status).toBe(400)
85+ })
86+
87+ it('saves and deletes presets', async () => {
88+ await env.send('POST', '/api/catalog/init')
89+ await env.send('PUT', '/api/catalog/items/skill/a', { content: skillMd('a') })
90+ const res = await env.send('PUT', '/api/catalog/presets/web', { description: 'Web', includes: [], items: { skill: ['a'] } })
91+ expect(res.status).toBe(200)
92+ const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse
93+ expect(cat.snapshot.presets.map((p) => p.name)).toEqual(['web'])
94+ expect((await env.send('PUT', '/api/catalog/presets/web2', { items: { widget: ['a'] } })).status).toBe(400)
95+ expect((await env.send('DELETE', '/api/catalog/presets/web')).status).toBe(204)
96+ })
97+
98+ it('imports inventory items and skips instructions / unknown / duplicates', async () => {
99+ await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo'))
100+ await put(`${env.project}/CLAUDE.md`, '# hi')
101+ await env.send('POST', '/api/projects', { path: env.project })
102+ const projects = (await (await env.req('/api/projects')).json()) as { id: string }[]
103+ const inv = (await (await env.req('/api/home')).json()) as { items: { id: string }[] }
104+ const projInv = (await (await env.req(`/api/projects/${projects[0]!.id}/inventory`)).json()) as { items: { id: string; type: string }[] }
105+ const instr = projInv.items.find((i) => i.type === 'instructions')!
106+ const first = (await (
107+ await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id, 'missing'], tags: ['t'] })
108+ ).json()) as ImportResult
109+ expect(first.imported.map((i) => i.name)).toEqual(['foo'])
110+ expect(first.imported[0]!.tags).toEqual(['t'])
111+ expect(first.skipped.map((s) => s.id)).toEqual(['missing'])
112+ const again = (await (await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id] })).json()) as ImportResult
113+ expect(again.imported).toEqual([])
114+ expect(again.skipped[0]!.reason).toMatch(/exist/i)
115+ const instrRes = (await (
116+ await env.send('POST', '/api/catalog/import', { scope: projects[0]!.id, itemIds: [instr.id] })
117+ ).json()) as ImportResult
118+ expect(instrRes.skipped[0]!.id).toBe(instr.id)
119+ expect((await env.send('POST', '/api/catalog/import', { scope: 'nope', itemIds: [] })).status).toBe(404)
120+ })
121+
122+ it('serves files from the catalog through /api/files', async () => {
123+ await env.send('POST', '/api/catalog/init')
124+ await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') })
125+ const file = path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md')
126+ const res = await env.req(`/api/files?path=${encodeURIComponent(file)}`)
127+ expect(res.status).toBe(200)
128+ expect(((await res.json()) as FileContent).content).toContain('alpha')
129+ const outside = path.join(env.tmp, 'outside.txt')
130+ await put(outside, 'x')
131+ expect((await env.req(`/api/files?path=${encodeURIComponent(outside)}`)).status).toBe(403)
132+ })
133+})
134+
135+describe('catalog git', () => {
136+ let remote: string
137+
138+ beforeEach(async () => {
139+ remote = path.join(env.tmp, 'remote.git')
140+ await mkdir(remote, { recursive: true })
141+ execFileSync('git', ['init', '--bare', '-q', '-b', 'main', remote], { stdio: 'ignore' })
142+ await env.send('POST', '/api/catalog/init')
143+ git(env.catalogDir, 'checkout', '-q', '-B', 'main')
144+ })
145+
146+ it('fails with 400 before the catalog exists', async () => {
147+ const e2 = await makeEnv({ syncEngine: fakeEngine() })
148+ try {
149+ const res = await e2.send('POST', '/api/catalog/git/sync')
150+ expect(res.status).toBe(400)
151+ expect(((await res.json()) as { error: string }).error).toBeTruthy()
152+ } finally {
153+ await e2.cleanup()
154+ }
155+ })
156+
157+ it('sets the remote, commits, pushes and syncs', async () => {
158+ expect((await env.send('POST', '/api/catalog/git/remote', { url: '--upload-pack=x' })).status).toBe(400)
159+ const withRemote = (await (await env.send('POST', '/api/catalog/git/remote', { url: remote })).json()) as GitStatus
160+ expect(withRemote.remote).toBeTruthy()
161+ await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') })
162+ expect(((await (await env.req('/api/catalog/git')).json()) as GitStatus).dirty.length).toBeGreaterThan(0)
163+ expect((await env.send('POST', '/api/catalog/git/commit', { message: ' ' })).status).toBe(400)
164+ const committed = (await (await env.send('POST', '/api/catalog/git/commit', { message: 'add alpha' })).json()) as GitStatus
165+ expect(committed.dirty).toEqual([])
166+ expect(committed.lastCommit?.message).toBe('add alpha')
167+ const pushed = (await (await env.send('POST', '/api/catalog/git/push')).json()) as GitStatus
168+ expect(pushed.ahead).toBe(0)
169+ expect(git(remote, 'log', '--oneline', 'main')).toContain('add alpha')
170+ expect((await env.send('POST', '/api/catalog/git/sync')).status).toBe(200)
171+ expect((await env.send('POST', '/api/catalog/git/pull')).status).toBe(200)
172+ })
173+
174+ it('validates resolve input', async () => {
175+ expect((await env.send('POST', '/api/catalog/git/resolve', { path: 'a.md', side: 'mine' })).status).toBe(400)
176+ expect((await env.send('POST', '/api/catalog/git/resolve', { side: 'ours' })).status).toBe(400)
177+ })
178+
179+ it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => {
180+ await env.send('PUT', '/api/settings', { autoSync: true })
181+ await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') })
182+ await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '')
183+ expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly')
184+
185+ await env.send('POST', '/api/catalog/git/remote', { url: remote })
186+ await env.send('PUT', '/api/catalog/items/skill/auto2', { content: skillMd('auto2') })
187+ await waitFor(() => safe(() => git(remote, 'log', '--oneline', 'main')).includes('skilly'))
188+ expect(git(remote, 'ls-tree', '-r', '--name-only', 'main')).toContain('skills/auto2/SKILL.md')
189+ })
190+
191+ it('does not commit when autoSync is off', async () => {
192+ await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') })
193+ await new Promise((r) => setTimeout(r, 300))
194+ expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('')
195+ })
196+})
197+
198+function safe(fn: () => string): string {
199+ try {
200+ return fn()
201+ } catch {
202+ return ''
203+ }
204+}
205+
206+async function waitFor(cond: () => boolean, timeoutMs = 8000): Promise<void> {
207+ const start = Date.now()
208+ while (Date.now() - start < timeoutMs) {
209+ if (cond()) return
210+ await new Promise((r) => setTimeout(r, 50))
211+ }
212+ throw new Error('Timed out waiting for condition')
213+}
new file mode 100644
@@ -0,0 +1,213 @@
1+import { execFileSync } from 'node:child_process'
2+import { mkdir, readFile } from 'node:fs/promises'
3+import path from 'node:path'
4+import type { CatalogItem, CatalogItemDetail, CatalogResponse, FileContent, GitStatus, ImportResult, Settings } from '@skilly/core'
5+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts'
7+
8+let env: Env
9+
10+beforeEach(async () => {
11+ env = await makeEnv({ syncEngine: fakeEngine() })
12+})
13+
14+afterEach(() => env.cleanup())
15+
16+describe('settings', () => {
17+ it('defaults, validates and persists', async () => {
18+ const s = (await (await env.req('/api/settings')).json()) as Settings
19+ expect(s).toEqual({ catalogPath: env.catalogDir, autoSync: false })
20+ expect((await env.send('PUT', '/api/settings', { catalogPath: 'relative' })).status).toBe(400)
21+ expect((await env.send('PUT', '/api/settings', { autoSync: 'yes' })).status).toBe(400)
22+ const other = path.join(env.tmp, 'other-catalog')
23+ const res = await env.send('PUT', '/api/settings', { catalogPath: other, autoSync: true })
24+ expect(await res.json()).toEqual({ catalogPath: other, autoSync: true })
25+ expect(JSON.parse(await readFile(path.join(env.configDir, 'config.json'), 'utf8')).settings.catalogPath).toBe(other)
26+ })
27+
28+ it('keeps projects when settings are written (and vice versa)', async () => {
29+ await env.send('POST', '/api/projects', { path: env.project })
30+ await env.send('PUT', '/api/settings', { autoSync: true })
31+ expect(((await (await env.req('/api/projects')).json()) as unknown[]).length).toBe(1)
32+ })
33+
34+ it('re-opens the catalog at the new path', async () => {
35+ const other = path.join(env.tmp, 'other-catalog')
36+ await env.send('PUT', '/api/settings', { catalogPath: other })
37+ await env.send('POST', '/api/catalog/init')
38+ const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse
39+ expect(cat.snapshot.root).toBe(other)
40+ expect(cat.snapshot.exists).toBe(true)
41+ })
42+})
43+
44+describe('catalog', () => {
45+ it('reports a missing catalog without failing', async () => {
46+ const res = await env.req('/api/catalog')
47+ expect(res.status).toBe(200)
48+ const cat = (await res.json()) as CatalogResponse
49+ expect(cat.snapshot.exists).toBe(false)
50+ expect(cat.git.isRepo).toBe(false)
51+ })
52+
53+ it('init creates the layout and a git repo', async () => {
54+ const cat = (await (await env.send('POST', '/api/catalog/init')).json()) as CatalogResponse
55+ expect(cat.snapshot.exists).toBe(true)
56+ expect(cat.git.isRepo).toBe(true)
57+ // idempotent
58+ expect((await env.send('POST', '/api/catalog/init')).status).toBe(200)
59+ })
60+
61+ it('saves, reads, tags, renames and deletes items', async () => {
62+ await env.send('POST', '/api/catalog/init')
63+ const saved = await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha'), tags: ['a'] })
64+ expect(saved.status).toBe(200)
65+ expect(((await saved.json()) as CatalogItem).tags).toEqual(['a'])
66+ const detail = (await (await env.req('/api/catalog/items/skill/alpha')).json()) as CatalogItemDetail
67+ expect(detail.content).toContain('Body of alpha')
68+ expect((await env.send('PUT', '/api/catalog/items/skill/alpha/tags', { tags: ['x', 'y'] })).status).toBe(204)
69+ const renamed = await env.send('PUT', '/api/catalog/items/skill/beta', { content: skillMd('beta'), previousName: 'alpha' })
70+ expect(renamed.status).toBe(200)
71+ expect((await env.req('/api/catalog/items/skill/alpha')).status).toBe(404)
72+ expect((await env.send('DELETE', '/api/catalog/items/skill/beta')).status).toBe(204)
73+ })
74+
75+ it('answers 400 with the core message on invalid input, never a stack', async () => {
76+ await env.send('POST', '/api/catalog/init')
77+ const bad = await env.send('PUT', '/api/catalog/items/skill/Bad_Name', { content: 'x' })
78+ expect(bad.status).toBe(400)
79+ const body = (await bad.json()) as { error: string }
80+ expect(body.error).toBeTruthy()
81+ expect(body.error).not.toContain(' at ')
82+ expect((await env.send('PUT', '/api/catalog/items/widget/x', { content: 'x' })).status).toBe(400)
83+ expect((await env.send('PUT', '/api/catalog/items/skill/x', { content: 5 })).status).toBe(400)
84+ expect((await env.send('PUT', '/api/catalog/items/mcp/x', { content: 'transport: [' })).status).toBe(400)
85+ })
86+
87+ it('saves and deletes presets', async () => {
88+ await env.send('POST', '/api/catalog/init')
89+ await env.send('PUT', '/api/catalog/items/skill/a', { content: skillMd('a') })
90+ const res = await env.send('PUT', '/api/catalog/presets/web', { description: 'Web', includes: [], items: { skill: ['a'] } })
91+ expect(res.status).toBe(200)
92+ const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse
93+ expect(cat.snapshot.presets.map((p) => p.name)).toEqual(['web'])
94+ expect((await env.send('PUT', '/api/catalog/presets/web2', { items: { widget: ['a'] } })).status).toBe(400)
95+ expect((await env.send('DELETE', '/api/catalog/presets/web')).status).toBe(204)
96+ })
97+
98+ it('imports inventory items and skips instructions / unknown / duplicates', async () => {
99+ await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo'))
100+ await put(`${env.project}/CLAUDE.md`, '# hi')
101+ await env.send('POST', '/api/projects', { path: env.project })
102+ const projects = (await (await env.req('/api/projects')).json()) as { id: string }[]
103+ const inv = (await (await env.req('/api/home')).json()) as { items: { id: string }[] }
104+ const projInv = (await (await env.req(`/api/projects/${projects[0]!.id}/inventory`)).json()) as { items: { id: string; type: string }[] }
105+ const instr = projInv.items.find((i) => i.type === 'instructions')!
106+ const first = (await (
107+ await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id, 'missing'], tags: ['t'] })
108+ ).json()) as ImportResult
109+ expect(first.imported.map((i) => i.name)).toEqual(['foo'])
110+ expect(first.imported[0]!.tags).toEqual(['t'])
111+ expect(first.skipped.map((s) => s.id)).toEqual(['missing'])
112+ const again = (await (await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id] })).json()) as ImportResult
113+ expect(again.imported).toEqual([])
114+ expect(again.skipped[0]!.reason).toMatch(/exist/i)
115+ const instrRes = (await (
116+ await env.send('POST', '/api/catalog/import', { scope: projects[0]!.id, itemIds: [instr.id] })
117+ ).json()) as ImportResult
118+ expect(instrRes.skipped[0]!.id).toBe(instr.id)
119+ expect((await env.send('POST', '/api/catalog/import', { scope: 'nope', itemIds: [] })).status).toBe(404)
120+ })
121+
122+ it('serves files from the catalog through /api/files', async () => {
123+ await env.send('POST', '/api/catalog/init')
124+ await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') })
125+ const file = path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md')
126+ const res = await env.req(`/api/files?path=${encodeURIComponent(file)}`)
127+ expect(res.status).toBe(200)
128+ expect(((await res.json()) as FileContent).content).toContain('alpha')
129+ const outside = path.join(env.tmp, 'outside.txt')
130+ await put(outside, 'x')
131+ expect((await env.req(`/api/files?path=${encodeURIComponent(outside)}`)).status).toBe(403)
132+ })
133+})
134+
135+describe('catalog git', () => {
136+ let remote: string
137+
138+ beforeEach(async () => {
139+ remote = path.join(env.tmp, 'remote.git')
140+ await mkdir(remote, { recursive: true })
141+ execFileSync('git', ['init', '--bare', '-q', '-b', 'main', remote], { stdio: 'ignore' })
142+ await env.send('POST', '/api/catalog/init')
143+ git(env.catalogDir, 'checkout', '-q', '-B', 'main')
144+ })
145+
146+ it('fails with 400 before the catalog exists', async () => {
147+ const e2 = await makeEnv({ syncEngine: fakeEngine() })
148+ try {
149+ const res = await e2.send('POST', '/api/catalog/git/sync')
150+ expect(res.status).toBe(400)
151+ expect(((await res.json()) as { error: string }).error).toBeTruthy()
152+ } finally {
153+ await e2.cleanup()
154+ }
155+ })
156+
157+ it('sets the remote, commits, pushes and syncs', async () => {
158+ expect((await env.send('POST', '/api/catalog/git/remote', { url: '--upload-pack=x' })).status).toBe(400)
159+ const withRemote = (await (await env.send('POST', '/api/catalog/git/remote', { url: remote })).json()) as GitStatus
160+ expect(withRemote.remote).toBeTruthy()
161+ await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') })
162+ expect(((await (await env.req('/api/catalog/git')).json()) as GitStatus).dirty.length).toBeGreaterThan(0)
163+ expect((await env.send('POST', '/api/catalog/git/commit', { message: ' ' })).status).toBe(400)
164+ const committed = (await (await env.send('POST', '/api/catalog/git/commit', { message: 'add alpha' })).json()) as GitStatus
165+ expect(committed.dirty).toEqual([])
166+ expect(committed.lastCommit?.message).toBe('add alpha')
167+ const pushed = (await (await env.send('POST', '/api/catalog/git/push')).json()) as GitStatus
168+ expect(pushed.ahead).toBe(0)
169+ expect(git(remote, 'log', '--oneline', 'main')).toContain('add alpha')
170+ expect((await env.send('POST', '/api/catalog/git/sync')).status).toBe(200)
171+ expect((await env.send('POST', '/api/catalog/git/pull')).status).toBe(200)
172+ })
173+
174+ it('validates resolve input', async () => {
175+ expect((await env.send('POST', '/api/catalog/git/resolve', { path: 'a.md', side: 'mine' })).status).toBe(400)
176+ expect((await env.send('POST', '/api/catalog/git/resolve', { side: 'ours' })).status).toBe(400)
177+ })
178+
179+ it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => {
180+ await env.send('PUT', '/api/settings', { autoSync: true })
181+ await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') })
182+ await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '')
183+ expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly')
184+
185+ await env.send('POST', '/api/catalog/git/remote', { url: remote })
186+ await env.send('PUT', '/api/catalog/items/skill/auto2', { content: skillMd('auto2') })
187+ await waitFor(() => safe(() => git(remote, 'log', '--oneline', 'main')).includes('skilly'))
188+ expect(git(remote, 'ls-tree', '-r', '--name-only', 'main')).toContain('skills/auto2/SKILL.md')
189+ })
190+
191+ it('does not commit when autoSync is off', async () => {
192+ await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') })
193+ await new Promise((r) => setTimeout(r, 300))
194+ expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('')
195+ })
196+})
197+
198+function safe(fn: () => string): string {
199+ try {
200+ return fn()
201+ } catch {
202+ return ''
203+ }
204+}
205+
206+async function waitFor(cond: () => boolean, timeoutMs = 8000): Promise<void> {
207+ const start = Date.now()
208+ while (Date.now() - start < timeoutMs) {
209+ if (cond()) return
210+ await new Promise((r) => setTimeout(r, 50))
211+ }
212+ throw new Error('Timed out waiting for condition')
213+}
added packages/server/test/helpers.ts +147 -0
new file mode 100644
@@ -0,0 +1,147 @@
1+import { execFileSync } from 'node:child_process'
2+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
3+import os from 'node:os'
4+import path from 'node:path'
5+import type { ApplyResult, ConflictResolution, InventoryItem, ScopeConfig, SyncEngine, SyncPlan, SyncTarget } from '@skilly/core'
6+import type { Hono } from 'hono'
7+import { createApp, type AppOptions } from '../src/index.ts'
8+
9+export const TOKEN = 'test-token'
10+
11+export interface Env {
12+ tmp: string
13+ home: string
14+ configDir: string
15+ catalogDir: string
16+ project: string
17+ app: Hono
18+ req(url: string, init?: RequestInit): Promise<Response>
19+ send(method: string, url: string, body?: unknown): Promise<Response>
20+ cleanup(): Promise<void>
21+}
22+
23+export async function put(file: string, content: string): Promise<void> {
24+ await mkdir(path.dirname(file), { recursive: true })
25+ await writeFile(file, content)
26+}
27+
28+export const skillMd = (name: string, description = 'A test skill'): string =>
29+ `---\nname: ${name}\ndescription: ${description}\n---\nBody of ${name}\n`
30+
31+export function git(cwd: string, ...args: string[]): string {
32+ return execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], {
33+ cwd,
34+ encoding: 'utf8',
35+ stdio: ['ignore', 'pipe', 'ignore'],
36+ })
37+}
38+
39+// Commits made by the catalog git wrapper need an identity; never touch the user's global config.
40+process.env.GIT_AUTHOR_NAME = 'Test'
41+process.env.GIT_AUTHOR_EMAIL = 'test@example.com'
42+process.env.GIT_COMMITTER_NAME = 'Test'
43+process.env.GIT_COMMITTER_EMAIL = 'test@example.com'
44+
45+export async function makeEnv(extra: Partial<AppOptions> = {}): Promise<Env> {
46+ const tmp = await realpath(await mkdtemp(path.join(os.tmpdir(), 'skilly-server-')))
47+ const home = path.join(tmp, 'home')
48+ const configDir = path.join(tmp, 'config')
49+ const project = path.join(tmp, 'work', 'my-project')
50+ await mkdir(path.join(home, '.claude'), { recursive: true })
51+ await mkdir(path.join(project, '.claude'), { recursive: true })
52+ const app = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home }, autoSyncDelayMs: 50, ...extra })
53+ const req = (url: string, init: RequestInit = {}) => {
54+ const headers = new Headers(init.headers)
55+ headers.set('x-skilly-token', TOKEN)
56+ return Promise.resolve(app.request(url, { ...init, headers }))
57+ }
58+ const send = (method: string, url: string, body?: unknown) =>
59+ req(url, {
60+ method,
61+ body: body === undefined ? undefined : JSON.stringify(body),
62+ headers: { 'content-type': 'application/json' },
63+ })
64+ return {
65+ tmp,
66+ home,
67+ configDir,
68+ catalogDir: path.join(configDir, 'catalog'),
69+ project,
70+ app,
71+ req,
72+ send,
73+ cleanup: () => rm(tmp, { recursive: true, force: true, maxRetries: 3 }),
74+ }
75+}
76+
77+export interface FakeEngine extends SyncEngine {
78+ calls: { method: string; args: unknown[] }[]
79+ configs: Map<string, ScopeConfig>
80+ /** Make `apply` report errors for these roots. */
81+ failApplyFor: Set<string>
82+ removed: string[]
83+}
84+
85+export function fakeEngine(): FakeEngine {
86+ const calls: FakeEngine['calls'] = []
87+ const configs = new Map<string, ScopeConfig>()
88+ const failApplyFor = new Set<string>()
89+ const removed: string[] = []
90+ const key = (t: SyncTarget): string => `${t.kind}:${t.root}`
91+ const record = (method: string, ...args: unknown[]): void => void calls.push({ method, args })
92+ return {
93+ calls,
94+ configs,
95+ failApplyFor,
96+ removed,
97+ async readConfig(t) {
98+ record('readConfig', t)
99+ return configs.get(key(t))
100+ },
101+ async writeConfig(t, config) {
102+ record('writeConfig', t, config)
103+ configs.set(key(t), config)
104+ },
105+ async readLock(t) {
106+ record('readLock', t)
107+ return undefined
108+ },
109+ async plan(t, config): Promise<SyncPlan> {
110+ record('plan', t, config)
111+ return {
112+ scope: t.kind,
113+ root: t.root,
114+ warnings: [],
115+ missing: [],
116+ actions: Object.entries(config.add).flatMap(([type, names]) =>
117+ (names ?? []).map((name) => ({
118+ id: `add:claude:${type}/${name}`,
119+ kind: 'add' as const,
120+ type: type as 'skill',
121+ name,
122+ agent: 'claude' as const,
123+ files: [],
124+ configKeys: [],
125+ reason: 'test',
126+ })),
127+ ),
128+ }
129+ },
130+ async apply(t, config, _catalog, plan, resolutions: Record<string, ConflictResolution>): Promise<ApplyResult> {
131+ record('apply', t, config, plan, resolutions)
132+ const lock = { version: 1 as const, syncedAt: new Date().toISOString(), entries: [] }
133+ if (failApplyFor.has(t.root)) {
134+ return { applied: [], skipped: [], errors: plan.actions.map((a) => ({ actionId: a.id, message: 'boom' })), lock }
135+ }
136+ return { applied: plan.actions.map((a) => a.id), skipped: [], errors: [], lock }
137+ },
138+ async annotate(inventory) {
139+ record('annotate')
140+ return Object.fromEntries(inventory.items.map((i) => [i.id, { managed: false, state: 'not-in-catalog' as const }]))
141+ },
142+ async removeHomeItem(t, item: InventoryItem) {
143+ record('removeHomeItem', t, item)
144+ removed.push(item.id)
145+ },
146+ }
147+}
new file mode 100644
@@ -0,0 +1,147 @@
1+import { execFileSync } from 'node:child_process'
2+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
3+import os from 'node:os'
4+import path from 'node:path'
5+import type { ApplyResult, ConflictResolution, InventoryItem, ScopeConfig, SyncEngine, SyncPlan, SyncTarget } from '@skilly/core'
6+import type { Hono } from 'hono'
7+import { createApp, type AppOptions } from '../src/index.ts'
8+
9+export const TOKEN = 'test-token'
10+
11+export interface Env {
12+ tmp: string
13+ home: string
14+ configDir: string
15+ catalogDir: string
16+ project: string
17+ app: Hono
18+ req(url: string, init?: RequestInit): Promise<Response>
19+ send(method: string, url: string, body?: unknown): Promise<Response>
20+ cleanup(): Promise<void>
21+}
22+
23+export async function put(file: string, content: string): Promise<void> {
24+ await mkdir(path.dirname(file), { recursive: true })
25+ await writeFile(file, content)
26+}
27+
28+export const skillMd = (name: string, description = 'A test skill'): string =>
29+ `---\nname: ${name}\ndescription: ${description}\n---\nBody of ${name}\n`
30+
31+export function git(cwd: string, ...args: string[]): string {
32+ return execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], {
33+ cwd,
34+ encoding: 'utf8',
35+ stdio: ['ignore', 'pipe', 'ignore'],
36+ })
37+}
38+
39+// Commits made by the catalog git wrapper need an identity; never touch the user's global config.
40+process.env.GIT_AUTHOR_NAME = 'Test'
41+process.env.GIT_AUTHOR_EMAIL = 'test@example.com'
42+process.env.GIT_COMMITTER_NAME = 'Test'
43+process.env.GIT_COMMITTER_EMAIL = 'test@example.com'
44+
45+export async function makeEnv(extra: Partial<AppOptions> = {}): Promise<Env> {
46+ const tmp = await realpath(await mkdtemp(path.join(os.tmpdir(), 'skilly-server-')))
47+ const home = path.join(tmp, 'home')
48+ const configDir = path.join(tmp, 'config')
49+ const project = path.join(tmp, 'work', 'my-project')
50+ await mkdir(path.join(home, '.claude'), { recursive: true })
51+ await mkdir(path.join(project, '.claude'), { recursive: true })
52+ const app = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home }, autoSyncDelayMs: 50, ...extra })
53+ const req = (url: string, init: RequestInit = {}) => {
54+ const headers = new Headers(init.headers)
55+ headers.set('x-skilly-token', TOKEN)
56+ return Promise.resolve(app.request(url, { ...init, headers }))
57+ }
58+ const send = (method: string, url: string, body?: unknown) =>
59+ req(url, {
60+ method,
61+ body: body === undefined ? undefined : JSON.stringify(body),
62+ headers: { 'content-type': 'application/json' },
63+ })
64+ return {
65+ tmp,
66+ home,
67+ configDir,
68+ catalogDir: path.join(configDir, 'catalog'),
69+ project,
70+ app,
71+ req,
72+ send,
73+ cleanup: () => rm(tmp, { recursive: true, force: true, maxRetries: 3 }),
74+ }
75+}
76+
77+export interface FakeEngine extends SyncEngine {
78+ calls: { method: string; args: unknown[] }[]
79+ configs: Map<string, ScopeConfig>
80+ /** Make `apply` report errors for these roots. */
81+ failApplyFor: Set<string>
82+ removed: string[]
83+}
84+
85+export function fakeEngine(): FakeEngine {
86+ const calls: FakeEngine['calls'] = []
87+ const configs = new Map<string, ScopeConfig>()
88+ const failApplyFor = new Set<string>()
89+ const removed: string[] = []
90+ const key = (t: SyncTarget): string => `${t.kind}:${t.root}`
91+ const record = (method: string, ...args: unknown[]): void => void calls.push({ method, args })
92+ return {
93+ calls,
94+ configs,
95+ failApplyFor,
96+ removed,
97+ async readConfig(t) {
98+ record('readConfig', t)
99+ return configs.get(key(t))
100+ },
101+ async writeConfig(t, config) {
102+ record('writeConfig', t, config)
103+ configs.set(key(t), config)
104+ },
105+ async readLock(t) {
106+ record('readLock', t)
107+ return undefined
108+ },
109+ async plan(t, config): Promise<SyncPlan> {
110+ record('plan', t, config)
111+ return {
112+ scope: t.kind,
113+ root: t.root,
114+ warnings: [],
115+ missing: [],
116+ actions: Object.entries(config.add).flatMap(([type, names]) =>
117+ (names ?? []).map((name) => ({
118+ id: `add:claude:${type}/${name}`,
119+ kind: 'add' as const,
120+ type: type as 'skill',
121+ name,
122+ agent: 'claude' as const,
123+ files: [],
124+ configKeys: [],
125+ reason: 'test',
126+ })),
127+ ),
128+ }
129+ },
130+ async apply(t, config, _catalog, plan, resolutions: Record<string, ConflictResolution>): Promise<ApplyResult> {
131+ record('apply', t, config, plan, resolutions)
132+ const lock = { version: 1 as const, syncedAt: new Date().toISOString(), entries: [] }
133+ if (failApplyFor.has(t.root)) {
134+ return { applied: [], skipped: [], errors: plan.actions.map((a) => ({ actionId: a.id, message: 'boom' })), lock }
135+ }
136+ return { applied: plan.actions.map((a) => a.id), skipped: [], errors: [], lock }
137+ },
138+ async annotate(inventory) {
139+ record('annotate')
140+ return Object.fromEntries(inventory.items.map((i) => [i.id, { managed: false, state: 'not-in-catalog' as const }]))
141+ },
142+ async removeHomeItem(t, item: InventoryItem) {
143+ record('removeHomeItem', t, item)
144+ removed.push(item.id)
145+ },
146+ }
147+}
added packages/server/test/integration.test.ts +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+import { syncEngine, type ApplyResponse, type ProjectSummary, type ScopeConfig, type ScopeState } from '@skilly/core'
2+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+import { makeEnv, skillMd, type Env } from './helpers.ts'
4+
5+// The sync engine may still be the placeholder that throws "not implemented".
6+const placeholder =
7+ syncEngine.readConfig.name === 'notImplemented' || /not implemented/i.test(Function.prototype.toString.call(syncEngine.readConfig))
8+
9+let env: Env
10+
11+beforeEach(async () => {
12+ env = await makeEnv() // real sync engine
13+})
14+
15+afterEach(() => env.cleanup())
16+
17+describe('real sync engine', () => {
18+ it.skipIf(placeholder)('PUT config -> state -> apply -> state shows managed items', async () => {
19+ await env.send('POST', '/api/catalog/init')
20+ await env.send('PUT', '/api/catalog/items/skill/hello', { content: skillMd('hello') })
21+ const projectId = ((await (await env.send('POST', '/api/projects', { path: env.project })).json()) as ProjectSummary).id
22+
23+ const before = (await (await env.req(`/api/scopes/${projectId}/state`)).json()) as ScopeState
24+ expect(before.config).toBeNull()
25+
26+ const config: ScopeConfig = {
27+ version: 1,
28+ targets: ['claude'],
29+ presets: [],
30+ add: { skill: ['hello'] },
31+ exclude: {},
32+ onDemand: { skills: [] },
33+ hideGlobal: { skills: [], mcp: [] },
34+ }
35+ const put = (await (await env.send('PUT', `/api/scopes/${projectId}/config`, config)).json()) as ScopeState
36+ expect(put.plan?.actions.some((a) => a.kind === 'add' && a.name === 'hello')).toBe(true)
37+
38+ const applied = (await (await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} })).json()) as ApplyResponse
39+ expect(applied.result.errors).toEqual([])
40+ const skill = applied.state.inventory.items.find((i) => i.type === 'skill' && i.name === 'hello')
41+ expect(skill).toBeDefined()
42+ expect(applied.state.statuses[skill!.id]?.managed).toBe(true)
43+ expect(applied.state.lock?.entries.length).toBeGreaterThan(0)
44+ })
45+})
new file mode 100644
@@ -0,0 +1,45 @@
1+import { syncEngine, type ApplyResponse, type ProjectSummary, type ScopeConfig, type ScopeState } from '@skilly/core'
2+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+import { makeEnv, skillMd, type Env } from './helpers.ts'
4+
5+// The sync engine may still be the placeholder that throws "not implemented".
6+const placeholder =
7+ syncEngine.readConfig.name === 'notImplemented' || /not implemented/i.test(Function.prototype.toString.call(syncEngine.readConfig))
8+
9+let env: Env
10+
11+beforeEach(async () => {
12+ env = await makeEnv() // real sync engine
13+})
14+
15+afterEach(() => env.cleanup())
16+
17+describe('real sync engine', () => {
18+ it.skipIf(placeholder)('PUT config -> state -> apply -> state shows managed items', async () => {
19+ await env.send('POST', '/api/catalog/init')
20+ await env.send('PUT', '/api/catalog/items/skill/hello', { content: skillMd('hello') })
21+ const projectId = ((await (await env.send('POST', '/api/projects', { path: env.project })).json()) as ProjectSummary).id
22+
23+ const before = (await (await env.req(`/api/scopes/${projectId}/state`)).json()) as ScopeState
24+ expect(before.config).toBeNull()
25+
26+ const config: ScopeConfig = {
27+ version: 1,
28+ targets: ['claude'],
29+ presets: [],
30+ add: { skill: ['hello'] },
31+ exclude: {},
32+ onDemand: { skills: [] },
33+ hideGlobal: { skills: [], mcp: [] },
34+ }
35+ const put = (await (await env.send('PUT', `/api/scopes/${projectId}/config`, config)).json()) as ScopeState
36+ expect(put.plan?.actions.some((a) => a.kind === 'add' && a.name === 'hello')).toBe(true)
37+
38+ const applied = (await (await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} })).json()) as ApplyResponse
39+ expect(applied.result.errors).toEqual([])
40+ const skill = applied.state.inventory.items.find((i) => i.type === 'skill' && i.name === 'hello')
41+ expect(skill).toBeDefined()
42+ expect(applied.state.statuses[skill!.id]?.managed).toBe(true)
43+ expect(applied.state.lock?.entries.length).toBeGreaterThan(0)
44+ })
45+})
added packages/server/test/marketplace.test.ts +93 -0
new file mode 100644
@@ -0,0 +1,93 @@
1+import { mkdir } from 'node:fs/promises'
2+import path from 'node:path'
3+import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
4+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts'
6+
7+let env: Env
8+let repo: string
9+let repoUrl: string
10+
11+beforeEach(async () => {
12+ env = await makeEnv({ syncEngine: fakeEngine() })
13+ repo = path.join(env.tmp, 'mkt-repo')
14+ await mkdir(repo, { recursive: true })
15+ git(repo, 'init', '-q')
16+ await put(path.join(repo, 'skills', 'one', 'SKILL.md'), skillMd('one', 'First skill'))
17+ await put(path.join(repo, 'skills', 'one', 'extra.txt'), 'extra')
18+ await put(path.join(repo, 'skills', 'two', 'SKILL.md'), skillMd('two'))
19+ git(repo, 'add', '-A')
20+ git(repo, 'commit', '-q', '-m', 'init')
21+ repoUrl = new URL(`file:///${repo.replace(/\\/g, '/')}`).href
22+})
23+
24+afterEach(() => env.cleanup())
25+
26+describe('marketplaces', () => {
27+ it('lists built-in sources and manages user sources', async () => {
28+ const before = (await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[]
29+ expect(before.length).toBeGreaterThan(0)
30+ expect(before.every((s) => s.builtIn)).toBe(true)
31+
32+ expect((await env.send('POST', '/api/marketplaces', { name: '', kind: 'git', url: repoUrl })).status).toBe(400)
33+ expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'svn', url: repoUrl })).status).toBe(400)
34+ expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'git', url: '--evil' })).status).toBe(400)
35+
36+ const res = await env.send('POST', '/api/marketplaces', { name: 'My Test Repo', kind: 'git', url: repoUrl })
37+ expect(res.status).toBe(201)
38+ const created = (await res.json()) as MarketplaceSource
39+ expect(created.id).toBe('my-test-repo')
40+ expect((await env.send('POST', '/api/marketplaces', { name: 'my test repo', kind: 'git', url: repoUrl })).status).toBe(409)
41+
42+ expect((await env.send('DELETE', `/api/marketplaces/${before[0]!.id}`)).status).toBe(400)
43+ expect((await env.send('DELETE', '/api/marketplaces/nope')).status).toBe(404)
44+ expect((await env.send('DELETE', '/api/marketplaces/my-test-repo')).status).toBe(204)
45+ expect(((await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[]).some((s) => s.id === 'my-test-repo')).toBe(false)
46+ })
47+
48+ it('refreshes, lists skills, previews and imports into the catalog', async () => {
49+ await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl })
50+ const refreshed = (await (await env.send('POST', '/api/marketplaces/local/refresh')).json()) as MarketplaceSource
51+ expect(refreshed.lastFetchedAt).toBeTruthy()
52+
53+ const skills = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[]
54+ expect(skills.map((s) => s.name).sort()).toEqual(['one', 'two'])
55+ const one = skills.find((s) => s.name === 'one')!
56+
57+ const detail = (await (await env.req(`/api/marketplaces/local/skill?path=${encodeURIComponent(one.path)}`)).json()) as MarketplaceSkillDetail
58+ expect(detail.name).toBe('one')
59+ expect(detail.description).toBe('First skill')
60+ expect(detail.content).toContain('Body of one')
61+ expect(detail.files.sort()).toEqual(['SKILL.md', 'extra.txt'])
62+ expect((await env.req('/api/marketplaces/local/skill?path=..%2F..')).status).toBe(400)
63+ expect((await env.req('/api/marketplaces/local/skill')).status).toBe(400)
64+
65+ const imp = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path, 'skills/missing'] })).json()) as ImportResult
66+ expect(imp.imported.map((i) => i.name)).toEqual(['one'])
67+ expect(imp.imported[0]!.origin).toMatchObject({ kind: 'marketplace', source: 'local', sourcePath: one.path })
68+ expect(imp.skipped.map((s) => s.id)).toEqual(['skills/missing'])
69+
70+ const again = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path] })).json()) as ImportResult
71+ expect(again.imported).toEqual([])
72+ expect(again.skipped).toHaveLength(1)
73+ const overwritten = (await (
74+ await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path], overwrite: true })
75+ ).json()) as ImportResult
76+ expect(overwritten.imported).toHaveLength(1)
77+
78+ const after = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[]
79+ expect(after.find((s) => s.name === 'one')!.inCatalog).toBe('same')
80+ })
81+
82+ it('serves cached marketplace files through /api/files', async () => {
83+ await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl })
84+ await env.send('POST', '/api/marketplaces/local/refresh')
85+ const file = path.join(env.configDir, 'cache', 'marketplaces', 'local', 'skills', 'one', 'SKILL.md')
86+ expect((await env.req(`/api/files?path=${encodeURIComponent(file)}`)).status).toBe(200)
87+ })
88+
89+ it('answers 404 for unknown sources', async () => {
90+ expect((await env.send('POST', '/api/marketplaces/nope/refresh')).status).toBe(404)
91+ expect((await env.req('/api/marketplaces/nope/skills')).status).toBe(404)
92+ })
93+})
new file mode 100644
@@ -0,0 +1,93 @@
1+import { mkdir } from 'node:fs/promises'
2+import path from 'node:path'
3+import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
4+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts'
6+
7+let env: Env
8+let repo: string
9+let repoUrl: string
10+
11+beforeEach(async () => {
12+ env = await makeEnv({ syncEngine: fakeEngine() })
13+ repo = path.join(env.tmp, 'mkt-repo')
14+ await mkdir(repo, { recursive: true })
15+ git(repo, 'init', '-q')
16+ await put(path.join(repo, 'skills', 'one', 'SKILL.md'), skillMd('one', 'First skill'))
17+ await put(path.join(repo, 'skills', 'one', 'extra.txt'), 'extra')
18+ await put(path.join(repo, 'skills', 'two', 'SKILL.md'), skillMd('two'))
19+ git(repo, 'add', '-A')
20+ git(repo, 'commit', '-q', '-m', 'init')
21+ repoUrl = new URL(`file:///${repo.replace(/\\/g, '/')}`).href
22+})
23+
24+afterEach(() => env.cleanup())
25+
26+describe('marketplaces', () => {
27+ it('lists built-in sources and manages user sources', async () => {
28+ const before = (await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[]
29+ expect(before.length).toBeGreaterThan(0)
30+ expect(before.every((s) => s.builtIn)).toBe(true)
31+
32+ expect((await env.send('POST', '/api/marketplaces', { name: '', kind: 'git', url: repoUrl })).status).toBe(400)
33+ expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'svn', url: repoUrl })).status).toBe(400)
34+ expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'git', url: '--evil' })).status).toBe(400)
35+
36+ const res = await env.send('POST', '/api/marketplaces', { name: 'My Test Repo', kind: 'git', url: repoUrl })
37+ expect(res.status).toBe(201)
38+ const created = (await res.json()) as MarketplaceSource
39+ expect(created.id).toBe('my-test-repo')
40+ expect((await env.send('POST', '/api/marketplaces', { name: 'my test repo', kind: 'git', url: repoUrl })).status).toBe(409)
41+
42+ expect((await env.send('DELETE', `/api/marketplaces/${before[0]!.id}`)).status).toBe(400)
43+ expect((await env.send('DELETE', '/api/marketplaces/nope')).status).toBe(404)
44+ expect((await env.send('DELETE', '/api/marketplaces/my-test-repo')).status).toBe(204)
45+ expect(((await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[]).some((s) => s.id === 'my-test-repo')).toBe(false)
46+ })
47+
48+ it('refreshes, lists skills, previews and imports into the catalog', async () => {
49+ await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl })
50+ const refreshed = (await (await env.send('POST', '/api/marketplaces/local/refresh')).json()) as MarketplaceSource
51+ expect(refreshed.lastFetchedAt).toBeTruthy()
52+
53+ const skills = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[]
54+ expect(skills.map((s) => s.name).sort()).toEqual(['one', 'two'])
55+ const one = skills.find((s) => s.name === 'one')!
56+
57+ const detail = (await (await env.req(`/api/marketplaces/local/skill?path=${encodeURIComponent(one.path)}`)).json()) as MarketplaceSkillDetail
58+ expect(detail.name).toBe('one')
59+ expect(detail.description).toBe('First skill')
60+ expect(detail.content).toContain('Body of one')
61+ expect(detail.files.sort()).toEqual(['SKILL.md', 'extra.txt'])
62+ expect((await env.req('/api/marketplaces/local/skill?path=..%2F..')).status).toBe(400)
63+ expect((await env.req('/api/marketplaces/local/skill')).status).toBe(400)
64+
65+ const imp = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path, 'skills/missing'] })).json()) as ImportResult
66+ expect(imp.imported.map((i) => i.name)).toEqual(['one'])
67+ expect(imp.imported[0]!.origin).toMatchObject({ kind: 'marketplace', source: 'local', sourcePath: one.path })
68+ expect(imp.skipped.map((s) => s.id)).toEqual(['skills/missing'])
69+
70+ const again = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path] })).json()) as ImportResult
71+ expect(again.imported).toEqual([])
72+ expect(again.skipped).toHaveLength(1)
73+ const overwritten = (await (
74+ await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path], overwrite: true })
75+ ).json()) as ImportResult
76+ expect(overwritten.imported).toHaveLength(1)
77+
78+ const after = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[]
79+ expect(after.find((s) => s.name === 'one')!.inCatalog).toBe('same')
80+ })
81+
82+ it('serves cached marketplace files through /api/files', async () => {
83+ await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl })
84+ await env.send('POST', '/api/marketplaces/local/refresh')
85+ const file = path.join(env.configDir, 'cache', 'marketplaces', 'local', 'skills', 'one', 'SKILL.md')
86+ expect((await env.req(`/api/files?path=${encodeURIComponent(file)}`)).status).toBe(200)
87+ })
88+
89+ it('answers 404 for unknown sources', async () => {
90+ expect((await env.send('POST', '/api/marketplaces/nope/refresh')).status).toBe(404)
91+ expect((await env.req('/api/marketplaces/nope/skills')).status).toBe(404)
92+ })
93+})
added packages/server/test/scopes.test.ts +177 -0
new file mode 100644
@@ -0,0 +1,177 @@
1+import type { ApplyResponse, CatalogItem, ProjectSummary, RemoveFromGlobalResult, ScopeConfig, ScopeState } from '@skilly/core'
2+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+import { fakeEngine, makeEnv, put, skillMd, type Env, type FakeEngine } from './helpers.ts'
4+
5+let env: Env
6+let engine: FakeEngine
7+let projectId: string
8+
9+const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({
10+ version: 1,
11+ targets: ['claude'],
12+ presets: [],
13+ add: { skill: ['foo'] },
14+ exclude: {},
15+ onDemand: { skills: [] },
16+ hideGlobal: { skills: [], mcp: [] },
17+ ...over,
18+})
19+
20+beforeEach(async () => {
21+ engine = fakeEngine()
22+ env = await makeEnv({ syncEngine: engine })
23+ await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo'))
24+ await put(`${env.home}/.claude/skills/bar/SKILL.md`, skillMd('bar'))
25+ const res = await env.send('POST', '/api/projects', { path: env.project })
26+ projectId = ((await res.json()) as ProjectSummary).id
27+})
28+
29+afterEach(() => env.cleanup())
30+
31+describe('scope state and config', () => {
32+ it('returns a state without config and plan, even without a catalog folder', async () => {
33+ const res = await env.req(`/api/scopes/${projectId}/state`)
34+ expect(res.status).toBe(200)
35+ const state = (await res.json()) as ScopeState
36+ expect(state.config).toBeNull()
37+ expect(state.plan).toBeNull()
38+ expect(state.lock).toBeNull()
39+ expect(state.scope).toBe(projectId)
40+ })
41+
42+ it('404s on unknown scopes', async () => {
43+ expect((await env.req('/api/scopes/nope/state')).status).toBe(404)
44+ })
45+
46+ it('validates and writes the config, then returns a fresh state with a plan', async () => {
47+ const res = await env.send('PUT', `/api/scopes/${projectId}/config`, config())
48+ expect(res.status).toBe(200)
49+ const state = (await res.json()) as ScopeState
50+ expect(state.config?.add.skill).toEqual(['foo'])
51+ expect(state.plan?.actions).toHaveLength(1)
52+ const write = engine.calls.find((c) => c.method === 'writeConfig')!
53+ expect(write.args[0]).toMatchObject({ kind: 'project', root: env.project, configDir: env.project, homeDir: env.home })
54+ })
55+
56+ it('uses the skilly config dir as target for Home', async () => {
57+ await env.send('PUT', '/api/scopes/home/config', config())
58+ const write = engine.calls.find((c) => c.method === 'writeConfig')!
59+ expect(write.args[0]).toMatchObject({ kind: 'home', root: env.home, configDir: env.configDir })
60+ })
61+
62+ it('rejects invalid configs', async () => {
63+ const put_ = (body: unknown) => env.send('PUT', `/api/scopes/${projectId}/config`, body)
64+ expect((await put_({ ...config(), targets: ['gemini'] })).status).toBe(400)
65+ expect((await put_({ ...config(), targets: 'claude' })).status).toBe(400)
66+ expect((await put_({ ...config(), presets: [1] })).status).toBe(400)
67+ expect((await put_({ ...config(), add: { widget: ['a'] } })).status).toBe(400)
68+ expect((await put_({ ...config(), add: { skill: 'a' } })).status).toBe(400)
69+ expect((await put_({ ...config(), hideGlobal: { skills: [3] } })).status).toBe(400)
70+ expect((await put_([])).status).toBe(400)
71+ expect(engine.calls.some((c) => c.method === 'writeConfig')).toBe(false)
72+ })
73+})
74+
75+describe('apply', () => {
76+ it('requires a config', async () => {
77+ const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} })
78+ expect(res.status).toBe(400)
79+ })
80+
81+ it('re-plans, applies with the resolutions, and returns result + state', async () => {
82+ await env.send('PUT', `/api/scopes/${projectId}/config`, config())
83+ const res = await env.send('POST', `/api/scopes/${projectId}/apply`, {
84+ resolutions: { 'conflict:claude:skill/foo': 'take-catalog' },
85+ })
86+ expect(res.status).toBe(200)
87+ const body = (await res.json()) as ApplyResponse
88+ expect(body.result.applied).toEqual(['add:claude:skill/foo'])
89+ expect(body.state.config).not.toBeNull()
90+ const apply = engine.calls.find((c) => c.method === 'apply')!
91+ expect(apply.args[3]).toEqual({ 'conflict:claude:skill/foo': 'take-catalog' })
92+ expect(engine.calls.filter((c) => c.method === 'plan').length).toBeGreaterThanOrEqual(2)
93+ })
94+
95+ it('rejects unknown resolutions', async () => {
96+ await env.send('PUT', `/api/scopes/${projectId}/config`, config())
97+ const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: { x: 'whatever' } })
98+ expect(res.status).toBe(400)
99+ })
100+})
101+
102+describe('remove-from-global', () => {
103+ const homeIds = async (): Promise<Record<string, string>> => {
104+ const inv = (await (await env.req('/api/home')).json()) as { items: { id: string; name: string }[] }
105+ return Object.fromEntries(inv.items.map((i) => [i.name, i.id]))
106+ }
107+ const run = async (body: unknown): Promise<RemoveFromGlobalResult> =>
108+ (await (await env.send('POST', '/api/home/remove-from-global', body)).json()) as RemoveFromGlobalResult
109+
110+ it('imports, enables in the project, applies, then removes from Home', async () => {
111+ const ids = await homeIds()
112+ const body = await run({ itemIds: [ids.foo], enableIn: [projectId] })
113+ expect(body.errors).toEqual([])
114+ expect(body.removed).toEqual([ids.foo])
115+ expect(body.imported).toEqual([{ type: 'skill', name: 'foo' }])
116+ expect(engine.removed).toEqual([ids.foo])
117+ const cfg = engine.configs.get(`project:${env.project}`)!
118+ expect(cfg.add.skill).toEqual(['foo'])
119+ expect(cfg.targets.length).toBeGreaterThan(0)
120+ expect(engine.calls.some((c) => c.method === 'apply')).toBe(true)
121+ const catalog = (await (await env.req('/api/catalog')).json()) as { snapshot: { items: CatalogItem[] } }
122+ expect(catalog.snapshot.items.map((i) => i.name)).toEqual(['foo'])
123+ })
124+
125+ it('skips the import when an identical item exists, and errors when a different one does', async () => {
126+ const ids = await homeIds()
127+ await run({ itemIds: [ids.foo], enableIn: [] })
128+ engine.removed.length = 0
129+ let body = await run({ itemIds: [ids.foo], enableIn: [] })
130+ expect(body.removed).toEqual([ids.foo])
131+ await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo', 'changed'))
132+ engine.removed.length = 0
133+ body = await run({ itemIds: [ids.foo], enableIn: [] })
134+ expect(body.removed).toEqual([])
135+ expect(body.errors[0]?.id).toBe(ids.foo)
136+ expect(body.errors[0]?.message).toMatch(/already exists/)
137+ expect(engine.removed).toEqual([])
138+ })
139+
140+ it('never removes from Home when a project apply fails', async () => {
141+ engine.failApplyFor.add(env.project)
142+ const ids = await homeIds()
143+ const body = await run({ itemIds: [ids.foo, ids.bar], enableIn: [projectId] })
144+ expect(body.removed).toEqual([])
145+ expect(body.errors.map((e) => e.id).sort()).toEqual([ids.bar, ids.foo].sort())
146+ expect(engine.removed).toEqual([])
147+ expect(body.imported).toHaveLength(2)
148+ })
149+
150+ it('reports unknown items and validates the body', async () => {
151+ const body = await run({ itemIds: ['nope'], enableIn: [] })
152+ expect(body.errors).toEqual([{ id: 'nope', message: 'Item not found in Home' }])
153+ expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: [], enableIn: [] })).status).toBe(400)
154+ expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: ['a'], enableIn: ['unknown'] })).status).toBe(404)
155+ })
156+})
157+
158+describe('catalog usage and deletion guards', () => {
159+ it('maps items and presets to scopes and blocks deletion with 409 unless forced', async () => {
160+ await env.send('POST', '/api/catalog/init')
161+ await env.send('PUT', '/api/catalog/items/skill/foo', { content: skillMd('foo') })
162+ await env.send('PUT', '/api/catalog/items/skill/viapreset', { content: skillMd('viapreset') })
163+ await env.send('PUT', '/api/catalog/presets/base', { includes: [], items: { skill: ['viapreset'] } })
164+ await env.send('PUT', `/api/scopes/${projectId}/config`, config({ presets: ['base'] }))
165+ await env.send('PUT', '/api/scopes/home/config', config())
166+
167+ const cat = (await (await env.req('/api/catalog')).json()) as { usage: Record<string, string[]> }
168+ expect(cat.usage['skill/foo']?.sort()).toEqual(['home', projectId].sort())
169+ expect(cat.usage['preset/base']).toEqual([projectId])
170+ expect(cat.usage['skill/viapreset']).toEqual([projectId])
171+
172+ expect((await env.send('DELETE', '/api/catalog/items/skill/foo')).status).toBe(409)
173+ expect((await env.send('DELETE', '/api/catalog/presets/base')).status).toBe(409)
174+ expect((await env.send('DELETE', '/api/catalog/items/skill/foo?force=1')).status).toBe(204)
175+ expect((await env.send('DELETE', '/api/catalog/presets/base?force=1')).status).toBe(204)
176+ })
177+})
new file mode 100644
@@ -0,0 +1,177 @@
1+import type { ApplyResponse, CatalogItem, ProjectSummary, RemoveFromGlobalResult, ScopeConfig, ScopeState } from '@skilly/core'
2+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
3+import { fakeEngine, makeEnv, put, skillMd, type Env, type FakeEngine } from './helpers.ts'
4+
5+let env: Env
6+let engine: FakeEngine
7+let projectId: string
8+
9+const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({
10+ version: 1,
11+ targets: ['claude'],
12+ presets: [],
13+ add: { skill: ['foo'] },
14+ exclude: {},
15+ onDemand: { skills: [] },
16+ hideGlobal: { skills: [], mcp: [] },
17+ ...over,
18+})
19+
20+beforeEach(async () => {
21+ engine = fakeEngine()
22+ env = await makeEnv({ syncEngine: engine })
23+ await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo'))
24+ await put(`${env.home}/.claude/skills/bar/SKILL.md`, skillMd('bar'))
25+ const res = await env.send('POST', '/api/projects', { path: env.project })
26+ projectId = ((await res.json()) as ProjectSummary).id
27+})
28+
29+afterEach(() => env.cleanup())
30+
31+describe('scope state and config', () => {
32+ it('returns a state without config and plan, even without a catalog folder', async () => {
33+ const res = await env.req(`/api/scopes/${projectId}/state`)
34+ expect(res.status).toBe(200)
35+ const state = (await res.json()) as ScopeState
36+ expect(state.config).toBeNull()
37+ expect(state.plan).toBeNull()
38+ expect(state.lock).toBeNull()
39+ expect(state.scope).toBe(projectId)
40+ })
41+
42+ it('404s on unknown scopes', async () => {
43+ expect((await env.req('/api/scopes/nope/state')).status).toBe(404)
44+ })
45+
46+ it('validates and writes the config, then returns a fresh state with a plan', async () => {
47+ const res = await env.send('PUT', `/api/scopes/${projectId}/config`, config())
48+ expect(res.status).toBe(200)
49+ const state = (await res.json()) as ScopeState
50+ expect(state.config?.add.skill).toEqual(['foo'])
51+ expect(state.plan?.actions).toHaveLength(1)
52+ const write = engine.calls.find((c) => c.method === 'writeConfig')!
53+ expect(write.args[0]).toMatchObject({ kind: 'project', root: env.project, configDir: env.project, homeDir: env.home })
54+ })
55+
56+ it('uses the skilly config dir as target for Home', async () => {
57+ await env.send('PUT', '/api/scopes/home/config', config())
58+ const write = engine.calls.find((c) => c.method === 'writeConfig')!
59+ expect(write.args[0]).toMatchObject({ kind: 'home', root: env.home, configDir: env.configDir })
60+ })
61+
62+ it('rejects invalid configs', async () => {
63+ const put_ = (body: unknown) => env.send('PUT', `/api/scopes/${projectId}/config`, body)
64+ expect((await put_({ ...config(), targets: ['gemini'] })).status).toBe(400)
65+ expect((await put_({ ...config(), targets: 'claude' })).status).toBe(400)
66+ expect((await put_({ ...config(), presets: [1] })).status).toBe(400)
67+ expect((await put_({ ...config(), add: { widget: ['a'] } })).status).toBe(400)
68+ expect((await put_({ ...config(), add: { skill: 'a' } })).status).toBe(400)
69+ expect((await put_({ ...config(), hideGlobal: { skills: [3] } })).status).toBe(400)
70+ expect((await put_([])).status).toBe(400)
71+ expect(engine.calls.some((c) => c.method === 'writeConfig')).toBe(false)
72+ })
73+})
74+
75+describe('apply', () => {
76+ it('requires a config', async () => {
77+ const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} })
78+ expect(res.status).toBe(400)
79+ })
80+
81+ it('re-plans, applies with the resolutions, and returns result + state', async () => {
82+ await env.send('PUT', `/api/scopes/${projectId}/config`, config())
83+ const res = await env.send('POST', `/api/scopes/${projectId}/apply`, {
84+ resolutions: { 'conflict:claude:skill/foo': 'take-catalog' },
85+ })
86+ expect(res.status).toBe(200)
87+ const body = (await res.json()) as ApplyResponse
88+ expect(body.result.applied).toEqual(['add:claude:skill/foo'])
89+ expect(body.state.config).not.toBeNull()
90+ const apply = engine.calls.find((c) => c.method === 'apply')!
91+ expect(apply.args[3]).toEqual({ 'conflict:claude:skill/foo': 'take-catalog' })
92+ expect(engine.calls.filter((c) => c.method === 'plan').length).toBeGreaterThanOrEqual(2)
93+ })
94+
95+ it('rejects unknown resolutions', async () => {
96+ await env.send('PUT', `/api/scopes/${projectId}/config`, config())
97+ const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: { x: 'whatever' } })
98+ expect(res.status).toBe(400)
99+ })
100+})
101+
102+describe('remove-from-global', () => {
103+ const homeIds = async (): Promise<Record<string, string>> => {
104+ const inv = (await (await env.req('/api/home')).json()) as { items: { id: string; name: string }[] }
105+ return Object.fromEntries(inv.items.map((i) => [i.name, i.id]))
106+ }
107+ const run = async (body: unknown): Promise<RemoveFromGlobalResult> =>
108+ (await (await env.send('POST', '/api/home/remove-from-global', body)).json()) as RemoveFromGlobalResult
109+
110+ it('imports, enables in the project, applies, then removes from Home', async () => {
111+ const ids = await homeIds()
112+ const body = await run({ itemIds: [ids.foo], enableIn: [projectId] })
113+ expect(body.errors).toEqual([])
114+ expect(body.removed).toEqual([ids.foo])
115+ expect(body.imported).toEqual([{ type: 'skill', name: 'foo' }])
116+ expect(engine.removed).toEqual([ids.foo])
117+ const cfg = engine.configs.get(`project:${env.project}`)!
118+ expect(cfg.add.skill).toEqual(['foo'])
119+ expect(cfg.targets.length).toBeGreaterThan(0)
120+ expect(engine.calls.some((c) => c.method === 'apply')).toBe(true)
121+ const catalog = (await (await env.req('/api/catalog')).json()) as { snapshot: { items: CatalogItem[] } }
122+ expect(catalog.snapshot.items.map((i) => i.name)).toEqual(['foo'])
123+ })
124+
125+ it('skips the import when an identical item exists, and errors when a different one does', async () => {
126+ const ids = await homeIds()
127+ await run({ itemIds: [ids.foo], enableIn: [] })
128+ engine.removed.length = 0
129+ let body = await run({ itemIds: [ids.foo], enableIn: [] })
130+ expect(body.removed).toEqual([ids.foo])
131+ await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo', 'changed'))
132+ engine.removed.length = 0
133+ body = await run({ itemIds: [ids.foo], enableIn: [] })
134+ expect(body.removed).toEqual([])
135+ expect(body.errors[0]?.id).toBe(ids.foo)
136+ expect(body.errors[0]?.message).toMatch(/already exists/)
137+ expect(engine.removed).toEqual([])
138+ })
139+
140+ it('never removes from Home when a project apply fails', async () => {
141+ engine.failApplyFor.add(env.project)
142+ const ids = await homeIds()
143+ const body = await run({ itemIds: [ids.foo, ids.bar], enableIn: [projectId] })
144+ expect(body.removed).toEqual([])
145+ expect(body.errors.map((e) => e.id).sort()).toEqual([ids.bar, ids.foo].sort())
146+ expect(engine.removed).toEqual([])
147+ expect(body.imported).toHaveLength(2)
148+ })
149+
150+ it('reports unknown items and validates the body', async () => {
151+ const body = await run({ itemIds: ['nope'], enableIn: [] })
152+ expect(body.errors).toEqual([{ id: 'nope', message: 'Item not found in Home' }])
153+ expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: [], enableIn: [] })).status).toBe(400)
154+ expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: ['a'], enableIn: ['unknown'] })).status).toBe(404)
155+ })
156+})
157+
158+describe('catalog usage and deletion guards', () => {
159+ it('maps items and presets to scopes and blocks deletion with 409 unless forced', async () => {
160+ await env.send('POST', '/api/catalog/init')
161+ await env.send('PUT', '/api/catalog/items/skill/foo', { content: skillMd('foo') })
162+ await env.send('PUT', '/api/catalog/items/skill/viapreset', { content: skillMd('viapreset') })
163+ await env.send('PUT', '/api/catalog/presets/base', { includes: [], items: { skill: ['viapreset'] } })
164+ await env.send('PUT', `/api/scopes/${projectId}/config`, config({ presets: ['base'] }))
165+ await env.send('PUT', '/api/scopes/home/config', config())
166+
167+ const cat = (await (await env.req('/api/catalog')).json()) as { usage: Record<string, string[]> }
168+ expect(cat.usage['skill/foo']?.sort()).toEqual(['home', projectId].sort())
169+ expect(cat.usage['preset/base']).toEqual([projectId])
170+ expect(cat.usage['skill/viapreset']).toEqual([projectId])
171+
172+ expect((await env.send('DELETE', '/api/catalog/items/skill/foo')).status).toBe(409)
173+ expect((await env.send('DELETE', '/api/catalog/presets/base')).status).toBe(409)
174+ expect((await env.send('DELETE', '/api/catalog/items/skill/foo?force=1')).status).toBe(204)
175+ expect((await env.send('DELETE', '/api/catalog/presets/base?force=1')).status).toBe(204)
176+ })
177+})