Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -66,14 +66,32 @@ 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,
}
```

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 |
| ----------- | ----------------------------------------- |
Expand Down
6 changes: 5 additions & 1 deletion lua/jumpy/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
164 changes: 164 additions & 0 deletions lua/jumpy/llm.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()")
Expand Down
56 changes: 56 additions & 0 deletions tests/llm_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading