Skip to content

Commit 217cf48

Browse files
jimgqyuclaude
andcommitted
feat: smart merge model_list on model switch, store real token in auth_token_env
- install.sh: infer provider from base_url, smart merge with jq when settings.json exists, JSON escaping for user input, store real API key instead of env var name in auth_token_env - entry.tsx: smart merge model_list by model+provider match on --model (both interactive and non-interactive), export to process.env - coder-client.ts: upsertModelList() helper, config.set smart merge for model/CODER_MODEL/generic keys, sync process.env on config changes, use auth_token_env as direct token value (not env var name) - Fix config.yaml -> settings.json in 4 comment references Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e5fccf4 commit 217cf48

8 files changed

Lines changed: 151 additions & 20 deletions

File tree

install.sh

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -196,38 +196,90 @@ echo "Press Enter for default (deepseek-chat):"
196196
read -r -p "> " coder_model
197197
coder_model="${coder_model:-deepseek-chat}"
198198

199+
# -- Infer provider from base_url --
200+
if [ -n "$coder_base_url" ]; then
201+
case "$coder_base_url" in
202+
*deepseek*) coder_provider="deepseek" ;;
203+
*anthropic*) coder_provider="anthropic" ;;
204+
*openai*) coder_provider="openai" ;;
205+
*) coder_provider="${coder_model}" ;;
206+
esac
207+
else
208+
coder_provider="deepseek"
209+
fi
210+
199211
# -- CODER_AUTH_TOKEN --
200212
echo ""
201213
echo -e "${CYAN}API Key / Auth Token${NC}"
202214
echo "Press Enter to skip (you can set CODER_AUTH_TOKEN env var later):"
203215
read -r -p "> " coder_auth_token
204216

217+
# Escape special characters for JSON safety (prevent JSON injection)
218+
coder_model_escaped=$(echo "$coder_model" | sed 's/\\/\\\\/g; s/"/\\"/g')
219+
coder_base_url_escaped=$(echo "$coder_base_url" | sed 's/\\/\\\\/g; s/"/\\"/g')
220+
coder_auth_token_escaped=$(echo "$coder_auth_token" | sed 's/\\/\\\\/g; s/"/\\"/g')
221+
coder_provider_escaped=$(echo "$coder_provider" | sed 's/\\/\\\\/g; s/"/\\"/g')
222+
205223
# Build settings.json
206224
SETTINGS_JSON=$(cat <<SETEOF
207225
{
208226
"theme": "dark",
209-
"default_model": "${coder_model}",
227+
"default_model": "${coder_model_escaped}",
210228
"model_list": [
211229
{
212-
"name": "${coder_model}",
213-
"model": "${coder_model}",
214-
"base_url": "${coder_base_url}",
215-
"auth_token_env": "CODER_AUTH_TOKEN",
216-
"provider": "deepseek"
230+
"name": "${coder_model_escaped}",
231+
"model": "${coder_model_escaped}",
232+
"base_url": "${coder_base_url_escaped}",
233+
"auth_token_env": "${coder_auth_token_escaped}",
234+
"provider": "${coder_provider_escaped}"
217235
}
218236
],
219237
"env": {
220-
"CODER_MODEL": "${coder_model}",
221-
"CODER_BASE_URL": "${coder_base_url}",
222-
"CODER_AUTH_TOKEN": "${coder_auth_token}"
238+
"CODER_MODEL": "${coder_model_escaped}",
239+
"CODER_BASE_URL": "${coder_base_url_escaped}",
240+
"CODER_AUTH_TOKEN": "${coder_auth_token_escaped}"
223241
}
224242
}
225243
SETEOF
226244
)
227245

