Skip to content

Commit 2fb6416

Browse files
committed
feat: add skills browsing and selection feature
This should close #418
1 parent 8f4451b commit 2fb6416

9 files changed

Lines changed: 233 additions & 5 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ The plugin provides the following actions that can be triggered via keymaps, com
642642
| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` |
643643
| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` |
644644
| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` |
645+
| Browse and select available skills | - | `:Opencode skills` / `/skills` | - |
645646
| Display list of available mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` |
646647
| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` |
647648
| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` |
@@ -979,6 +980,22 @@ When `port = 'auto'` is used, opencode.nvim:
979980
- Only kills the server when the last nvim instance exits (if `auto_kill = true`). Only applies to servers spawned by the plugin with `spawn_command`/`kill_command`.
980981
- Locally spawned servers will be killed automatically regardless of the auto_kill setting if they are the last nvim instance using them
981982

983+
## 🎯 Skills
984+
985+
Skills are reusable, installable instruction packs that enhance opencode.nvim with domain-specific workflows. Each skill provides its own behavior, prompts, and tool configurations.
986+
987+
### Browsing Skills
988+
989+
- **Via command:** Run `:Opencode skills` to open the skills picker
990+
- **Via slash command:** Type `/skills` in the input window to open the skills picker
991+
- **Via completion:** Type `/` in the input window and select a skill from the completion menu
992+
993+
The skills picker displays each skill with its name, description, and full content rendered as markdown in the preview pane. Selecting a skill executes it directly — opening a session and sending the skill's content as a prompt.
994+
995+
### Installing Skills
996+
997+
See the [Opencode Skills Documentation](https://opencode.ai/docs/skills/) for how to discover and install community skills.
998+
982999
## User Commands and Slash Commands
9831000

9841001
You can run predefined user commands and built-in slash commands from the input window by typing `/`. This opens a command picker where you can select a command to execute. The output of the command will be included in your prompt context.
@@ -993,6 +1010,7 @@ You can run predefined user commands and built-in slash commands from the input
9931010
- `/agents_init` — Initialize/update AGENTS.md
9941011
- `/help` — Show help
9951012
- `/mcp` — Show MCP servers
1013+
- `/skills` — Browse and select available skills
9961014
- `/models` — Switch provider/model
9971015
- `/variant` — Switch model variant
9981016
- `/sessions` — Switch session

lua/opencode/api_client.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,15 @@ function OpencodeApiClient:_subscribe_to_global_events(directory, on_event)
571571
end)
572572
end
573573

574+
-- Skill endpoints
575+
576+
--- List all skills
577+
--- @param directory string|nil Directory path
578+
--- @return Promise<OpencodeSkill[]>
579+
function OpencodeApiClient:list_skills(directory)
580+
return self:_call('/skill', 'GET', nil, { directory = directory })
581+
end
582+
574583
-- Tool endpoints
575584

576585
--- List all tool IDs (including built-in and dynamically registered)

lua/opencode/commands/handlers/surface.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ M.actions.mcp = Promise.async(function()
120120
mcp_picker.pick()
121121
end)
122122

