diff --git a/README.md b/README.md index 0774826..004faf0 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,39 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md). } ``` +### Working Directory Control + +You can fix the Claude terminal's working directory regardless of `autochdir` and buffer-local cwd changes. Options (precedence order): + +- `cwd_provider(ctx)`: function that returns a directory string. Receives `{ file, file_dir, cwd }`. +- `cwd`: static path to use as working directory. +- `git_repo_cwd = true`: resolves git root from the current file directory (or cwd if no file). + +Examples: + +```lua +require("claudecode").setup({ + -- Top-level aliases are supported and forwarded to terminal config + git_repo_cwd = true, +}) + +require("claudecode").setup({ + terminal = { + cwd = vim.fn.expand("~/projects/my-app"), + }, +}) + +require("claudecode").setup({ + terminal = { + cwd_provider = function(ctx) + -- Prefer repo root; fallback to file's directory + local cwd = require("claudecode.cwd").git_root(ctx.file_dir or ctx.cwd) or ctx.file_dir or ctx.cwd + return cwd + end, + }, +}) +``` + ## Floating Window Configuration The `snacks_win_opts` configuration allows you to create floating Claude Code terminals with custom positioning, sizing, and key bindings. Here are several practical examples: diff --git a/lua/claudecode/cwd.lua b/lua/claudecode/cwd.lua new file mode 100644 index 0000000..27f4dc1 --- /dev/null +++ b/lua/claudecode/cwd.lua @@ -0,0 +1,109 @@ +--- Working directory resolution helpers for ClaudeCode.nvim +---@module 'claudecode.cwd' + +local M = {} + +---Normalize and validate a directory path +---@param dir string|nil +---@return string|nil +local function normalize_dir(dir) + if type(dir) ~= "string" or dir == "" then + return nil + end + -- Expand ~ and similar + local expanded = vim.fn.expand(dir) + local isdir = 1 + if vim.fn.isdirectory then + isdir = vim.fn.isdirectory(expanded) + end + if isdir == 1 then + return expanded + end + return nil +end + +---Find the git repository root starting from a directory +---@param start_dir string|nil +---@return string|nil +function M.git_root(start_dir) + start_dir = normalize_dir(start_dir) + if not start_dir then + return nil + end + + -- Prefer running without shell by passing a list + local result + if vim.fn.systemlist then + local ok, _ = pcall(function() + local _ = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" }) + end) + if ok then + result = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" }) + else + -- Fallback to string command if needed + local cmd = "git -C " .. vim.fn.shellescape(start_dir) .. " rev-parse --show-toplevel" + result = vim.fn.systemlist(cmd) + end + end + + if vim.v.shell_error == 0 and result and #result > 0 then + local root = normalize_dir(result[1]) + if root then + return root + end + end + + -- Fallback: search for .git directory upward + if vim.fn.finddir then + local git_dir = vim.fn.finddir(".git", start_dir .. ";") + if type(git_dir) == "string" and git_dir ~= "" then + local parent = vim.fn.fnamemodify(git_dir, ":h") + return normalize_dir(parent) + end + end + + return nil +end + +---Resolve the effective working directory based on terminal config and context +---@param term_cfg ClaudeCodeTerminalConfig +---@param ctx ClaudeCodeCwdContext +---@return string|nil +function M.resolve(term_cfg, ctx) + if type(term_cfg) ~= "table" then + return nil + end + + -- 1) Custom provider takes precedence + local provider = term_cfg.cwd_provider + local provider_type = type(provider) + if provider_type == "function" then + local ok, res = pcall(provider, ctx) + if ok then + local p = normalize_dir(res) + if p then + return p + end + end + end + + -- 2) Static cwd + local static_cwd = normalize_dir(term_cfg.cwd) + if static_cwd then + return static_cwd + end + + -- 3) Git repository root + if term_cfg.git_repo_cwd then + local start_dir = ctx and (ctx.file_dir or ctx.cwd) or vim.fn.getcwd() + local root = M.git_root(start_dir) + if root then + return root + end + end + + -- 4) No override + return nil +end + +return M diff --git a/lua/claudecode/init.lua b/lua/claudecode/init.lua index 75b23c5..763aa02 100644 --- a/lua/claudecode/init.lua +++ b/lua/claudecode/init.lua @@ -300,6 +300,27 @@ function M.setup(opts) -- Setup terminal module: always try to call setup to pass terminal_cmd and env, -- even if terminal_opts (for split_side etc.) are not provided. + -- Map top-level cwd-related aliases into terminal config for convenience + do + local t = opts.terminal or {} + local had_alias = false + if opts.git_repo_cwd ~= nil then + t.git_repo_cwd = opts.git_repo_cwd + had_alias = true + end + if opts.cwd ~= nil then + t.cwd = opts.cwd + had_alias = true + end + if opts.cwd_provider ~= nil then + t.cwd_provider = opts.cwd_provider + had_alias = true + end + if had_alias then + opts.terminal = t + end + end + local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal") if terminal_setup_ok then -- Guard in case tests or user replace the module with a minimal stub without `setup`. diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua index 65273db..5c5e35d 100644 --- a/lua/claudecode/terminal.lua +++ b/lua/claudecode/terminal.lua @@ -19,6 +19,10 @@ local defaults = { auto_close = true, env = {}, snacks_win_opts = {}, + -- Working directory control + cwd = nil, -- static cwd override + git_repo_cwd = false, -- resolve to git root when spawning + cwd_provider = nil, -- function(ctx) -> cwd string } M.defaults = defaults @@ -191,6 +195,23 @@ local function build_config(opts_override) snacks_win_opts = function(val) return type(val) == "table" end, + cwd = function(val) + return val == nil or type(val) == "string" + end, + git_repo_cwd = function(val) + return type(val) == "boolean" + end, + cwd_provider = function(val) + local t = type(val) + if t == "function" then + return true + end + if t == "table" then + local mt = getmetatable(val) + return mt and mt.__call ~= nil + end + return false + end, } for key, val in pairs(opts_override) do if effective_config[key] ~= nil and validators[key] and validators[key](val) then @@ -198,11 +219,43 @@ local function build_config(opts_override) end end end + -- Resolve cwd at config-build time so providers receive it directly + local cwd_ctx = { + file = (function() + local path = vim.fn.expand("%:p") + if type(path) == "string" and path ~= "" then + return path + end + return nil + end)(), + cwd = vim.fn.getcwd(), + } + cwd_ctx.file_dir = cwd_ctx.file and vim.fn.fnamemodify(cwd_ctx.file, ":h") or nil + + local resolved_cwd = nil + -- Prefer provider function, then static cwd, then git root via resolver + if effective_config.cwd_provider then + local ok_p, res = pcall(effective_config.cwd_provider, cwd_ctx) + if ok_p and type(res) == "string" and res ~= "" then + resolved_cwd = vim.fn.expand(res) + end + end + if not resolved_cwd and type(effective_config.cwd) == "string" and effective_config.cwd ~= "" then + resolved_cwd = vim.fn.expand(effective_config.cwd) + end + if not resolved_cwd and effective_config.git_repo_cwd then + local ok_r, cwd_mod = pcall(require, "claudecode.cwd") + if ok_r and cwd_mod and type(cwd_mod.git_root) == "function" then + resolved_cwd = cwd_mod.git_root(cwd_ctx.file_dir or cwd_ctx.cwd) + end + end + return { split_side = effective_config.split_side, split_width_percentage = effective_config.split_width_percentage, auto_close = effective_config.auto_close, snacks_win_opts = effective_config.snacks_win_opts, + cwd = resolved_cwd, } end @@ -319,9 +372,30 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) end for k, v in pairs(user_term_config) do - if k == "terminal_cmd" then - -- terminal_cmd is handled above, skip - break + if k == "split_side" then + if v == "left" or v == "right" then + defaults.split_side = v + else + vim.notify("claudecode.terminal.setup: Invalid value for split_side: " .. tostring(v), vim.log.levels.WARN) + end + elseif k == "split_width_percentage" then + if type(v) == "number" and v > 0 and v < 1 then + defaults.split_width_percentage = v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for split_width_percentage: " .. tostring(v), + vim.log.levels.WARN + ) + end + elseif k == "provider" then + if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" then + defaults.provider = v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for provider: " .. tostring(v) .. ". Defaulting to 'native'.", + vim.log.levels.WARN + ) + end elseif k == "provider_opts" then -- Handle nested provider options if type(v) == "table" then @@ -344,26 +418,60 @@ function M.setup(user_term_config, p_terminal_cmd, p_env) else vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN) end - elseif defaults[k] ~= nil then -- Other known config keys - if k == "split_side" and (v == "left" or v == "right") then - defaults[k] = v - elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then - defaults[k] = v - elseif - k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table") - then - defaults[k] = v - elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then - defaults[k] = v - elseif k == "auto_close" and type(v) == "boolean" then - defaults[k] = v - elseif k == "snacks_win_opts" and type(v) == "table" then - defaults[k] = v + elseif k == "show_native_term_exit_tip" then + if type(v) == "boolean" then + defaults.show_native_term_exit_tip = v + else + vim.notify( + "claudecode.terminal.setup: Invalid value for show_native_term_exit_tip: " .. tostring(v), + vim.log.levels.WARN + ) + end + elseif k == "auto_close" then + if type(v) == "boolean" then + defaults.auto_close = v + else + vim.notify("claudecode.terminal.setup: Invalid value for auto_close: " .. tostring(v), vim.log.levels.WARN) + end + elseif k == "snacks_win_opts" then + if type(v) == "table" then + defaults.snacks_win_opts = v + else + vim.notify("claudecode.terminal.setup: Invalid value for snacks_win_opts", vim.log.levels.WARN) + end + elseif k == "cwd" then + if v == nil or type(v) == "string" then + defaults.cwd = v + else + vim.notify("claudecode.terminal.setup: Invalid value for cwd: " .. tostring(v), vim.log.levels.WARN) + end + elseif k == "git_repo_cwd" then + if type(v) == "boolean" then + defaults.git_repo_cwd = v else - vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN) + vim.notify("claudecode.terminal.setup: Invalid value for git_repo_cwd: " .. tostring(v), vim.log.levels.WARN) + end + elseif k == "cwd_provider" then + local t = type(v) + if t == "function" then + defaults.cwd_provider = v + elseif t == "table" then + local mt = getmetatable(v) + if mt and mt.__call then + defaults.cwd_provider = v + else + vim.notify( + "claudecode.terminal.setup: cwd_provider table is not callable (missing __call)", + vim.log.levels.WARN + ) + end + else + vim.notify("claudecode.terminal.setup: Invalid cwd_provider type: " .. tostring(t), vim.log.levels.WARN) end else - vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN) + if k ~= "terminal_cmd" then + vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN) + end end end diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index f37d3b8..7cd24dd 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -88,6 +88,7 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) jobid = vim.fn.termopen(term_cmd_arg, { env = env_table, + cwd = effective_config.cwd, on_exit = function(job_id, _, _) vim.schedule(function() if job_id == jobid then diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 5775992..2b4c7c9 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -50,6 +50,7 @@ local function build_opts(config, env_table, focus) focus = utils.normalize_focus(focus) return { env = env_table, + cwd = config.cwd, start_insert = focus, auto_insert = focus, auto_close = false, diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua index 53f4217..b00dcd1 100644 --- a/lua/claudecode/types.lua +++ b/lua/claudecode/types.lua @@ -45,6 +45,14 @@ ---@class ClaudeCodeTerminalProviderOptions ---@field external_terminal_cmd string? Command template for external terminal (e.g., "alacritty -e %s") +-- Working directory resolution context and provider +---@class ClaudeCodeCwdContext +---@field file string|nil -- absolute path of current buffer file (if any) +---@field file_dir string|nil -- directory of current buffer file (if any) +---@field cwd string -- current Neovim working directory + +---@alias ClaudeCodeCwdProvider fun(ctx: ClaudeCodeCwdContext): string|nil + -- @ mention queued for Claude Code ---@class ClaudeCodeMention ---@field file_path string The absolute file path to mention @@ -76,6 +84,9 @@ ---@field auto_close boolean ---@field env table ---@field snacks_win_opts snacks.win.Config +---@field cwd string|nil -- static working directory for Claude terminal +---@field git_repo_cwd boolean|nil -- use git root of current file/cwd as working directory +---@field cwd_provider? ClaudeCodeCwdProvider -- custom function to compute working directory -- Port range configuration ---@class ClaudeCodePortRange diff --git a/tests/integration/command_args_spec.lua b/tests/integration/command_args_spec.lua index 7e6539d..b798d0e 100644 --- a/tests/integration/command_args_spec.lua +++ b/tests/integration/command_args_spec.lua @@ -393,5 +393,54 @@ describe("ClaudeCode command arguments integration", function() assert.is_true(close_command_found, "ClaudeCodeClose command should still be registered") end) + + it("should pass cwd in termopen opts when terminal.cwd is set", function() + claudecode.setup({ + auto_start = false, + terminal = { provider = "native", cwd = "/mock/repo" }, + }) + + local handler + for _, call in ipairs(vim.api.nvim_create_user_command.calls) do + if call.vals[1] == "ClaudeCode" then + handler = call.vals[2] + break + end + end + assert.is_function(handler) + + handler({}) + assert.is_true(#executed_commands > 0, "No terminal commands were executed") + local last = executed_commands[#executed_commands] + assert.is_table(last.opts, "termopen options missing") + assert.are.equal("/mock/repo", last.opts.cwd) + end) + + it("should support cwd_provider function for working directory", function() + claudecode.setup({ + auto_start = false, + terminal = { + provider = "native", + cwd_provider = function(ctx) + return "/from/provider" + end, + }, + }) + + local handler + for _, call in ipairs(vim.api.nvim_create_user_command.calls) do + if call.vals[1] == "ClaudeCode" then + handler = call.vals[2] + break + end + end + assert.is_function(handler) + + handler({}) + assert.is_true(#executed_commands > 0, "No terminal commands were executed") + local last = executed_commands[#executed_commands] + assert.is_table(last.opts, "termopen options missing") + assert.are.equal("/from/provider", last.opts.cwd) + end) end) end)