From cf16d39abe73734e59ec77cc1e454e79154e337f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 12:13:55 +0200 Subject: [PATCH 1/5] fix(terminal): pass command to Snacks as argv list, not shell string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Snacks provider passed the Claude command to `Snacks.terminal.open` as a string, which Snacks hands to the shell. The shell glob-expands bracketed `--model` aliases such as `opus[1m]`: zsh aborts with "no matches found" (so Claude never launches), and if a matching file happens to exist the alias is silently corrupted to e.g. `opus1`. Pass the command as an argv list instead, so Snacks runs it via termopen() with no shell — mirroring the native provider. Adds a regression test asserting the bracketed alias reaches Snacks intact. Change-Id: Ideecd319417306fe2440ef27f0d54d4a75d8c4a9 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/snacks.lua | 12 ++- tests/unit/terminal/snacks_spec.lua | 148 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/unit/terminal/snacks_spec.lua diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index 2b4c7c98..a5e83ffb 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -128,7 +128,17 @@ function M.open(cmd_string, env_table, config, focus) end local opts = build_opts(config, env_table, focus) - local term_instance = Snacks.terminal.open(cmd_string, opts) + -- Pass the command as an argv list so Snacks runs it via termopen() without a + -- shell. As a string, Snacks would hand it to the shell, which glob-expands + -- bracketed model aliases like "opus[1m]" (e.g. zsh aborts with "no matches + -- found"), preventing Claude from launching. Mirrors the native provider. + local cmd + if cmd_string:find(" ", 1, true) then + cmd = vim.split(cmd_string, " ", { plain = true, trimempty = false }) + else + cmd = { cmd_string } + end + local term_instance = Snacks.terminal.open(cmd, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) terminal = term_instance diff --git a/tests/unit/terminal/snacks_spec.lua b/tests/unit/terminal/snacks_spec.lua new file mode 100644 index 00000000..7daeda23 --- /dev/null +++ b/tests/unit/terminal/snacks_spec.lua @@ -0,0 +1,148 @@ +-- Regression test for Snacks provider command handling. +-- +-- The Snacks provider must pass the Claude command to Snacks.terminal.open as an +-- argv list, NOT a shell string. As a string, Snacks hands it to the shell, +-- which glob-expands bracketed model aliases such as "opus[1m]" (zsh aborts with +-- "no matches found"), preventing Claude from launching. See snacks.lua M.open. + +describe("claudecode.terminal.snacks command handling", function() + local snacks_provider + local original_vim + local spy + local captured + local mock_snacks + + local function make_term_instance() + return { + buf = 1, + win = nil, + buf_valid = function() + return true + end, + win_valid = function() + return false + end, + on = function() end, + toggle = function() end, + focus = function() end, + close = function() end, + } + end + + before_each(function() + original_vim = vim + spy = require("luassert.spy") + + _G.vim = { + log = { levels = { ERROR = 3, WARN = 2, INFO = 1, DEBUG = 0 } }, + notify = spy.new(function() end), + inspect = function(v) + return tostring(v) + end, + schedule = function(fn) + fn() + end, + cmd = function() end, + split = function(str, sep, _opts) + local result = {} + local start = 1 + while true do + local s, e = string.find(str, sep, start, true) + if not s then + table.insert(result, string.sub(str, start)) + break + end + table.insert(result, string.sub(str, start, s - 1)) + start = e + 1 + end + return result + end, + tbl_deep_extend = function(_, ...) + local res = {} + for _, t in ipairs({ ... }) do + if type(t) == "table" then + for k, v in pairs(t) do + res[k] = v + end + end + end + return res + end, + api = { + nvim_buf_is_valid = function() + return true + end, + nvim_win_is_valid = function() + return true + end, + nvim_buf_get_option = function() + return "terminal" + end, + nvim_win_call = function(_, fn) + fn() + end, + nvim_get_current_win = function() + return 1 + end, + nvim_set_current_win = function() end, + }, + } + + captured = {} + mock_snacks = { + terminal = { + open = spy.new(function(cmd, opts) + captured.cmd = cmd + captured.opts = opts + return make_term_instance() + end), + }, + } + + package.loaded["snacks"] = mock_snacks + package.loaded["claudecode.logger"] = { + debug = spy.new(function() end), + info = spy.new(function() end), + warn = spy.new(function() end), + error = spy.new(function() end), + } + package.loaded["claudecode.utils"] = { + normalize_focus = function(focus) + if focus == nil then + return true + end + return focus + end, + } + package.loaded["claudecode.terminal.snacks"] = nil + + snacks_provider = require("claudecode.terminal.snacks") + end) + + after_each(function() + _G.vim = original_vim + package.loaded["snacks"] = nil + package.loaded["claudecode.utils"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.terminal.snacks"] = nil + end) + + local function base_config() + return { cwd = "/work", split_side = "right", split_width_percentage = 0.30, auto_close = false } + end + + it("passes a bracketed model alias to Snacks as an argv list, not a shell string", function() + snacks_provider.open("claude --model opus[1m]", { FOO = "bar" }, base_config(), true) + + assert.spy(mock_snacks.terminal.open).was_called(1) + assert.is_table(captured.cmd) + assert.are.same({ "claude", "--model", "opus[1m]" }, captured.cmd) + end) + + it("wraps an argument-less command in a single-element list", function() + snacks_provider.open("claude", {}, base_config(), true) + + assert.is_table(captured.cmd) + assert.are.same({ "claude" }, captured.cmd) + end) +end) From 6d45fbaca7b6e6e0ca660a99dff4a504d651364a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 12:13:56 +0200 Subject: [PATCH 2/5] feat(config): refresh model picker, add 1M-context and default entries Model marketing names (Opus 4.1, Sonnet 4.5) had drifted out of date, but the short `--model` aliases (opus/sonnet/haiku) already resolve to the latest model on the Anthropic API. Make the picker labels version-free so they never go stale again, and modernize the list: - Evergreen labels, e.g. "Claude Opus (Latest)" (was "Opus 4.1 (Latest)") - Add opus[1m] / sonnet[1m] 1M-context variants (safe now that Snacks no longer shell-expands the command) - Add a "Default (account recommended)" entry (the `default` alias) - Drop the niche `opusplan` entry - Refresh deprecated full IDs in tests to current pinned snapshots (claude-opus-4-8 / claude-sonnet-4-6) Change-Id: I93a1910c96f2384b4ed8ad745b720a880683e0da Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/config.lua | 13 +++++++++---- tests/config_test.lua | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua index a4fd4362..2860bdd6 100644 --- a/lua/claudecode/config.lua +++ b/lua/claudecode/config.lua @@ -27,11 +27,16 @@ M.defaults = { hide_terminal_in_new_tab = false, -- If true and opening in a new tab, do not show Claude terminal there on_new_file_reject = "keep_empty", -- "keep_empty" leaves an empty buffer; "close_window" closes the placeholder split }, + -- `value` is passed verbatim to `claude --model`. These short aliases resolve + -- to the latest model on the Anthropic API, so labels stay version-free to + -- avoid going stale on every release. models = { - { name = "Claude Opus 4.1 (Latest)", value = "opus" }, - { name = "Claude Sonnet 4.5 (Latest)", value = "sonnet" }, - { name = "Opusplan: Claude Opus 4.1 (Latest) + Sonnet 4.5 (Latest)", value = "opusplan" }, - { name = "Claude Haiku 4.5 (Latest)", value = "haiku" }, + { name = "Claude Opus (Latest)", value = "opus" }, + { name = "Claude Opus (Latest, 1M context)", value = "opus[1m]" }, + { name = "Claude Sonnet (Latest)", value = "sonnet" }, + { name = "Claude Sonnet (Latest, 1M context)", value = "sonnet[1m]" }, + { name = "Claude Haiku (Latest)", value = "haiku" }, + { name = "Default (account recommended)", value = "default" }, }, terminal = nil, -- Will be lazy-loaded to avoid circular dependency } diff --git a/tests/config_test.lua b/tests/config_test.lua index 5457452b..697d2911 100644 --- a/tests/config_test.lua +++ b/tests/config_test.lua @@ -181,8 +181,8 @@ describe("Config module", function() log_level = "debug", track_selection = false, models = { - { name = "Claude Opus 4 (Latest)", value = "claude-opus-4-20250514" }, - { name = "Claude Sonnet 4 (Latest)", value = "claude-sonnet-4-20250514" }, + { name = "Claude Opus 4.8", value = "claude-opus-4-8" }, + { name = "Claude Sonnet 4.6", value = "claude-sonnet-4-6" }, }, } From 4a1b429c0b2edf694c6bf64859707c49058b0e80 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 13:37:21 +0200 Subject: [PATCH 3/5] fix(terminal): preserve quoted args with shell-aware command splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex P2 review feedback on #256: switching the Snacks provider from a shell string to a raw space-split corrupted quoted arguments — e.g. `--message='hello world'` became argv entries `--message='hello` and `world'` before reaching Claude. Add `utils.shell_split`, a POSIX-style word splitter that honors single quotes, double quotes, and backslash escapes, and use it in both the Snacks and native providers. Quoted arguments now survive intact while Claude is still spawned without a shell, so bracketed model aliases like `opus[1m]` are never glob-expanded. Both providers share identical, shell-free argv construction. Also strengthens the integration test to assert the actual argv instead of a lossy re-joined string, which previously masked the corruption. Change-Id: I29ff1066f760d7590efac90b8abb6d4c3fb4995c Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/native.lua | 9 ++-- lua/claudecode/terminal/snacks.lua | 15 ++---- lua/claudecode/utils.lua | 67 +++++++++++++++++++++++++ tests/integration/command_args_spec.lua | 18 +++++-- tests/unit/terminal/snacks_spec.lua | 18 ++++--- tests/unit/utils_spec.lua | 48 ++++++++++++++++++ 6 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 tests/unit/utils_spec.lua diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index 7cd24dd5..defcd765 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -79,12 +79,9 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) vim.cmd("enew") end) - local term_cmd_arg - if cmd_string:find(" ", 1, true) then - term_cmd_arg = vim.split(cmd_string, " ", { plain = true, trimempty = false }) - else - term_cmd_arg = { cmd_string } - end + -- Shell-aware split so quoted args survive and no shell touches bracketed + -- model aliases like "opus[1m]" (see utils.shell_split). + local term_cmd_arg = utils.shell_split(cmd_string) jobid = vim.fn.termopen(term_cmd_arg, { env = env_table, diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index a5e83ffb..f588f49b 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -128,16 +128,11 @@ function M.open(cmd_string, env_table, config, focus) end local opts = build_opts(config, env_table, focus) - -- Pass the command as an argv list so Snacks runs it via termopen() without a - -- shell. As a string, Snacks would hand it to the shell, which glob-expands - -- bracketed model aliases like "opus[1m]" (e.g. zsh aborts with "no matches - -- found"), preventing Claude from launching. Mirrors the native provider. - local cmd - if cmd_string:find(" ", 1, true) then - cmd = vim.split(cmd_string, " ", { plain = true, trimempty = false }) - else - cmd = { cmd_string } - end + -- Pass an argv list (not a string) so Snacks spawns Claude via termopen() + -- without a shell. A shell would glob-expand bracketed model aliases like + -- "opus[1m]" (e.g. zsh aborts with "no matches found"). shell_split keeps + -- quoted arguments intact. Mirrors the native provider. + local cmd = utils.shell_split(cmd_string) local term_instance = Snacks.terminal.open(cmd, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) diff --git a/lua/claudecode/utils.lua b/lua/claudecode/utils.lua index 397d7982..73b8c4ca 100644 --- a/lua/claudecode/utils.lua +++ b/lua/claudecode/utils.lua @@ -14,4 +14,71 @@ function M.normalize_focus(focus) end end +---Split a command string into an argument vector using POSIX shell word rules. +--- +---Honors single quotes, double quotes, and backslash escapes so terminal +---providers can spawn Claude directly (without a shell) while preserving quoted +---arguments such as `--message='hello world'`. Spawning without a shell also +---avoids glob expansion of bracketed model aliases like `opus[1m]` (e.g. zsh +---aborts an unmatched glob with "no matches found", so Claude never launches). +---@param cmd string The command string to split. +---@return string[] argv The parsed argument vector. +function M.shell_split(cmd) + local argv = {} + local current = nil -- nil = between words; string (incl. "") = building a word + local i = 1 + local n = #cmd + while i <= n do + local c = cmd:sub(i, i) + if c == " " or c == "\t" then + if current ~= nil then + argv[#argv + 1] = current + current = nil + end + elseif c == "'" then + -- Single quotes: everything up to the next single quote is literal. + current = current or "" + local close = cmd:find("'", i + 1, true) + if close then + current = current .. cmd:sub(i + 1, close - 1) + i = close + else + current = current .. cmd:sub(i + 1) + i = n + end + elseif c == '"' then + -- Double quotes: backslash escapes only " \ $ `. + current = current or "" + i = i + 1 + while i <= n do + local d = cmd:sub(i, i) + if d == '"' then + break + elseif d == "\\" and i < n then + local nextc = cmd:sub(i + 1, i + 1) + if nextc == '"' or nextc == "\\" or nextc == "$" or nextc == "`" then + current = current .. nextc + i = i + 1 + else + current = current .. d + end + else + current = current .. d + end + i = i + 1 + end + elseif c == "\\" and i < n then + current = (current or "") .. cmd:sub(i + 1, i + 1) + i = i + 1 + else + current = (current or "") .. c + end + i = i + 1 + end + if current ~= nil then + argv[#argv + 1] = current + end + return argv +end + return M diff --git a/tests/integration/command_args_spec.lua b/tests/integration/command_args_spec.lua index b798d0ef..876c6da9 100644 --- a/tests/integration/command_args_spec.lua +++ b/tests/integration/command_args_spec.lua @@ -321,9 +321,21 @@ describe("ClaudeCode command arguments integration", function() assert.is_true(#executed_commands > 0, "No terminal commands were executed") local last_cmd = executed_commands[#executed_commands] - local cmd_string = type(last_cmd.cmd) == "table" and table.concat(last_cmd.cmd, " ") or last_cmd.cmd - assert.is_true(cmd_string:find("--message='hello world'") ~= nil, "Special characters not preserved") - assert.is_true(cmd_string:find("--path=/tmp/test") ~= nil, "Path arguments not preserved") + -- The native provider parses the command into an argv table via + -- utils.shell_split, so a quoted argument with a space survives as a single + -- entry (surrounding quotes are consumed, like a real shell) instead of + -- being mangled into "--message='hello" and "world'". + assert.is_table(last_cmd.cmd, "Native provider should pass an argv table") + local has_message, has_path = false, false + for _, arg in ipairs(last_cmd.cmd) do + if arg == "--message=hello world" then + has_message = true + elseif arg == "--path=/tmp/test" then + has_path = true + end + end + assert.is_true(has_message, "Quoted argument with space should be a single argv entry") + assert.is_true(has_path, "Path argument not preserved") end) it("should handle very long argument strings", function() diff --git a/tests/unit/terminal/snacks_spec.lua b/tests/unit/terminal/snacks_spec.lua index 7daeda23..e04c579f 100644 --- a/tests/unit/terminal/snacks_spec.lua +++ b/tests/unit/terminal/snacks_spec.lua @@ -106,14 +106,9 @@ describe("claudecode.terminal.snacks command handling", function() warn = spy.new(function() end), error = spy.new(function() end), } - package.loaded["claudecode.utils"] = { - normalize_focus = function(focus) - if focus == nil then - return true - end - return focus - end, - } + -- Use the real utils module: snacks.lua calls utils.shell_split, and utils + -- is pure Lua (safe under the mocked vim). + package.loaded["claudecode.utils"] = nil package.loaded["claudecode.terminal.snacks"] = nil snacks_provider = require("claudecode.terminal.snacks") @@ -145,4 +140,11 @@ describe("claudecode.terminal.snacks command handling", function() assert.is_table(captured.cmd) assert.are.same({ "claude" }, captured.cmd) end) + + it("keeps quoted arguments intact instead of splitting on inner spaces", function() + snacks_provider.open("claude --message='hello world'", {}, base_config(), true) + + assert.is_table(captured.cmd) + assert.are.same({ "claude", "--message=hello world" }, captured.cmd) + end) end) diff --git a/tests/unit/utils_spec.lua b/tests/unit/utils_spec.lua new file mode 100644 index 00000000..83a3a652 --- /dev/null +++ b/tests/unit/utils_spec.lua @@ -0,0 +1,48 @@ +-- Tests for claudecode.utils helpers. + +describe("claudecode.utils.shell_split", function() + local utils = require("claudecode.utils") + + it("splits a plain command on whitespace", function() + assert.are.same({ "claude", "--resume", "--verbose" }, utils.shell_split("claude --resume --verbose")) + end) + + it("returns a single-element argv for an argument-less command", function() + assert.are.same({ "claude" }, utils.shell_split("claude")) + end) + + it("collapses runs of whitespace between words", function() + assert.are.same({ "claude", "--resume" }, utils.shell_split("claude --resume")) + end) + + it("preserves bracketed model aliases verbatim (no glob expansion)", function() + assert.are.same({ "claude", "--model", "opus[1m]" }, utils.shell_split("claude --model opus[1m]")) + end) + + it("keeps single-quoted arguments containing spaces intact", function() + assert.are.same( + { "claude", "--message=hello world", "--path=/tmp/test" }, + utils.shell_split("claude --message='hello world' --path=/tmp/test") + ) + end) + + it("keeps double-quoted arguments containing spaces intact", function() + assert.are.same({ "claude", "--foo", "a b", "bar" }, utils.shell_split('claude --foo "a b" bar')) + end) + + it("unescapes recognized backslash sequences inside double quotes", function() + assert.are.same({ 'a"b' }, utils.shell_split('"a\\"b"')) + end) + + it("concatenates adjacent quoted and unquoted segments", function() + assert.are.same({ "claude", "--x=ab" }, utils.shell_split("claude --x='a''b'")) + end) + + it("honors backslash-escaped spaces outside quotes", function() + assert.are.same({ "claude", "a b" }, utils.shell_split("claude a\\ b")) + end) + + it("returns an empty argv for an empty string", function() + assert.are.same({}, utils.shell_split("")) + end) +end) From ae5bd156fc4255f41ac7fee253d0d4fbc5f2f8ea Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 13:48:37 +0200 Subject: [PATCH 4/5] fix(terminal): expand leading tilde in command before argv launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a second Codex P2 on #256: spawning without a shell also drops tilde expansion, which broke the documented local-install config `terminal_cmd = "~/.claude/local/claude"` (README) for Snacks users — the literal `~` reached termopen() so the executable could not be found. Add `utils.parse_command` = `shell_split` + per-word leading-tilde expansion (`utils.expand_tilde`), and route both the Snacks and native providers through it. Tilde is expanded only at the start of a word, like a shell; globbing and variable expansion stay intentionally disabled so bracketed aliases like `opus[1m]` remain intact. Adds unit tests for expand_tilde / parse_command and a provider-level tilde regression test. Change-Id: Ieae07591aeed3f99a62d981ccc035b0452f36d9b Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/native.lua | 7 +++-- lua/claudecode/terminal/snacks.lua | 6 ++-- lua/claudecode/utils.lua | 39 +++++++++++++++++++++++++ tests/unit/terminal/snacks_spec.lua | 8 +++++ tests/unit/utils_spec.lua | 45 +++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/lua/claudecode/terminal/native.lua b/lua/claudecode/terminal/native.lua index defcd765..9a22901e 100644 --- a/lua/claudecode/terminal/native.lua +++ b/lua/claudecode/terminal/native.lua @@ -79,9 +79,10 @@ local function open_terminal(cmd_string, env_table, effective_config, focus) vim.cmd("enew") end) - -- Shell-aware split so quoted args survive and no shell touches bracketed - -- model aliases like "opus[1m]" (see utils.shell_split). - local term_cmd_arg = utils.shell_split(cmd_string) + -- Shell-aware split + leading-tilde expansion so quoted args and "~/..." + -- paths survive, while no shell touches bracketed model aliases like + -- "opus[1m]" (see utils.parse_command). + local term_cmd_arg = utils.parse_command(cmd_string) jobid = vim.fn.termopen(term_cmd_arg, { env = env_table, diff --git a/lua/claudecode/terminal/snacks.lua b/lua/claudecode/terminal/snacks.lua index f588f49b..3c798994 100644 --- a/lua/claudecode/terminal/snacks.lua +++ b/lua/claudecode/terminal/snacks.lua @@ -130,9 +130,9 @@ function M.open(cmd_string, env_table, config, focus) local opts = build_opts(config, env_table, focus) -- Pass an argv list (not a string) so Snacks spawns Claude via termopen() -- without a shell. A shell would glob-expand bracketed model aliases like - -- "opus[1m]" (e.g. zsh aborts with "no matches found"). shell_split keeps - -- quoted arguments intact. Mirrors the native provider. - local cmd = utils.shell_split(cmd_string) + -- "opus[1m]" (e.g. zsh aborts with "no matches found"). parse_command keeps + -- quoted arguments intact and expands a leading "~". Mirrors native. + local cmd = utils.parse_command(cmd_string) local term_instance = Snacks.terminal.open(cmd, opts) if term_instance and term_instance:buf_valid() then setup_terminal_events(term_instance, config) diff --git a/lua/claudecode/utils.lua b/lua/claudecode/utils.lua index 73b8c4ca..1b59348e 100644 --- a/lua/claudecode/utils.lua +++ b/lua/claudecode/utils.lua @@ -81,4 +81,43 @@ function M.shell_split(cmd) return argv end +---Expand a leading `~` or `~/` in a single argument to the user's home +---directory, matching shell tilde expansion at the start of a word. Embedded +---tildes (e.g. `--path=~/x`) and the `~user` form are intentionally left +---untouched, exactly as a shell would treat a non-word-initial tilde. +---@param arg string +---@return string +function M.expand_tilde(arg) + if arg:sub(1, 1) ~= "~" then + return arg + end + local home = os.getenv("HOME") + if not home or home == "" then + return arg + end + if arg == "~" then + return home + elseif arg:sub(1, 2) == "~/" then + return home .. arg:sub(2) + end + return arg +end + +---Parse a command string into an argv list the way a shell would for our +---purposes: split into words honoring quotes/escapes (see `shell_split`), then +---expand a leading tilde in each word. Terminal providers use this to spawn +---Claude directly (no shell) while still preserving quoted arguments and the +---documented `terminal_cmd = "~/.claude/local/claude"` local-install path. +---Globbing and variable expansion are deliberately NOT performed -- avoiding the +---shell is what keeps bracketed aliases like `opus[1m]` intact. +---@param cmd string +---@return string[] argv +function M.parse_command(cmd) + local argv = M.shell_split(cmd) + for i = 1, #argv do + argv[i] = M.expand_tilde(argv[i]) + end + return argv +end + return M diff --git a/tests/unit/terminal/snacks_spec.lua b/tests/unit/terminal/snacks_spec.lua index e04c579f..32540480 100644 --- a/tests/unit/terminal/snacks_spec.lua +++ b/tests/unit/terminal/snacks_spec.lua @@ -147,4 +147,12 @@ describe("claudecode.terminal.snacks command handling", function() assert.is_table(captured.cmd) assert.are.same({ "claude", "--message=hello world" }, captured.cmd) end) + + it("expands a tilde in terminal_cmd before handing argv to Snacks", function() + local home = os.getenv("HOME") + snacks_provider.open("~/.claude/local/claude", {}, base_config(), true) + + assert.is_table(captured.cmd) + assert.are.same({ home .. "/.claude/local/claude" }, captured.cmd) + end) end) diff --git a/tests/unit/utils_spec.lua b/tests/unit/utils_spec.lua index 83a3a652..60c3758f 100644 --- a/tests/unit/utils_spec.lua +++ b/tests/unit/utils_spec.lua @@ -46,3 +46,48 @@ describe("claudecode.utils.shell_split", function() assert.are.same({}, utils.shell_split("")) end) end) + +describe("claudecode.utils.expand_tilde", function() + local utils = require("claudecode.utils") + local home = os.getenv("HOME") + + it("expands a bare tilde to $HOME", function() + assert.are.equal(home, utils.expand_tilde("~")) + end) + + it("expands a leading ~/ to $HOME/...", function() + assert.are.equal(home .. "/.claude/local/claude", utils.expand_tilde("~/.claude/local/claude")) + end) + + it("leaves a tilde that is not at the start of the word untouched", function() + assert.are.equal("--path=~/x", utils.expand_tilde("--path=~/x")) + end) + + it("leaves the ~user form untouched", function() + assert.are.equal("~root/x", utils.expand_tilde("~root/x")) + end) + + it("returns non-tilde arguments unchanged", function() + assert.are.equal("--model", utils.expand_tilde("--model")) + end) +end) + +describe("claudecode.utils.parse_command", function() + local utils = require("claudecode.utils") + local home = os.getenv("HOME") + + it("splits and expands the documented local-install terminal_cmd", function() + assert.are.same({ home .. "/.claude/local/claude" }, utils.parse_command("~/.claude/local/claude")) + end) + + it("expands a tilde executable while preserving quoted args and aliases", function() + assert.are.same( + { home .. "/.claude/local/claude", "--message=hello world", "--model", "opus[1m]" }, + utils.parse_command("~/.claude/local/claude --message='hello world' --model opus[1m]") + ) + end) + + it("keeps bracketed aliases intact (no glob expansion)", function() + assert.are.same({ "claude", "--model", "opus[1m]" }, utils.parse_command("claude --model opus[1m]")) + end) +end) From c99957b4f0082f5830468c9e61e163df78a8af5c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 3 Jun 2026 13:59:15 +0200 Subject: [PATCH 5/5] fix(terminal): route external provider through shared parse_command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Claude review feedback on #256: external.lua still used raw `vim.split` at two sites, so the quoted-args breakage the new helper was written to fix persisted for `provider = "external"` (e.g. `--message='hello world'` mangled into two argv entries). Route both external call sites through `utils.parse_command`, so all three providers (native, snacks, external) now share identical argv construction — quote-aware splitting plus leading-tilde expansion. Adds a quoted-args regression test for the external provider. Change-Id: I5d151d95e88ba73b00da97363ebe9af232344c5c Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- lua/claudecode/terminal/external.lua | 8 +++++--- tests/unit/terminal/external_spec.lua | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua index 8ac226e8..42cb82cd 100644 --- a/lua/claudecode/terminal/external.lua +++ b/lua/claudecode/terminal/external.lua @@ -6,6 +6,7 @@ local M = {} local logger = require("claudecode.logger") +local utils = require("claudecode.utils") local jobid = nil ---@type ClaudeCodeTerminalConfig @@ -62,8 +63,9 @@ function M.open(cmd_string, env_table) -- Result can be either a string or a table if type(result) == "string" then - -- Parse the string into command parts - cmd_parts = vim.split(result, " ") + -- Shell-aware parse (quotes/escapes + leading ~) so it stays consistent + -- with the native/snacks providers (see utils.parse_command). + cmd_parts = utils.parse_command(result) full_command = result elseif type(result) == "table" then -- Use the table directly as command parts @@ -109,7 +111,7 @@ function M.open(cmd_string, env_table) return end - cmd_parts = vim.split(full_command, " ") + cmd_parts = utils.parse_command(full_command) else vim.notify("external_terminal_cmd must be a string or function, got: " .. type(external_cmd), vim.log.levels.ERROR) return diff --git a/tests/unit/terminal/external_spec.lua b/tests/unit/terminal/external_spec.lua index 3f8a67cc..d49ac93a 100644 --- a/tests/unit/terminal/external_spec.lua +++ b/tests/unit/terminal/external_spec.lua @@ -97,6 +97,21 @@ describe("claudecode.terminal.external", function() assert.are.equal("/cwd", call_args[2].cwd) end) + it("preserves quoted arguments instead of splitting on inner spaces", function() + local config = { + provider_opts = { + external_terminal_cmd = "alacritty -e %s", + }, + } + external_provider.setup(config) + + external_provider.open("claude --message='hello world'", { ENABLE_IDE_INTEGRATION = "true" }) + + assert.spy(mock_vim.fn.jobstart).was_called(1) + local call_args = mock_vim.fn.jobstart.calls[1].vals + assert.are.same({ "alacritty", "-e", "claude", "--message=hello world" }, call_args[1]) + end) + it("should error if string command missing %s placeholder", function() local config = { provider_opts = {