228-
# Write settings.json
229-
echo "$SETTINGS_JSON" > "$SETTINGS_FILE"
230-
echo -e "${GREEN}✅ Created ${SETTINGS_FILE}${NC}"
246+
# Write settings.json with smart merge (Issue 2: don't destructively overwrite)
247+
if [ -f "$SETTINGS_FILE" ]; then
248+
if command -v jq &> /dev/null; then
249+
echo -e "${YELLOW}Existing settings.json found. Smart merging model_list...${NC}"
250+
# Merge: if model+provider match, update entry; otherwise append new entry.
251+
# Also update default_model and env to reflect the new choices.
252+
jq \
253+
--arg new_model "$coder_model_escaped" \
254+
--arg new_provider "$coder_provider" \
255+
--arg new_base_url "$coder_base_url_escaped" \
256+
--arg new_auth_token "$coder_auth_token_escaped" \
257+
'
258+
.model_list = (if (.model_list // [] | map(select(.model == $new_model and .provider == $new_provider)) | length > 0) then
259+
[.model_list[] | if .model == $new_model and .provider == $new_provider then
260+
. * {name: $new_model, base_url: $new_base_url, auth_token_env: $new_auth_token}
261+
else . end]
262+
else
263+
(.model_list // []) + [{name: $new_model, model: $new_model, base_url: $new_base_url, auth_token_env: $new_auth_token, provider: $new_provider}]
264+
end) |
265+
.default_model = $new_model |
266+
.env.CODER_MODEL = $new_model |
267+
.env.CODER_BASE_URL = $new_base_url |
268+
.env.CODER_AUTH_TOKEN = $new_auth_token
269+
' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
270+
echo -e "${GREEN}✅ Merged with existing ${SETTINGS_FILE}${NC}"
271+
else
272+
echo -e "${YELLOW}WARNING: Existing settings.json found but jq not available. Backing up and creating new.${NC}"
273+
BACKUP_FILE="${SETTINGS_FILE}.bak.$(date +%s)"
274+
cp "$SETTINGS_FILE" "$BACKUP_FILE"
275+
echo -e "${YELLOW} Backup saved to ${BACKUP_FILE}${NC}"
276+
echo "$SETTINGS_JSON" > "$SETTINGS_FILE"
277+
echo -e "${GREEN}✅ Created ${SETTINGS_FILE}${NC}"
278+
fi
279+
else
280+
echo "$SETTINGS_JSON" > "$SETTINGS_FILE"
281+
echo -e "${GREEN}✅ Created ${SETTINGS_FILE}${NC}"
282+
fi
231283

232284
# Export env vars for immediate use
233285
if [ -n "$coder_base_url" ]; then

packages/cli/src/app/slash/commands/ops.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export const opsCommands: SlashCommand[] = [
147147

148148
if (!['connect', 'disconnect', 'status'].includes(action)) {
149149
return ctx.transcript.sys(
150-
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
150+
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in settings.json'
151151
)
152152
}
153153

@@ -172,7 +172,7 @@ export const opsCommands: SlashCommand[] = [
172172
return ctx.transcript.sys(
173173
r.connected
174174
? `browser connected: ${r.url || '(url unavailable)'}`
175-
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
175+
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in settings.json)'
176176
)
177177
}
178178

packages/cli/src/app/slash/commands/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export const sessionCommands: SlashCommand[] = [
234234
ctx.voice.setVoiceEnabled(!!r.enabled)
235235
ctx.voice.setVoiceTts(!!r.tts)
236236

237-
// Render the configured record key (config.yaml ``voice.record_key``)
237+
// Render the configured record key (settings.json ``voice.record_key``)
238238
// instead of hardcoded "Ctrl+B" — the gateway response carries the
239239
// current value so /voice status and /voice on stay in sync with
240240
// both the CLI and the TUI's actual binding (#18994).

packages/cli/src/app/useSubmission.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
215215
[interpolate, send, shellExec]
216216
)
217217

218-
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
218+
// Honors `display.busy_input_mode` from settings.json (CLI parity):
219219
// - 'queue' (legacy): append to queueRef; drains on busy → false
220220
// - 'steer' : inject into the current turn via session.steer; falls
221221
// back to queue when steer is rejected (no agent / no

packages/cli/src/domain/details.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const SECTION_NAMES = ['thinking', 'tools', 'subagents', 'activity'] as c
1818
// - subagents: not set — falls through to the global details_mode so
1919
// Spawn trees stay under a chevron until a delegation actually happens.
2020
//
21-
// Opt out of any of these with `display.sections.<name>` in config.yaml
21+
// Opt out of any of these with `display.sections.<name>` in settings.json
2222
// or at runtime via `/details <name> collapsed|hidden`.
2323
const SECTION_DEFAULTS: SectionVisibility = {
2424
thinking: 'collapsed',

packages/cli/src/entry.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,19 @@ if (cliArgs.model || process.argv.includes('--model')) {
237237
settings.env.CODER_MODEL = entry.model;
238238
if (entry.base_url) settings.env.CODER_BASE_URL = entry.base_url;
239239
if (entry.auth_token_env) settings.env.CODER_AUTH_TOKEN = process.env.CODER_AUTH_TOKEN ?? '';
240+
// Smart merge into model_list (match by model + provider)
241+
settings.model_list = settings.model_list ?? [];
242+
const existingIdx = settings.model_list.findIndex(
243+
(m: any) => m.model === entry.model && (m.provider ?? '') === (entry.provider ?? '')
244+
);
245+
if (existingIdx >= 0) {
246+
settings.model_list[existingIdx] = { ...settings.model_list[existingIdx], ...entry };
247+
} else {
248+
settings.model_list.push(entry);
249+
}
250+
// Export env vars for immediate effect
251+
process.env.CODER_MODEL = entry.model;
252+
if (entry.base_url) process.env.CODER_BASE_URL = entry.base_url;
240253
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
241254
console.log(`Default model set to: ${entry.name} (${entry.model})`);
242255
} else {
@@ -267,6 +280,19 @@ if (cliArgs.model || process.argv.includes('--model')) {
267280
settings.env.CODER_MODEL = entry.model;
268281
if (entry.base_url) settings.env.CODER_BASE_URL = entry.base_url;
269282
if (entry.auth_token_env) settings.env.CODER_AUTH_TOKEN = process.env.CODER_AUTH_TOKEN ?? '';
283+
// Smart merge into model_list (match by model + provider)
284+
settings.model_list = settings.model_list ?? [];
285+
const existingIdx = settings.model_list.findIndex(
286+
(m: any) => m.model === entry.model && (m.provider ?? '') === (entry.provider ?? '')
287+
);
288+
if (existingIdx >= 0) {
289+
settings.model_list[existingIdx] = { ...settings.model_list[existingIdx], ...entry };
290+
} else {
291+
settings.model_list.push(entry);
292+
}
293+
// Export env vars for immediate effect
294+
process.env.CODER_MODEL = entry.model;
295+
if (entry.base_url) process.env.CODER_BASE_URL = entry.base_url;
270296
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
271297
console.log(`Default model set to: ${entry.name} (${entry.model})`);
272298
} else {

packages/cli/src/gateway/coder-client.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ function inferProvider(name: string): string {
6060
return 'anthropic'
6161
}
6262

63+
function upsertModelList(list: ModelEntry[], newEntry: ModelEntry): void {
64+
const idx = list.findIndex(m => m.model === newEntry.model && m.provider === newEntry.provider)
65+
if (idx >= 0) {
66+
list[idx] = { ...list[idx], ...newEntry }
67+
} else {
68+
list.push(newEntry)
69+
}
70+
}
71+
6372
interface ClaudeSettings {
6473
env?: Record<string, string>
6574
theme?: string
@@ -522,6 +531,21 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
522531
if (entry.auth_token_env) settings.env.CODER_AUTH_TOKEN = entry.auth_token_env
523532
settings.default_model = value
524533

534+
// Smart merge into model_list (match by model + provider)
535+
settings.model_list = settings.model_list ?? []
536+
const existingIdx = settings.model_list.findIndex(
537+
(m: ModelEntry) => m.model === entry.model && (m.provider ?? '') === (entry.provider ?? '')
538+
)
539+
if (existingIdx >= 0) {
540+
settings.model_list[existingIdx] = { ...settings.model_list[existingIdx], ...entry }
541+
} else {
542+
settings.model_list.push(entry)
543+
}
544+
545+
// Export to process env for immediate effect
546+
process.env.CODER_MODEL = entry.model
547+
if (entry.base_url) process.env.CODER_BASE_URL = entry.base_url
548+
525549
// Persist to disk
526550
writeFileSync(
527551
join(homedir(), '.coder', 'settings.json'),
@@ -537,12 +561,41 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
537561
name: entry.name,
538562
provider: entry.provider ?? inferProvider(entry.name),
539563
}
540-
this.log(`config.set model=${value}${entry.model} (provider=${this.modelConfig.provider})`)
564+
this.log(`config.set model=${value} -> ${entry.model} (provider=${this.modelConfig.provider})`)
565+
}
566+
} else if (key === 'CODER_MODEL' && value) {
567+
// Direct model name set: upsert to model_list
568+
settings.env = settings.env ?? {}
569+
settings.env.CODER_MODEL = value
570+
settings.model_list = settings.model_list ?? []
571+
const existingIdx = settings.model_list.findIndex(
572+
(m: ModelEntry) => (m.model === value || m.name === value)
573+
)
574+
if (existingIdx >= 0) {
575+
settings.model_list[existingIdx] = { ...settings.model_list[existingIdx], model: value, name: value }
576+
} else {
577+
settings.model_list.push({ name: value, model: value, provider: inferProvider(value) })
541578
}
579+
settings.default_model = value
580+
process.env.CODER_MODEL = value
581+
writeFileSync(
582+
join(homedir(), '.coder', 'settings.json'),
583+
JSON.stringify(settings, null, 2),
584+
)
585+
this.log(`config.set CODER_MODEL=${value}`)
542586
} else {
543587
// Generic key: update env[key] in settings and persist
544588
settings.env = settings.env ?? {}
545589
settings.env[key] = value
590+
// Also update the current default model entry in model_list
591+
if (settings.model_list && settings.default_model) {
592+
const defEntry = settings.model_list.find((m: ModelEntry) => m.name === settings.default_model)
593+
if (defEntry) {
594+
if (key === 'CODER_BASE_URL') defEntry.base_url = value
595+
if (key === 'CODER_AUTH_TOKEN') defEntry.auth_token_env = 'CODER_AUTH_TOKEN'
596+
}
597+
}
598+
process.env[key] = value
546599
writeFileSync(
547600
join(homedir(), '.coder', 'settings.json'),
548601
JSON.stringify(settings, null, 2),

packages/cli/src/lib/platform.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ export const isCopyShortcut = (
5252

5353
/**
5454
* Voice recording toggle key — configurable via ``voice.record_key`` in
55-
* ``config.yaml`` (default ``ctrl+b``).
55+
* ``settings.json`` (default ``ctrl+b``).
5656
*
5757
* Documented in tips.py, the Python CLI prompt_toolkit handler, and the
58-
* config.yaml default. The TUI honours the same config knob (#18994);
58+
* settings.json default. The TUI honours the same config knob (#18994);
5959
* when ``voice.record_key`` is e.g. ``ctrl+o`` the TUI binds Ctrl+O.
6060
*
6161
* Only the documented default (``ctrl+b``) additionally accepts the

0 commit comments

Comments
 (0)