Skip to content

Commit c3a2fc3

Browse files
authored
feat: add claude code subscription provider (#34)
2 parents 17f9a2c + b698334 commit c3a2fc3

4 files changed

Lines changed: 245 additions & 3 deletions

File tree

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Some of the existing features are to be fleshed out, but the sole purpose is to
4040
- **hunk navigation**: jump between proposed changes with `]h` / `[h`
4141
- **quickfix integration**: collect pending hunks across buffers into one list
4242
- **token-efficient**: sends only the targeted edit context, not full-file rewrites
43-
- **multi-provider**: openrouter, openai, anthropic
43+
- **multi-provider**: openrouter, openai, Anthropic API, Claude Code subscriptions
4444
- **`@lsp` context**: pull workspace symbols into the prompt when needed
4545
- **zero context switch**: no sidebar, no terminal agent, no leaving your file
4646

@@ -66,14 +66,32 @@ Very open to PRs, issues, etc.! There is no concrete set of guidelines right now
6666
"cachebag/jumpy",
6767
config = function()
6868
require("jumpy").setup({
69-
provider = "anthropic", -- or "openai", "openrouter"
69+
provider = "anthropic", -- or "openai", "openrouter", "claude_code"
7070
})
7171
end,
7272
}
7373
```
7474

7575
set your API key: `export ANTHROPIC_API_KEY="sk-ant-..."` (or `OPENAI_API_KEY`, `JUMPY_API_KEY` for openrouter).
7676

77+
### Claude Code / Max
78+
79+
Claude Pro and Max subscribers can use Jumpy without an API key. Install Claude Code,
80+
run `claude login`, then configure Jumpy to use the local CLI:
81+
82+
```lua
83+
require("jumpy").setup({
84+
provider = "claude_code",
85+
model = "sonnet", -- optional: uses your Claude Code model selection
86+
})
87+
```
88+
89+
Jumpy invokes Claude Code in non-interactive mode with its tools, MCP servers, and
90+
session persistence disabled. Claude Code only returns proposed SEARCH/REPLACE blocks;
91+
Jumpy remains the only process that can apply them. Jumpy removes `ANTHROPIC_API_KEY`
92+
from the Claude Code child process so an inherited API key cannot switch a subscription
93+
user to API billing. Set `claude_code_command` in `setup()` if `claude` is not on `PATH`.
94+
7795
## Use
7896
| Keybind | Action |
7997
| ----------- | ----------------------------------------- |

lua/jumpy/init.lua

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ M.config = {
55
endpoint = nil,
66
model = nil,
77
api_key = nil,
8+
claude_code_command = "claude",
89
system_prompt = table.concat({
910
"You are a code editor. The user will give you a file and an instruction.",
1011
"Return ONLY the changed sections as SEARCH/REPLACE blocks.",
@@ -69,6 +70,9 @@ local provider_defaults = {
6970
model = "claude-sonnet-4-6",
7071
env_key = "ANTHROPIC_API_KEY",
7172
},
73+
claude_code = {
74+
model = "sonnet",
75+
},
7276
}
7377

7478
function M.setup(opts)
@@ -78,7 +82,7 @@ function M.setup(opts)
7882
if p then
7983
M.config.endpoint = M.config.endpoint or p.endpoint
8084
M.config.model = M.config.model or p.model
81-
if not M.config.api_key then
85+
if p.env_key and not M.config.api_key then
8286
M.config.api_key = vim.env[p.env_key] or vim.env.JUMPY_API_KEY
8387
end
8488
else

lua/jumpy/llm.lua

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ local function is_anthropic()
7272
return config.provider == "anthropic"
7373
end
7474

75+
local function is_claude_code()
76+
local config = get_config()
77+
return config.provider == "claude_code"
78+
end
79+
7580
local function build_curl_cmd(body_json, config, extra_headers)
7681
local cmd = { "curl", "-s", "-H", "Content-Type: application/json" }
7782
for _, h in ipairs(extra_headers or {}) do
@@ -113,9 +118,168 @@ local function extract_content_anthropic(parsed)
113118
return nil
114119
end
115120

121+
local function extract_content_claude_code(parsed)
122+
return parsed.result
123+
end
124+
125+
local function claude_code_env()
126+
local env = vim.fn.environ()
127+
-- Claude Code prefers an API key over its subscription login if both exist.
128+
env.ANTHROPIC_API_KEY = nil
129+
return env
130+
end
131+
132+
function M._build_claude_code_cmd(messages, config)
133+
local system_text = ""
134+
local user_text = ""
135+
for _, message in ipairs(messages) do
136+
if message.role == "system" then
137+
system_text = message.content
138+
elseif message.role == "user" then
139+
user_text = message.content
140+
end
141+
end
142+
143+
local cmd = {
144+
config.claude_code_command or "claude",
145+
"-p",
146+
"--output-format",
147+
"json",
148+
"--tools",
149+
"",
150+
"--bare",
151+
"--strict-mcp-config",
152+
"--no-session-persistence",
153+
"--system-prompt",
154+
system_text,
155+
}
156+
if config.model and config.model ~= "" then
157+
table.insert(cmd, "--model")
158+
table.insert(cmd, config.model)
159+
end
160+
table.insert(cmd, user_text)
161+
return cmd
162+
end
163+
164+
local function make_claude_code_request(messages, callback)
165+
local config = get_config()
166+
local command = config.claude_code_command or "claude"
167+
local loading = require("jumpy.loading")
168+
169+
if vim.fn.executable(command) ~= 1 then
170+
loading.error("Claude Code CLI not found; install it or set claude_code_command")
171+
return
172+
end
173+
174+
local env = claude_code_env()
175+
local cancelled = false
176+
loading.start()
177+
178+
local auth_jid
179+
auth_jid = vim.fn.jobstart({ command, "auth", "status" }, {
180+
env = env,
181+
clear_env = true,
182+
stdout_buffered = true,
183+
stderr_buffered = true,
184+
on_exit = function(_, exit_code)
185+
if not loading.is_active() then
186+
cancelled = true
187+
end
188+
if cancelled then
189+
loading.stop()
190+
return
191+
end
192+
if exit_code ~= 0 then
193+
vim.schedule(function()
194+
loading.error("Claude Code is not authenticated; run 'claude login' and select your Claude plan")
195+
end)
196+
return
197+
end
198+
199+
local response_chunks = {}
200+
local stderr_chunks = {}
201+
local request_jid
202+
request_jid = vim.fn.jobstart(M._build_claude_code_cmd(messages, config), {
203+
env = env,
204+
clear_env = true,
205+
stdout_buffered = true,
206+
stderr_buffered = true,
207+
on_stdout = function(_, data)
208+
if data then
209+
for _, line in ipairs(data) do
210+
table.insert(response_chunks, line)
211+
end
212+
end
213+
end,
214+
on_stderr = function(_, data)
215+
if data then
216+
for _, line in ipairs(data) do
217+
if line ~= "" then
218+
table.insert(stderr_chunks, line)
219+
end
220+
end
221+
end
222+
end,
223+
on_exit = function(_, request_exit_code)
224+
if not loading.is_active() then
225+
cancelled = true
226+
end
227+
if cancelled then
228+
loading.stop()
229+
return
230+
end
231+
232+
local stderr_text = table.concat(stderr_chunks, "\n")
233+
if request_exit_code ~= 0 then
234+
vim.schedule(function()
235+
local msg = "Claude Code request failed (exit " .. request_exit_code .. ")"
236+
if stderr_text ~= "" then
237+
msg = msg .. "" .. stderr_text
238+
end
239+
loading.error(msg)
240+
end)
241+
return
242+
end
243+
244+
local raw = table.concat(response_chunks, "\n")
245+
local ok, parsed = pcall(vim.fn.json_decode, raw)
246+
local content = ok and extract_content_claude_code(parsed) or nil
247+
if type(content) ~= "string" or content == "" then
248+
vim.schedule(function()
249+
loading.error("Claude Code returned an invalid response (update the CLI and try again)")
250+
end)
251+
return
252+
end
253+
254+
loading.stop()
255+
content = content:gsub("^```[%w]*\n", ""):gsub("\n```%s*$", "")
256+
callback(content)
257+
end,
258+
})
259+
260+
if request_jid <= 0 then
261+
loading.error("failed to start Claude Code CLI")
262+
else
263+
loading.set_job(request_jid)
264+
end
265+
end,
266+
})
267+
268+
if auth_jid <= 0 then
269+
loading.error("failed to start Claude Code CLI")
270+
else
271+
loading.set_job(auth_jid)
272+
end
273+
end
274+
116275
local function make_request(messages, callback)
117276
local config = get_config()
118277

278+
if is_claude_code() then
279+
make_claude_code_request(messages, callback)
280+
return
281+
end
282+
119283
if not config.api_key or config.api_key == "" then
120284
local loading = require("jumpy.loading")
121285
loading.error("no API key — set " .. (config.provider or "JUMPY") .. " env var or pass api_key in setup()")

tests/llm_spec.lua

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package.path = package.path .. ";lua/?.lua;lua/?/init.lua"
2+
3+
local llm = require("jumpy.llm")
4+
5+
describe("llm Claude Code command", function()
6+
it("runs Claude Code as a stateless, tool-free JSON request", function()
7+
local cmd = llm._build_claude_code_cmd({
8+
{ role = "system", content = "Return only SEARCH/REPLACE blocks." },
9+
{ role = "user", content = "Change this function." },
10+
}, {
11+
claude_code_command = "claude",
12+
model = "sonnet",
13+
})
14+
15+
assert.are.same({
16+
"claude",
17+
"-p",
18+
"--output-format",
19+
"json",
20+
"--tools",
21+
"",
22+
"--bare",
23+
"--strict-mcp-config",
24+
"--no-session-persistence",
25+
"--system-prompt",
26+
"Return only SEARCH/REPLACE blocks.",
27+
"--model",
28+
"sonnet",
29+
"Change this function.",
30+
}, cmd)
31+
end)
32+
33+
it("allows a custom Claude Code executable and no model override", function()
34+
local cmd = llm._build_claude_code_cmd({
35+
{ role = "system", content = "system" },
36+
{ role = "user", content = "user" },
37+
}, {
38+
claude_code_command = "/opt/bin/claude",
39+
})
40+
41+
assert.are.same({
42+
"/opt/bin/claude",
43+
"-p",
44+
"--output-format",
45+
"json",
46+
"--tools",
47+
"",
48+
"--bare",
49+
"--strict-mcp-config",
50+
"--no-session-persistence",
51+
"--system-prompt",
52+
"system",
53+
"user",
54+
}, cmd)
55+
end)
56+
end)

0 commit comments

Comments
 (0)