Skip to content

Commit c09eb43

Browse files
authored
Merge pull request #70 from kaitranntt/dev
feat: CLIProxy multi-account support and analytics improvements
2 parents a7410bc + 277836f commit c09eb43

22 files changed

+1870
-113
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ jobs:
2323
node-version: '22'
2424

2525
- name: Install dependencies
26-
run: bun install --frozen-lockfile
26+
run: |
27+
bun install --frozen-lockfile
28+
cd ui && bun install --frozen-lockfile
2729
2830
- name: Build package
29-
run: bun run build
31+
run: bun run build:all
3032

3133
- name: Validate (typecheck + lint + tests)
3234
run: bun run validate

.github/workflows/dev-release.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ jobs:
3636
bun install --frozen-lockfile
3737
cd ui && bun install --frozen-lockfile
3838
39-
- name: Build and validate
40-
run: |
41-
bun run build:all
42-
bun run validate
39+
- name: Build
40+
run: bun run build:all
41+
42+
- name: Validate (typecheck + lint + tests)
43+
run: bun run validate
4344

4445
- name: Bump dev version
4546
id: bump

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ ccs agy --headless # Displays URL, paste in browser elsewhere
106106
ccs gemini --logout
107107
```
108108

109+
### Multi-Account for OAuth Providers
110+
111+
Use multiple accounts per provider (work + personal):
112+
113+
```bash
114+
# First account (default)
115+
ccs gemini --auth
116+
117+
# Add another account
118+
ccs gemini --auth --add
119+
120+
# Add with nickname for easy identification
121+
ccs gemini --auth --add --nickname work
122+
123+
# List all accounts
124+
ccs agy --accounts
125+
126+
# Switch to a different account
127+
ccs agy --use work
128+
```
129+
130+
Accounts are stored in `~/.ccs/cliproxy/accounts.json` and can be managed via web dashboard (`ccs config`).
131+
109132
### OAuth vs API Key Models
110133

111134
| Feature | OAuth Providers<br>(gemini, codex, agy) | API Key Models<br>(glm, kimi) |

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5.12.1
1+
5.12.1-dev.4

docs/project-roadmap.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ src/types/
589589
**Release Date**: 2025-12-08
590590

591591
#### UI Fixes & Improvements
592+
-**Analytics UI Enhancements**: Standardized colors, fixed truncated model names, and ensured color consistency in the analytics dashboard.
592593
-**Auto-formatting**: 31 UI files auto-formatted for consistent styling.
593594
-**Fast Refresh Exports**: Resolved `react-refresh/only-export-components` by extracting `buttonVariants`, `useSidebar`, and `useWebSocketContext` to separate files.
594595
-**React Hooks Issues**: Fixed `react-hooks/purity` (`Math.random()` in `useMemo` for `sidebar.tsx`) and `react-hooks/set-state-in-effect` (`use-theme.ts`, `settings.tsx`).
@@ -821,6 +822,6 @@ src/types/
821822
---
822823

823824
**Document Status**: Living document, updated with each major release
824-
**Last Updated**: 2025-12-08 (UI Layout Improvements)
825+
**Last Updated**: 2025-12-09 (Analytics UI Enhancements)
825826
**Next Update**: v4.6.0 UI Enhancements Planning
826827
**Maintainer**: CCS Development Team

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@kaitranntt/ccs",
3-
"version": "5.12.1",
3+
"version": "5.12.1-dev.4",
44
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
55
"keywords": [
66
"cli",
@@ -62,9 +62,10 @@
6262
"lint:fix": "eslint src/ --fix",
6363
"format": "prettier --write src/",
6464
"format:check": "prettier --check src/",
65-
"validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test",
65+
"validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all",
6666
"verify:bundle": "node scripts/verify-bundle.js",
6767
"test": "bun run build && bun run test:all",
68+
"test:ci": "bun run test:all",
6869
"test:all": "bun test",
6970
"test:unit": "bun test tests/unit/",
7071
"test:npm": "bun test tests/npm/",

src/cliproxy/account-manager.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface AccountInfo {
1919
id: string;
2020
/** Email address from OAuth (if available) */
2121
email?: string;
22+
/** User-friendly nickname for quick reference (auto-generated from email prefix) */
23+
nickname?: string;
2224
/** Provider this account belongs to */
2325
provider: CLIProxyProvider;
2426
/** Whether this is the default account for the provider */
@@ -53,6 +55,36 @@ const DEFAULT_REGISTRY: AccountsRegistry = {
5355
providers: {},
5456
};
5557

58+
/**
59+
* Generate nickname from email
60+
* Takes prefix before @ symbol, sanitizes whitespace
61+
* Validation: 1-50 chars, any non-whitespace (permissive per user preference)
62+
*/
63+
export function generateNickname(email?: string): string {
64+
if (!email) return 'default';
65+
const prefix = email.split('@')[0];
66+
// Sanitize: remove whitespace, limit to 50 chars
67+
return prefix.replace(/\s+/g, '').slice(0, 50) || 'default';
68+
}
69+
70+
/**
71+
* Validate nickname
72+
* Rules: 1-50 chars, any non-whitespace allowed (permissive)
73+
* @returns null if valid, error message if invalid
74+
*/
75+
export function validateNickname(nickname: string): string | null {
76+
if (!nickname || nickname.length === 0) {
77+
return 'Nickname is required';
78+
}
79+
if (nickname.length > 50) {
80+
return 'Nickname must be 50 characters or less';
81+
}
82+
if (/\s/.test(nickname)) {
83+
return 'Nickname cannot contain whitespace';
84+
}
85+
return null;
86+
}
87+
5688
/**
5789
* Get path to accounts registry file
5890
*/
@@ -133,14 +165,41 @@ export function getAccount(provider: CLIProxyProvider, accountId: string): Accou
133165
return accounts.find((a) => a.id === accountId) || null;
134166
}
135167

168+
/**
169+
* Find account by query (nickname, email, or id)
170+
* Supports partial matching for convenience
171+
*/
172+
export function findAccountByQuery(provider: CLIProxyProvider, query: string): AccountInfo | null {
173+
const accounts = getProviderAccounts(provider);
174+
const lowerQuery = query.toLowerCase();
175+
176+
// Exact match first (id, email, nickname)
177+
const exactMatch = accounts.find(
178+
(a) =>
179+
a.id === query ||
180+
a.email?.toLowerCase() === lowerQuery ||
181+
a.nickname?.toLowerCase() === lowerQuery
182+
);
183+
if (exactMatch) return exactMatch;
184+
185+
// Partial match on nickname or email prefix
186+
const partialMatch = accounts.find(
187+
(a) =>
188+
a.nickname?.toLowerCase().startsWith(lowerQuery) ||
189+
a.email?.toLowerCase().startsWith(lowerQuery)
190+
);
191+
return partialMatch || null;
192+
}
193+
136194
/**
137195
* Register a new account
138196
* Called after successful OAuth to record the account
139197
*/
140198
export function registerAccount(
141199
provider: CLIProxyProvider,
142200
tokenFile: string,
143-
email?: string
201+
email?: string,
202+
nickname?: string
144203
): AccountInfo {
145204
const registry = loadAccountsRegistry();
146205

@@ -161,9 +220,13 @@ export function registerAccount(
161220
const accountId = email || 'default';
162221
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
163222

223+
// Generate nickname if not provided
224+
const accountNickname = nickname || generateNickname(email);
225+
164226
// Create or update account
165227
providerAccounts.accounts[accountId] = {
166228
email,
229+
nickname: accountNickname,
167230
tokenFile,
168231
createdAt: new Date().toISOString(),
169232
lastUsedAt: new Date().toISOString(),
@@ -181,6 +244,7 @@ export function registerAccount(
181244
provider,
182245
isDefault: accountId === providerAccounts.default,
183246
email,
247+
nickname: accountNickname,
184248
tokenFile,
185249
createdAt: providerAccounts.accounts[accountId].createdAt,
186250
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
@@ -333,9 +397,10 @@ export function discoverExistingAccounts(): void {
333397
// Get file stats for creation time
334398
const stats = fs.statSync(filePath);
335399

336-
// Register account
400+
// Register account with auto-generated nickname
337401
providerAccounts.accounts[accountId] = {
338402
email,
403+
nickname: generateNickname(email),
339404
tokenFile: file,
340405
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
341406
lastUsedAt: stats.mtime?.toISOString(),

src/cliproxy/auth-handler.ts

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import { CLIProxyProvider } from './types';
2222
import {
2323
AccountInfo,
2424
discoverExistingAccounts,
25+
generateNickname,
2526
getDefaultAccount,
2627
getProviderAccounts,
2728
registerAccount,
2829
touchAccount,
2930
} from './account-manager';
31+
import { preflightOAuthCheck } from '../management/oauth-port-diagnostics';
3032

3133
/**
3234
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
@@ -90,26 +92,50 @@ function killProcessOnPort(port: number, verbose: boolean): boolean {
9092

9193
/**
9294
* Detect if running in a headless environment (no browser available)
95+
*
96+
* IMPROVED: Avoids false positives on Windows desktop environments
97+
* where isTTY may be undefined due to terminal wrapper behavior.
98+
*
99+
* Case study: Vietnamese Windows users reported "command hangs" because
100+
* their terminal (PowerShell via npm) didn't set isTTY correctly.
93101
*/
94102
function isHeadlessEnvironment(): boolean {
95-
// SSH session
103+
// SSH session - always headless
96104
if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) {
97105
return true;
98106
}
99107

100-
// No display (Linux/X11)
108+
// No display on Linux (X11/Wayland) - headless
101109
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
102110
return true;
103111
}
104112

105-
// Non-interactive (piped stdin) - skip on Windows
106-
// Windows npm wrappers don't set isTTY correctly (returns undefined, not true)
113+
// Windows desktop - NEVER headless unless SSH (already checked above)
114+
// This fixes false positive where Windows npm wrappers don't set isTTY correctly
107115
// Windows desktop environments always have browser capability
108-
if (process.platform !== 'win32' && !process.stdin.isTTY) {
109-
return true;
116+
if (process.platform === 'win32') {
117+
return false;
118+
}
119+
120+
// macOS - check for proper terminal
121+
if (process.platform === 'darwin') {
122+
// Non-interactive stdin on macOS means likely piped/scripted
123+
if (!process.stdin.isTTY) {
124+
return true;
125+
}
126+
return false;
110127
}
111128

112-
return false;
129+
// Linux with display - check TTY
130+
if (process.platform === 'linux') {
131+
if (!process.stdin.isTTY) {
132+
return true;
133+
}
134+
return false;
135+
}
136+
137+
// Default fallback for unknown platforms
138+
return !process.stdin.isTTY;
113139
}
114140

115141
/**
@@ -404,14 +430,15 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
404430
* Auto-detects headless environment and uses --no-browser flag accordingly
405431
* @param provider - The CLIProxy provider to authenticate
406432
* @param options - OAuth options
433+
* @param options.add - If true, skip confirm prompt when adding another account
407434
* @returns Account info if successful, null otherwise
408435
*/
409436
export async function triggerOAuth(
410437
provider: CLIProxyProvider,
411-
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
438+
options: { verbose?: boolean; headless?: boolean; account?: string; add?: boolean } = {}
412439
): Promise<AccountInfo | null> {
413440
const oauthConfig = getOAuthConfig(provider);
414-
const { verbose = false } = options;
441+
const { verbose = false, add = false } = options;
415442

416443
// Auto-detect headless if not explicitly set
417444
const headless = options.headless ?? isHeadlessEnvironment();
@@ -422,6 +449,47 @@ export async function triggerOAuth(
422449
}
423450
};
424451

452+
// Check for existing accounts and prompt if --add not specified
453+
const existingAccounts = getProviderAccounts(provider);
454+
if (existingAccounts.length > 0 && !add) {
455+
console.log('');
456+
console.log(
457+
`[i] ${existingAccounts.length} account(s) already authenticated for ${oauthConfig.displayName}`
458+
);
459+
460+
// Import readline for confirm prompt
461+
const readline = await import('readline');
462+
const rl = readline.createInterface({
463+
input: process.stdin,
464+
output: process.stdout,
465+
});
466+
467+
const confirmed = await new Promise<boolean>((resolve) => {
468+
rl.question('[?] Add another account? (y/N): ', (answer) => {
469+
rl.close();
470+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
471+
});
472+
});
473+
474+
if (!confirmed) {
475+
console.log('[i] Cancelled');
476+
return null;
477+
}
478+
}
479+
480+
// Pre-flight check: verify OAuth callback port is available
481+
const preflight = await preflightOAuthCheck(provider);
482+
if (!preflight.ready) {
483+
console.log('');
484+
console.log('[!] OAuth pre-flight check failed:');
485+
for (const issue of preflight.issues) {
486+
console.log(` ${issue}`);
487+
}
488+
console.log('');
489+
console.log('[i] Resolve the port conflict and try again.');
490+
return null;
491+
}
492+
425493
// Ensure binary exists
426494
let binaryPath: string;
427495
try {
@@ -624,8 +692,8 @@ function registerAccountFromToken(
624692
const data = JSON.parse(content);
625693
const email = data.email || undefined;
626694

627-
// Register the account
628-
return registerAccount(provider, newestFile, email);
695+
// Register the account with auto-generated nickname
696+
return registerAccount(provider, newestFile, email, generateNickname(email));
629697
} catch {
630698
return null;
631699
}

0 commit comments

Comments
 (0)