Merge pull request #217 from kaitranntt/dev

feat: auth profile management, Kiro import, and config recovery
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-27 13:45:48 -08:00
committed by GitHub
41 changed files with 1090 additions and 162 deletions
+28 -2
View File
@@ -8,10 +8,22 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
## Design Principles (ENFORCE STRICTLY)
### Technical Excellence
- **YAGNI**: No features "just in case"
- **KISS**: Simple bash/PowerShell/Node.js only
- **DRY**: One source of truth (config.json)
- **CLI-First**: All features must have CLI interface
- **DRY**: One source of truth (config.yaml)
### User Experience (EQUALLY IMPORTANT)
- **CLI-Complete**: All features MUST have CLI interface
- **Dashboard-Parity**: Configuration features MUST also have Dashboard interface
- **Execution is CLI**: Running profiles happens via terminal, not dashboard buttons
- **UX > Brevity**: Error messages and help text prioritize user success over terseness
- **Progressive Disclosure**: Simple by default, power features accessible but not overwhelming
### When Principles Conflict
- **UX > YAGNI** for user-facing features (if users need it, it's not "just in case")
- **KISS applies to BOTH** code AND user experience (simple journey, not just simple code)
- **DRY applies to BOTH** code AND interface patterns (consistent behavior across CLI/Dashboard)
## Common Mistakes (AVOID)
@@ -89,6 +101,20 @@ bun run validate # Step 3: Final check (must pass)
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
6. **Idempotent** - All install operations safe to run multiple times
7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard
## Feature Interface Requirements
| Feature Type | CLI | Dashboard | Example |
|--------------|-----|-----------|---------|
| Profile creation | ✓ | ✓ | `ccs auth create`, Dashboard "Add Account" |
| Profile switching | ✓ | ✓ | `ccs <profile>` (execution is CLI-only) |
| API key config | ✓ | ✓ | `ccs api create`, Dashboard API Profiles |
| Health check | ✓ | ✓ | `ccs doctor`, Dashboard Live Monitor |
| OAuth auth flow | ✓ | ✓ | Browser opens from CLI or Dashboard |
| Analytics/monitoring | ✗ | ✓ | Dashboard Analytics (visual by nature) |
| WebSearch config | ✓ | ✓ | CLI flags, Dashboard Settings |
| Remote proxy config | ✓ | ✓ | CLI flags, Dashboard Settings |
## File Structure
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.8.0",
"version": "7.8.0-dev.4",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+4 -4
View File
@@ -7,7 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfig } from '../../utils/config-manager';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
@@ -20,7 +20,7 @@ export function apiProfileExists(name: string): boolean {
const config = loadOrCreateUnifiedConfig();
return name in config.profiles;
}
const config = loadConfig();
const config = loadConfigSafe();
return name in config.profiles;
} catch {
return false;
@@ -79,7 +79,7 @@ export function listApiProfiles(): ApiListResult {
});
}
} else {
const config = loadConfig();
const config = loadConfigSafe();
for (const [name, settingsPath] of Object.entries(config.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
@@ -116,7 +116,7 @@ export function getApiProfileNames(): string[] {
const config = loadOrCreateUnifiedConfig();
return Object.keys(config.profiles);
}
const config = loadConfig();
const config = loadConfigSafe();
return Object.keys(config.profiles);
}
+2 -2
View File
@@ -5,7 +5,7 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import {
loadOrCreateUnifiedConfig,
@@ -166,7 +166,7 @@ function removeApiProfileUnified(name: string): void {
/** Remove API profile from legacy config */
function removeApiProfileLegacy(name: string): void {
const config = loadConfig();
const config = loadConfigSafe();
delete config.profiles[name];
const configPath = getConfigPath();
+18 -10
View File
@@ -291,6 +291,24 @@ async function main(): Promise<void> {
await autoMigrate();
}
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
// This ensures ALL commands benefit from auto-recovery, not just profile-switching flow
// Recovery is safe to run early - it only creates missing files with safe defaults
// Wrapped in try-catch to prevent blocking --version/--help on permission errors
try {
const RecoveryManagerModule = await import('./management/recovery-manager');
const RecoveryManager = RecoveryManagerModule.default;
const recovery = new RecoveryManager();
const recovered = recovery.recoverAll();
if (recovered) {
recovery.showRecoveryHints();
}
} catch (err) {
// Recovery is best-effort - don't block basic CLI functionality
console.warn('[!] Recovery failed:', (err as Error).message);
}
// Special case: version command (check BEFORE profile detection)
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
handleVersionCommand();
@@ -455,16 +473,6 @@ async function main(): Promise<void> {
return;
}
// Auto-recovery for missing configuration
const RecoveryManagerModule = await import('./management/recovery-manager');
const RecoveryManager = RecoveryManagerModule.default;
const recovery = new RecoveryManager();
const recovered = recovery.recoverAll();
if (recovered) {
recovery.showRecoveryHints();
}
// First-time install: offer setup wizard for interactive users
// Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment
+2
View File
@@ -173,4 +173,6 @@ export interface OAuthOptions {
fromUI?: boolean;
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
noIncognito?: boolean;
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
import?: boolean;
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Kiro Import Helper
*
* Imports Kiro token from Kiro IDE when OAuth callback redirects to IDE instead of CLI.
* Spawns cli-proxy-api-plus --kiro-import to import token from Kiro IDE's storage.
*/
import { spawn } from 'child_process';
import { info, ok, fail } from '../../utils/ui';
import { ensureCLIProxyBinary } from '../binary-manager';
import { generateConfig } from '../config-generator';
import { getProviderTokenDir } from './token-manager';
export interface KiroImportResult {
success: boolean;
provider?: string;
email?: string;
error?: string;
}
/**
* Try to import Kiro token from Kiro IDE
* Uses cli-proxy-api-plus --kiro-import flag
*/
export async function tryKiroImport(tokenDir: string, verbose = false): Promise<KiroImportResult> {
const log = (msg: string) => {
if (verbose) console.error(`[kiro-import] ${msg}`);
};
try {
log('Ensuring CLIProxy binary is available...');
const binaryPath = await ensureCLIProxyBinary(verbose);
const configPath = generateConfig('kiro');
log(`Binary: ${binaryPath}`);
log(`Config: ${configPath}`);
log(`Token dir: ${tokenDir}`);
return new Promise<KiroImportResult>((resolve) => {
const args = ['--config', configPath, '--kiro-import'];
log(`Running: ${binaryPath} ${args.join(' ')}`);
const proc = spawn(binaryPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
});
let stdout = '';
let stderr = '';
let resolved = false;
const safeResolve = (result: KiroImportResult) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
resolve(result);
};
proc.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
stdout += output;
log(`stdout: ${output.trim()}`);
});
proc.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
stderr += output;
log(`stderr: ${output.trim()}`);
});
proc.on('exit', (code) => {
log(`Exit code: ${code}`);
if (code === 0) {
// Parse output for provider info
const providerMatch = stdout.match(/Provider:\s*(\w+)/i);
const emailMatch = stdout.match(/email[:\s]+([^\s,)]+)/i);
const successMatch =
stdout.includes('Kiro token import successful') ||
stdout.includes('Imported Kiro token') ||
stdout.includes('Authentication saved');
if (successMatch) {
safeResolve({
success: true,
provider: providerMatch?.[1],
email: emailMatch?.[1],
});
} else {
safeResolve({
success: false,
error: 'Import completed but token not confirmed',
});
}
} else {
const errorLine = stderr.trim().split('\n')[0] || stdout.trim().split('\n')[0];
safeResolve({
success: false,
error: errorLine || `Exit code ${code}`,
});
}
});
proc.on('error', (error) => {
log(`Process error: ${error.message}`);
safeResolve({
success: false,
error: error.message,
});
});
// Timeout after 30 seconds
const timeoutId = setTimeout(() => {
if (!resolved && !proc.killed) {
proc.kill();
safeResolve({
success: false,
error: 'Import timed out after 30 seconds',
});
}
}, 30000);
});
} catch (error) {
log(`Error: ${(error as Error).message}`);
return {
success: false,
error: (error as Error).message,
};
}
}
/**
* Import Kiro token with user-facing output
* Shows progress and result to user
*/
export async function importKiroToken(verbose = false): Promise<boolean> {
const tokenDir = getProviderTokenDir('kiro');
console.log('');
console.log(info('Importing token from Kiro IDE...'));
const result = await tryKiroImport(tokenDir, verbose);
if (result.success) {
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
return true;
}
console.log(fail(`Import failed: ${result.error}`));
console.log('');
console.log('Make sure you are logged into Kiro IDE first:');
console.log(' 1. Open Kiro IDE');
console.log(' 2. Sign in with your AWS/Google account');
console.log(' 3. Run: ccs kiro --import');
return false;
}
+13 -1
View File
@@ -27,8 +27,9 @@ import {
} from '../../management/oauth-port-diagnostics';
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
import { getProviderTokenDir, isAuthenticated } from './token-manager';
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
/**
* Prompt user to add another account
@@ -127,6 +128,17 @@ export async function triggerOAuth(
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options;
// Handle --import flag: skip OAuth and import from Kiro IDE directly
if (options.import && provider === 'kiro') {
const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose);
if (success) {
return registerAccountFromToken(provider, tokenDir, nickname);
}
return null;
}
const callbackPort = OAUTH_PORTS[provider];
const isCLI = !fromUI;
const headless = options.headless ?? isHeadlessEnvironment();
+42 -5
View File
@@ -6,7 +6,8 @@
*/
import { spawn, ChildProcess } from 'child_process';
import { ok, fail, info } from '../../utils/ui';
import { ok, fail, info, warn } from '../../utils/ui';
import { tryKiroImport } from './kiro-import';
import { CLIProxyProvider } from '../types';
import { AccountInfo } from '../account-manager';
import {
@@ -205,7 +206,35 @@ function displayUrlFromStderr(
}
/** Handle token not found after successful process exit */
function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number | null): void {
async function handleTokenNotFound(
provider: CLIProxyProvider,
callbackPort: number | null,
tokenDir: string,
nickname: string | undefined,
verbose: boolean
): Promise<AccountInfo | null> {
// Kiro-specific: Try auto-import from Kiro IDE
if (provider === 'kiro') {
console.log('');
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
const result = await tryKiroImport(tokenDir, verbose);
if (result.success) {
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
return registerAccountFromToken(provider, tokenDir, nickname);
}
console.log(fail(`Auto-import failed: ${result.error}`));
console.log('');
console.log('To manually import from Kiro IDE:');
console.log(' 1. Ensure you are logged into Kiro IDE');
console.log(' 2. Run: ccs kiro --import');
return null;
}
// Default behavior for other providers
console.log('');
console.log(fail('Token not found after authentication'));
console.log('');
@@ -225,6 +254,7 @@ function handleTokenNotFound(provider: CLIProxyProvider, callbackPort: number |
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
/** Handle process exit with error */
@@ -356,7 +386,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
resolve(null);
}, timeoutMs);
authProcess.on('exit', (code) => {
authProcess.on('exit', async (code) => {
clearTimeout(timeout);
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
@@ -383,8 +413,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
});
}
handleTokenNotFound(provider, callbackPort);
resolve(null);
// Try auto-import for Kiro, show error for others
const account = await handleTokenNotFound(
provider,
callbackPort,
tokenDir,
nickname,
verbose
);
resolve(account);
}
} else {
// Emit device code failure event for UI
+24 -2
View File
@@ -5,10 +5,12 @@
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
*/
import { info } from '../utils/ui';
import { getBinDir } from './config-generator';
import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
import { BinaryInfo, BinaryManagerConfig } from './types';
import { CLIPROXY_FALLBACK_VERSION } from './platform-detector';
import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service';
import { waitForPortFree } from '../utils/port-utils';
import {
UpdateCheckResult,
checkForUpdates,
@@ -108,12 +110,32 @@ export function getInstalledCliproxyVersion(): string {
/** Install a specific version of CLIProxyAPI */
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
const manager = new BinaryManager({ version, verbose, forceVersion: true });
// Check if proxy is running and stop it first
if (isProxyRunning()) {
if (verbose) console.log(info('Stopping running CLIProxy before update...'));
const result = await stopProxy();
if (result.stopped) {
// Wait for port to be fully released
const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000);
if (!portFree && verbose) {
console.log(warn('Port did not free up in time, proceeding anyway...'));
}
} else if (verbose && result.error) {
console.log(warn(`Could not stop proxy: ${result.error}`));
}
}
if (manager.isBinaryInstalled()) {
if (verbose)
console.log(info(`Removing existing CLIProxy Plus v${getInstalledCliproxyVersion()}`));
manager.deleteBinary();
}
await manager.ensureBinary();
if (verbose) {
console.log(info('New version will be active on next CLIProxy command'));
}
}
/** Fetch the latest CLIProxyAPI version from GitHub API */
+73 -6
View File
@@ -17,7 +17,7 @@ import * as net from 'net';
import { ProgressIndicator } from '../utils/progress-indicator';
import { ok, fail, info, warn } from '../utils/ui';
import { escapeShellArg } from '../utils/shell-executor';
import { ensureCLIProxyBinary } from './binary-manager';
import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager';
import {
generateConfig,
getEffectiveEnvVars,
@@ -48,7 +48,12 @@ import {
installWebSearchHook,
displayWebSearchStatus,
} from '../utils/websearch-manager';
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
import {
registerSession,
unregisterSession,
cleanupOrphanedSessions,
stopProxy,
} from './session-tracker';
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
@@ -250,6 +255,8 @@ export async function execClaudeWithCLIProxy(
const forceConfig = argsWithoutProxy.includes('--config');
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
// Kiro-specific: --import to import token from Kiro IDE directly
const forceImport = argsWithoutProxy.includes('--import');
// Kiro-specific: browser mode for OAuth
// Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux
// --incognito flag opts into incognito mode, --no-incognito is legacy (now default)
@@ -362,6 +369,38 @@ export async function execClaudeWithCLIProxy(
process.exit(0);
}
// Handle --import: import token from Kiro IDE directly (Kiro only)
if (forceImport) {
if (provider !== 'kiro') {
console.error(fail('--import is only available for Kiro'));
console.error(` Run "ccs ${provider} --auth" to authenticate`);
process.exit(1);
}
// Validate flag conflicts
if (forceAuth) {
console.error(fail('Cannot use --import with --auth'));
console.error(' --import: Import existing token from Kiro IDE');
console.error(' --auth: Trigger new OAuth flow in browser');
process.exit(1);
}
if (forceLogout) {
console.error(fail('Cannot use --import with --logout'));
process.exit(1);
}
const { triggerOAuth } = await import('./auth-handler');
const authSuccess = await triggerOAuth(provider, {
verbose,
import: true,
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
console.error(fail('Failed to import Kiro token from IDE'));
console.error(' Make sure you are logged into Kiro IDE first');
process.exit(1);
}
process.exit(0);
}
// 3. Ensure OAuth completed (if provider requires it)
if (providerConfig.requiresOAuth) {
log(`Checking authentication for ${provider}`);
@@ -429,9 +468,33 @@ export async function execClaudeWithCLIProxy(
// Use startup lock to coordinate with other CCS processes
await withStartupLock(async () => {
// Detect running proxy using multiple methods (HTTP, session-lock, port-process)
const proxyStatus = await detectRunningProxy(cfg.port);
let proxyStatus = await detectRunningProxy(cfg.port);
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
// Check for version mismatch - restart proxy if installed version differs from running
if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) {
const installedVersion = getInstalledCliproxyVersion();
if (installedVersion !== proxyStatus.version) {
console.log(
warn(
`Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...`
)
);
log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`);
const stopResult = await stopProxy(cfg.port);
if (stopResult.stopped) {
log(`Stopped outdated proxy successfully`);
} else {
log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`);
}
// Wait for port to be released
await new Promise((r) => setTimeout(r, 500));
// Re-detect proxy status (should now be not running)
proxyStatus = await detectRunningProxy(cfg.port);
log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`);
}
}
if (proxyStatus.running && proxyStatus.verified) {
// Healthy proxy found - join it
if (proxyStatus.pid) {
@@ -542,9 +605,12 @@ export async function execClaudeWithCLIProxy(
throw new Error(`CLIProxy startup failed: ${err.message}`);
}
// Register this session with the new proxy
sessionId = registerSession(cfg.port, proxy.pid as number);
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
// Register this session with the new proxy, including the installed version
const installedVersion = getInstalledCliproxyVersion();
sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion);
log(
`Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})`
);
});
}
@@ -604,6 +670,7 @@ export async function execClaudeWithCLIProxy(
'--nickname',
'--incognito',
'--no-incognito',
'--import',
// Proxy flags are handled by resolveProxyConfig, but list for documentation
...PROXY_CLI_FLAGS,
];
+4 -3
View File
@@ -129,9 +129,10 @@ export function getConfigPathForPort(port: number): string {
}
/**
* Get config file path (default port)
* Get CLIProxy config file path (default port)
* Named distinctly from config-manager's getConfigPath to avoid confusion.
*/
export function getConfigPath(): string {
export function getCliproxyConfigPath(): string {
return getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
}
@@ -377,7 +378,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
* @returns true if config should be regenerated
*/
export function configNeedsRegeneration(): boolean {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
return false; // Will be created on first use
}
+1 -1
View File
@@ -65,7 +65,7 @@ export {
getCliproxyDir,
getProviderAuthDir,
getAuthDir,
getConfigPath,
getCliproxyConfigPath,
getBinDir,
configExists,
deleteConfig,
+3 -3
View File
@@ -7,7 +7,7 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { getConfigPath } from './config-generator';
import { getCliproxyConfigPath } from './config-generator';
/** Model alias configuration */
export interface OpenAICompatModel {
@@ -48,7 +48,7 @@ interface ConfigYaml {
* Load current config.yaml
*/
function loadConfig(): ConfigYaml {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
return {};
}
@@ -65,7 +65,7 @@ function loadConfig(): ConfigYaml {
* Save config.yaml with proper formatting
*/
function saveConfig(config: ConfigYaml): void {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
const content = yaml.dump(config, {
lineWidth: -1, // Disable line wrapping
quotingType: '"',
+15 -3
View File
@@ -15,7 +15,7 @@
* Solves race conditions between cliproxy-executor.ts and service-manager.ts
*/
import { getExistingProxy, registerSession } from './session-tracker';
import { getExistingProxy, registerSession, getRunningProxyVersion } from './session-tracker';
import { isCliproxyRunning } from './stats-fetcher';
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
@@ -38,6 +38,8 @@ export interface ProxyStatus {
blocker?: PortProcess;
/** Number of active sessions (if session-lock found) */
sessionCount?: number;
/** Version of the running proxy (from session lock) */
version?: string;
}
/** Optional logger function for verbose output */
@@ -86,13 +88,19 @@ export async function detectRunningProxy(
}
}
log(`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'})`);
// Get version from session lock
const runningVersion = getRunningProxyVersion(port);
log(
`HTTP check passed, proxy healthy (PID: ${pid ?? 'unknown'}, version: ${runningVersion ?? 'unknown'})`
);
return {
running: true,
verified: true,
method: 'http',
pid,
sessionCount: lock?.sessions?.length,
version: runningVersion ?? undefined,
};
}
log('HTTP check failed, proxy not responding');
@@ -103,13 +111,17 @@ export async function detectRunningProxy(
if (lock) {
// Session lock exists - proxy might be starting up
// The lock validates PID is running, so proxy exists but not ready
log(`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions`);
const lockVersion = getRunningProxyVersion(port);
log(
`Session lock found: PID ${lock.pid}, ${lock.sessions.length} sessions, version: ${lockVersion ?? 'unknown'}`
);
return {
running: true,
verified: false,
method: 'session-lock',
pid: lock.pid,
sessionCount: lock.sessions.length,
version: lockVersion ?? undefined,
};
}
log('No session lock found');
@@ -5,7 +5,7 @@
*/
import * as fs from 'fs';
import { getConfigPath, loadConfig } from '../../utils/config-manager';
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { CLIProxyProvider } from '../types';
import {
loadOrCreateUnifiedConfig,
@@ -38,7 +38,7 @@ export function variantExistsInConfig(name: string): boolean {
const config = loadOrCreateUnifiedConfig();
return !!(config.cliproxy?.variants && name in config.cliproxy.variants);
}
const config = loadConfig();
const config = loadConfigSafe();
return !!(config.cliproxy && name in config.cliproxy);
} catch {
return false;
@@ -94,7 +94,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
return result;
}
const config = loadConfig();
const config = loadConfigSafe();
const variants = config.cliproxy || {};
const result: Record<string, VariantConfig> = {};
for (const name of Object.keys(variants)) {
+71 -1
View File
@@ -27,6 +27,8 @@ interface SessionLock {
pid: number;
sessions: string[];
startedAt: string;
/** CLIProxy version running (added for version mismatch detection) */
version?: string;
}
/** Generate unique session ID */
@@ -125,6 +127,23 @@ function isProcessRunning(pid: number): boolean {
}
}
/**
* Wait for a process to exit within a timeout.
* @param pid Process ID to wait for
* @param timeoutMs Maximum time to wait in milliseconds
* @returns true if process exited, false if timeout
*/
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!isProcessRunning(pid)) {
return true; // Process exited
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false; // Timeout
}
/**
* Check if there's an existing proxy running that we can reuse.
* Returns the existing lock if proxy is healthy, null otherwise.
@@ -153,9 +172,12 @@ export function getExistingProxy(port: number): SessionLock | null {
/**
* Register a new session with the proxy.
* Call this when starting a new CCS session that will use an existing proxy.
* @param port Port the proxy is running on
* @param proxyPid PID of the proxy process
* @param version Optional CLIProxy version (stored when spawning new proxy)
* @returns Session ID for this session
*/
export function registerSession(port: number, proxyPid: number): string {
export function registerSession(port: number, proxyPid: number, version?: string): string {
const sessionId = generateSessionId();
const existingLock = readSessionLockForPort(port);
@@ -170,6 +192,7 @@ export function registerSession(port: number, proxyPid: number): string {
pid: proxyPid,
sessions: [sessionId],
startedAt: new Date().toISOString(),
version,
};
writeSessionLockForPort(newLock);
}
@@ -315,6 +338,19 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
// Found CLIProxy running without session lock - kill it
try {
process.kill(portProcess.pid, 'SIGTERM');
// Wait for graceful shutdown
const exited = await waitForProcessExit(portProcess.pid, 3000);
if (!exited) {
// Escalate to SIGKILL
try {
process.kill(portProcess.pid, 'SIGKILL');
await waitForProcessExit(portProcess.pid, 1000);
} catch {
// Process may have exited between check and kill
}
}
return { stopped: true, pid: portProcess.pid, sessionCount: 0 };
} catch (err) {
const error = err as NodeJS.ErrnoException;
@@ -338,6 +374,18 @@ export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
// Kill the proxy process
process.kill(pid, 'SIGTERM');
// Wait for graceful shutdown
const exited = await waitForProcessExit(pid, 3000);
if (!exited) {
// Escalate to SIGKILL
try {
process.kill(pid, 'SIGKILL');
await waitForProcessExit(pid, 1000);
} catch {
// Process may have exited between check and kill
}
}
// Clean up session lock
deleteSessionLockForPort(port);
@@ -362,6 +410,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
pid?: number;
sessionCount?: number;
startedAt?: string;
version?: string;
} {
const lock = readSessionLockForPort(port);
@@ -381,5 +430,26 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
pid: lock.pid,
sessionCount: lock.sessions.length,
startedAt: lock.startedAt,
version: lock.version,
};
}
/**
* Get the version of the running proxy from session lock.
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
* @returns Version string if available, null otherwise
*/
export function getRunningProxyVersion(port: number = CLIPROXY_DEFAULT_PORT): string | null {
const lock = readSessionLockForPort(port);
if (!lock) {
return null;
}
// Verify proxy is still running
if (!isProcessRunning(lock.pid)) {
deleteSessionLockForPort(port);
return null;
}
return lock.version ?? null;
}
+3 -1
View File
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
import { isUnifiedMode } from '../config/unified-config-loader';
// Get version from package.json (same as version-command.ts)
const VERSION = JSON.parse(
@@ -173,6 +174,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs <provider> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs kiro --import', 'Import token from Kiro IDE'],
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
['ccs codex "explain code"', 'Use with prompt'],
]
@@ -234,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// Configuration
printConfigSection('Configuration', [
['Config File:', '~/.ccs/config.json'],
['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'],
['Profiles:', '~/.ccs/profiles.json'],
['Instances:', '~/.ccs/instances/'],
['Settings:', '~/.ccs/*.settings.json'],
+2 -2
View File
@@ -8,7 +8,7 @@ import {
isCLIProxyInstalled,
getCLIProxyPath,
getAllAuthStatus,
getConfigPath,
getCliproxyConfigPath,
getInstalledCliproxyVersion,
CLIPROXY_DEFAULT_PORT,
configNeedsRegeneration,
@@ -60,7 +60,7 @@ export class CLIProxyConfigChecker implements IHealthChecker {
run(results: HealthCheck): void {
const spinner = ora('Checking CLIProxy config').start();
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (fs.existsSync(configPath)) {
// Check if config needs regeneration (version mismatch or missing features)
+4 -4
View File
@@ -17,10 +17,10 @@ export interface ProfilesConfig {
* Example: "flash" → gemini provider with gemini-2.5-flash model
*/
export interface CLIProxyVariantConfig {
/** CLIProxy provider to use (gemini, codex, agy, qwen) */
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
/** Path to settings.json with custom model configuration */
settings: string;
/** CLIProxy provider to use */
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
/** Path to settings.json with custom model configuration (optional) */
settings?: string;
/** Account identifier for multi-account support (optional, defaults to 'default') */
account?: string;
/** Unique port for variant isolation (8318-8417) */
+1
View File
@@ -12,6 +12,7 @@ export type {
EnvValue,
ProfileMetadata,
ProfilesRegistry,
CLIProxyVariantsConfig,
} from './config';
export { isConfig, isSettings } from './config';
+94 -12
View File
@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { Config, isConfig, Settings, isSettings } from '../types';
import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types';
import { expandPath, error } from './helpers';
import { info } from './ui';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
@@ -27,12 +27,26 @@ export function getCcsDir(): string {
}
/**
* Get config file path
* Get config file path (legacy JSON path)
* @deprecated Use getActiveConfigPath() for mode-aware config path
*/
export function getConfigPath(): string {
return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json');
}
/**
* Get the active config file path based on current mode.
* Returns config.yaml in unified mode, config.json in legacy mode.
* @returns Path to the active config file
*/
export function getActiveConfigPath(): string {
const ccsDir = getCcsDir();
if (isUnifiedMode()) {
return path.join(ccsDir, 'config.yaml');
}
return path.join(ccsDir, 'config.json');
}
/**
* Load and validate config.json
*/
@@ -82,6 +96,69 @@ export function readConfig(): Config {
return loadConfig();
}
/**
* Load config safely with unified mode support.
* Returns Config with profiles from unified config.yaml or legacy config.json.
* Throws on error (catchable) instead of process.exit.
* Use this in web server routes where try/catch is needed.
*/
export function loadConfigSafe(): Config {
// Unified mode: extract profiles from config.yaml
if (isUnifiedMode()) {
const unifiedConfig = loadOrCreateUnifiedConfig();
// Convert unified profiles to legacy format for compatibility
const profiles: Record<string, string> = {};
for (const [name, profile] of Object.entries(unifiedConfig.profiles)) {
if (profile.settings) {
profiles[name] = profile.settings;
}
}
// Convert unified cliproxy variants to legacy format
let cliproxy: CLIProxyVariantsConfig | undefined;
if (unifiedConfig.cliproxy?.variants) {
cliproxy = {};
for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) {
cliproxy[name] = {
provider: variant.provider,
settings: variant.settings,
account: variant.account,
port: variant.port,
};
}
}
return {
profiles,
cliproxy,
};
}
// Legacy mode: read config.json
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
// Return empty config for graceful degradation (matches unified mode behavior)
return { profiles: {} };
}
const raw = fs.readFileSync(configPath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error(`Malformed JSON in config: ${configPath} - ${(e as Error).message}`);
}
if (!isConfig(parsed)) {
throw new Error(`Invalid config format: ${configPath}`);
}
return parsed;
}
/**
* Get settings path for profile.
* In unified mode (config.yaml exists), reads from config.yaml first,
@@ -106,19 +183,24 @@ export function getSettingsPath(profile: string): string {
// If not found in unified config, try legacy config.json as fallback
if (!settingsPath) {
try {
const legacyConfig = loadConfig();
if (legacyConfig.profiles[profile]) {
settingsPath = legacyConfig.profiles[profile];
// Merge legacy profiles into available list (avoid duplicates)
for (const p of Object.keys(legacyConfig.profiles)) {
if (!availableProfiles.includes(p)) {
availableProfiles.push(p);
// Use inline safe logic - loadConfig() calls process.exit() which cannot be caught
const configPath = getConfigPath();
if (fs.existsSync(configPath)) {
try {
const raw = fs.readFileSync(configPath, 'utf8');
const legacyConfig: unknown = JSON.parse(raw);
if (isConfig(legacyConfig) && legacyConfig.profiles[profile]) {
settingsPath = legacyConfig.profiles[profile];
// Merge legacy profiles into available list (avoid duplicates)
for (const p of Object.keys(legacyConfig.profiles)) {
if (!availableProfiles.includes(p)) {
availableProfiles.push(p);
}
}
}
} catch {
// Legacy config is invalid JSON - that's OK in unified mode
}
} catch {
// Legacy config doesn't exist or is invalid - that's OK in unified mode
}
}
} else {
+26
View File
@@ -181,6 +181,32 @@ export interface BindingTestResult {
message: string;
}
/**
* Wait for a port to become free (no process listening)
* @param port Port number to wait for
* @param timeoutMs Maximum time to wait in milliseconds (default: 5000)
* @param pollIntervalMs Interval between checks in milliseconds (default: 200)
* @returns True if port became free, false if timeout
*/
export async function waitForPortFree(
port: number,
timeoutMs = 5000,
pollIntervalMs = 200
): Promise<boolean> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const process = await getPortProcess(port);
if (!process) {
return true; // Port is free
}
// Wait before next check
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
return false; // Timeout reached
}
/**
* Test if we can bind to localhost on a specific port
* This verifies network stack is working and port is actually available
+1 -1
View File
@@ -9,7 +9,7 @@ import {
isCLIProxyInstalled,
getInstalledCliproxyVersion,
getCLIProxyPath,
getConfigPath as getCliproxyConfigPath,
getCliproxyConfigPath,
getAllAuthStatus,
CLIPROXY_DEFAULT_PORT,
} from '../../cliproxy';
+2 -2
View File
@@ -5,7 +5,7 @@
*/
import { Router, Request, Response } from 'express';
import { loadConfig } from '../utils/config-manager';
import { loadConfigSafe } from '../utils/config-manager';
import { runHealthChecks } from './health-service';
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
import { getVersion } from '../utils/version';
@@ -18,7 +18,7 @@ export const overviewRoutes = Router();
*/
overviewRoutes.get('/', async (_req: Request, res: Response) => {
try {
const config = loadConfig();
const config = loadConfigSafe();
const profileCount = Object.keys(config.profiles).length;
const cliproxyVariantCount = Object.keys(config.cliproxy || {}).length;
+46
View File
@@ -82,4 +82,50 @@ router.post('/default', (req: Request, res: Response): void => {
}
});
/**
* DELETE /api/accounts/reset-default - Reset to CCS default
*/
router.delete('/reset-default', (_req: Request, res: Response): void => {
try {
if (isUnifiedMode()) {
registry.clearDefaultUnified();
} else {
registry.clearDefaultProfile();
}
res.json({ success: true, default: null });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* DELETE /api/accounts/:name - Delete an account
*/
router.delete('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({ error: 'Missing account name' });
return;
}
// Check if trying to delete default
const currentDefault = registry.getDefaultUnified() ?? registry.getDefaultProfile();
if (name === currentDefault) {
res
.status(400)
.json({ error: 'Cannot delete the default account. Set a different default first.' });
return;
}
// Delete the profile
registry.deleteProfile(name);
res.json({ success: true, deleted: name });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
@@ -24,6 +24,8 @@ import {
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
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 type { CLIProxyProvider } from '../../cliproxy/types';
const router = Router();
@@ -350,4 +352,52 @@ router.post('/project-selection/:sessionId', (req: Request, res: Response): void
}
});
/**
* POST /api/cliproxy/auth/kiro/import - Import Kiro token from Kiro IDE
* Alternative auth path when OAuth callback fails to redirect properly
*/
router.post('/kiro/import', async (_req: Request, res: Response): Promise<void> => {
// Check if remote mode is enabled - import not available remotely
const target = getProxyTarget();
if (target.isRemote) {
res.status(501).json({
error: 'Kiro import not available in remote mode',
});
return;
}
try {
const tokenDir = getProviderTokenDir('kiro');
const result = await tryKiroImport(tokenDir, false);
if (result.success) {
// Re-initialize accounts to pick up new token
initializeAccounts();
// Get the newly added account
const accounts = getProviderAccounts('kiro');
const newAccount = accounts.find((a) => a.isDefault) || accounts[0];
res.json({
success: true,
account: newAccount
? {
id: newAccount.id,
email: newAccount.email,
provider: 'kiro',
isDefault: newAccount.isDefault,
}
: null,
});
} else {
res.status(400).json({
success: false,
error: result.error || 'Failed to import Kiro token',
});
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
@@ -14,7 +14,7 @@ import {
} from '../../cliproxy/stats-fetcher';
import {
getCliproxyWritablePath,
getConfigPath,
getCliproxyConfigPath,
getAuthDir,
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
@@ -272,7 +272,7 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
*/
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
try {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
@@ -299,7 +299,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise<void> =>
return;
}
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
// Ensure parent directory exists
const configDir = path.dirname(configPath);
+4 -3
View File
@@ -4,7 +4,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager';
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import type { Config, Settings } from '../../types/config';
@@ -17,11 +17,12 @@ export interface ModelMapping {
}
/**
* Read config safely with fallback
* Read config safely with fallback.
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json).
*/
export function readConfigSafe(): Config {
try {
return loadConfig();
return loadConfigSafe();
} catch {
return { profiles: {} };
}
@@ -19,7 +19,7 @@ process.env.CCS_HOME = testHome;
const {
getConfigPathForPort,
getConfigPath,
getCliproxyConfigPath,
generateConfig,
regenerateConfig,
configExists,
@@ -98,9 +98,9 @@ describe('Config Generator Port', function () {
});
});
describe('getConfigPath', function () {
describe('getCliproxyConfigPath', function () {
it('returns path for default port', function () {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
assert.strictEqual(configPath, defaultPath);
});
+2 -2
View File
@@ -362,7 +362,7 @@ auth-dir: "/test"
let testDir;
let originalCcsHome;
let regenerateConfig;
let getConfigPath;
let getCliproxyConfigPath;
beforeEach(() => {
// Create a temporary test directory
@@ -375,7 +375,7 @@ auth-dir: "/test"
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
const configGenerator = require('../../../dist/cliproxy/config-generator');
regenerateConfig = configGenerator.regenerateConfig;
getConfigPath = configGenerator.getConfigPath;
getCliproxyConfigPath = configGenerator.getCliproxyConfigPath;
});
afterEach(() => {
@@ -27,14 +27,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
let httpsRequests = [];
beforeAll(async function () {
// Build the project first
const { execSync } = require('child_process');
try {
execSync('bun run build', { cwd: path.resolve(__dirname, '../../..'), stdio: 'pipe' });
} catch (error) {
console.warn('Build failed, tests may not work:', error.message);
}
// Note: Build is handled by CI before tests run (bun run build:all)
// Import the built module
updateCheckerModule = await import('../../../dist/utils/update-checker.js');
});
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write src/ tests/",
+132 -53
View File
@@ -1,8 +1,9 @@
/**
* Accounts Table Component
* Phase 03: REST API Routes & CRUD
* Dashboard parity: Full CRUD for auth profiles
*/
import { useState } from 'react';
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
import {
Table,
@@ -13,17 +14,35 @@ import {
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Check } from 'lucide-react';
import { useSetDefaultAccount } from '@/hooks/use-accounts';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Check, Trash2, RotateCcw } from 'lucide-react';
import {
useSetDefaultAccount,
useDeleteAccount,
useResetDefaultAccount,
} from '@/hooks/use-accounts';
import type { Account } from '@/lib/api-client';
interface AccountsTableProps {
data: Account[];
defaultAccount: string | null;
onRefresh?: () => void;
}
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const setDefaultMutation = useSetDefaultAccount();
const deleteMutation = useDeleteAccount();
const resetDefaultMutation = useResetDefaultAccount();
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const columns: ColumnDef<Account>[] = [
{
@@ -71,20 +90,34 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
{
id: 'actions',
header: 'Actions',
size: 100,
size: 180,
cell: ({ row }) => {
const isDefault = row.original.name === defaultAccount;
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
return (
<Button
variant={isDefault ? 'secondary' : 'default'}
size="sm"
className="h-8 px-2 w-full"
disabled={isDefault || setDefaultMutation.isPending}
onClick={() => setDefaultMutation.mutate(row.original.name)}
>
<Check className={`w-3 h-3 mr-1.5 ${isDefault ? 'opacity-50' : ''}`} />
{isDefault ? 'Active' : 'Set Default'}
</Button>
<div className="flex items-center gap-1">
<Button
variant={isDefault ? 'secondary' : 'default'}
size="sm"
className="h-8 px-2"
disabled={isDefault || isPending}
onClick={() => setDefaultMutation.mutate(row.original.name)}
>
<Check className={`w-3 h-3 mr-1 ${isDefault ? 'opacity-50' : ''}`} />
{isDefault ? 'Active' : 'Set Default'}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={isDefault || isPending}
onClick={() => setDeleteTarget(row.original.name)}
title={isDefault ? 'Cannot delete default account' : 'Delete account'}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
},
},
@@ -100,51 +133,97 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No accounts found. Use <code className="text-sm bg-muted px-1 rounded">ccs login</code> to
add accounts.
No accounts found. Use{' '}
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
</div>
);
}
return (
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const widthClass =
{
name: 'w-[200px]',
type: 'w-[100px]',
created: 'w-[150px]',
last_used: 'w-[150px]',
actions: 'w-[100px]',
}[header.id] || 'w-auto';
<>
<div className="space-y-4">
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const widthClass =
{
name: 'w-[200px]',
type: 'w-[100px]',
created: 'w-[150px]',
last_used: 'w-[150px]',
actions: 'w-[180px]',
}[header.id] || 'w-auto';
return (
<TableHead key={header.id} className={widthClass}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
return (
<TableHead key={header.id} className={widthClass}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Reset default button */}
{defaultAccount && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetDefaultMutation.mutate()}
disabled={resetDefaultMutation.isPending}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset to CCS Default
</Button>
</div>
)}
</div>
{/* Delete confirmation dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Account</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the account &quot;{deleteTarget}&quot;? This will
remove the profile and all its session data. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget);
setDeleteTarget(null);
}
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -2,6 +2,7 @@
* Add Account Dialog Component
* Triggers OAuth flow server-side to add another account to a provider
* Applies default preset when adding first account
* For Kiro: Also shows "Import from IDE" option as fallback
*/
import { useState } from 'react';
@@ -15,8 +16,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, ExternalLink, User } from 'lucide-react';
import { useStartAuth } from '@/hooks/use-cliproxy';
import { Loader2, ExternalLink, User, Download } from 'lucide-react';
import { useStartAuth, useKiroImport } from '@/hooks/use-cliproxy';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { toast } from 'sonner';
@@ -38,6 +39,10 @@ export function AddAccountDialog({
}: AddAccountDialogProps) {
const [nickname, setNickname] = useState('');
const startAuthMutation = useStartAuth();
const kiroImportMutation = useKiroImport();
const isKiro = provider === 'kiro';
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
const handleStartAuth = () => {
startAuthMutation.mutate(
@@ -60,8 +65,24 @@ export function AddAccountDialog({
);
};
const handleKiroImport = () => {
kiroImportMutation.mutate(undefined, {
onSuccess: async () => {
// Apply default preset if this is the first account
if (isFirstAccount) {
const result = await applyDefaultPreset('kiro');
if (result.success && result.presetName) {
toast.success(`Applied "${result.presetName}" preset`);
}
}
setNickname('');
onClose();
},
});
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen && !startAuthMutation.isPending) {
if (!isOpen && !isPending) {
setNickname('');
onClose();
}
@@ -73,8 +94,9 @@ export function AddAccountDialog({
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogDescription>
Click the button below to authenticate a new account. A browser window will open for
OAuth.
{isKiro
? 'Authenticate via browser or import an existing token from Kiro IDE.'
: 'Click the button below to authenticate a new account. A browser window will open for OAuth.'}
</DialogDescription>
</DialogHeader>
@@ -88,7 +110,7 @@ export function AddAccountDialog({
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="e.g., work, personal"
disabled={startAuthMutation.isPending}
disabled={isPending}
className="flex-1"
/>
</div>
@@ -98,10 +120,25 @@ export function AddAccountDialog({
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose} disabled={startAuthMutation.isPending}>
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleStartAuth} disabled={startAuthMutation.isPending}>
{isKiro && (
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
{kiroImportMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Import from IDE
</>
)}
</Button>
)}
<Button onClick={handleStartAuth} disabled={isPending}>
{startAuthMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -121,6 +158,11 @@ export function AddAccountDialog({
Complete the OAuth flow in your browser...
</p>
)}
{kiroImportMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
Importing token from Kiro IDE...
</p>
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,124 @@
/**
* Create Auth Profile Dialog
* Shows CLI command for creating auth profiles (Dashboard cannot spawn Claude login directly)
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Check, Terminal } from 'lucide-react';
interface CreateAuthProfileDialogProps {
open: boolean;
onClose: () => void;
}
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
const [profileName, setProfileName] = useState('');
const [copied, setCopied] = useState(false);
// Validate profile name: alphanumeric, dash, underscore only
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create <name>';
const handleCopy = async () => {
if (!isValidName) return;
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleClose = () => {
setProfileName('');
setCopied(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Account</DialogTitle>
<DialogDescription>
Auth profiles require Claude CLI login. Run the command below in your terminal.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
placeholder="e.g., work, personal, client"
autoComplete="off"
/>
{profileName && !isValidName && (
<p className="text-xs text-destructive">
Name must start with a letter and contain only letters, numbers, dashes, or
underscores.
</p>
)}
</div>
<div className="space-y-2">
<Label>Command</Label>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
<code className="flex-1 break-all">{command}</code>
<Button
variant="ghost"
size="sm"
className="shrink-0 h-8 px-2"
onClick={handleCopy}
disabled={!isValidName}
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>After running the command:</p>
<ol className="list-decimal list-inside pl-2 space-y-0.5">
<li>Complete the Claude login in your browser</li>
<li>Return here and refresh to see the new account</li>
</ol>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleClose}>
Close
</Button>
<Button onClick={handleCopy} disabled={!isValidName}>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy Command
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -5,6 +5,7 @@
// Main components
export { AccountsTable } from './accounts-table';
export { AddAccountDialog } from './add-account-dialog';
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
// Flow visualization (from subdirectory)
export { AccountFlowViz } from './flow-viz';
+31 -1
View File
@@ -1,6 +1,6 @@
/**
* React Query hooks for accounts (profiles.json)
* Phase 03: REST API Routes & CRUD
* Dashboard parity: Full CRUD operations for auth profiles
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -28,3 +28,33 @@ export function useSetDefaultAccount() {
},
});
}
export function useResetDefaultAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.accounts.resetDefault(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.success('Default account reset to CCS');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useDeleteAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => api.accounts.delete(name),
onSuccess: (_data, name) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.success(`Account "${name}" deleted`);
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+21
View File
@@ -136,6 +136,27 @@ export function useStartAuth() {
});
}
// Kiro IDE import hook (alternative auth path when OAuth callback fails)
export function useKiroImport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.cliproxy.auth.kiroImport(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
if (data.account) {
toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`);
} else {
toast.success('Kiro token imported');
}
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
// Stats and models hooks for Overview tab
export function useCliproxyStats() {
return useQuery({
+8
View File
@@ -331,6 +331,12 @@ export const api = {
method: 'POST',
body: JSON.stringify({ nickname }),
}),
/** Import Kiro token from Kiro IDE (Kiro only) */
kiroImport: () =>
request<{ success: boolean; account: OAuthAccount | null; error?: string }>(
'/cliproxy/auth/kiro/import',
{ method: 'POST' }
),
},
// Error logs
errorLogs: {
@@ -351,6 +357,8 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name }),
}),
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }),
},
// Unified config API
config: {
+17 -11
View File
@@ -1,13 +1,18 @@
/**
* Accounts Page
* Phase 03: REST API Routes & CRUD
* Dashboard parity: Auth profile CRUD operations
*/
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { AccountsTable } from '@/components/account/accounts-table';
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
import { Button } from '@/components/ui/button';
import { useAccounts } from '@/hooks/use-accounts';
export function AccountsPage() {
const { data, isLoading } = useAccounts();
const { data, isLoading, refetch } = useAccounts();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
@@ -18,22 +23,23 @@ export function AccountsPage() {
Manage multi-account Claude sessions (profiles.json)
</p>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
</div>
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<AccountsTable data={data?.accounts || []} defaultAccount={data?.default ?? null} />
<AccountsTable
data={data?.accounts || []}
defaultAccount={data?.default ?? null}
onRefresh={refetch}
/>
)}
<div className="text-sm text-muted-foreground border-t pt-4 mt-4">
<p>
Accounts are isolated Claude instances with separate sessions.
<br />
Use <code className="bg-muted px-1 rounded">ccs auth create &lt;name&gt;</code> to add new
accounts via CLI.
</p>
</div>
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
</div>
);
}