123+
M.actions.skills = Promise.async(function()
124+
local skill_picker = require('opencode.ui.skill_picker')
125+
skill_picker.pick()
126+
end)
127+
123128
M.command_defs = {
124129
help = {
125130
desc = 'Show this help message',
@@ -129,6 +134,10 @@ M.command_defs = {
129134
desc = 'Show user-defined commands',
130135
execute = M.actions.commands_list,
131136
},
137+
skills = {
138+
desc = 'Browse and select available skills',
139+
execute = M.actions.skills,
140+
},
132141
mcp = {
133142
desc = 'Show MCP server configuration',
134143
execute = M.actions.mcp,

lua/opencode/commands/slash.lua

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ local slash_command_presets = {
2323
['/variant'] = { name = 'variant' },
2424
['/new'] = { name = 'session', preset_args = { 'new' } },
2525
['/redo'] = { name = 'redo' },
26-
['/sessions'] = { name = 'session', preset_args = { 'select' } },
27-
['/share'] = { name = 'session', preset_args = { 'share' } },
26+
['/sessions'] = { name = 'session', preset_args = { 'select' } },
27+
['/skills'] = { name = 'skills' },
28+
['/share'] = { name = 'session', preset_args = { 'share' } },
2829
['/clear_selections'] = { name = 'clear_selections' },
2930
['/clear_files'] = { name = 'clear_files' },
3031
['/timeline'] = { name = 'timeline' },

lua/opencode/types.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
---@field worktree string
3535
---@field directory string
3636

37+
---@class OpencodeSkill
38+
---@field name string
39+
---@field description string|nil
40+
---@field location string
41+
---@field content string
42+
3743
---@class OpencodeCommand
3844
---@field description string
3945
---@field agent string

lua/opencode/ui/completion.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ function M.setup()
88
local subagents_source = require('opencode.ui.completion.subagents')
99
local commands_source = require('opencode.ui.completion.commands')
1010
local context_source = require('opencode.ui.completion.context')
11+
local skills_source = require('opencode.ui.completion.skills')
1112

1213
M.register_source(files_source.get_source())
1314
M.register_source(subagents_source.get_source())
1415
M.register_source(commands_source.get_source())
1516
M.register_source(context_source.get_source())
17+
M.register_source(skills_source.get_source())
1618

1719
table.sort(M._sources, function(a, b)
1820
return (a.priority or 0) > (b.priority or 0)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
local icons = require('opencode.ui.icons')
2+
local Promise = require('opencode.promise')
3+
4+
local M = {}
5+
6+
local custom_kind = require('opencode.ui.completion.kind')
7+
8+
---@type CompletionSource
9+
local skill_source = {
10+
name = 'skills',
11+
priority = 1,
12+
custom_kind = custom_kind.register('skills', icons.get('skill')),
13+
complete = Promise.async(function(context)
14+
local config = require('opencode.config')
15+
local expected_trigger = config.get_key_for_function('input_window', 'slash_commands') or '/'
16+
if context.trigger_char ~= expected_trigger then
17+
return {}
18+
end
19+
20+
if not context.line:match('^' .. vim.pesc(expected_trigger) .. '[^%s/]*$') then
21+
return {}
22+
end
23+
24+
local state = require('opencode.state')
25+
local api_client = state and state.api_client
26+
if not api_client then
27+
return {}
28+
end
29+
30+
local ok, skills = pcall(function()
31+
return api_client:list_skills():await()
32+
end)
33+
if not ok or not skills then
34+
return {}
35+
end
36+
---@cast skills OpencodeSkill[]
37+
38+
local items = {}
39+
40+
for _, skill in ipairs(skills) do
41+
local item = {
42+
label = expected_trigger .. skill.name,
43+
kind = 'skill',
44+
kind_icon = icons.get('skill'),
45+
detail = skill.description or '',
46+
insert_text = skill.name .. ' ',
47+
source_name = 'skills',
48+
data = {
49+
name = skill.name,
50+
content = skill.content,
51+
},
52+
}
53+
table.insert(items, item)
54+
end
55+
56+
local sort_util = require('opencode.ui.completion.sort')
57+
sort_util.sort_by_relevance(items, context.input)
58+
59+
return items
60+
end),
61+
on_complete = function(item)
62+
if item.kind ~= 'skill' or not item.data or not item.data.content then
63+
return
64+
end
65+
66+
vim.defer_fn(function()
67+
require('opencode.services.session_runtime').open({ new_session = false, focus = 'output' }):and_then(function()
68+
return require('opencode.services.messaging').send_message(item.data.content, {})
69+
end)
70+
end, 10)
71+
require('opencode.ui.input_window').set_content('')
72+
end,
73+
get_trigger_character = function()
74+
local config = require('opencode.config')
75+
return config.get_key_for_function('input_window', 'slash_commands') or '/'
76+
end,
77+
}
78+
79+
---Get the skill completion source
80+
---@return CompletionSource
81+
function M.get_source()
82+
return skill_source
83+
end
84+
85+
return M

lua/opencode/ui/skill_picker.lua

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
local base_picker = require('opencode.ui.base_picker')
2+
local Promise = require('opencode.promise')
3+
4+
local M = {}
5+
6+
---@param skill OpencodeSkill
7+
---@param width number
8+
---@return PickerItem
9+
local function format_skill_item(skill, width)
10+
width = width or vim.api.nvim_win_get_width(0)
11+
local desc_width = math.max(20, width - 30)
12+
local desc = skill.description or ''
13+
if #desc > desc_width then
14+
desc = desc:sub(1, desc_width - 3) .. '...'
15+
end
16+
return base_picker.create_picker_item({
17+
{ text = base_picker.align(skill.name, 20, { truncate = true }), highlight = 'OpencodeFile' },
18+
{ text = desc, highlight = 'OpencodeHint' },
19+
})
20+
end
21+
22+
---@param skill OpencodeSkill
23+
---@param target PickerPreviewTarget
24+
local function preview_skill(skill, target)
25+
if not skill or not skill.content then
26+
return
27+
end
28+
29+
local lines = vim.split(skill.content, '\n')
30+
target:set_lines(lines)
31+
32+
local bufnr = target:get_bufnr()
33+
if bufnr then
34+
vim.bo[bufnr].filetype = 'markdown'
35+
end
36+
end
37+
38+
---Show skills picker
39+
function M.pick()
40+
local state = require('opencode.state')
41+
local ui = require('opencode.ui.ui')
42+
local input_window = require('opencode.ui.input_window')
43+
44+
local ok, skills = pcall(function()
45+
return state.api_client:list_skills():await()
46+
end)
47+
48+
if not ok or not skills then
49+
vim.notify('Failed to fetch skills: ' .. tostring(skills), vim.log.levels.ERROR)
50+
return
51+
end
52+
53+
if #skills == 0 then
54+
vim.notify('No skills available', vim.log.levels.WARN)
55+
return
56+
end
57+
58+
local callback = function(selected)
59+
if not selected then
60+
return
61+
end
62+
63+
ui.focus_input()
64+
input_window.set_content('/' .. selected.name .. ' ')
65+
vim.api.nvim_win_set_cursor(state.windows.input_win, { 1, #selected.name + 2 })
66+
end
67+
68+
base_picker.pick({
69+
items = skills,
70+
format_fn = format_skill_item,
71+
actions = {},
72+
callback = callback,
73+
title = 'Skills',
74+
width = 65,
75+
preview = 'custom',
76+
preview_fn = preview_skill,
77+
})
78+
end
79+
80+
return M

tests/unit/completion_spec.lua

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('opencode.ui.completion', function()
3333
package.loaded['opencode.ui.completion.subagents'] = nil
3434
package.loaded['opencode.ui.completion.commands'] = nil
3535
package.loaded['opencode.ui.completion.context'] = nil
36+
package.loaded['opencode.ui.completion.skills'] = nil
3637

3738
completion = require('opencode.ui.completion')
3839
completion._sources = {}
@@ -48,10 +49,11 @@ describe('opencode.ui.completion', function()
4849
package.loaded['opencode.ui.completion.subagents'] = nil
4950
package.loaded['opencode.ui.completion.commands'] = nil
5051
package.loaded['opencode.ui.completion.context'] = nil
52+
package.loaded['opencode.ui.completion.skills'] = nil
5153
end)
5254

5355
describe('setup', function()
54-
it('registers all four built-in sources', function()
56+
it('registers all built-in sources', function()
5557
local registered = {}
5658

5759
package.loaded['opencode.ui.completion.files'] = {
@@ -74,6 +76,11 @@ describe('opencode.ui.completion', function()
7476
return { name = 'context', priority = 1, complete = function() end }
7577
end,
7678
}
79+
package.loaded['opencode.ui.completion.skills'] = {
80+
get_source = function()
81+
return { name = 'skills', priority = 1, complete = function() end }
82+
end,
83+
}
7784

7885
package.loaded['opencode.ui.completion'] = nil
7986
completion = require('opencode.ui.completion')
@@ -82,7 +89,7 @@ describe('opencode.ui.completion', function()
8289
completion.setup()
8390

8491
local sources = completion.get_sources()
85-
assert.are.equal(4, #sources)
92+
assert.are.equal(5, #sources)
8693

8794
for _, s in ipairs(sources) do
8895
registered[s.name] = true
@@ -91,6 +98,7 @@ describe('opencode.ui.completion', function()
9198
assert.is_true(registered['subagents'])
9299
assert.is_true(registered['commands'])
93100
assert.is_true(registered['context'])
101+
assert.is_true(registered['skills'])
94102
end)
95103

96104
it('sorts sources in descending priority order after setup', function()
@@ -114,6 +122,11 @@ describe('opencode.ui.completion', function()
114122
return { name = 'context', priority = 1, complete = function() end }
115123
end,
116124
}
125+
package.loaded['opencode.ui.completion.skills'] = {
126+
get_source = function()
127+
return { name = 'skills', priority = 1, complete = function() end }
128+
end,
129+
}
117130

118131
package.loaded['opencode.ui.completion'] = nil
119132
completion = require('opencode.ui.completion')
@@ -148,6 +161,11 @@ describe('opencode.ui.completion', function()
148161
return { name = 'context', complete = function() end } -- no priority
149162
end,
150163
}
164+
package.loaded['opencode.ui.completion.skills'] = {
165+
get_source = function()
166+
return { name = 'skills', priority = 1, complete = function() end }
167+
end,
168+
}
151169

152170
package.loaded['opencode.ui.completion'] = nil
153171
completion = require('opencode.ui.completion')
@@ -158,7 +176,7 @@ describe('opencode.ui.completion', function()
158176
end)
159177

160178
local sources = completion.get_sources()
161-
assert.are.equal(4, #sources)
179+
assert.are.equal(5, #sources)
162180
end)
163181
end)
164182
end)

0 commit comments

Comments
 (0)