Skip to content

Commit e438659

Browse files
authored
Merge pull request #77 from kaitranntt/kai/feat/api-profile-ux
feat(ui): refactor profile creation UX with dialog interface and validation
2 parents cbeb28f + 720ff9d commit e438659

File tree

17 files changed

+2487
-332
lines changed

17 files changed

+2487
-332
lines changed

src/commands/api-command.ts

Lines changed: 165 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ function validateApiName(name: string): string | null {
9393
}
9494

9595
/**
96-
* Validate URL format
96+
* Validate URL format and warn about common mistakes
9797
*/
9898
function validateUrl(url: string): string | null {
9999
if (!url) {
@@ -107,6 +107,25 @@ function validateUrl(url: string): string | null {
107107
}
108108
}
109109

110+
/**
111+
* Check if URL looks like it includes endpoint path (common mistake)
112+
* Returns warning message if problematic, null if OK
113+
*/
114+
function getUrlWarning(url: string): string | null {
115+
const problematicPaths = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
116+
const lowerUrl = url.toLowerCase();
117+
118+
for (const path of problematicPaths) {
119+
if (lowerUrl.endsWith(path)) {
120+
return (
121+
`URL ends with "${path}" - Claude appends this automatically.\n` +
122+
` You likely want: ${url.replace(new RegExp(path + '$', 'i'), '')}`
123+
);
124+
}
125+
}
126+
return null;
127+
}
128+
110129
/**
111130
* Check if unified config mode is active
112131
*/
@@ -130,22 +149,35 @@ function apiExists(name: string): boolean {
130149
}
131150
}
132151

152+
/** Model mapping for API profiles */
153+
interface ModelMapping {
154+
default: string;
155+
opus: string;
156+
sonnet: string;
157+
haiku: string;
158+
}
159+
133160
/**
134161
* Create settings.json file for API profile
135162
* Includes all 4 model fields for proper Claude CLI integration
136163
*/
137-
function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string {
164+
function createSettingsFile(
165+
name: string,
166+
baseUrl: string,
167+
apiKey: string,
168+
models: ModelMapping
169+
): string {
138170
const ccsDir = getCcsDir();
139171
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
140172

141173
const settings = {
142174
env: {
143175
ANTHROPIC_BASE_URL: baseUrl,
144176
ANTHROPIC_AUTH_TOKEN: apiKey,
145-
ANTHROPIC_MODEL: model,
146-
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
147-
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
148-
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
177+
ANTHROPIC_MODEL: models.default,
178+
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
179+
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
180+
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
149181
},
150182
};
151183

@@ -192,7 +224,7 @@ function createApiProfileUnified(
192224
name: string,
193225
baseUrl: string,
194226
apiKey: string,
195-
model: string
227+
models: ModelMapping
196228
): void {
197229
const ccsDir = path.join(os.homedir(), '.ccs');
198230
const settingsFile = `${name}.settings.json`;
@@ -203,10 +235,10 @@ function createApiProfileUnified(
203235
env: {
204236
ANTHROPIC_BASE_URL: baseUrl,
205237
ANTHROPIC_AUTH_TOKEN: apiKey,
206-
ANTHROPIC_MODEL: model,
207-
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
208-
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
209-
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
238+
ANTHROPIC_MODEL: models.default,
239+
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
240+
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
241+
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
210242
},
211243
};
212244

@@ -293,9 +325,12 @@ async function handleCreate(args: string[]): Promise<void> {
293325
// Step 2: Base URL
294326
let baseUrl = parsedArgs.baseUrl;
295327
if (!baseUrl) {
296-
baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', {
297-
validate: validateUrl,
298-
});
328+
baseUrl = await InteractivePrompt.input(
329+
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
330+
{
331+
validate: validateUrl,
332+
}
333+
);
299334
} else {
300335
const error = validateUrl(baseUrl);
301336
if (error) {
@@ -304,6 +339,23 @@ async function handleCreate(args: string[]): Promise<void> {
304339
}
305340
}
306341

342+
// Check for common URL mistakes and warn
343+
const urlWarning = getUrlWarning(baseUrl);
344+
if (urlWarning) {
345+
console.log('');
346+
console.log(warn(urlWarning));
347+
const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', {
348+
default: false,
349+
});
350+
if (!continueAnyway) {
351+
// Let user re-enter URL
352+
baseUrl = await InteractivePrompt.input('API Base URL', {
353+
validate: validateUrl,
354+
default: baseUrl.replace(/\/(chat\/completions|v1\/messages|messages|completions)$/i, ''),
355+
});
356+
}
357+
}
358+
307359
// Step 3: API Key
308360
let apiKey = parsedArgs.apiKey;
309361
if (!apiKey) {
@@ -318,50 +370,127 @@ async function handleCreate(args: string[]): Promise<void> {
318370
const defaultModel = 'claude-sonnet-4-5-20250929';
319371
let model = parsedArgs.model;
320372
if (!model && !parsedArgs.yes) {
321-
model = await InteractivePrompt.input('Default model', {
373+
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
322374
default: defaultModel,
323375
});
324376
}
325377
model = model || defaultModel;
326378

379+
// Step 5: Model mapping for Opus/Sonnet/Haiku
380+
// Auto-show if user entered a custom model, otherwise ask
381+
let opusModel = model;
382+
let sonnetModel = model;
383+
let haikuModel = model;
384+
385+
const isCustomModel = model !== defaultModel;
386+
387+
if (!parsedArgs.yes) {
388+
// If user entered custom model, auto-prompt for model mapping
389+
// Otherwise, ask if they want to configure it
390+
let wantCustomMapping = isCustomModel;
391+
392+
if (!isCustomModel) {
393+
console.log('');
394+
console.log(dim('Some API proxies route different model types to different backends.'));
395+
wantCustomMapping = await InteractivePrompt.confirm(
396+
'Configure different models for Opus/Sonnet/Haiku?',
397+
{ default: false }
398+
);
399+
}
400+
401+
if (wantCustomMapping) {
402+
console.log('');
403+
if (isCustomModel) {
404+
console.log(dim('Configure model IDs for each tier (defaults to your model):'));
405+
} else {
406+
console.log(dim('Leave blank to use the default model for each.'));
407+
}
408+
opusModel =
409+
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
410+
default: model,
411+
})) || model;
412+
sonnetModel =
413+
(await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', {
414+
default: model,
415+
})) || model;
416+
haikuModel =
417+
(await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', {
418+
default: model,
419+
})) || model;
420+
}
421+
}
422+
423+
// Build model mapping
424+
const models: ModelMapping = {
425+
default: model,
426+
opus: opusModel,
427+
sonnet: sonnetModel,
428+
haiku: haikuModel,
429+
};
430+
431+
// Check if custom model mapping is configured
432+
const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
433+
327434
// Create files
328435
console.log('');
329436
console.log(info('Creating API profile...'));
330437

