diff --git a/README.md b/README.md index 7d7e5c8..e7b8ede 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Some of the existing features are to be fleshed out, but the sole purpose is to - **hunk navigation**: jump between proposed changes with `]h` / `[h` - **quickfix integration**: collect pending hunks across buffers into one list - **token-efficient**: sends only the targeted edit context, not full-file rewrites -- **multi-provider**: openrouter, openai, anthropic +- **multi-provider**: openrouter, openai, Anthropic API, Claude Code subscriptions - **`@lsp` context**: pull workspace symbols into the prompt when needed - **zero context switch**: no sidebar, no terminal agent, no leaving your file @@ -66,7 +66,7 @@ Very open to PRs, issues, etc.! There is no concrete set of guidelines right now "cachebag/jumpy", config = function() require("jumpy").setup({ - provider = "anthropic", -- or "openai", "openrouter" + provider = "anthropic", -- or "openai", "openrouter", "claude_code" }) end, } @@ -74,6 +74,24 @@ Very open to PRs, issues, etc.! There is no concrete set of guidelines right now set your API key: `export ANTHROPIC_API_KEY="sk-ant-..."` (or `OPENAI_API_KEY`, `JUMPY_API_KEY` for openrouter). +### Claude Code / Max + +Claude Pro and Max subscribers can use Jumpy without an API key. Install Claude Code, +run `claude login`, then configure Jumpy to use the local CLI: + +```lua +require("jumpy").setup({ + provider = "claude_code", + model = "sonnet", -- optional: uses your Claude Code model selection +}) +``` + +Jumpy invokes Claude Code in non-interactive mode with its tools, MCP servers, and +session persistence disabled. Claude Code only returns proposed SEARCH/REPLACE blocks; +Jumpy remains the only process that can apply them. Jumpy removes `ANTHROPIC_API_KEY` +from the Claude Code child process so an inherited API key cannot switch a subscription +user to API billing. Set `claude_code_command` in `setup()` if `claude` is not on `PATH`. + ## Use | Keybind | Action | | ----------- | ----------------------------------------- | diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index df727de..d37fc5e 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -5,6 +5,7 @@ M.config = { endpoint = nil, model = nil, api_key = nil, + claude_code_command = "claude", system_prompt = table.concat({ "You are a code editor. The user will give you a file and an instruction.", "Return ONLY the changed sections as SEARCH/REPLACE blocks.", @@ -69,6 +70,9 @@ local provider_defaults = { model = "claude-sonnet-4-6", env_key = "ANTHROPIC_API_KEY", }, + claude_code = { + model = "sonnet", + }, } function M.setup(opts) @@ -78,7 +82,7 @@ function M.setup(opts) if p then M.config.endpoint = M.config.endpoint or p.endpoint M.config.model = M.config.model or p.model - if not M.config.api_key then + if p.env_key and not M.config.api_key then M.config.api_key = vim.env[p.env_key] or vim.env.JUMPY_API_KEY end else diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index 5b1f8cc..3859a5a 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -72,6 +72,11 @@ local function is_anthropic() return config.provider == "anthropic" end +local function is_claude_code() + local config = get_config() + return config.provider == "claude_code" +end + local function build_curl_cmd(body_json, config, extra_headers) local cmd = { "curl", "-s", "-H", "Content-Type: application/json" } for _, h in ipairs(extra_headers or {}) do @@ -113,9 +118,168 @@ local function extract_content_anthropic(parsed) return nil end +local function extract_content_claude_code(parsed) + return parsed.result +end + +local function claude_code_env() + local env = vim.fn.environ() + -- Claude Code prefers an API key over its subscription login if both exist. + env.ANTHROPIC_API_KEY = nil + return env +end + +function M._build_claude_code_cmd(messages, config) + local system_text = "" + local user_text = "" + for _, message in ipairs(messages) do + if message.role == "system" then + system_text = message.content + elseif message.role == "user" then + user_text = message.content + end + end + + local cmd = { + config.claude_code_command or "claude", + "-p", + "--output-format", + "json", + "--tools", + "", + "--bare", + "--strict-mcp-config", + "--no-session-persistence", + "--system-prompt", + system_text, + } + if config.model and config.model ~= "" then + table.insert(cmd, "--model") + table.insert(cmd, config.model) + end + table.insert(cmd, user_text) + return cmd +end + +local function make_claude_code_request(messages, callback) + local config = get_config() + local command = config.claude_code_command or "claude" + local loading = require("jumpy.loading") + + if vim.fn.executable(command) ~= 1 then + loading.error("Claude Code CLI not found; install it or set claude_code_command") + return + end + + local env = claude_code_env() + local cancelled = false + loading.start() + + local auth_jid + auth_jid = vim.fn.jobstart({ command, "auth", "status" }, { + env = env, + clear_env = true, + stdout_buffered = true, + stderr_buffered = true, + on_exit = function(_, exit_code) + if not loading.is_active() then + cancelled = true + end + if cancelled then + loading.stop() + return + end + if exit_code ~= 0 then + vim.schedule(function() + loading.error("Claude Code is not authenticated; run 'claude login' and select your Claude plan") + end) + return + end + + local response_chunks = {} + local stderr_chunks = {} + local request_jid + request_jid = vim.fn.jobstart(M._build_claude_code_cmd(messages, config), { + env = env, + clear_env = true, + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + for _, line in ipairs(data) do + table.insert(response_chunks, line) + end + end + end, + on_stderr = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stderr_chunks, line) + end + end + end + end, + on_exit = function(_, request_exit_code) + if not loading.is_active() then + cancelled = true + end + if cancelled then + loading.stop() + return + end + + local stderr_text = table.concat(stderr_chunks, "\n") + if request_exit_code ~= 0 then + vim.schedule(function() + local msg = "Claude Code request failed (exit " .. request_exit_code .. ")" + if stderr_text ~= "" then + msg = msg .. " — " .. stderr_text + end + loading.error(msg) + end) + return + end + + local raw = table.concat(response_chunks, "\n") + local ok, parsed = pcall(vim.fn.json_decode, raw) + local content = ok and extract_content_claude_code(parsed) or nil + if type(content) ~= "string" or content == "" then + vim.schedule(function() + loading.error("Claude Code returned an invalid response (update the CLI and try again)") + end) + return + end + + loading.stop() + content = content:gsub("^```[%w]*\n", ""):gsub("\n```%s*$", "") + callback(content) + end, + }) + + if request_jid <= 0 then + loading.error("failed to start Claude Code CLI") + else + loading.set_job(request_jid) + end + end, + }) + + if auth_jid <= 0 then + loading.error("failed to start Claude Code CLI") + else + loading.set_job(auth_jid) + end +end + local function make_request(messages, callback) local config = get_config() + if is_claude_code() then + make_claude_code_request(messages, callback) + return + end + if not config.api_key or config.api_key == "" then local loading = require("jumpy.loading") loading.error("no API key — set " .. (config.provider or "JUMPY") .. " env var or pass api_key in setup()") diff --git a/tests/llm_spec.lua b/tests/llm_spec.lua new file mode 100644 index 0000000..6b891be --- /dev/null +++ b/tests/llm_spec.lua @@ -0,0 +1,56 @@ +package.path = package.path .. ";lua/?.lua;lua/?/init.lua" + +local llm = require("jumpy.llm") + +describe("llm Claude Code command", function() + it("runs Claude Code as a stateless, tool-free JSON request", function() + local cmd = llm._build_claude_code_cmd({ + { role = "system", content = "Return only SEARCH/REPLACE blocks." }, + { role = "user", content = "Change this function." }, + }, { + claude_code_command = "claude", + model = "sonnet", + }) + + assert.are.same({ + "claude", + "-p", + "--output-format", + "json", + "--tools", + "", + "--bare", + "--strict-mcp-config", + "--no-session-persistence", + "--system-prompt", + "Return only SEARCH/REPLACE blocks.", + "--model", + "sonnet", + "Change this function.", + }, cmd) + end) + + it("allows a custom Claude Code executable and no model override", function() + local cmd = llm._build_claude_code_cmd({ + { role = "system", content = "system" }, + { role = "user", content = "user" }, + }, { + claude_code_command = "/opt/bin/claude", + }) + + assert.are.same({ + "/opt/bin/claude", + "-p", + "--output-format", + "json", + "--tools", + "", + "--bare", + "--strict-mcp-config", + "--no-session-persistence", + "--system-prompt", + "system", + "user", + }, cmd) + end) +end)