diff --git a/lua/goose/api.lua b/lua/goose/api.lua index d012298..123c898 100644 --- a/lua/goose/api.lua +++ b/lua/goose/api.lua @@ -46,8 +46,8 @@ function M.toggle_focus() end function M.change_mode(mode) - local info_mod = require("goose.info") - info_mod.set_config_value(info_mod.GOOSE_INFO.MODE, mode) + local info = require("goose.info") + info.set(info.KEY.MODE, mode) if state.windows then require('goose.ui.topbar').render() @@ -57,26 +57,38 @@ function M.change_mode(mode) end function M.goose_mode_chat() - M.change_mode(require('goose.info').GOOSE_MODE.CHAT) + M.change_mode(require('goose.info').MODE.CHAT) end function M.goose_mode_auto() - M.change_mode(require('goose.info').GOOSE_MODE.AUTO) + M.change_mode(require('goose.info').MODE.AUTO) end function M.configure_provider() core.configure_provider() end +function M.mention_skill() + core.mention_skill() +end + function M.open_config() - local info = require('goose.info').parse_goose_info() + local info = require('goose.info') + + local result = vim.system({ 'goose', 'info', '-v' }):wait() + if result.code ~= 0 then + vim.notify("Could not get config path", vim.log.levels.ERROR) + return + end - if not info.config_file then + local config_path = result.stdout:match("Config yaml:%s*(.-)[\n$]") + if not config_path then vim.notify("Could not find config file path", vim.log.levels.ERROR) - return nil + return end - require('goose.ui.ui').open_file_in_code_window(info.config_file) + require('goose.ui.ui').open_file_in_code_window(vim.trim(config_path)) + info.invalidate() end function M.inspect_session() @@ -282,6 +294,14 @@ M.commands = { end }, + mention_skill = { + name = "GooseMentionSkill", + desc = "Mention a skill from ~/.claude/skills", + fn = function() + M.mention_skill() + end + }, + open_config = { name = "GooseOpenConfig", desc = "Open goose config file", diff --git a/lua/goose/config.lua b/lua/goose/config.lua index 836d40c..1e227fc 100644 --- a/lua/goose/config.lua +++ b/lua/goose/config.lua @@ -36,6 +36,7 @@ M.defaults = { next_message = ']]', prev_message = '[[', mention_file = '@', + mention_skill = '#', toggle_pane = '', prev_prompt_history = '', next_prompt_history = '' diff --git a/lua/goose/context.lua b/lua/goose/context.lua index e2a8d37..a575ad9 100644 --- a/lua/goose/context.lua +++ b/lua/goose/context.lua @@ -7,18 +7,18 @@ local config = require("goose.config"); local M = {} M.context = { - -- current file current_file = nil, cursor_data = nil, - -- attachments mentioned_files = nil, + mentioned_skills = nil, selections = nil, linter_errors = nil } function M.unload_attachments() M.context.mentioned_files = nil + M.context.mentioned_skills = nil M.context.selections = nil M.context.linter_errors = nil end @@ -83,7 +83,7 @@ function M.add_file(file) end if vim.fn.filereadable(file) ~= 1 then - vim.notify("File not added to context. Could not read.") + vim.notify("File not added to context. Could not read.", vim.log.levels.WARN) return end @@ -92,6 +92,16 @@ function M.add_file(file) end end +function M.add_skill(skill_name) + if not M.context.mentioned_skills then + M.context.mentioned_skills = {} + end + + if not vim.tbl_contains(M.context.mentioned_skills, skill_name) then + table.insert(M.context.mentioned_skills, skill_name) + end +end + function M.delta_context() local context = vim.deepcopy(M.context) local last_context = require('goose.state').last_sent_context @@ -164,11 +174,8 @@ function M.format_message(prompt) local info = require('goose.info') local context = nil - if info.parse_goose_info().goose_mode == info.GOOSE_MODE.CHAT then - -- For chat mode only send selection context - context = { - selections = M.context.selections - } + if info.mode() == info.MODE.CHAT then + context = { selections = M.context.selections } else context = M.delta_context() end diff --git a/lua/goose/core.lua b/lua/goose/core.lua index d7125b7..c6c38d2 100644 --- a/lua/goose/core.lua +++ b/lua/goose/core.lua @@ -4,6 +4,7 @@ local context = require("goose.context") local session = require("goose.session") local ui = require("goose.ui.ui") local job = require('goose.job') +local keymap = require('goose.config').get('keymap') function M.select_session() local all_sessions = session.get_sessions() @@ -124,16 +125,16 @@ function M.add_file_to_context() mention_cb(file.name) context.add_file(file.path) end) - end) + end, keymap.window.mention_file) end function M.configure_provider() - local info_mod = require("goose.info") + local info = require("goose.info") require("goose.provider").select(function(selection) if not selection then return end - info_mod.set_config_value(info_mod.GOOSE_INFO.PROVIDER, selection.provider) - info_mod.set_config_value(info_mod.GOOSE_INFO.MODEL, selection.model) + info.set(info.KEY.PROVIDER, selection.provider) + info.set(info.KEY.MODEL, selection.model) if state.windows then require('goose.ui.topbar').render() @@ -143,6 +144,21 @@ function M.configure_provider() end) end +function M.mention_skill() + if not require('goose.info').is_extension_enabled('skills') then + vim.notify("Skills extension is not enabled", vim.log.levels.WARN) + return + end + + require('goose.ui.mention').mention(function(mention_cb) + require("goose.skills").select(function(skill) + if not skill then return end + mention_cb(skill.name) + context.add_skill(skill.name) + end) + end, keymap.window.mention_skill) +end + function M.stop() if (state.goose_run_job) then job.stop(state.goose_run_job) end state.goose_run_job = nil diff --git a/lua/goose/info.lua b/lua/goose/info.lua index 56d762f..be91fb1 100644 --- a/lua/goose/info.lua +++ b/lua/goose/info.lua @@ -1,63 +1,84 @@ local M = {} -local util = require('goose.util') -M.GOOSE_INFO = { - MODEL = "GOOSE_MODEL", - PROVIDER = "GOOSE_PROVIDER", - MODE = "GOOSE_MODE", - CONFIG = "Config yaml", -} - -M.GOOSE_MODE = { +M.MODE = { CHAT = "chat", AUTO = "auto" } --- Parse the output of `goose info -v` command -function M.parse_goose_info() - local result = {} +M.KEY = { + MODE = "GOOSE_MODE", + MODEL = "GOOSE_MODEL", + PROVIDER = "GOOSE_PROVIDER" +} + +local cache = nil - local handle = io.popen("goose info -v") - if not handle then - return result - end +local function load_config() + if cache then return cache end - local output = handle:read("*a") - handle:close() + local result = vim.system({ 'goose', 'info', '-v' }):wait() + if result.code ~= 0 then return nil end - local model = output:match(M.GOOSE_INFO.MODEL .. ":%s*(.-)\n") or output:match(M.GOOSE_INFO.MODEL .. ":%s*(.-)$") - if model then - result.goose_model = vim.trim(model) - end + local config_path = result.stdout:match("Config yaml:%s*(.-)[\n$]") + if not config_path then return nil end - local provider = output:match(M.GOOSE_INFO.PROVIDER .. ":%s*(.-)\n") or - output:match(M.GOOSE_INFO.PROVIDER .. ":%s*(.-)$") - if provider then - result.goose_provider = vim.trim(provider) - end + config_path = vim.trim(config_path) - local mode = output:match(M.GOOSE_INFO.MODE .. ":%s*(.-)\n") or output:match(M.GOOSE_INFO.MODE .. ":%s*(.-)$") - if mode then - result.goose_mode = vim.trim(mode) - end + local file = io.open(config_path, "r") + if not file then return nil end + + local content = file:read("*a") + file:close() - local config_file = output:match(M.GOOSE_INFO.CONFIG .. ":%s*(.-)\n") or - output:match(M.GOOSE_INFO.CONFIG .. ":%s*(.-)$") - if config_file then - result.config_file = vim.trim(config_file) + local data = require('goose.util').parse_yaml(content) + if data then + cache = { + path = config_path, + data = data + } end - return result + return cache end --- Set a value in the goose config file -function M.set_config_value(key, value) - local info = M.parse_goose_info() - if not info.config_file then - return false, "Could not find config file path" - end +function M.model() + local cfg = load_config() + return cfg and cfg.data[M.KEY.MODEL] +end + +function M.provider() + local cfg = load_config() + return cfg and cfg.data[M.KEY.PROVIDER] +end + +function M.mode() + local cfg = load_config() + return cfg and cfg.data[M.KEY.MODE] +end + +function M.extensions() + local cfg = load_config() + return cfg and cfg.data.extensions or {} +end + +function M.is_extension_enabled(name) + local exts = M.extensions() + return exts[name] and exts[name].enabled == true +end + +function M.set(key, value) + local cfg = load_config() + if not cfg then return false, "Could not load config" end + + local util = require('goose.util') + local ok, err = util.set_yaml_value(cfg.path, key, value) + if ok then cache = nil end + + return ok, err +end - return util.set_yaml_value(info.config_file, key, value) +function M.invalidate() + cache = nil end return M diff --git a/lua/goose/skills.lua b/lua/goose/skills.lua new file mode 100644 index 0000000..678bb90 --- /dev/null +++ b/lua/goose/skills.lua @@ -0,0 +1,104 @@ +local M = {} + +local SKILLS_DIRS = { + "./.goose/skills", + "./.claude/skills", + vim.fn.expand("~/.config/goose/skills"), + vim.fn.expand("~/.claude/skills"), +} + +local function parse_frontmatter(content) + local lines = vim.split(content, "\n") + if lines[1] ~= "---" then return nil end + + local frontmatter = {} + for i = 2, #lines do + if lines[i] == "---" then break end + + local key, value = lines[i]:match("^(%w+):%s*(.+)") + if key and value then + frontmatter[key] = value + end + end + + return frontmatter +end + +local function read_skill_metadata(skill_dir) + local skill_file = skill_dir .. "/SKILL.md" + local fd = vim.loop.fs_open(skill_file, "r", 438) + if not fd then return nil end + + local stat = vim.loop.fs_fstat(fd) + if not stat then + vim.loop.fs_close(fd) + return nil + end + + local content = vim.loop.fs_read(fd, stat.size, 0) + vim.loop.fs_close(fd) + + local frontmatter = parse_frontmatter(content) + if not frontmatter then return nil end + + return { + name = frontmatter.name or vim.fn.fnamemodify(skill_dir, ":t"), + description = frontmatter.description or "", + path = skill_file + } +end + +local function scan_skills_dir(dir) + local handle = vim.loop.fs_scandir(dir) + if not handle then return {} end + + local skills = {} + while true do + local name, type = vim.loop.fs_scandir_next(handle) + if not name then break end + + if type == "directory" then + local metadata = read_skill_metadata(dir .. "/" .. name) + if metadata then + table.insert(skills, metadata) + end + end + end + + return skills +end + +local function scan_skills() + local all_skills = {} + local seen = {} + + for _, dir in ipairs(SKILLS_DIRS) do + local skills = scan_skills_dir(dir) + for _, skill in ipairs(skills) do + if not seen[skill.name] then + seen[skill.name] = true + table.insert(all_skills, skill) + end + end + end + + return all_skills +end + +function M.select(cb) + local skills = scan_skills() + + if #skills == 0 then + vim.notify("No skills found", vim.log.levels.WARN) + return + end + + vim.ui.select(skills, { + prompt = "Select skill:", + format_item = function(skill) + return skill.name + end + }, cb) +end + +return M diff --git a/lua/goose/ui/highlight.lua b/lua/goose/ui/highlight.lua index cef7ca0..e8c41d4 100644 --- a/lua/goose/ui/highlight.lua +++ b/lua/goose/ui/highlight.lua @@ -3,7 +3,6 @@ local M = {} function M.setup() vim.api.nvim_set_hl(0, 'GooseBorder', { fg = '#616161' }) vim.api.nvim_set_hl(0, 'GooseBackground', { link = "Normal" }) - vim.api.nvim_set_hl(0, "GooseMention", { link = "Special" }) end return M diff --git a/lua/goose/ui/mention.lua b/lua/goose/ui/mention.lua index 51af6cb..5197538 100644 --- a/lua/goose/ui/mention.lua +++ b/lua/goose/ui/mention.lua @@ -3,38 +3,35 @@ local M = {} local mentions_namespace = vim.api.nvim_create_namespace("GooseMentions") function M.highlight_all_mentions(buf) - -- Pattern for mentions - local mention_pattern = "@[%w_%-%.][%w_%-%.]*" + local patterns = { + "@[%w_%-%.]+", -- files + "#[%w_%-%.]+" -- skills + } - -- Clear existing extmarks vim.api.nvim_buf_clear_namespace(buf, mentions_namespace, 0, -1) - - -- Get all lines in buffer local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) for row, line in ipairs(lines) do - local start_idx = 1 - -- Find all mentions in the line - while true do - local mention_start, mention_end = line:find(mention_pattern, start_idx) - if not mention_start then break end - - -- Add extmark for this mention - vim.api.nvim_buf_set_extmark(buf, mentions_namespace, row - 1, mention_start - 1, { - end_col = mention_end, - hl_group = "GooseMention", - }) - - -- Move to search for the next mention - start_idx = mention_end + 1 + for _, pattern in ipairs(patterns) do + local start_idx = 1 + while true do + local mention_start, mention_end = line:find(pattern, start_idx) + if not mention_start then break end + + vim.api.nvim_buf_set_extmark(buf, mentions_namespace, row - 1, mention_start - 1, { + end_col = mention_end, + hl_group = "Special", + }) + start_idx = mention_end + 1 + end end end end -local function insert_mention(windows, row, col, name) +local function insert_mention(windows, row, col, name, mention_key) local current_line = vim.api.nvim_buf_get_lines(windows.input_buf, row - 1, row, false)[1] - local insert_name = '@' .. name .. " " + local insert_name = mention_key .. name .. " " local new_line = current_line:sub(1, col) .. insert_name .. current_line:sub(col + 2) vim.api.nvim_buf_set_lines(windows.input_buf, row - 1, row, false, { new_line }) @@ -49,20 +46,17 @@ local function insert_mention(windows, row, col, name) end, 100) end -function M.mention(get_name) +function M.mention(get_name, mention_key) local windows = require('goose.state').windows - local mention_key = require('goose.config').get('keymap').window.mention_file - -- insert @ in case we just want the character - if mention_key == '@' then - vim.api.nvim_feedkeys('@', 'in', true) - end + -- insert mention key in case we just want the character + vim.api.nvim_feedkeys(mention_key, 'in', true) local cursor_pos = vim.api.nvim_win_get_cursor(windows.input_win) local row, col = cursor_pos[1], cursor_pos[2] get_name(function(name) - insert_mention(windows, row, col, name) + insert_mention(windows, row, col, name, mention_key) end) end diff --git a/lua/goose/ui/session_formatter.lua b/lua/goose/ui/session_formatter.lua index dcce3a1..a12b02b 100644 --- a/lua/goose/ui/session_formatter.lua +++ b/lua/goose/ui/session_formatter.lua @@ -9,9 +9,9 @@ M.separator = { function M.format_session(session_name) local output = require("goose.session").export(session_name) + if not output then return end local success, session = pcall(vim.fn.json_decode, output) - if not success or not session or not session.conversation then return end @@ -90,22 +90,60 @@ function M._format_message(message) end function M._format_context(lines, type, value) - if not type or not value then return end + if not type then return end + + local formatted_action = ' **' .. type .. '**' - -- escape new lines - value = value:gsub("\n", "\\n") + if value and value ~= '' then + -- escape new lines + value = value:gsub("\n", "\\n") + formatted_action = formatted_action .. ' ` ' .. value .. ' `' + end - local formatted_action = ' **' .. type .. '** ` ' .. value .. ' `' table.insert(lines, formatted_action) end +local function extract_task_titles(task_parameters) + if type(task_parameters) == 'table' then + local titles = {} + for _, param in ipairs(task_parameters) do + if param.title then + table.insert(titles, param.title) + end + end + return #titles > 0 and table.concat(titles, ", ") or "" + elseif type(task_parameters) == 'string' then + local titles = {} + for title in task_parameters:gmatch('"title"%s*:%s*"([^"]+)"') do + table.insert(titles, title) + end + return #titles > 0 and table.concat(titles, ", ") or "" + end + return "" +end + function M._format_tool(lines, part) local tool = part.toolCall.value if not tool then return end local command = tool.arguments.command - if tool.name == 'developer__shell' then + if tool.name == 'skills__loadSkill' then + M._format_context(lines, '💎 skill', tool.arguments.name) + elseif tool.name == 'subagent__execute_task' then + M._format_context(lines, '⚡️ execute task') + elseif tool.name == 'developer__analyze' then + local full_path = tool.arguments.path + local dir = vim.fn.fnamemodify(full_path, ":t") + M._format_context(lines, '👀 analyze', dir) + elseif tool.name == 'dynamic_task__create_task' then + local titles = extract_task_titles(tool.arguments.task_parameters) + M._format_context(lines, '📋 create task', titles) + elseif tool.name == 'developer__shell' then M._format_context(lines, '🚀 run', command) + elseif tool.name == 'developer__image_processor' then + local image_path = tool.arguments.path + local image_name = vim.fn.fnamemodify(image_path, ":t") + M._format_context(lines, '🎇 process image', image_name) elseif tool.name == 'developer__text_editor' then local path = tool.arguments.path local file_name = vim.fn.fnamemodify(path, ":t") diff --git a/lua/goose/ui/topbar.lua b/lua/goose/ui/topbar.lua index 258b399..3fc01e3 100644 --- a/lua/goose/ui/topbar.lua +++ b/lua/goose/ui/topbar.lua @@ -7,19 +7,20 @@ local LABELS = { } local function format_model_info() - local info = require("goose.info").parse_goose_info() + local info = require("goose.info") local config = require("goose.config").get() local parts = {} if config.ui.display_model then - local model = info.goose_model and (info.goose_model:match("[^/]+$") or info.goose_model) or "" - if model ~= "" then + local model = info.model() + if model then + model = model:match("[^/]+$") or model table.insert(parts, model) end end if config.ui.display_goose_mode then - local mode = info.goose_mode + local mode = info.mode() if mode then table.insert(parts, "[" .. mode .. "]") end diff --git a/lua/goose/ui/window_config.lua b/lua/goose/ui/window_config.lua index 26e7b9e..10065ec 100644 --- a/lua/goose/ui/window_config.lua +++ b/lua/goose/ui/window_config.lua @@ -296,6 +296,10 @@ function M.setup_keymaps(windows) require('goose.core').add_file_to_context() end, { buffer = windows.input_buf, silent = true }) + vim.keymap.set('i', window_keymap.mention_skill, function() + require('goose.core').mention_skill() + end, { buffer = windows.input_buf, silent = true }) + vim.keymap.set({ 'n', 'i' }, window_keymap.toggle_pane, function() api.toggle_pane() end, { buffer = windows.input_buf, silent = true }) diff --git a/lua/goose/util.lua b/lua/goose/util.lua index c4728f6..4a1abe9 100644 --- a/lua/goose/util.lua +++ b/lua/goose/util.lua @@ -191,27 +191,67 @@ function M.time_ago(dateTime) end end --- Simple YAML key/value setter --- Note: This is a basic implementation that assumes simple YAML structure --- It will either update an existing key or append a new key at the end -function M.set_yaml_value(path, key, value) - if not path then - return false, "No file path provided" +local function parse_yaml_value(value) + if value == "true" then + return true + elseif value == "false" then + return false + elseif value == "null" or value == "~" then + return nil + elseif tonumber(value) then + return tonumber(value) + else + return value + end +end + +local function get_indent(line) + return #line:match("^%s*") +end + +function M.parse_yaml(content) + local result = {} + local stack = { { data = result, indent = -1 } } + + for line in content:gmatch("[^\r\n]+") do + if not line:match("^%s*#") and not line:match("^%s*$") then + local indent = get_indent(line) + local key, value = line:match("^%s*([^:]+):%s*(.*)$") + + if key then + while #stack > 1 and stack[#stack].indent >= indent do + table.remove(stack) + end + + local parent = stack[#stack].data + key = vim.trim(key) + value = vim.trim(value) + + if value == "" then + parent[key] = {} + table.insert(stack, { data = parent[key], indent = indent }) + else + parent[key] = parse_yaml_value(value) + end + end + end end - -- Read the current content + return result +end + +function M.set_yaml_value(path, key, value) + if not path then return false, "No file path provided" end + + local file = io.open(path, "r") + if not file then return false, "Could not open file" end + local lines = {} local key_pattern = "^%s*" .. vim.pesc(key) .. ":%s*" local found = false - local file = io.open(path, "r") - if not file then - return false, "Could not open file" - end - for line in file:lines() do if line:match(key_pattern) then - -- Update existing key lines[#lines + 1] = string.format("%s: %s", key, value) found = true else @@ -220,16 +260,12 @@ function M.set_yaml_value(path, key, value) end file:close() - -- If key wasn't found, append it if not found then lines[#lines + 1] = string.format("%s: %s", key, value) end - -- Write back to file file = io.open(path, "w") - if not file then - return false, "Could not open file for writing" - end + if not file then return false, "Could not open file for writing" end file:write(table.concat(lines, "\n")) file:close() diff --git a/template/prompt.tpl b/template/prompt.tpl index 13de61b..0a887c3 100644 --- a/template/prompt.tpl +++ b/template/prompt.tpl @@ -1,6 +1,6 @@ - + - Below is context that may help answer the user query. Ignore if not relevant + Below is context that may help with the user query. Ignore if not relevant Path: <%= current_file.path %> @@ -50,6 +50,14 @@ <%= prompt %> + + + Load these skills and follow them exactly as written + + Skill name: <%= name %> + + + <%= prompt %>