Skip to content

Commit 03f8bbe

Browse files
geoff-twThomasK33claude
authored
fix: handle $ in file paths for ClaudeCodeAdd and openFile (#286)
This is a slop PR for fixing #285. Will test later. --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70a1438 commit 03f8bbe

8 files changed

Lines changed: 522 additions & 29 deletions

File tree

fixtures/issue-285/init.lua

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
-- Fixture for issue #285:
2+
-- "[BUG] ClaudeCodeAdd fails when adding a file in a directory with a `$`"
3+
-- https://github.com/coder/claudecode.nvim/issues/285
4+
--
5+
-- The sample tree under fixtures/issue-285/sample/ contains a REAL file whose
6+
-- parent directory is literally named "$post":
7+
-- fixtures/issue-285/sample/src/routes/$post/index.tsx
8+
--
9+
-- :ClaudeCodeAdd and the openFile MCP tool both pass the path through
10+
-- vim.fn.expand(), which substitutes "$post" with the (undefined) env var ->
11+
-- the path becomes ".../src/routes//index.tsx", which does not exist, so the
12+
-- command reports "File or directory does not exist".
13+
--
14+
-- Usage (from repo root):
15+
-- source fixtures/nvim-aliases.sh && vv issue-285
16+
-- The $-path file opens automatically. Then either:
17+
-- * press <leader>x -> runs :Repro285 (self-contained verdict), or
18+
-- * run :Repro285 -> drives the REAL :ClaudeCodeAdd + openFile on the
19+
-- $-path file and echoes a one-line PASS/FAIL verdict.
20+
-- For a fully hand-driven check: :ClaudeCodeStart, then
21+
-- :ClaudeCodeAdd <paste the absolute path printed in the banner> (FAILS), vs
22+
-- :ClaudeCodeAdd % (works),
23+
-- then read :messages.
24+
25+
local config_dir = vim.fn.stdpath("config")
26+
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
27+
vim.opt.rtp:prepend(repo_root)
28+
29+
vim.g.mapleader = " "
30+
vim.g.maplocalleader = "\\"
31+
vim.o.laststatus = 2
32+
33+
local ok, claudecode = pcall(require, "claudecode")
34+
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))
35+
36+
claudecode.setup({
37+
auto_start = false,
38+
log_level = "info",
39+
terminal = { provider = "native", auto_close = false },
40+
})
41+
42+
-- Resolve the real $-path sample file shipped with this fixture.
43+
local sample = repo_root .. "/fixtures/issue-285/sample/src/routes/$post/index.tsx"
44+
45+
-- Open it so the buffer name itself carries the `$` (this is the file a user
46+
-- would be "adding to the buffer"). fnameescape keeps `$` literal for :edit.
47+
vim.cmd("edit " .. vim.fn.fnameescape(sample))
48+
49+
local banner_buf = vim.api.nvim_create_buf(false, true)
50+
vim.api.nvim_buf_set_lines(banner_buf, 0, -1, false, {
51+
"claudecode.nvim -- issue #285 reproduction fixture",
52+
"",
53+
"Sample file (exists on disk, parent dir is literally '$post'):",
54+
" " .. sample,
55+
"",
56+
"Press <leader>x (or run :Repro285) for a one-line PASS/FAIL verdict.",
57+
"",
58+
"Manual check (with the fix applied, both succeed):",
59+
" :ClaudeCodeStart",
60+
" :ClaudeCodeAdd " .. sample,
61+
" :ClaudeCodeAdd % (current buffer; % is still expanded, not taken literally)",
62+
" :messages",
63+
"",
64+
"Before this fix, the first form errored with:",
65+
" [ClaudeCode] [command] [ERROR] ClaudeCodeAdd: File or directory does",
66+
" not exist: .../src/routes//index.tsx (note the '//' -- $post vanished)",
67+
})
68+
vim.bo[banner_buf].modifiable = false
69+
vim.bo[banner_buf].buftype = "nofile"
70+
71+
-- Capture the plugin logger so the verdict can show the exact error it logs.
72+
local logger = require("claudecode.logger")
73+
local function with_captured_logger(fn)
74+
local saved = { error = logger.error, debug = logger.debug, warn = logger.warn, info = logger.info }
75+
local lines = {}
76+
local function cap(level)
77+
return function(component, ...)
78+
local parts = {}
79+
for _, v in ipairs({ ... }) do
80+
parts[#parts + 1] = tostring(v)
81+
end
82+
lines[#lines + 1] = { level = level, msg = table.concat(parts, " ") }
83+
end
84+
end
85+
logger.error, logger.debug, logger.warn, logger.info = cap("error"), cap("debug"), cap("warn"), cap("info")
86+
local ok_run, err = pcall(fn)
87+
logger.error, logger.debug, logger.warn, logger.info = saved.error, saved.debug, saved.warn, saved.info
88+
return lines, ok_run, err
89+
end
90+
91+
local function last_error(lines)
92+
for i = #lines, 1, -1 do
93+
if lines[i].level == "error" then
94+
return lines[i].msg
95+
end
96+
end
97+
return nil
98+
end
99+
100+
---Drive the REAL :ClaudeCodeAdd and openFile on the $-path sample, then echo a
101+
---one-line verdict. Self-contained: if the integration isn't started, the
102+
---run-state guard is stubbed so the expand()/filereadable() gate is still
103+
---reached (that gate is where #285 lives -- no Claude connection is involved).
104+
local function repro_285()
105+
local cc = require("claudecode")
106+
cc.state = cc.state or {}
107+
local restore_server = false
108+
if not cc.state.server then
109+
cc.state.server = { _stub = true }
110+
restore_server = true
111+
end
112+
local saved_send = cc.send_at_mention
113+
local reached_send = false
114+
cc.send_at_mention = function()
115+
reached_send = true
116+
return true, nil
117+
end
118+
119+
local lines = with_captured_logger(function()
120+
vim.cmd({ cmd = "ClaudeCodeAdd", args = { sample } })
121+
end)
122+
local add_err = last_error(lines)
123+
124+
local open_ok, open_err = pcall(require("claudecode.tools.open_file").handler, {
125+
filePath = sample,
126+
makeFrontmost = false,
127+
})
128+
local open_msg = type(open_err) == "table" and tostring(open_err.data or open_err.message) or tostring(open_err)
129+
130+
cc.send_at_mention = saved_send
131+
if restore_server then
132+
cc.state.server = nil
133+
end
134+
135+
local add_bug = (add_err ~= nil) and not reached_send
136+
local open_bug = (not open_ok) and open_msg:find("not found", 1, true) ~= nil
137+
local reproduced = add_bug or open_bug
138+
139+
local report = {}
140+
report[#report + 1] = {
141+
reproduced and "issue #285 REPRODUCED" or "issue #285 FIXED",
142+
reproduced and "ErrorMsg" or "MoreMsg",
143+
}
144+
vim.api.nvim_echo(report, true, {})
145+
-- Detail lines land in :messages.
146+
vim.api.nvim_echo({
147+
{ (" ClaudeCodeAdd : %s"):format(add_err or (reached_send and "accepted (reached send)" or "no error")) },
148+
}, true, {})
149+
vim.api.nvim_echo({
150+
{ (" openFile tool : %s"):format(open_ok and "opened" or open_msg) },
151+
}, true, {})
152+
end
153+
154+
vim.api.nvim_create_user_command("Repro285", repro_285, { desc = "Repro #285 ($ in path)" })
155+
vim.keymap.set("n", "<leader>x", repro_285, { desc = "Repro #285 ($ in path)" })
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Sample file for issue #285. The PARENT directory is literally named "$post"
2+
// (a TanStack Router / file-based-routing dynamic segment). The `$` is what
3+
// trips vim.fn.expand(): it reads `$post` as the (undefined) env var `post` and
4+
// substitutes the empty string, so the path the plugin checks no longer exists.
5+
export default function Post() {
6+
return null;
7+
}

lua/claudecode/init.lua

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1030,7 +1030,19 @@ function M._create_commands()
10301030
return
10311031
end
10321032

1033-
file_path = vim.fn.expand(file_path)
1033+
-- Resolve the path argument. Vim's current/alternate-file tokens (`%`,
1034+
-- `%:p`, `#`, `<cfile>`, ...) must still be expanded -- `:ClaudeCodeAdd %`
1035+
-- is the documented "add current buffer" keymap (README). But a plain
1036+
-- filesystem path must NOT go through `vim.fn.expand`, which treats `$name`
1037+
-- as an environment variable and strips undefined ones -- mangling literal
1038+
-- `$` in paths (e.g. TanStack Router `$param` files like `src/routes/$post.tsx`)
1039+
-- so the existence check below would fail. So: expand() only the special
1040+
-- token forms; otherwise expand just a leading `~` and leave `$` intact.
1041+
if file_path:match("^[%%#<]") then
1042+
file_path = vim.fn.expand(file_path)
1043+
else
1044+
file_path = require("claudecode.utils").expand_tilde(file_path)
1045+
end
10341046
if vim.fn.filereadable(file_path) == 0 and vim.fn.isdirectory(file_path) == 0 then
10351047
logger.error("command", "ClaudeCodeAdd: File or directory does not exist: " .. file_path)
10361048
return

lua/claudecode/tools/open_file.lua

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@ local function handler(params)
114114
error({ code = -32602, message = "Invalid params", data = "Missing filePath parameter" })
115115
end
116116

117-
local file_path = vim.fn.expand(params.filePath)
117+
-- Expand a leading `~` only. `vim.fn.expand` would treat `$name` as an
118+
-- environment variable and strip undefined ones, breaking literal `$` paths
119+
-- (e.g. TanStack Router `$param` files like `src/routes/$post.tsx`).
120+
local file_path = require("claudecode.utils").expand_tilde(params.filePath)
118121

119122
if vim.fn.filereadable(file_path) == 0 then
120123
-- Using a generic error code for tool-specific operational errors

0 commit comments

Comments
 (0)