opencode-provider-litellm 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-provider-litellm",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export { LiteLLMPlugin } from './plugin.js'
1
+ export { LiteLLMPlugin as server } from './plugin.js'
@@ -3,13 +3,37 @@ import { tool } from '@opencode-ai/plugin'
3
3
  import type { PluginConfig, Skill } from './types.js'
4
4
  import {
5
5
  listSkills,
6
- createSkill,
7
- deleteSkill,
6
+ listPublicSkills,
7
+ registerSkill,
8
+ enableSkill,
9
+ disableSkill,
10
+ fetchSkillContent,
8
11
  createSkillToolDefinitions,
9
12
  createSkillsInjector,
10
13
  resetSkillsCache,
11
14
  } from './skills.js'
12
15
 
16
+ const mockSkill: Skill = {
17
+ id: 'skill-1',
18
+ name: 'code-review',
19
+ version: '1.0.0',
20
+ description: 'Reviews code for best practices',
21
+ source: {
22
+ source: 'git-subdir',
23
+ url: 'https://github.com/org/repo',
24
+ path: 'skills/code-review',
25
+ },
26
+ author: null,
27
+ homepage: null,
28
+ keywords: null,
29
+ category: null,
30
+ domain: null,
31
+ namespace: null,
32
+ enabled: true,
33
+ created_at: '2026-01-01T00:00:00Z',
34
+ updated_at: '2026-01-01T00:00:00Z',
35
+ }
36
+
13
37
  describe('listSkills', () => {
14
38
  const config: PluginConfig = {
15
39
  url: 'https://litellm.example.com',
@@ -21,24 +45,21 @@ describe('listSkills', () => {
21
45
  vi.restoreAllMocks()
22
46
  })
23
47
 
24
- it('returns skills from mock response', async () => {
25
- const mockSkills: Skill[] = [
26
- { id: 'skill-1', name: 'code-review', description: 'Reviews code for best practices', enabled: true },
27
- { id: 'skill-2', name: 'security-scan', description: 'Scans for security issues', enabled: true },
28
- ]
48
+ it('returns skills from plugins response', async () => {
49
+ const mockPlugins = { plugins: [mockSkill] }
29
50
 
30
51
  const mockFetch = vi.fn().mockResolvedValue({
31
52
  ok: true,
32
53
  status: 200,
33
- json: async () => mockSkills,
54
+ json: async () => mockPlugins,
34
55
  })
35
56
  vi.stubGlobal('fetch', mockFetch)
36
57
 
37
58
  const result = await listSkills(config, token)
38
59
 
39
- expect(result).toEqual(mockSkills)
60
+ expect(result).toEqual([mockSkill])
40
61
  expect(mockFetch).toHaveBeenCalledWith(
41
- 'https://litellm.example.com/v1/skills',
62
+ 'https://litellm.example.com/claude-code/plugins',
42
63
  expect.objectContaining({
43
64
  method: 'GET',
44
65
  headers: expect.objectContaining({
@@ -68,6 +89,18 @@ describe('listSkills', () => {
68
89
  expect(result).toEqual([])
69
90
  })
70
91
 
92
+ it('returns [] on invalid response format', async () => {
93
+ const mockFetch = vi.fn().mockResolvedValue({
94
+ ok: true,
95
+ status: 200,
96
+ json: async () => { plugins: null },
97
+ })
98
+ vi.stubGlobal('fetch', mockFetch)
99
+
100
+ const result = await listSkills(config, token)
101
+ expect(result).toEqual([])
102
+ })
103
+
71
104
  it('respects timeout (AbortError after 10s)', async () => {
72
105
  vi.useFakeTimers()
73
106
 
@@ -95,7 +128,48 @@ describe('listSkills', () => {
95
128
  })
96
129
  })
97
130
 
98
- describe('createSkill', () => {
131
+ describe('listPublicSkills', () => {
132
+ const config: PluginConfig = {
133
+ url: 'https://litellm.example.com',
134
+ apiKey: 'test-api-key',
135
+ }
136
+
137
+ beforeEach(() => {
138
+ vi.restoreAllMocks()
139
+ })
140
+
141
+ it('returns public skills without auth', async () => {
142
+ const mockResponse = { plugins: [mockSkill] }
143
+
144
+ const mockFetch = vi.fn().mockResolvedValue({
145
+ ok: true,
146
+ status: 200,
147
+ json: async () => mockResponse,
148
+ })
149
+ vi.stubGlobal('fetch', mockFetch)
150
+
151
+ const result = await listPublicSkills(config)
152
+
153
+ expect(result).toEqual([mockSkill])
154
+ expect(mockFetch).toHaveBeenCalledWith(
155
+ 'https://litellm.example.com/public/skill_hub',
156
+ expect.objectContaining({
157
+ method: 'GET',
158
+ signal: expect.any(AbortSignal),
159
+ }),
160
+ )
161
+ })
162
+
163
+ it('returns [] on error', async () => {
164
+ const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
165
+ vi.stubGlobal('fetch', mockFetch)
166
+
167
+ const result = await listPublicSkills(config)
168
+ expect(result).toEqual([])
169
+ })
170
+ })
171
+
172
+ describe('registerSkill', () => {
99
173
  const config: PluginConfig = {
100
174
  url: 'https://litellm.example.com',
101
175
  apiKey: 'test-api-key',
@@ -110,22 +184,42 @@ describe('createSkill', () => {
110
184
  const mockFetch = vi.fn().mockResolvedValue({
111
185
  ok: true,
112
186
  status: 200,
113
- json: async () => ({ id: 'skill-new-1', name: 'my-skill' }),
187
+ json: async () => ({
188
+ status: 'success',
189
+ action: 'created',
190
+ plugin: { id: 'new-skill-1', name: 'my-skill' },
191
+ }),
114
192
  })
115
193
  vi.stubGlobal('fetch', mockFetch)
116
194
 
117
- const result = await createSkill(config, token, 'my-skill', 'A test skill')
195
+ const result = await registerSkill(
196
+ config,
197
+ token,
198
+ 'my-skill',
199
+ 'https://github.com/org/repo',
200
+ 'skills/my-skill',
201
+ 'A test skill',
202
+ )
118
203
 
119
- expect(result).toBe('Skill "my-skill" created (id: skill-new-1)')
204
+ expect(result).toBe('Skill "my-skill" registered (id: new-skill-1)')
120
205
  expect(mockFetch).toHaveBeenCalledWith(
121
- 'https://litellm.example.com/v1/skills',
206
+ 'https://litellm.example.com/claude-code/plugins',
122
207
  expect.objectContaining({
123
208
  method: 'POST',
124
209
  headers: expect.objectContaining({
125
210
  Authorization: 'Bearer test-token',
126
211
  'Content-Type': 'application/json',
127
212
  }),
128
- body: JSON.stringify({ name: 'my-skill', description: 'A test skill', input_schema: undefined, code: undefined }),
213
+ body: JSON.stringify({
214
+ name: 'my-skill',
215
+ source: {
216
+ source: 'git-subdir',
217
+ url: 'https://github.com/org/repo',
218
+ path: 'skills/my-skill',
219
+ },
220
+ description: 'A test skill',
221
+ domain: null,
222
+ }),
129
223
  signal: expect.any(AbortSignal),
130
224
  }),
131
225
  )
@@ -135,9 +229,15 @@ describe('createSkill', () => {
135
229
  const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
136
230
  vi.stubGlobal('fetch', mockFetch)
137
231
 
138
- const result = await createSkill(config, token, 'my-skill', 'A test skill')
232
+ const result = await registerSkill(
233
+ config,
234
+ token,
235
+ 'my-skill',
236
+ 'https://github.com/org/repo',
237
+ 'skills/my-skill',
238
+ )
139
239
 
140
- expect(result).toBe('Error creating skill: connection refused')
240
+ expect(result).toBe('Error registering skill: connection refused')
141
241
  })
142
242
 
143
243
  it('returns error string on non-ok response', async () => {
@@ -147,43 +247,54 @@ describe('createSkill', () => {
147
247
  })
148
248
  vi.stubGlobal('fetch', mockFetch)
149
249
 
150
- const result = await createSkill(config, token, 'my-skill', 'A test skill')
250
+ const result = await registerSkill(
251
+ config,
252
+ token,
253
+ 'my-skill',
254
+ 'https://github.com/org/repo',
255
+ 'skills/my-skill',
256
+ )
151
257
 
152
- expect(result).toContain('Error creating skill')
258
+ expect(result).toContain('Error registering skill')
153
259
  })
154
260
 
155
- it('includes input_schema and code when provided', async () => {
261
+ it('includes domain when provided', async () => {
156
262
  const mockFetch = vi.fn().mockResolvedValue({
157
263
  ok: true,
158
264
  status: 200,
159
- json: async () => ({ id: 'skill-3', name: 'complex-skill' }),
265
+ json: async () => ({ plugin: { id: 'skill-3', name: 'domain-skill' } }),
160
266
  })
161
267
  vi.stubGlobal('fetch', mockFetch)
162
268
 
163
- await createSkill(
269
+ await registerSkill(
164
270
  config,
165
271
  token,
166
- 'complex-skill',
167
- 'A complex skill',
168
- { type: 'object', properties: { value: { type: 'string' } } },
169
- 'print("hello")',
272
+ 'domain-skill',
273
+ 'https://github.com/org/repo',
274
+ 'skills/domain-skill',
275
+ 'A domain skill',
276
+ 'Productivity',
170
277
  )
171
278
 
172
279
  expect(mockFetch).toHaveBeenCalledWith(
173
- 'https://litellm.example.com/v1/skills',
280
+ 'https://litellm.example.com/claude-code/plugins',
174
281
  expect.objectContaining({
175
282
  body: JSON.stringify({
176
- name: 'complex-skill',
177
- description: 'A complex skill',
178
- input_schema: { type: 'object', properties: { value: { type: 'string' } } },
179
- code: 'print("hello")',
283
+ name: 'domain-skill',
284
+ source: {
285
+ source: 'git-subdir',
286
+ url: 'https://github.com/org/repo',
287
+ path: 'skills/domain-skill',
288
+ },
289
+ description: 'A domain skill',
290
+ domain: 'Productivity',
180
291
  }),
181
292
  }),
182
293
  )
183
294
  })
184
295
  })
185
296
 
186
- describe('deleteSkill', () => {
297
+ describe('enableSkill', () => {
187
298
  const config: PluginConfig = {
188
299
  url: 'https://litellm.example.com',
189
300
  apiKey: 'test-api-key',
@@ -201,13 +312,13 @@ describe('deleteSkill', () => {
201
312
  })
202
313
  vi.stubGlobal('fetch', mockFetch)
203
314
 
204
- const result = await deleteSkill(config, token, 'skill-1')
315
+ const result = await enableSkill(config, token, 'my-skill')
205
316
 
206
- expect(result).toBe('Skill "skill-1" deleted')
317
+ expect(result).toBe('Skill "my-skill" enabled')
207
318
  expect(mockFetch).toHaveBeenCalledWith(
208
- 'https://litellm.example.com/v1/skills/skill-1',
319
+ 'https://litellm.example.com/claude-code/plugins/my-skill/enable',
209
320
  expect.objectContaining({
210
- method: 'DELETE',
321
+ method: 'POST',
211
322
  headers: expect.objectContaining({
212
323
  Authorization: 'Bearer test-token',
213
324
  }),
@@ -220,9 +331,9 @@ describe('deleteSkill', () => {
220
331
  const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
221
332
  vi.stubGlobal('fetch', mockFetch)
222
333
 
223
- const result = await deleteSkill(config, token, 'skill-1')
334
+ const result = await enableSkill(config, token, 'my-skill')
224
335
 
225
- expect(result).toBe('Error deleting skill: connection refused')
336
+ expect(result).toBe('Error enabling skill: connection refused')
226
337
  })
227
338
 
228
339
  it('returns error string on non-ok response', async () => {
@@ -232,9 +343,122 @@ describe('deleteSkill', () => {
232
343
  })
233
344
  vi.stubGlobal('fetch', mockFetch)
234
345
 
235
- const result = await deleteSkill(config, token, 'skill-1')
346
+ const result = await enableSkill(config, token, 'my-skill')
236
347
 
237
- expect(result).toContain('Error deleting skill')
348
+ expect(result).toContain('Error enabling skill')
349
+ })
350
+ })
351
+
352
+ describe('disableSkill', () => {
353
+ const config: PluginConfig = {
354
+ url: 'https://litellm.example.com',
355
+ apiKey: 'test-api-key',
356
+ }
357
+ const token = 'test-token'
358
+
359
+ beforeEach(() => {
360
+ vi.restoreAllMocks()
361
+ })
362
+
363
+ it('returns success message', async () => {
364
+ const mockFetch = vi.fn().mockResolvedValue({
365
+ ok: true,
366
+ status: 200,
367
+ })
368
+ vi.stubGlobal('fetch', mockFetch)
369
+
370
+ const result = await disableSkill(config, token, 'my-skill')
371
+
372
+ expect(result).toBe('Skill "my-skill" disabled')
373
+ expect(mockFetch).toHaveBeenCalledWith(
374
+ 'https://litellm.example.com/claude-code/plugins/my-skill/disable',
375
+ expect.objectContaining({
376
+ method: 'POST',
377
+ headers: expect.objectContaining({
378
+ Authorization: 'Bearer test-token',
379
+ }),
380
+ signal: expect.any(AbortSignal),
381
+ }),
382
+ )
383
+ })
384
+
385
+ it('returns error string on failure', async () => {
386
+ const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
387
+ vi.stubGlobal('fetch', mockFetch)
388
+
389
+ const result = await disableSkill(config, token, 'my-skill')
390
+
391
+ expect(result).toBe('Error disabling skill: connection refused')
392
+ })
393
+
394
+ it('returns error string on non-ok response', async () => {
395
+ const mockFetch = vi.fn().mockResolvedValue({
396
+ ok: false,
397
+ status: 404,
398
+ })
399
+ vi.stubGlobal('fetch', mockFetch)
400
+
401
+ const result = await disableSkill(config, token, 'my-skill')
402
+
403
+ expect(result).toContain('Error disabling skill')
404
+ })
405
+ })
406
+
407
+ describe('fetchSkillContent', () => {
408
+ beforeEach(() => {
409
+ vi.restoreAllMocks()
410
+ })
411
+
412
+ it('returns SKILL.md content from GitHub', async () => {
413
+ const mockContent = '# Test Skill\n\nThis is a test skill.'
414
+
415
+ const mockFetch = vi.fn().mockResolvedValue({
416
+ ok: true,
417
+ status: 200,
418
+ text: async () => mockContent,
419
+ })
420
+ vi.stubGlobal('fetch', mockFetch)
421
+
422
+ const result = await fetchSkillContent(mockSkill)
423
+
424
+ expect(result).toBe(mockContent)
425
+ expect(mockFetch).toHaveBeenCalledWith(
426
+ 'https://raw.githubusercontent.com/org/repo/master/skills/code-review/SKILL.md',
427
+ expect.any(Object),
428
+ )
429
+ })
430
+
431
+ it('returns null on non-ok response', async () => {
432
+ const mockFetch = vi.fn().mockResolvedValue({
433
+ ok: false,
434
+ status: 404,
435
+ })
436
+ vi.stubGlobal('fetch', mockFetch)
437
+
438
+ const result = await fetchSkillContent(mockSkill)
439
+ expect(result).toBeNull()
440
+ })
441
+
442
+ it('returns null on network error', async () => {
443
+ const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
444
+ vi.stubGlobal('fetch', mockFetch)
445
+
446
+ const result = await fetchSkillContent(mockSkill)
447
+ expect(result).toBeNull()
448
+ })
449
+
450
+ it('returns null for unsupported source type', async () => {
451
+ const mockFetch = vi.fn()
452
+ vi.stubGlobal('fetch', mockFetch)
453
+
454
+ const unsupportedSkill: Skill = {
455
+ ...mockSkill,
456
+ source: { source: 'inline', url: 'not-a-git-url' },
457
+ }
458
+
459
+ const result = await fetchSkillContent(unsupportedSkill)
460
+ expect(result).toBeNull()
461
+ expect(mockFetch).not.toHaveBeenCalled()
238
462
  })
239
463
  })
240
464
 
@@ -249,41 +473,50 @@ describe('createSkillToolDefinitions', () => {
249
473
  vi.restoreAllMocks()
250
474
  })
251
475
 
252
- it('returns 3 tools with correct names', () => {
476
+ it('returns 4 tools with correct names', () => {
253
477
  const result = createSkillToolDefinitions(config, token)
254
478
 
255
- expect(Object.keys(result)).toHaveLength(3)
479
+ expect(Object.keys(result)).toHaveLength(4)
256
480
  expect(result).toHaveProperty('skill_list')
257
- expect(result).toHaveProperty('skill_create')
258
- expect(result).toHaveProperty('skill_delete')
481
+ expect(result).toHaveProperty('skill_register')
482
+ expect(result).toHaveProperty('skill_enable')
483
+ expect(result).toHaveProperty('skill_disable')
259
484
  })
260
485
 
261
486
  it('skill_list has correct description', () => {
262
487
  const result = createSkillToolDefinitions(config, token)
263
488
 
264
- expect(result.skill_list.description).toBe('List all skills registered on the LiteLLM proxy')
489
+ expect(result.skill_list.description).toBe('List all skills registered on the LiteLLM Skills Gateway')
490
+ })
491
+
492
+ it('skill_register has correct description', () => {
493
+ const result = createSkillToolDefinitions(config, token)
494
+
495
+ expect(result.skill_register.description).toBe('Register a new skill on the LiteLLM Skills Gateway pointing to a git source')
265
496
  })
266
497
 
267
- it('skill_create has correct description', () => {
498
+ it('skill_enable has correct description', () => {
268
499
  const result = createSkillToolDefinitions(config, token)
269
500
 
270
- expect(result.skill_create.description).toBe('Create a new skill on the LiteLLM proxy')
501
+ expect(result.skill_enable.description).toBe('Enable (publish) a skill on the LiteLLM Skills Gateway')
271
502
  })
272
503
 
273
- it('skill_delete has correct description', () => {
504
+ it('skill_disable has correct description', () => {
274
505
  const result = createSkillToolDefinitions(config, token)
275
506
 
276
- expect(result.skill_delete.description).toBe('Delete a skill from the LiteLLM proxy')
507
+ expect(result.skill_disable.description).toBe('Disable (unpublish) a skill on the LiteLLM Skills Gateway')
277
508
  })
278
509
 
279
510
  it('skill_list execute returns formatted markdown table', async () => {
280
511
  const mockFetch = vi.fn().mockResolvedValue({
281
512
  ok: true,
282
513
  status: 200,
283
- json: async () => [
284
- { id: 'skill-1', name: 'code-review', description: 'Reviews code', enabled: true },
285
- { id: 'skill-2', name: 'security-scan', description: 'Scans security', enabled: false },
286
- ],
514
+ json: async () => ({
515
+ plugins: [
516
+ { ...mockSkill, name: 'code-review', description: 'Reviews code', enabled: true },
517
+ { ...mockSkill, name: 'security-scan', description: 'Scans security', enabled: false },
518
+ ],
519
+ }),
287
520
  })
288
521
  vi.stubGlobal('fetch', mockFetch)
289
522
 
@@ -297,25 +530,47 @@ describe('createSkillToolDefinitions', () => {
297
530
  expect(output).toContain('Scans security')
298
531
  })
299
532
 
300
- it('skill_create execute calls createSkill', async () => {
533
+ it('skill_register execute calls registerSkill', async () => {
534
+ const mockFetch = vi.fn().mockResolvedValue({
535
+ ok: true,
536
+ status: 200,
537
+ json: async () => ({ plugin: { id: 'new-skill', name: 'new-skill' } }),
538
+ })
539
+ vi.stubGlobal('fetch', mockFetch)
540
+
541
+ const result = createSkillToolDefinitions(config, token)
542
+
543
+ const output = await result.skill_register.execute(
544
+ {
545
+ name: 'new-skill',
546
+ git_url: 'https://github.com/org/repo',
547
+ git_path: 'skills/new-skill',
548
+ description: 'A new skill',
549
+ },
550
+ {} as any,
551
+ )
552
+
553
+ expect(output).toBe('Skill "new-skill" registered (id: new-skill)')
554
+ })
555
+
556
+ it('skill_enable execute calls enableSkill', async () => {
301
557
  const mockFetch = vi.fn().mockResolvedValue({
302
558
  ok: true,
303
559
  status: 200,
304
- json: async () => ({ id: 'skill-new', name: 'new-skill' }),
305
560
  })
306
561
  vi.stubGlobal('fetch', mockFetch)
307
562
 
308
563
  const result = createSkillToolDefinitions(config, token)
309
564
 
310
- const output = await result.skill_create.execute(
311
- { name: 'new-skill', description: 'A new skill' },
565
+ const output = await result.skill_enable.execute(
566
+ { name: 'skill-to-enable' },
312
567
  {} as any,
313
568
  )
314
569
 
315
- expect(output).toBe('Skill "new-skill" created (id: skill-new)')
570
+ expect(output).toBe('Skill "skill-to-enable" enabled')
316
571
  })
317
572
 
318
- it('skill_delete execute calls deleteSkill', async () => {
573
+ it('skill_disable execute calls disableSkill', async () => {
319
574
  const mockFetch = vi.fn().mockResolvedValue({
320
575
  ok: true,
321
576
  status: 200,
@@ -324,12 +579,12 @@ describe('createSkillToolDefinitions', () => {
324
579
 
325
580
  const result = createSkillToolDefinitions(config, token)
326
581
 
327
- const output = await result.skill_delete.execute(
328
- { skill_id: 'skill-to-delete' },
582
+ const output = await result.skill_disable.execute(
583
+ { name: 'skill-to-disable' },
329
584
  {} as any,
330
585
  )
331
586
 
332
- expect(output).toBe('Skill "skill-to-delete" deleted')
587
+ expect(output).toBe('Skill "skill-to-disable" disabled')
333
588
  })
334
589
  })
335
590
 
@@ -349,10 +604,12 @@ describe('createSkillsInjector', () => {
349
604
  const mockFetch = vi.fn().mockResolvedValue({
350
605
  ok: true,
351
606
  status: 200,
352
- json: async () => [
353
- { id: 'skill-1', name: 'code-review', description: 'Reviews code', enabled: true },
354
- { id: 'skill-2', name: 'security-scan', description: 'Scans security' },
355
- ],
607
+ json: async () => ({
608
+ plugins: [
609
+ { ...mockSkill, name: 'code-review', description: 'Reviews code', enabled: true },
610
+ { ...mockSkill, name: 'security-scan', description: 'Scans security', enabled: true },
611
+ ],
612
+ }),
356
613
  })
357
614
  vi.stubGlobal('fetch', mockFetch)
358
615
 
@@ -373,9 +630,7 @@ describe('createSkillsInjector', () => {
373
630
  const mockFetch = vi.fn().mockResolvedValue({
374
631
  ok: true,
375
632
  status: 200,
376
- json: async () => [
377
- { id: 'skill-1', name: 'code-review', description: 'Reviews code', enabled: true },
378
- ],
633
+ json: async () => ({ plugins: [mockSkill] }),
379
634
  })
380
635
  vi.stubGlobal('fetch', mockFetch)
381
636
 
@@ -386,9 +641,7 @@ describe('createSkillsInjector', () => {
386
641
 
387
642
  await injector(input, output)
388
643
 
389
- // Should not have injected anything — sub-agent sessions are skipped
390
644
  expect(output.parts).toEqual([])
391
- // Should not have called fetch since it returns early
392
645
  expect(mockFetch).not.toHaveBeenCalled()
393
646
  })
394
647
 
@@ -396,9 +649,11 @@ describe('createSkillsInjector', () => {
396
649
  const mockFetch = vi.fn().mockResolvedValue({
397
650
  ok: true,
398
651
  status: 200,
399
- json: async () => [
400
- { id: 'skill-1', name: 'disabled-skill', description: 'Disabled', enabled: false },
401
- ],
652
+ json: async () => ({
653
+ plugins: [
654
+ { ...mockSkill, name: 'disabled-skill', description: 'Disabled', enabled: false },
655
+ ],
656
+ }),
402
657
  })
403
658
  vi.stubGlobal('fetch', mockFetch)
404
659
 
@@ -412,23 +667,45 @@ describe('createSkillsInjector', () => {
412
667
  expect(output.parts).toEqual([])
413
668
  })
414
669
 
670
+ it('uses "No description" for null descriptions', async () => {
671
+ const mockFetch = vi.fn().mockResolvedValue({
672
+ ok: true,
673
+ status: 200,
674
+ json: async () => ({
675
+ plugins: [
676
+ { ...mockSkill, name: 'no-desc-skill', description: null, enabled: true },
677
+ ],
678
+ }),
679
+ })
680
+ vi.stubGlobal('fetch', mockFetch)
681
+
682
+ const injector = createSkillsInjector(config, token)
683
+
684
+ const input = { sessionID: 'main-session' }
685
+ const output = { message: { content: 'Hello' }, parts: [] }
686
+
687
+ await injector(input, output)
688
+
689
+ expect(output.parts[0].text).toContain('<skill name="no-desc-skill">No description</skill>')
690
+ })
691
+
415
692
  it('cache TTL works (second call within TTL uses cache)', async () => {
416
693
  const mockFetch = vi.fn().mockResolvedValue({
417
694
  ok: true,
418
695
  status: 200,
419
- json: async () => [
420
- { id: 'skill-1', name: 'cached-skill', description: 'Cached', enabled: true },
421
- ],
696
+ json: async () => ({
697
+ plugins: [
698
+ { ...mockSkill, name: 'cached-skill', description: 'Cached', enabled: true },
699
+ ],
700
+ }),
422
701
  })
423
702
  vi.stubGlobal('fetch', mockFetch)
424
703
 
425
704
  const injector = createSkillsInjector(config, token)
426
705
 
427
- // First call — should fetch
428
706
  await injector({ sessionID: 'session-1' }, { message: {}, parts: [] })
429
707
  expect(mockFetch).toHaveBeenCalledTimes(1)
430
708
 
431
- // Second call — should use cache (fetch not called again)
432
709
  await injector({ sessionID: 'session-2' }, { message: {}, parts: [] })
433
710
  expect(mockFetch).toHaveBeenCalledTimes(1)
434
711
  })
@@ -442,7 +719,6 @@ describe('createSkillsInjector', () => {
442
719
  const input = { sessionID: 'main-session' }
443
720
  const output = { message: { content: 'Hello' }, parts: [] }
444
721
 
445
- // Should not throw
446
722
  await expect(injector(input, output)).resolves.toBeUndefined()
447
723
  expect(output.parts).toEqual([])
448
724
  })
package/src/skills.ts CHANGED
@@ -15,7 +15,7 @@ export function resetSkillsCache(): void {
15
15
  }
16
16
 
17
17
  /**
18
- * Fetches all skills from the LiteLLM proxy.
18
+ * Fetches all skills from the LiteLLM Skills Gateway.
19
19
  * Returns an empty array on any error (network, 4xx, 5xx, parse failure).
20
20
  * Uses a 10s timeout via AbortController.
21
21
  */
@@ -27,7 +27,7 @@ export async function listSkills(
27
27
  const timeoutId = setTimeout(() => controller.abort(), 10_000)
28
28
 
29
29
  try {
30
- const response = await fetch(`${config.url}/v1/skills`, {
30
+ const response = await fetch(`${config.url}/claude-code/plugins`, {
31
31
  method: 'GET',
32
32
  headers: {
33
33
  Authorization: `Bearer ${token}`,
@@ -41,11 +41,11 @@ export async function listSkills(
41
41
 
42
42
  const body = await response.json()
43
43
 
44
- if (!Array.isArray(body)) {
44
+ if (!body || !Array.isArray(body.plugins)) {
45
45
  return []
46
46
  }
47
47
 
48
- return body as Skill[]
48
+ return body.plugins as Skill[]
49
49
  } catch {
50
50
  return []
51
51
  } finally {
@@ -54,23 +54,55 @@ export async function listSkills(
54
54
  }
55
55
 
56
56
  /**
57
- * Creates a new skill on the LiteLLM proxy.
58
- * Returns a success message string on success, error string on failure.
59
- * Uses a 10s timeout via AbortController.
57
+ * Fetches only enabled (public) skills from the LiteLLM Skill Hub.
58
+ * No auth required. Useful for discovery without credentials.
60
59
  */
61
- export async function createSkill(
60
+ export async function listPublicSkills(config: PluginConfig): Promise<Skill[]> {
61
+ const controller = new AbortController()
62
+ const timeoutId = setTimeout(() => controller.abort(), 10_000)
63
+
64
+ try {
65
+ const response = await fetch(`${config.url}/public/skill_hub`, {
66
+ method: 'GET',
67
+ signal: controller.signal,
68
+ })
69
+
70
+ if (!response.ok) {
71
+ return []
72
+ }
73
+
74
+ const body = await response.json()
75
+
76
+ if (!body || !Array.isArray(body.plugins)) {
77
+ return []
78
+ }
79
+
80
+ return body.plugins as Skill[]
81
+ } catch {
82
+ return []
83
+ } finally {
84
+ clearTimeout(timeoutId)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Registers a new skill on the LiteLLM Skills Gateway.
90
+ * The skill points to a git source containing a SKILL.md file.
91
+ */
92
+ export async function registerSkill(
62
93
  config: PluginConfig,
63
94
  token: string,
64
95
  name: string,
65
- description: string,
66
- inputSchema?: Record<string, unknown>,
67
- code?: string,
96
+ gitUrl: string,
97
+ gitPath: string,
98
+ description?: string,
99
+ domain?: string,
68
100
  ): Promise<string> {
69
101
  const controller = new AbortController()
70
102
  const timeoutId = setTimeout(() => controller.abort(), 10_000)
71
103
 
72
104
  try {
73
- const response = await fetch(`${config.url}/v1/skills`, {
105
+ const response = await fetch(`${config.url}/claude-code/plugins`, {
74
106
  method: 'POST',
75
107
  headers: {
76
108
  Authorization: `Bearer ${token}`,
@@ -78,44 +110,79 @@ export async function createSkill(
78
110
  },
79
111
  body: JSON.stringify({
80
112
  name,
81
- description,
82
- input_schema: inputSchema,
83
- code,
113
+ source: {
114
+ source: 'git-subdir',
115
+ url: gitUrl,
116
+ path: gitPath,
117
+ },
118
+ description: description || null,
119
+ domain: domain || null,
84
120
  }),
85
121
  signal: controller.signal,
86
122
  })
87
123
 
88
124
  if (!response.ok) {
89
- return `Error creating skill: HTTP ${response.status}`
125
+ return `Error registering skill: HTTP ${response.status}`
90
126
  }
91
127
 
92
128
  const body = await response.json()
93
- const id = body.id ?? 'unknown'
94
- return `Skill "${name}" created (id: ${id})`
129
+ const id = body?.plugin?.id ?? 'unknown'
130
+ return `Skill "${name}" registered (id: ${id})`
95
131
  } catch (error: unknown) {
96
132
  const message = error instanceof Error ? error.message : String(error)
97
- return `Error creating skill: ${message}`
133
+ return `Error registering skill: ${message}`
98
134
  } finally {
99
135
  clearTimeout(timeoutId)
100
136
  }
101
137
  }
102
138
 
103
139
  /**
104
- * Deletes a skill from the LiteLLM proxy.
105
- * Returns a success message string on success, error string on failure.
106
- * Uses a 10s timeout via AbortController.
140
+ * Enables (publishes) a skill on the LiteLLM Skills Gateway.
141
+ */
142
+ export async function enableSkill(
143
+ config: PluginConfig,
144
+ token: string,
145
+ name: string,
146
+ ): Promise<string> {
147
+ const controller = new AbortController()
148
+ const timeoutId = setTimeout(() => controller.abort(), 10_000)
149
+
150
+ try {
151
+ const response = await fetch(`${config.url}/claude-code/plugins/${name}/enable`, {
152
+ method: 'POST',
153
+ headers: {
154
+ Authorization: `Bearer ${token}`,
155
+ },
156
+ signal: controller.signal,
157
+ })
158
+
159
+ if (!response.ok) {
160
+ return `Error enabling skill: HTTP ${response.status}`
161
+ }
162
+
163
+ return `Skill "${name}" enabled`
164
+ } catch (error: unknown) {
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ return `Error enabling skill: ${message}`
167
+ } finally {
168
+ clearTimeout(timeoutId)
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Disables (unpublishes) a skill on the LiteLLM Skills Gateway.
107
174
  */
108
- export async function deleteSkill(
175
+ export async function disableSkill(
109
176
  config: PluginConfig,
110
177
  token: string,
111
- skillId: string,
178
+ name: string,
112
179
  ): Promise<string> {
113
180
  const controller = new AbortController()
114
181
  const timeoutId = setTimeout(() => controller.abort(), 10_000)
115
182
 
116
183
  try {
117
- const response = await fetch(`${config.url}/v1/skills/${skillId}`, {
118
- method: 'DELETE',
184
+ const response = await fetch(`${config.url}/claude-code/plugins/${name}/disable`, {
185
+ method: 'POST',
119
186
  headers: {
120
187
  Authorization: `Bearer ${token}`,
121
188
  },
@@ -123,21 +190,83 @@ export async function deleteSkill(
123
190
  })
124
191
 
125
192
  if (!response.ok) {
126
- return `Error deleting skill: HTTP ${response.status}`
193
+ return `Error disabling skill: HTTP ${response.status}`
127
194
  }
128
195
 
129
- return `Skill "${skillId}" deleted`
196
+ return `Skill "${name}" disabled`
130
197
  } catch (error: unknown) {
131
198
  const message = error instanceof Error ? error.message : String(error)
132
- return `Error deleting skill: ${message}`
199
+ return `Error disabling skill: ${message}`
133
200
  } finally {
134
201
  clearTimeout(timeoutId)
135
202
  }
136
203
  }
137
204
 
138
205
  /**
139
- * Creates opencode tool definitions for skill CRUD operations.
140
- * Returns a static Record with three tools: skill_list, skill_create, skill_delete.
206
+ * Fetches the SKILL.md content from a skill's git source.
207
+ * Currently supports GitHub raw URLs.
208
+ */
209
+ export async function fetchSkillContent(skill: Skill): Promise<string | null> {
210
+ const controller = new AbortController()
211
+ const timeoutId = setTimeout(() => controller.abort(), 10_000)
212
+
213
+ try {
214
+ const rawUrl = buildRawGitUrl(skill.source)
215
+ if (!rawUrl) return null
216
+
217
+ const response = await fetch(rawUrl, {
218
+ signal: controller.signal,
219
+ })
220
+
221
+ if (!response.ok) return null
222
+
223
+ return await response.text()
224
+ } catch {
225
+ return null
226
+ } finally {
227
+ clearTimeout(timeoutId)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Builds a raw git URL for the SKILL.md file from a skill's source.
233
+ * Currently supports GitHub git-subdir sources.
234
+ */
235
+ function buildRawGitUrl(source: Skill['source']): string | null {
236
+ if (source.source !== 'git-subdir') return null
237
+
238
+ const url = source.url
239
+ if (!url.includes('github.com')) return null
240
+
241
+ const isRaw = url.startsWith('https://raw.githubusercontent.com')
242
+ if (isRaw) {
243
+ const branch = extractBranch(url)
244
+ const path = source.path || ''
245
+ return `https://raw.githubusercontent.com/${url.replace('https://raw.githubusercontent.com/', '').split('/').slice(0, 2).join('/')}/${branch}/${path}/SKILL.md`
246
+ }
247
+
248
+ const match = url.match(/https:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?/)
249
+ if (!match) return null
250
+
251
+ const [, owner, repo] = match
252
+ const branch = extractBranch(url) || 'master'
253
+ const path = source.path || ''
254
+
255
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}/SKILL.md`
256
+ }
257
+
258
+ /**
259
+ * Extracts the branch name from a GitHub URL.
260
+ * Falls back to 'master' if not found.
261
+ */
262
+ function extractBranch(url: string): string | null {
263
+ const match = url.match(/\/tree\/([^/]+)/)
264
+ return match ? match[1] : null
265
+ }
266
+
267
+ /**
268
+ * Creates opencode tool definitions for skill management operations.
269
+ * Returns tools: skill_list, skill_register, skill_enable, skill_disable.
141
270
  */
142
271
  export function createSkillToolDefinitions(
143
272
  config: PluginConfig,
@@ -145,7 +274,7 @@ export function createSkillToolDefinitions(
145
274
  ): Record<string, any> {
146
275
  return {
147
276
  skill_list: tool({
148
- description: 'List all skills registered on the LiteLLM proxy',
277
+ description: 'List all skills registered on the LiteLLM Skills Gateway',
149
278
  args: {},
150
279
  async execute(_args: Record<string, unknown>, _context: unknown): Promise<string> {
151
280
  const skills = await listSkills(config, token)
@@ -154,12 +283,12 @@ export function createSkillToolDefinitions(
154
283
  return 'No skills found.'
155
284
  }
156
285
 
157
- const header = '| ID | Name | Description | Enabled |'
158
- const sep = '|------|------|-------------|---------|'
286
+ const header = '| Name | Description | Enabled | Source |'
287
+ const sep = '|--------|-------------|---------|--------|'
159
288
  const rows = skills
160
289
  .map(
161
290
  (s) =>
162
- `| ${s.id} | ${s.name} | ${s.description} | ${s.enabled !== false ? 'yes' : 'no'} |`,
291
+ `| ${s.name} | ${s.description || '-'} | ${s.enabled ? 'yes' : 'no'} | ${s.source.url} |`,
163
292
  )
164
293
  .join('\n')
165
294
 
@@ -167,37 +296,45 @@ export function createSkillToolDefinitions(
167
296
  },
168
297
  }),
169
298
 
170
- skill_create: tool({
171
- description: 'Create a new skill on the LiteLLM proxy',
299
+ skill_register: tool({
300
+ description: 'Register a new skill on the LiteLLM Skills Gateway pointing to a git source',
172
301
  args: {
173
302
  name: tool.schema.string().describe('Name of the skill'),
174
- description: tool.schema.string().describe('Description of the skill'),
175
- input_schema: tool.schema
176
- .object({})
177
- .passthrough()
178
- .optional()
179
- .describe('Input schema for the skill'),
180
- code: tool.schema.string().optional().describe('Code for the skill'),
303
+ git_url: tool.schema.string().describe('GitHub repository URL containing the skill'),
304
+ git_path: tool.schema.string().describe('Path within the repo to the skill directory (must contain SKILL.md)'),
305
+ description: tool.schema.string().optional().describe('Description of the skill'),
306
+ domain: tool.schema.string().optional().describe('Domain/category for the skill'),
181
307
  },
182
308
  async execute(args: Record<string, unknown>, _context: unknown): Promise<string> {
183
- return createSkill(
309
+ return registerSkill(
184
310
  config,
185
311
  token,
186
312
  args.name as string,
187
- args.description as string,
188
- args.input_schema as Record<string, unknown> | undefined,
189
- args.code as string | undefined,
313
+ args.git_url as string,
314
+ args.git_path as string,
315
+ args.description as string | undefined,
316
+ args.domain as string | undefined,
190
317
  )
191
318
  },
192
319
  }),
193
320
 
194
- skill_delete: tool({
195
- description: 'Delete a skill from the LiteLLM proxy',
321
+ skill_enable: tool({
322
+ description: 'Enable (publish) a skill on the LiteLLM Skills Gateway',
323
+ args: {
324
+ name: tool.schema.string().describe('Name of the skill to enable'),
325
+ },
326
+ async execute(args: Record<string, unknown>, _context: unknown): Promise<string> {
327
+ return enableSkill(config, token, args.name as string)
328
+ },
329
+ }),
330
+
331
+ skill_disable: tool({
332
+ description: 'Disable (unpublish) a skill on the LiteLLM Skills Gateway',
196
333
  args: {
197
- skill_id: tool.schema.string().describe('ID of the skill to delete'),
334
+ name: tool.schema.string().describe('Name of the skill to disable'),
198
335
  },
199
336
  async execute(args: Record<string, unknown>, _context: unknown): Promise<string> {
200
- return deleteSkill(config, token, args.skill_id as string)
337
+ return disableSkill(config, token, args.name as string)
201
338
  },
202
339
  }),
203
340
  }
@@ -216,10 +353,8 @@ export function createSkillsInjector(
216
353
  output: { message: any; parts: any[] },
217
354
  ) => Promise<void> {
218
355
  return async (input, output) => {
219
- // Only inject for main agent session — skip ALL sub-agents
220
356
  if (input.agent) return
221
357
 
222
- // Fetch skills with simple in-memory cache
223
358
  let skills: Skill[] = []
224
359
  if (skillsCache && Date.now() - skillsCache.timestamp < CACHE_TTL_MS) {
225
360
  skills = skillsCache.data
@@ -232,7 +367,7 @@ export function createSkillsInjector(
232
367
  if (enabledSkills.length === 0) return
233
368
 
234
369
  const context = enabledSkills
235
- .map((s) => `<skill name="${s.name}">${s.description}</skill>`)
370
+ .map((s) => `<skill name="${s.name}">${s.description || 'No description'}</skill>`)
236
371
  .join('\n')
237
372
 
238
373
  output.parts.push({ type: 'text', text: context })
package/src/types.ts CHANGED
@@ -15,12 +15,27 @@ export interface McpTool {
15
15
  input_schema: Record<string, unknown>
16
16
  }
17
17
 
18
+ export interface SkillSource {
19
+ source: string
20
+ url: string
21
+ path?: string
22
+ }
23
+
18
24
  export interface Skill {
19
25
  id: string
20
26
  name: string
21
- description: string
22
- enabled?: boolean
23
- [key: string]: unknown
27
+ version: string
28
+ description: string | null
29
+ source: SkillSource
30
+ author: string | null
31
+ homepage: string | null
32
+ keywords: string | null
33
+ category: string | null
34
+ domain: string | null
35
+ namespace: string | null
36
+ enabled: boolean
37
+ created_at: string
38
+ updated_at: string
24
39
  }
25
40
 
26
41
  export interface OpencodeModelConfig {