331438
try {
439+
const settingsFile = `~/.ccs/${name}.settings.json`;
440+
332441
if (isUnifiedMode()) {
333442
// Use unified config format
334-
createApiProfileUnified(name, baseUrl, apiKey, model);
443+
createApiProfileUnified(name, baseUrl, apiKey, models);
335444
console.log('');
336-
console.log(
337-
infoBox(
338-
`API: ${name}\n` +
339-
`Config: ~/.ccs/config.yaml\n` +
340-
`Secrets: ~/.ccs/secrets.yaml\n` +
341-
`Base URL: ${baseUrl}\n` +
342-
`Model: ${model}`,
343-
'API Profile Created (Unified Config)'
344-
)
345-
);
445+
446+
// Build info message
447+
let infoMsg =
448+
`API: ${name}\n` +
449+
`Config: ~/.ccs/config.yaml\n` +
450+
`Settings: ${settingsFile}\n` +
451+
`Base URL: ${baseUrl}\n` +
452+
`Model: ${model}`;
453+
454+
if (hasCustomMapping) {
455+
infoMsg +=
456+
`\n\nModel Mapping:\n` +
457+
` Opus: ${opusModel}\n` +
458+
` Sonnet: ${sonnetModel}\n` +
459+
` Haiku: ${haikuModel}`;
460+
}
461+
462+
console.log(infoBox(infoMsg, 'API Profile Created'));
346463
} else {
347464
// Use legacy JSON format
348-
const settingsPath = createSettingsFile(name, baseUrl, apiKey, model);
465+
const settingsPath = createSettingsFile(name, baseUrl, apiKey, models);
349466
updateConfig(name, settingsPath);
350467
console.log('');
351-
console.log(
352-
infoBox(
353-
`API: ${name}\n` +
354-
`Settings: ~/.ccs/${name}.settings.json\n` +
355-
`Base URL: ${baseUrl}\n` +
356-
`Model: ${model}`,
357-
'API Profile Created'
358-
)
359-
);
468+
469+
let infoMsg =
470+
`API: ${name}\n` +
471+
`Settings: ${settingsFile}\n` +
472+
`Base URL: ${baseUrl}\n` +
473+
`Model: ${model}`;
474+
475+
if (hasCustomMapping) {
476+
infoMsg +=
477+
`\n\nModel Mapping:\n` +
478+
` Opus: ${opusModel}\n` +
479+
` Sonnet: ${sonnetModel}\n` +
480+
` Haiku: ${haikuModel}`;
481+
}
482+
483+
console.log(infoBox(infoMsg, 'API Profile Created'));
360484
}
485+
361486
console.log('');
362487
console.log(header('Usage'));
363488
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
364489
console.log('');
490+
console.log(header('Edit Settings'));
491+
console.log(` ${dim('To modify env vars later:')}`);
492+
console.log(` ${color(`nano ${settingsFile.replace('~', '$HOME')}`, 'command')}`);
493+
console.log('');
365494
} catch (error) {
366495
console.log(fail(`Failed to create API profile: ${(error as Error).message}`));
367496
process.exit(1);

0 commit comments

Comments
 (0)