Skip to content

Commit e40e8f6

Browse files
vraj00222claude
andcommitted
feat(cli): add /local slash command for runtime local-provider toggle
UX layer on top of the providerOptions.baseUrl plumbing — lets a user toggle between local and cloud inference without restarting codebuff or editing agent files. Subcommands: /local — show current status /local on — enable with default Ollama URL (localhost:11434/v1) /local on <url> — enable with a specific URL /local set <url> — alias for `/local on <url>` /local off — disable, return to Codebuff backend /local status — same as `/local` Implementation mutates process.env.CODEBUFF_BASE_URL at runtime. The SDK reads this env var lazily on every promptAiSdkStream call, so changes take effect immediately for the next request without needing to rebuild the CodebuffClient. Agent-level providerOptions.baseUrl still wins — /local only affects agents that don't set their own baseUrl. Communicated in the enable message so users aren't surprised. 29 unit tests covering: parse/apply separation, all subcommands and aliases, URL validation, idempotent disable, end-to-end toggle cycle, and verification that mutations are visible to the SDK env getter. All 2354 existing CLI tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c9ca1e8 commit e40e8f6

4 files changed

Lines changed: 428 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2+
3+
import {
4+
applyLocalAction,
5+
DEFAULT_LOCAL_BASE_URL,
6+
getActiveLocalBaseUrl,
7+
parseLocalArgs,
8+
} from '../local-provider'
9+
10+
describe('parseLocalArgs', () => {
11+
test('empty args → status', () => {
12+
expect(parseLocalArgs('').kind).toBe('status')
13+
expect(parseLocalArgs(' ').kind).toBe('status')
14+
expect(parseLocalArgs('\t\n').kind).toBe('status')
15+
})
16+
17+
test('"status" → status', () => {
18+
expect(parseLocalArgs('status').kind).toBe('status')
19+
expect(parseLocalArgs(' status ').kind).toBe('status')
20+
expect(parseLocalArgs('STATUS').kind).toBe('status') // case-insensitive
21+
})
22+
23+
test('"on" with no URL → enable with default Ollama URL', () => {
24+
const r = parseLocalArgs('on')
25+
expect(r.kind).toBe('enable')
26+
if (r.kind === 'enable') expect(r.baseUrl).toBe(DEFAULT_LOCAL_BASE_URL)
27+
})
28+
29+
test('"on <url>" → enable with that URL', () => {
30+
const r = parseLocalArgs('on http://localhost:1234/v1')
31+
expect(r.kind).toBe('enable')
32+
if (r.kind === 'enable') expect(r.baseUrl).toBe('http://localhost:1234/v1')
33+
})
34+
35+
test('"enable <url>" alias works', () => {
36+
const r = parseLocalArgs('enable http://localhost:1234/v1')
37+
expect(r.kind).toBe('enable')
38+
})
39+
40+
test('"set <url>" alias works', () => {
41+
const r = parseLocalArgs('set http://localhost:1234/v1')
42+
expect(r.kind).toBe('enable')
43+
if (r.kind === 'enable') expect(r.baseUrl).toBe('http://localhost:1234/v1')
44+
})
45+
46+
test('"set" with no URL → invalid', () => {
47+
const r = parseLocalArgs('set')
48+
expect(r.kind).toBe('enable')
49+
// "set" with no URL falls back to default — that's debatable but matches "on"
50+
if (r.kind === 'enable') expect(r.baseUrl).toBe(DEFAULT_LOCAL_BASE_URL)
51+
})
52+
53+
test('bare URL (no subcommand) → treated as enable', () => {
54+
const r = parseLocalArgs('http://localhost:11434/v1')
55+
expect(r.kind).toBe('enable')
56+
if (r.kind === 'enable') expect(r.baseUrl).toBe('http://localhost:11434/v1')
57+
})
58+
59+
test('"off" → disable', () => {
60+
expect(parseLocalArgs('off').kind).toBe('disable')
61+
expect(parseLocalArgs('disable').kind).toBe('disable')
62+
})
63+
64+
test('"off" with stray args → invalid', () => {
65+
const r = parseLocalArgs('off http://oops')
66+
expect(r.kind).toBe('invalid')
67+
})
68+
69+
test('non-http URL → invalid', () => {
70+
const r = parseLocalArgs('on ftp://localhost')
71+
expect(r.kind).toBe('invalid')
72+
})
73+
74+
test('malformed URL → invalid', () => {
75+
const r = parseLocalArgs('on http://')
76+
expect(r.kind).toBe('invalid')
77+
})
78+
79+
test('unknown subcommand → invalid with helpful message', () => {
80+
const r = parseLocalArgs('foobar')
81+
expect(r.kind).toBe('invalid')
82+
if (r.kind === 'invalid') expect(r.reason).toContain('Unknown')
83+
})
84+
85+
test('https URL is accepted (for remote endpoints)', () => {
86+
const r = parseLocalArgs('on https://my-vm.example.com:8080/v1')
87+
expect(r.kind).toBe('enable')
88+
if (r.kind === 'enable')
89+
expect(r.baseUrl).toBe('https://my-vm.example.com:8080/v1')
90+
})
91+
92+
test('extra whitespace in URL is preserved as-is when valid', () => {
93+
const r = parseLocalArgs(' on http://localhost:11434/v1 ')
94+
expect(r.kind).toBe('enable')
95+
if (r.kind === 'enable') expect(r.baseUrl).toBe('http://localhost:11434/v1')
96+
})
97+
})
98+
99+
describe('applyLocalAction (side effects on process.env)', () => {
100+
let originalBaseUrl: string | undefined
101+
let originalApiKey: string | undefined
102+
103+
beforeEach(() => {
104+
originalBaseUrl = process.env.CODEBUFF_BASE_URL
105+
originalApiKey = process.env.CODEBUFF_PROVIDER_API_KEY
106+
delete process.env.CODEBUFF_BASE_URL
107+
delete process.env.CODEBUFF_PROVIDER_API_KEY
108+
})
109+
110+
afterEach(() => {
111+
if (originalBaseUrl === undefined) delete process.env.CODEBUFF_BASE_URL
112+
else process.env.CODEBUFF_BASE_URL = originalBaseUrl
113+
if (originalApiKey === undefined)
114+
delete process.env.CODEBUFF_PROVIDER_API_KEY
115+
else process.env.CODEBUFF_PROVIDER_API_KEY = originalApiKey
116+
})
117+
118+
test('enable sets process.env.CODEBUFF_BASE_URL', () => {
119+
const msg = applyLocalAction({
120+
kind: 'enable',
121+
baseUrl: 'http://localhost:11434/v1',
122+
})
123+
expect(process.env.CODEBUFF_BASE_URL).toBe('http://localhost:11434/v1')
124+
expect(getActiveLocalBaseUrl()).toBe('http://localhost:11434/v1')
125+
expect(msg).toContain('ON')
126+
expect(msg).toContain('http://localhost:11434/v1')
127+
})
128+
129+
test('disable deletes process.env.CODEBUFF_BASE_URL', () => {
130+
process.env.CODEBUFF_BASE_URL = 'http://localhost:11434/v1'
131+
const msg = applyLocalAction({ kind: 'disable' })
132+
expect(process.env.CODEBUFF_BASE_URL).toBeUndefined()
133+
expect(msg).toContain('OFF')
134+
expect(msg).toContain('Previously: http://localhost:11434/v1')
135+
})
136+
137+
test('disable also clears the API key env var', () => {
138+
process.env.CODEBUFF_BASE_URL = 'http://localhost:11434/v1'
139+
process.env.CODEBUFF_PROVIDER_API_KEY = 'ollama'
140+
applyLocalAction({ kind: 'disable' })
141+
expect(process.env.CODEBUFF_BASE_URL).toBeUndefined()
142+
expect(process.env.CODEBUFF_PROVIDER_API_KEY).toBeUndefined()
143+
})
144+
145+
test('disable when already off is idempotent and friendly', () => {
146+
const msg = applyLocalAction({ kind: 'disable' })
147+
expect(msg).toContain('already OFF')
148+
})
149+
150+
test('status when off shows OFF', () => {
151+
const msg = applyLocalAction({ kind: 'status' })
152+
expect(msg).toContain('OFF')
153+
})
154+
155+
test('status when on shows the URL', () => {
156+
process.env.CODEBUFF_BASE_URL = 'http://localhost:1234/v1'
157+
const msg = applyLocalAction({ kind: 'status' })
158+
expect(msg).toContain('ON')
159+
expect(msg).toContain('http://localhost:1234/v1')
160+
})
161+
162+
test('invalid action returns the reason prefixed', () => {
163+
const msg = applyLocalAction({
164+
kind: 'invalid',
165+
reason: 'something wrong',
166+
})
167+
expect(msg).toContain('something wrong')
168+
expect(process.env.CODEBUFF_BASE_URL).toBeUndefined()
169+
})
170+
171+
test('enable overwrites a previously-set URL', () => {
172+
applyLocalAction({ kind: 'enable', baseUrl: 'http://localhost:11434/v1' })
173+
applyLocalAction({ kind: 'enable', baseUrl: 'http://localhost:1234/v1' })
174+
expect(process.env.CODEBUFF_BASE_URL).toBe('http://localhost:1234/v1')
175+
})
176+
177+
test('full toggle cycle: off → on → status → off', () => {
178+
expect(applyLocalAction({ kind: 'status' })).toContain('OFF')
179+
180+
applyLocalAction({ kind: 'enable', baseUrl: DEFAULT_LOCAL_BASE_URL })
181+
expect(getActiveLocalBaseUrl()).toBe(DEFAULT_LOCAL_BASE_URL)
182+
183+
const statusOn = applyLocalAction({ kind: 'status' })
184+
expect(statusOn).toContain('ON')
185+
186+
const off = applyLocalAction({ kind: 'disable' })
187+
expect(off).toContain('OFF')
188+
expect(off).toContain(`Previously: ${DEFAULT_LOCAL_BASE_URL}`)
189+
expect(getActiveLocalBaseUrl()).toBeUndefined()
190+
})
191+
192+
test('mentions agent-level override in the enable message', () => {
193+
const msg = applyLocalAction({
194+
kind: 'enable',
195+
baseUrl: DEFAULT_LOCAL_BASE_URL,
196+
})
197+
expect(msg.toLowerCase()).toContain('providerOptions.baseUrl'.toLowerCase())
198+
})
199+
})
200+
201+
describe('parseLocalArgs + applyLocalAction end-to-end', () => {
202+
let originalBaseUrl: string | undefined
203+
204+
beforeEach(() => {
205+
originalBaseUrl = process.env.CODEBUFF_BASE_URL
206+
delete process.env.CODEBUFF_BASE_URL
207+
})
208+
209+
afterEach(() => {
210+
if (originalBaseUrl === undefined) delete process.env.CODEBUFF_BASE_URL
211+
else process.env.CODEBUFF_BASE_URL = originalBaseUrl
212+
})
213+
214+
test('user types `/local on` → URL is set to default', () => {
215+
applyLocalAction(parseLocalArgs('on'))
216+
expect(process.env.CODEBUFF_BASE_URL).toBe(DEFAULT_LOCAL_BASE_URL)
217+
})
218+
219+
test('user types `/local on http://x` → URL is set', () => {
220+
applyLocalAction(parseLocalArgs('on http://x.example.com:9999/v1'))
221+
expect(process.env.CODEBUFF_BASE_URL).toBe('http://x.example.com:9999/v1')
222+
})
223+
224+
test('user types `/local off` after `/local on` → URL is cleared', () => {
225+
applyLocalAction(parseLocalArgs('on'))
226+
applyLocalAction(parseLocalArgs('off'))
227+
expect(process.env.CODEBUFF_BASE_URL).toBeUndefined()
228+
})
229+
230+
test('user types `/local garbage` → no env change, error message returned', () => {
231+
const msg = applyLocalAction(parseLocalArgs('garbage'))
232+
expect(process.env.CODEBUFF_BASE_URL).toBeUndefined()
233+
expect(msg).toContain('Unknown')
234+
})
235+
})

cli/src/commands/command-registry.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { handleAdsEnable, handleAdsDisable } from './ads'
55
import { handleHelpCommand } from './help'
66
import { handleImageCommand } from './image'
77
import { handleInitializationFlowLocally } from './init'
8+
import { applyLocalAction, parseLocalArgs } from './local-provider'
89
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
910
import { runBashCommand } from './router'
1011
import { handleUsageCommand } from './usage'
@@ -392,6 +393,19 @@ const ALL_COMMANDS: CommandDefinition[] = [
392393
clearInput(params)
393394
},
394395
}),
396+
defineCommandWithArgs({
397+
name: 'local',
398+
handler: (params, args) => {
399+
const message = applyLocalAction(parseLocalArgs(args))
400+
params.setMessages((prev) => [
401+
...prev,
402+
getUserMessage(params.inputValue.trim()),
403+
getSystemMessage(message),
404+
])
405+
params.saveToHistory(params.inputValue.trim())
406+
clearInput(params)
407+
},
408+
}),
395409
// Mode commands generated from AGENT_MODES (excluded in Freebuff)
396410
...(IS_FREEBUFF ? [] : AGENT_MODES).map((mode) =>
397411
defineCommandWithArgs({

0 commit comments

Comments
 (0)