feat(cliproxy): allow optional provider nicknames

- stop requiring manual unique nicknames for no-email providers by default

- preserve explicit nickname validation and same-account reauth semantics

- persist optional nicknames through dashboard manual callback flows
This commit is contained in:
Tam Nhu Tran
2026-03-23 11:02:08 -04:00
parent 185f7f469e
commit bdb7ac2937
15 changed files with 398 additions and 220 deletions
+3
View File
@@ -28,8 +28,11 @@ export {
getPausedDir,
getAccountTokenPath,
extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname,
validateNickname,
hasAccountNameConflict,
findAccountNameMatch,
tokenFileExists,
loadAccountsRegistry,
saveAccountsRegistry,
+3
View File
@@ -21,8 +21,11 @@ export {
getPausedDir,
getAccountTokenPath,
extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname,
validateNickname,
hasAccountNameConflict,
findAccountNameMatch,
tokenFileExists,
} from './token-file-ops';
+2 -2
View File
@@ -67,12 +67,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
if (exactMatch) return exactMatch;
// Partial match on nickname or email prefix
const partialMatch = accounts.find(
const partialMatches = accounts.filter(
(a) =>
a.nickname?.toLowerCase().startsWith(lowerQuery) ||
a.email?.toLowerCase().startsWith(lowerQuery)
);
return partialMatch || null;
return partialMatches.length === 1 ? partialMatches[0] : null;
}
/**
+41 -54
View File
@@ -13,7 +13,9 @@ import {
getAccountsRegistryPath,
getPausedDir,
extractAccountIdFromTokenFile,
deriveNoEmailProviderAccountId,
generateNickname,
hasAccountNameConflict,
validateNickname,
moveTokenToPaused,
moveTokenFromPaused,
@@ -21,10 +23,12 @@ import {
} from './token-file-ops';
/** Default registry structure */
const DEFAULT_REGISTRY: AccountsRegistry = {
version: 1,
providers: {},
};
function createDefaultRegistry(): AccountsRegistry {
return {
version: 1,
providers: {},
};
}
/**
* Load accounts registry
@@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
const registryPath = getAccountsRegistryPath();
if (!fs.existsSync(registryPath)) {
return { ...DEFAULT_REGISTRY };
return createDefaultRegistry();
}
try {
@@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
providers: data.providers || {},
};
} catch {
return { ...DEFAULT_REGISTRY };
return createDefaultRegistry();
}
}
@@ -115,8 +119,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
* Called after successful OAuth to record the account
*
* For providers without email (kiro, ghcp):
* - nickname is REQUIRED and used as accountId
* - Uniqueness is enforced to prevent overwriting
* - internal accountId is derived from token metadata
* - nickname is optional metadata
*
* For providers with email:
* - email is used as accountId
@@ -149,23 +153,22 @@ export function registerAccount(
let accountNickname: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
// For kiro/ghcp: nickname is REQUIRED and used as accountId
if (!nickname || nickname === 'default') {
throw new Error(
`Nickname is required when adding ${provider} accounts. ` +
`Use --nickname <name> or provide a nickname in the UI.`
);
}
accountId = email
? extractAccountIdFromTokenFile(tokenFile, email)
: deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
const existingAccount = providerAccounts.accounts[accountId];
// Validate nickname format
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
if (nickname) {
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
// Check uniqueness
for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) {
if (existingId.toLowerCase() === nickname.toLowerCase()) {
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.`
@@ -173,8 +176,8 @@ export function registerAccount(
}
}
accountId = nickname;
accountNickname = nickname;
accountNickname =
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else {
// For other providers: use email as accountId, fallback to filename extraction
accountId = extractAccountIdFromTokenFile(tokenFile, email);
@@ -184,11 +187,12 @@ export function registerAccount(
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account
const existingAccount = providerAccounts.accounts[accountId];
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: accountNickname,
tokenFile,
createdAt: new Date().toISOString(),
createdAt: existingAccount?.createdAt || new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
};
@@ -339,11 +343,12 @@ export function renameAccount(
return false;
}
// Check if nickname is already used by another account
for (const [id, account] of Object.entries(providerAccounts.accounts)) {
if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) {
throw new Error(`Nickname "${newNickname}" is already used by another account`);
}
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
throw new Error(`Nickname "${newNickname}" is already used by another account`);
}
providerAccounts.accounts[accountId].nickname = newNickname;
@@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void {
continue;
}
// Determine accountId based on provider type
let accountId: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) {
// For kiro/ghcp without email: extract from filename or generate unique
// Pattern: kiro-github-ABC123.json -> github-ABC123
const filenameId = extractAccountIdFromTokenFile(file, undefined);
if (filenameId !== 'default') {
accountId = filenameId;
} else {
// Generate unique ID: provider + incrementing index
let index = 1;
while (providerAccounts.accounts[`${provider}-${index}`]) {
index++;
}
accountId = `${provider}-${index}`;
}
} else {
// For providers with email: use email or filename extraction
accountId = extractAccountIdFromTokenFile(file, email);
}
const accountId =
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
: extractAccountIdFromTokenFile(file, email);
// Skip if account already registered
if (providerAccounts.accounts[accountId]) {
@@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void {
const lastModified = stats.mtime || stats.birthtime || new Date();
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: generateNickname(email),
nickname: email ? generateNickname(email) : accountId,
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: lastModified.toISOString(),
+86
View File
@@ -5,6 +5,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir, getAuthDir } from '../config-generator';
import type { CLIProxyProvider } from '../types';
import { AccountInfo } from './types';
/**
@@ -143,6 +144,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string):
return 'default';
}
/**
* Derive a collision-safe internal account ID for providers that may not expose email.
* Reuses the existing entry when the token file is already known, otherwise prefers the
* filename-derived ID before falling back to provider-scoped sequential IDs.
*/
export function deriveNoEmailProviderAccountId(
provider: CLIProxyProvider,
tokenFile: string,
existingAccounts: Record<string, Pick<AccountInfo, 'tokenFile' | 'nickname'>>
): string {
const existingEntries = Object.entries(existingAccounts);
const existingEntry = existingEntries.find(([, account]) => account.tokenFile === tokenFile);
if (existingEntry) {
return existingEntry[0];
}
const extractedId = extractAccountIdFromTokenFile(tokenFile);
const lowerExtractedId = extractedId.toLowerCase();
if (
extractedId !== 'default' &&
!existingEntries.some(
([existingId, account]) =>
existingId.toLowerCase() === lowerExtractedId ||
account.nickname?.toLowerCase() === lowerExtractedId
)
) {
return extractedId;
}
let index = 1;
while (
existingEntries.some(
([existingId, account]) =>
existingId.toLowerCase() === `${provider}-${index}` ||
account.nickname?.toLowerCase() === `${provider}-${index}`
)
) {
index++;
}
return `${provider}-${index}`;
}
/**
* Generate nickname from email
* Takes prefix before @ symbol, sanitizes whitespace
@@ -180,3 +225,44 @@ export function validateNickname(nickname: string): string | null {
}
return null;
}
/**
* Check whether a nickname would collide with any existing account ID or nickname.
*/
export function hasAccountNameConflict(
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
candidateName: string,
excludeAccountId?: string
): boolean {
const normalizedCandidate = candidateName.toLowerCase();
const normalizedExcludedId = excludeAccountId?.toLowerCase();
return accounts.some((account) => {
if (normalizedExcludedId && account.id.toLowerCase() === normalizedExcludedId) {
return false;
}
return (
account.id.toLowerCase() === normalizedCandidate ||
account.nickname?.toLowerCase() === normalizedCandidate
);
});
}
/**
* Find the existing account that already owns the supplied id/nickname.
*/
export function findAccountNameMatch(
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
candidateName: string
): Pick<AccountInfo, 'id' | 'nickname'> | null {
const normalizedCandidate = candidateName.toLowerCase();
return (
accounts.find(
(account) =>
account.id.toLowerCase() === normalizedCandidate ||
account.nickname?.toLowerCase() === normalizedCandidate
) || null
);
}
+68 -85
View File
@@ -20,6 +20,8 @@ import {
getProviderAccounts,
getDefaultAccount,
touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../account-manager';
@@ -88,6 +90,28 @@ export async function requestPasteCallbackStart(
return (await response.json()) as PasteCallbackStartData;
}
export function getCliAuthNicknameError(
provider: CLIProxyProvider,
nickname: string | undefined,
existingAccounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
allowExistingAccountId?: string
): string | null {
if (!nickname || !PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
return null;
}
const validationError = validateNickname(nickname);
if (validationError) {
return validationError;
}
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
return `Nickname "${nickname}" is already in use. Choose a different one.`;
}
return null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -206,74 +230,6 @@ async function promptOAuthModeChoice(callbackPort: number | null): Promise<'past
});
}
/**
* Prompt user for account nickname (required for kiro/ghcp)
* Returns null if user cancels
*/
async function promptNickname(
provider: CLIProxyProvider,
existingAccounts: AccountInfo[]
): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
console.log('');
console.log(info(`${provider} accounts require a unique nickname to distinguish them.`));
if (existingNicknames.length > 0) {
console.log(` Existing: ${existingNicknames.join(', ')}`);
}
return new Promise<string | null>((resolve) => {
let resolved = false;
// Handle Ctrl+C gracefully (only if not already resolved)
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve(null);
}
});
const askForNickname = () => {
rl.question('[?] Enter a nickname for this account: ', (answer) => {
const nickname = answer.trim();
if (!nickname) {
console.log(fail('Nickname cannot be empty'));
askForNickname();
return;
}
const validationError = validateNickname(nickname);
if (validationError) {
console.log(fail(validationError));
askForNickname();
return;
}
if (existingNicknames.includes(nickname.toLowerCase())) {
console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`));
askForNickname();
return;
}
resolved = true;
rl.close();
resolve(nickname);
});
};
askForNickname();
});
}
/**
* Run pre-flight OAuth checks
*/
@@ -350,7 +306,8 @@ async function handlePasteCallbackMode(
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
tokenDir: string,
nickname?: string
nickname?: string,
expectedAccountId?: string
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -474,7 +431,13 @@ async function handlePasteCallbackMode(
}
console.log(ok('Authentication successful!'));
const account = registerAccountFromToken(provider, tokenDir, nickname);
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId
);
// Account safety: check for cross-provider conflicts
if (account?.email) {
@@ -509,7 +472,7 @@ export async function triggerOAuth(
warnOAuthBanRisk(provider);
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
const acceptAgyRisk = options.acceptAgyRisk === true;
let { nickname } = options;
const { nickname } = options;
const resolvedKiroMethod =
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
@@ -533,21 +496,26 @@ export async function triggerOAuth(
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = !fromUI
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
: null;
if (nicknameError) {
console.log(fail(nicknameError));
return null;
}
// Handle paste-callback mode
if (options.pasteCallback) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
}
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
const promptedNickname = await promptNickname(provider, existingAccounts);
if (!promptedNickname) {
console.log(info('Cancelled'));
return null;
}
nickname = promptedNickname;
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
// Handle --import flag: skip OAuth and import from Kiro IDE directly
@@ -555,7 +523,7 @@ export async function triggerOAuth(
const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose);
if (success) {
return registerAccountFromToken(provider, tokenDir, nickname);
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
}
return null;
}
@@ -589,12 +557,26 @@ export async function triggerOAuth(
// Non-interactive environment (piped input) - default to paste mode
if (!process.stdin.isTTY) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
// mode === 'forward' continues to existing port-forwarding flow below
}
@@ -678,6 +660,7 @@ export async function triggerOAuth(
verbose,
isCLI,
nickname,
expectedAccountId: existingNameMatch?.id,
});
// Show hint for Kiro users about --no-incognito option (first-time auth only)
+8 -2
View File
@@ -50,6 +50,7 @@ export interface OAuthProcessOptions {
verbose: boolean;
isCLI: boolean;
nickname?: string;
expectedAccountId?: string;
}
/** Internal state for OAuth process */
@@ -276,6 +277,7 @@ async function handleTokenNotFound(
callbackPort: number | null,
tokenDir: string,
nickname: string | undefined,
expectedAccountId: string | undefined,
verbose: boolean,
failureReason?: string
): Promise<AccountInfo | null> {
@@ -289,7 +291,7 @@ async function handleTokenNotFound(
if (result.success) {
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
return registerAccountFromToken(provider, tokenDir, nickname);
return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId);
}
console.log(fail(`Auto-import failed: ${result.error}`));
@@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
headless,
verbose,
nickname,
expectedAccountId,
} = options;
const log = (msg: string) => {
@@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
}
resolve(registerAccountFromToken(provider, tokenDir, nickname));
resolve(
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
);
} else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
@@ -556,6 +561,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
callbackPort,
tokenDir,
nickname,
expectedAccountId,
verbose,
failureReason || undefined
);
+27 -11
View File
@@ -11,6 +11,7 @@ import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile } from '../accounts/token-file-ops';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -202,14 +203,15 @@ export function registerAccountFromToken(
provider: CLIProxyProvider,
tokenDir: string,
nickname?: string,
verbose = false
verbose = false,
expectedAccountId?: string
): import('../account-manager').AccountInfo | null {
const { registerAccount, generateNickname } = require('../account-manager');
const { registerAccount } = require('../account-manager');
let newestFile: string | null = null;
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
let newestFile: string | null = null;
let newestMtime = 0;
for (const file of jsonFiles) {
@@ -233,19 +235,33 @@ export function registerAccountFromToken(
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const account = registerAccount(
provider,
newestFile,
email,
nickname || generateNickname(email),
projectId
);
const account = registerAccount(provider, newestFile, email, nickname, projectId);
// Upload token to remote server if configured (async, don't block)
uploadTokenToRemoteAsync(tokenPath, verbose);
return account;
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (verbose) {
console.error(`[auth] Failed to register token-backed account: ${message}`);
}
if (typeof newestFile === 'string') {
const hasExistingRegistration = getProviderAccounts(provider).some(
(account) => account.tokenFile === newestFile
);
if (!hasExistingRegistration) {
deleteTokenFile(newestFile);
}
}
if (expectedAccountId && verbose) {
console.error(
`[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token`
);
}
return null;
}
}
+1 -1
View File
@@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy(
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
}
console.log(`\n Use "ccs ${provider} --use <nickname>" to switch accounts`);
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
}
process.exit(0);
}
+1 -1
View File
@@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'Show auth URL and prompt for callback paste (cross-browser)',
],
['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', 'Switch to account'],
['ccs <provider> --use <nickname-or-id>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
[
'ccs agy --accept-agr-risk',
+150 -36
View File
@@ -22,6 +22,8 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
hasAccountNameConflict,
findAccountNameMatch,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../../cliproxy/account-manager';
@@ -33,7 +35,7 @@ import {
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -53,12 +55,55 @@ import {
import { createRouteErrorHelpers } from './route-helpers';
const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
const pendingManualAuthState = new Map<
string,
{ nickname?: string; expectedAccountId?: string; createdAt: number }
>();
// Valid providers list - derived from canonical CLIPROXY_PROFILES
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes');
function pruneExpiredManualAuthState(now = Date.now()): void {
for (const [state, pending] of pendingManualAuthState.entries()) {
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
pendingManualAuthState.delete(state);
}
}
}
function rememberManualAuthState(
state: string,
pending: { nickname?: string; expectedAccountId?: string }
): void {
pruneExpiredManualAuthState();
pendingManualAuthState.set(state, {
...pending,
createdAt: Date.now(),
});
}
function getManualAuthState(
state: string | undefined
): { nickname?: string; expectedAccountId?: string } | null {
if (!state) {
return null;
}
pruneExpiredManualAuthState();
const pending = pendingManualAuthState.get(state);
if (!pending) {
return null;
}
return {
nickname: pending.nickname,
expectedAccountId: pending.expectedAccountId,
};
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
return 'Authentication failed or was cancelled';
}
export function getStartAuthNicknameError(
provider: CLIProxyProvider,
nickname: string | undefined,
existingAccounts: Array<{ id: string; nickname?: string }>,
allowExistingAccountId?: string
): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null {
if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) {
return null;
}
const validationError = validateNickname(nickname);
if (validationError) {
return {
error: validationError,
code: 'INVALID_NICKNAME',
};
}
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
return {
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
};
}
return null;
}
/**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
@@ -430,37 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
res.status(400).json({
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`,
code: 'NICKNAME_REQUIRED',
});
return;
}
const validationError = validateNickname(nickname);
if (validationError) {
res.status(400).json({
error: validationError,
code: 'INVALID_NICKNAME',
});
return;
}
// Check uniqueness
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
if (existingNicknames.includes(nickname.toLowerCase())) {
res.status(400).json({
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
});
return;
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
);
if (nicknameError) {
res.status(400).json(nicknameError);
return;
}
// Check Kiro no-incognito setting from config (or request body)
@@ -625,7 +678,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {};
const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
// Check remote mode
@@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
nickname,
existingAccounts,
existingNameMatch?.id
);
if (nicknameError) {
res.status(400).json(nicknameError);
return;
}
try {
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
@@ -696,19 +767,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
method?: string;
};
const authUrl = data.url || data.auth_url;
const oauthState = data.state || parseAuthUrlState(authUrl);
// Some upstream flows return state first and provide auth_url in subsequent status polling.
if (!authUrl && !data.state) {
if (!authUrl && !oauthState) {
res
.status(500)
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
return;
}
if (oauthState) {
rememberManualAuthState(oauthState, {
nickname: nickname || undefined,
expectedAccountId: existingNameMatch?.id,
});
}
res.json({
success: true,
authUrl: authUrl || null,
state: data.state || null,
state: oauthState,
method: data.method || null,
});
} catch (error) {
@@ -768,6 +847,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } {
}
}
function parseAuthUrlState(url: string | null | undefined): string | null {
if (!url) {
return null;
}
try {
return new URL(url).searchParams.get('state');
} catch {
return null;
}
}
/**
* POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually
* For cross-browser OAuth flows where callback cannot redirect directly
@@ -800,6 +891,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
res.status(400).json({ error: 'Invalid callback URL: missing code parameter' });
return;
}
const pendingAuth = getManualAuthState(parsed.state);
try {
const callbackProvider =
@@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
return;
}
res.json({ success: true });
const account = registerAccountFromToken(
provider as CLIProxyProvider,
getProviderTokenDir(provider as CLIProxyProvider),
pendingAuth?.nickname,
false,
pendingAuth?.expectedAccountId
);
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
res.json({
success: true,
account: account
? {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
}
: null,
});
} catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
}