mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
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:
@@ -156,6 +156,8 @@ The dashboard provides visual management for all account types:
|
||||
|
||||
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
|
||||
|
||||
> **Kiro / Copilot account naming:** Manual nicknames are optional. If the provider does not expose an email, CCS derives a safe internal identifier automatically and you can rename it later.
|
||||
|
||||
> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles.
|
||||
|
||||
**Powered by:**
|
||||
|
||||
@@ -28,8 +28,11 @@ export {
|
||||
getPausedDir,
|
||||
getAccountTokenPath,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
validateNickname,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
tokenFileExists,
|
||||
loadAccountsRegistry,
|
||||
saveAccountsRegistry,
|
||||
|
||||
@@ -21,8 +21,11 @@ export {
|
||||
getPausedDir,
|
||||
getAccountTokenPath,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
validateNickname,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
tokenFileExists,
|
||||
} from './token-file-ops';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroAuthMethodOption,
|
||||
isDeviceCodeProvider,
|
||||
isNicknameRequiredProvider,
|
||||
KIRO_AUTH_METHOD_OPTIONS,
|
||||
} from '@/lib/provider-config';
|
||||
import type { KiroAuthMethod } from '@/lib/provider-config';
|
||||
@@ -89,7 +88,6 @@ export function AddAccountDialog({
|
||||
const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist);
|
||||
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
|
||||
const defaultDeviceCode = isDeviceCodeProvider(provider);
|
||||
const requiresNickname = isNicknameRequiredProvider(provider);
|
||||
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
|
||||
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
|
||||
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
|
||||
@@ -266,10 +264,6 @@ export function AddAccountDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requiresNickname && !nicknameTrimmed) {
|
||||
setLocalError(`Nickname is required for ${displayName} accounts.`);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, {
|
||||
@@ -394,11 +388,7 @@ export function AddAccountDialog({
|
||||
{/* Nickname input - only show before auth starts */}
|
||||
{!showAuthUI && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">
|
||||
{requiresNickname
|
||||
? t('addAccountDialog.nicknameRequired')
|
||||
: t('addAccountDialog.nicknameOptional')}
|
||||
</Label>
|
||||
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -414,9 +404,7 @@ export function AddAccountDialog({
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{requiresNickname
|
||||
? t('addAccountDialog.nicknameRequiredHint')
|
||||
: t('addAccountDialog.nicknameOptionalHint')}
|
||||
{t('addAccountDialog.nicknameOptionalHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -554,7 +542,6 @@ export function AddAccountDialog({
|
||||
disabled={
|
||||
isPending ||
|
||||
isAgyBypassStatePending ||
|
||||
(requiresNickname && !nicknameTrimmed) ||
|
||||
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
|
||||
(requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged)
|
||||
}
|
||||
|
||||
+4
-4
@@ -473,7 +473,7 @@ const resources = {
|
||||
nicknameRequiredHint:
|
||||
'Required for this provider. Use a unique friendly name (e.g., work, personal).',
|
||||
nicknameOptionalHint:
|
||||
'A friendly name to identify this account. Auto-generated from email if left empty.',
|
||||
'A friendly name to identify this account. Leave blank to use a safe generated identifier.',
|
||||
waitingForAuth: 'Waiting for authentication...',
|
||||
deviceCodeHint:
|
||||
'A verification code dialog will appear shortly. Enter the code on the provider website.',
|
||||
@@ -1632,7 +1632,7 @@ const resources = {
|
||||
nicknameOptional: '昵称(选填)',
|
||||
nicknamePlaceholder: '例如:工作、个人',
|
||||
nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。',
|
||||
nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。',
|
||||
nicknameOptionalHint: '用于区分账号的友好名称。留空将自动生成安全标识。',
|
||||
waitingForAuth: '等待认证中...',
|
||||
deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。',
|
||||
browserHint: '在浏览器中完成认证后,本对话框将自动关闭。',
|
||||
@@ -2804,7 +2804,7 @@ const resources = {
|
||||
nicknameRequiredHint:
|
||||
'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).',
|
||||
nicknameOptionalHint:
|
||||
'Một cái tên thân thiện để xác định tài khoản này. Tự động tạo từ email nếu để trống.',
|
||||
'Tên thân thiện để nhận biết tài khoản này. Để trống để dùng mã nhận dạng an toàn do hệ thống tạo.',
|
||||
waitingForAuth: 'Đang chờ xác thực...',
|
||||
deviceCodeHint:
|
||||
'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.',
|
||||
@@ -4004,7 +4004,7 @@ const resources = {
|
||||
nicknameRequiredHint:
|
||||
'このプロバイダーでは必須です。重複しないわかりやすい名前を付けてください(例: work, personal)。',
|
||||
nicknameOptionalHint:
|
||||
'このアカウントを識別しやすい名前です。空欄ならメールアドレスから自動生成されます。',
|
||||
'このアカウントを識別しやすい名前です。空欄の場合は安全な識別子を自動生成します。',
|
||||
waitingForAuth: '認証を待機中...',
|
||||
deviceCodeHint:
|
||||
'確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。',
|
||||
|
||||
@@ -233,15 +233,6 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
|
||||
return 'Complete the authorization in your browser.';
|
||||
}
|
||||
|
||||
/** Providers that require nickname because token payload may not include email. */
|
||||
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
|
||||
|
||||
/** Check if provider requires user-supplied nickname in auth flow */
|
||||
export function isNicknameRequiredProvider(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
|
||||
}
|
||||
|
||||
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
|
||||
Reference in New Issue
Block a user