diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8095569a..0891b2f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,105 @@
+## [7.9.0](https://github.com/kaitranntt/ccs/compare/v7.8.0...v7.9.0) (2025-12-27)
+
+### Features
+
+* **dashboard:** add Import from Kiro IDE button ([5f59d71](https://github.com/kaitranntt/ccs/commit/5f59d710a687aa23b22f470114fc763bf1412fbd))
+* **ui:** add auth profile management to Dashboard ([fa8830e](https://github.com/kaitranntt/ccs/commit/fa8830e1ce97b6f0bb5f89c93325414a15412369))
+
+### Bug Fixes
+
+* **cliproxy:** ensure version sync after binary update ([29f1930](https://github.com/kaitranntt/ccs/commit/29f19308e627f46a5144521f8f2c75e9ff746f6a))
+* **config:** use safe inline logic in getSettingsPath() legacy fallback ([a4a473a](https://github.com/kaitranntt/ccs/commit/a4a473ac93a3adf9b51a0e9371bbd48fa7363157))
+* **dashboard:** support unified config.yaml in web server routes ([0c69740](https://github.com/kaitranntt/ccs/commit/0c697406947ef37f194db26e31d5822cc7e12463)), closes [#206](https://github.com/kaitranntt/ccs/issues/206)
+* improve type safety and error handling in config-manager ([8a3c5a4](https://github.com/kaitranntt/ccs/commit/8a3c5a446beb197148a132900a88f09043cbab55)), closes [#215](https://github.com/kaitranntt/ccs/issues/215)
+* **kiro:** add fallback import from Kiro IDE when OAuth callback redirects ([add4aa5](https://github.com/kaitranntt/ccs/commit/add4aa55c752be54164b42b0c108f54c24944570)), closes [#212](https://github.com/kaitranntt/ccs/issues/212)
+* run RecoveryManager before early-exit commands and improve config handling ([0be3977](https://github.com/kaitranntt/ccs/commit/0be397784525275d0bbcc94877942f8963ca3d33)), closes [#214](https://github.com/kaitranntt/ccs/issues/214)
+* **test:** remove redundant build from beforeAll hook ([67a48a8](https://github.com/kaitranntt/ccs/commit/67a48a8305125959ecab468f117cc9de0badddd5))
+* **tests:** update test files for renamed getCliproxyConfigPath function ([ec2ee0a](https://github.com/kaitranntt/ccs/commit/ec2ee0a36d8498fb596d2e3ef793ce89a9f254f8))
+* wrap RecoveryManager in try-catch to prevent blocking CLI commands ([2fff770](https://github.com/kaitranntt/ccs/commit/2fff770b6bc67616e855cc8dc940751bd1267a67)), closes [#215](https://github.com/kaitranntt/ccs/issues/215)
+
+### Documentation
+
+* update design principles and add feature interface requirements ([c200334](https://github.com/kaitranntt/ccs/commit/c20033473b150689ff4c581d5b4b2a6e12adb758))
+
+## [7.8.0](https://github.com/kaitranntt/ccs/compare/v7.7.1...v7.8.0) (2025-12-26)
+
+### Features
+
+* **api:** add auth tokens REST endpoints ([ffd4996](https://github.com/kaitranntt/ccs/commit/ffd499698e03f1849a0deef3d289c08079a0951e))
+* **cli:** add tokens command for auth token management ([0c6491c](https://github.com/kaitranntt/ccs/commit/0c6491c9d27a3bfb1ecc8c1627d1e1a70f59220a))
+* **cliproxy:** add customizable auth token manager ([c4f0916](https://github.com/kaitranntt/ccs/commit/c4f09168ff35e52c8613a3181a86e4e4e5392dfc))
+* **cliproxy:** add variant port isolation for concurrent proxy instances ([0bcaf4b](https://github.com/kaitranntt/ccs/commit/0bcaf4bc681e26bd13485678c88b55f4ac471eed))
+* **ui:** add auth tokens settings tab ([71335a6](https://github.com/kaitranntt/ccs/commit/71335a61935971c7621fefcef973cc2b42e313fd))
+* **ui:** add Settings link to control panel key hint ([7a6341f](https://github.com/kaitranntt/ccs/commit/7a6341f0d9a8dffdcbb318cf34f3dbbfbea70cb5))
+
+### Bug Fixes
+
+* **cliproxy:** use auth inheritance in stats-fetcher and config-generator ([133aeba](https://github.com/kaitranntt/ccs/commit/133aebaabc2295b75c13a14a448c2cc60d471363))
+* **dashboard:** read accounts from unified config ([8d7845d](https://github.com/kaitranntt/ccs/commit/8d7845d67fb156671713888726d631415d0f4f9c)), closes [#203](https://github.com/kaitranntt/ccs/issues/203)
+* **dashboard:** support unified config across health checks and settings ([9722e19](https://github.com/kaitranntt/ccs/commit/9722e1905dd25b9dd4d602e860ba36586db043b9)), closes [#203](https://github.com/kaitranntt/ccs/issues/203)
+* **dashboard:** support unified config in overview and file watcher ([25f0ddb](https://github.com/kaitranntt/ccs/commit/25f0ddb9ddb19f9eb75c880b7c878d840cb2a494)), closes [#203](https://github.com/kaitranntt/ccs/issues/203)
+* **doctor:** comprehensive health check fixes ([ac74550](https://github.com/kaitranntt/ccs/commit/ac745503e2a1644b2cb3542b917dbce5e6109200))
+* **doctor:** prefer config.yaml and make settings files optional ([4fca7d1](https://github.com/kaitranntt/ccs/commit/4fca7d16edc6985d14422a21d45dccd619ef9aba))
+* **ui:** initialize colors early for consistent status output ([e38af6a](https://github.com/kaitranntt/ccs/commit/e38af6ad6e2f65a73b4d16f6b4f0cead2eb7374d)), closes [#201](https://github.com/kaitranntt/ccs/issues/201)
+* **ui:** simplify config header and add explicit save button ([7e031b5](https://github.com/kaitranntt/ccs/commit/7e031b5097b49f0cfc07334d31b83af41fac9669))
+* **ui:** use effective management secret in control panel embed ([a762563](https://github.com/kaitranntt/ccs/commit/a762563f1b1fa8d984b7c9abf6b3b3c7f8ab6f97))
+
+### Tests
+
+* **cliproxy:** add comprehensive auth token test suite ([ed6776a](https://github.com/kaitranntt/ccs/commit/ed6776aadcf06c0c8572babe1ddc1de4e0902a17))
+* **cliproxy:** add integration tests for variant port isolation ([8f120b5](https://github.com/kaitranntt/ccs/commit/8f120b515f0b71a2730c7affb50c9b148c00e502)), closes [#184](https://github.com/kaitranntt/ccs/issues/184)
+
+## [7.7.1](https://github.com/kaitranntt/ccs/compare/v7.7.0...v7.7.1) (2025-12-26)
+
+### Bug Fixes
+
+* **health:** correct CLIProxy port detection on macOS/Linux ([d1a0ebe](https://github.com/kaitranntt/ccs/commit/d1a0ebee61b8987df85c328d359967e46d1e5226))
+* **health:** use prefix matching for Linux process name truncation ([91e7b9f](https://github.com/kaitranntt/ccs/commit/91e7b9f93787e5b2d45bffdaed75e75c151281e4))
+
+## [7.7.0](https://github.com/kaitranntt/ccs/compare/v7.6.0...v7.7.0) (2025-12-25)
+
+### Features
+
+* **api:** add Minimax, DeepSeek, Qwen provider presets ([e7066b9](https://github.com/kaitranntt/ccs/commit/e7066b99972129114fb223c6cde40f3127599ae6)), closes [#123](https://github.com/kaitranntt/ccs/issues/123)
+* **kiro:** add UI toggle and auth hint for --no-incognito option ([083e674](https://github.com/kaitranntt/ccs/commit/083e67426c382ce534bed4830bedbede94cfdca7))
+* **kiro:** improve auth UX with normal browser default and URL display ([df0c947](https://github.com/kaitranntt/ccs/commit/df0c94781e5f198f867723e1b5bccf17d6c4b250))
+
+### Bug Fixes
+
+* **cliproxy:** preserve user API keys during config regeneration ([2b4d21e](https://github.com/kaitranntt/ccs/commit/2b4d21e8ae615c840d76007d733017d375e6036f)), closes [#200](https://github.com/kaitranntt/ccs/issues/200)
+* **core:** address all code review issues from PR [#199](https://github.com/kaitranntt/ccs/issues/199) ([f2a4200](https://github.com/kaitranntt/ccs/commit/f2a4200625e13754c7f79738dba0562e8ff27895))
+* **kiro:** add --no-incognito option for normal browser auth ([13e4bac](https://github.com/kaitranntt/ccs/commit/13e4baca228313462b3e0e83d0b97594654a989b))
+* **profiles:** prevent GLM auth regression from first-time install detection ([cc2d62d](https://github.com/kaitranntt/ccs/commit/cc2d62db38977fd5a0597388c2882e3600e5e179)), closes [#195](https://github.com/kaitranntt/ccs/issues/195)
+* **qwen:** inherit stdin for Device Code flows to enable interactive prompts ([c811fdf](https://github.com/kaitranntt/ccs/commit/c811fdfc7914cc3bde3811ea04281055ebb3e273)), closes [#188](https://github.com/kaitranntt/ccs/issues/188)
+* **ui:** add gemini-3-flash-preview to model dropdowns ([50653d1](https://github.com/kaitranntt/ccs/commit/50653d1054f89f0eaff24a6d8f471266269383b6)), closes [#194](https://github.com/kaitranntt/ccs/issues/194)
+* **ui:** respect initialMode in profile create dialog ([db3662b](https://github.com/kaitranntt/ccs/commit/db3662b47986269ba9c12385021f4aa4bd1633f6))
+
+### Code Refactoring
+
+* **paths:** use expandPath() consistently for cross-platform path handling ([adb6222](https://github.com/kaitranntt/ccs/commit/adb6222bc671c3c4ade1bb019705a985de1947fa))
+
+### Tests
+
+* **auth:** add comprehensive tests for GLM auth persistence fix ([92a79aa](https://github.com/kaitranntt/ccs/commit/92a79aa78ba14aaf2b22f10eaab23f6e04220b17))
+
+## [7.6.0](https://github.com/kaitranntt/ccs/compare/v7.5.1...v7.6.0) (2025-12-24)
+
+### Features
+
+* **cli:** add config command hints to help and error messages ([e981c39](https://github.com/kaitranntt/ccs/commit/e981c391a26d51de749099ca844915ffc06976e2))
+* **setup:** add first-time setup wizard for config initialization ([cec616d](https://github.com/kaitranntt/ccs/commit/cec616d530d9cf61a3a45032465b01e9a4037558)), closes [#142](https://github.com/kaitranntt/ccs/issues/142)
+
+### Bug Fixes
+
+* **cliproxy:** respect enabled:false and use protocol-based port defaults ([a99b6eb](https://github.com/kaitranntt/ccs/commit/a99b6eb93f06c6788bbf13a196bbca908fa06f4c))
+* **config:** improve edge case handling for config initialization ([ca78993](https://github.com/kaitranntt/ccs/commit/ca78993e7612143b3193e3cec3f8976be909e2d6))
+* **ghcp:** display device code during OAuth device code flow ([46f1699](https://github.com/kaitranntt/ccs/commit/46f1699b1c6f716d06c1eaa3dc6aac94dd5761ec)), closes [#189](https://github.com/kaitranntt/ccs/issues/189)
+
+### Code Refactoring
+
+* **config:** migrate to config.yaml as primary format ([b34469d](https://github.com/kaitranntt/ccs/commit/b34469d75fd2c2b7fd4f4cc4c0cc28885001649b)), closes [#142](https://github.com/kaitranntt/ccs/issues/142)
+* **ghcp:** remove unused device code session management ([5de6ccc](https://github.com/kaitranntt/ccs/commit/5de6cccee08aa06d6533181a1db189a595c5e123))
+
## [7.5.1](https://github.com/kaitranntt/ccs/compare/v7.5.0...v7.5.1) (2025-12-23)
### Bug Fixes
diff --git a/CLAUDE.md b/CLAUDE.md
index e08ed1df..e85547cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 ` (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
diff --git a/README.md b/README.md
index bcc539aa..a380165f 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,9 @@ The dashboard provides visual management for all account types:
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
| **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure |
+| **Minimax** | API Key | `ccs minimax` | M2 series, 1M context |
+| **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning |
+| **Qwen** | API Key | `ccs qwen` | Alibaba Cloud, qwen3-coder |
**OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard.
diff --git a/package.json b/package.json
index 247f5227..3dee93f2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
- "version": "7.5.1-dev.1",
+ "version": "7.9.0",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
index 60251501..46c6bbf0 100755
--- a/scripts/postinstall.js
+++ b/scripts/postinstall.js
@@ -68,24 +68,13 @@ function validateConfiguration() {
errors.push('~/.ccs/ directory not found');
}
- // Check required files (GLM/GLMT/Kimi are now optional - created via presets)
- const requiredFiles = [
- { path: path.join(ccsDir, 'config.json'), name: 'config.json' }
- ];
+ // Check for config file - prefer config.yaml, fallback to config.json
+ const configYaml = path.join(ccsDir, 'config.yaml');
+ const configJson = path.join(ccsDir, 'config.json');
+ const hasConfig = fs.existsSync(configYaml) || fs.existsSync(configJson);
- for (const file of requiredFiles) {
- if (!fs.existsSync(file.path)) {
- errors.push(`${file.name} not found`);
- continue;
- }
-
- // Validate JSON syntax
- try {
- const content = fs.readFileSync(file.path, 'utf8');
- JSON.parse(content);
- } catch (e) {
- errors.push(`${file.name} has invalid JSON: ${e.message}`);
- }
+ if (!hasConfig) {
+ errors.push('config.yaml (or config.json) not found');
}
// Check ~/.claude/settings.json (warning only, not critical)
@@ -104,7 +93,15 @@ function createConfigFiles() {
const ccsDir = path.join(homedir, '.ccs');
// Create ~/.ccs/ directory if missing
- if (!fs.existsSync(ccsDir)) {
+ if (fs.existsSync(ccsDir)) {
+ // Check if it's a file instead of directory (edge case)
+ const stats = fs.statSync(ccsDir);
+ if (!stats.isDirectory()) {
+ console.error('[X] ~/.ccs exists but is not a directory');
+ console.error(' Remove or rename it: mv ~/.ccs ~/.ccs.bak');
+ process.exit(1);
+ }
+ } else {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o755 });
console.log('[OK] Created directory: ~/.ccs/');
}
@@ -150,62 +147,107 @@ function createConfigFiles() {
// Users can run "ccs sync" to install CCS commands/skills to ~/.claude/
// This gives users control over when to modify their Claude configuration
- // Create config.json if missing
+ // Create config.yaml if missing (primary format)
// NOTE: gemini/codex profiles NOT included - they are added on-demand when user
// runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first)
// NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created
- const configPath = path.join(ccsDir, 'config.json');
- if (!fs.existsSync(configPath)) {
- // NOTE: No 'default' entry - when no profile specified, CCS passes through
- // to Claude's native auth without --settings flag. This prevents env var
- // pollution from affecting the default profile.
- // Profiles are empty by default - users create via `ccs api create --preset` or UI
- const config = {
- profiles: {}
- };
+ const configYamlPath = path.join(ccsDir, 'config.yaml');
+ const legacyConfigPath = path.join(ccsDir, 'config.json');
- // Atomic write: temp file → rename
- const tmpPath = `${configPath}.tmp`;
- fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
- fs.renameSync(tmpPath, configPath);
-
- console.log('[OK] Created config: ~/.ccs/config.json');
- } else {
- // Update existing config (migration for older versions)
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
- // Ensure profiles object exists
- if (!config.profiles) {
- config.profiles = {};
- }
- let configUpdated = false;
-
- // Migration: Add glmt if missing (v3.x)
- if (!config.profiles.glmt) {
- config.profiles.glmt = '~/.ccs/glmt.settings.json';
- configUpdated = true;
- }
-
- // Migration: Remove 'default' entry pointing to ~/.claude/settings.json (v5.4.0)
- // This entry caused the default profile to pass --settings flag, which could
- // pick up stale env vars (ANTHROPIC_BASE_URL) from previous profile sessions.
- // Fix: Let CCS pass through to Claude's native auth without --settings flag.
- if (config.profiles.default === '~/.claude/settings.json') {
- delete config.profiles.default;
- configUpdated = true;
- console.log('[OK] Removed legacy default profile (now uses native Claude auth)');
- }
-
- // NOTE: gemini/codex profiles added on-demand, not during migration
- if (configUpdated) {
- const tmpPath = `${configPath}.tmp`;
- fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
- fs.renameSync(tmpPath, configPath);
- if (!config.profiles.glmt) {
- console.log('[OK] Updated config with glmt profile');
+ if (!fs.existsSync(configYamlPath)) {
+ // Check for legacy config.json - autoMigrate() in ccs.ts will handle migration
+ if (fs.existsSync(legacyConfigPath)) {
+ // Validate legacy config.json before assuming migration will work
+ try {
+ const content = fs.readFileSync(legacyConfigPath, 'utf8');
+ JSON.parse(content);
+ console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run');
+ } catch {
+ console.warn('[!] Legacy config.json is corrupted/invalid');
+ console.warn(' Backup: mv ~/.ccs/config.json ~/.ccs/config.json.bak');
+ console.warn(' Creating fresh config.yaml instead');
+ // Fall through to create new config.yaml
+ fs.renameSync(legacyConfigPath, `${legacyConfigPath}.bak`);
}
- } else {
- console.log('[OK] Config exists: ~/.ccs/config.json (preserved)');
}
+
+ // Create config.yaml if it doesn't exist (and legacy wasn't valid)
+ if (!fs.existsSync(configYamlPath) && !fs.existsSync(legacyConfigPath)) {
+ // Try to use unified config loader if dist is available
+ try {
+ const { saveUnifiedConfig } = require('../dist/config/unified-config-loader');
+ const { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } = require('../dist/config/unified-config-types');
+
+ const config = createEmptyUnifiedConfig();
+ config.version = UNIFIED_CONFIG_VERSION;
+ saveUnifiedConfig(config);
+
+ console.log('[OK] Created config: ~/.ccs/config.yaml');
+ } catch (loaderErr) {
+ // Dist not built yet (fresh clone) - create minimal config.yaml manually
+ // Wrap js-yaml require in try-catch in case it's not available
+ let yaml;
+ try {
+ yaml = require('js-yaml');
+ } catch {
+ // js-yaml not available - fallback to JSON
+ console.warn('[!] js-yaml not available, creating legacy config.json');
+ const fallbackConfig = { profiles: {} };
+ const tmpPath = `${legacyConfigPath}.tmp`;
+ fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
+ fs.renameSync(tmpPath, legacyConfigPath);
+ console.log('[OK] Created config: ~/.ccs/config.json (fallback)');
+ yaml = null;
+ }
+
+ if (yaml) {
+ const config = {
+ version: '2.0',
+ profiles: {},
+ accounts: {},
+ cliproxy: {
+ variants: {},
+ oauth_accounts: {}
+ },
+ cliproxy_server: {
+ local: {
+ port: 8317,
+ auto_start: true
+ }
+ }
+ };
+
+ try {
+ const yamlContent = yaml.dump(config, {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false
+ });
+ const tmpPath = `${configYamlPath}.tmp`;
+ fs.writeFileSync(tmpPath, yamlContent, 'utf8');
+ fs.renameSync(tmpPath, configYamlPath);
+ console.log('[OK] Created config: ~/.ccs/config.yaml');
+ } catch (yamlErr) {
+ // Final fallback: create legacy config.json
+ console.warn('[!] YAML write failed, creating legacy config.json');
+ const fallbackConfig = { profiles: {} };
+ const tmpPath = `${legacyConfigPath}.tmp`;
+ fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
+ fs.renameSync(tmpPath, legacyConfigPath);
+ console.log('[OK] Created config: ~/.ccs/config.json (fallback)');
+ }
+ }
+ }
+ }
+ } else {
+ console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)');
+ }
+
+ // Warn if both config files exist (user may want to clean up)
+ if (fs.existsSync(legacyConfigPath) && fs.existsSync(configYamlPath)) {
+ console.log('[!] Both config.yaml and config.json exist');
+ console.log(' config.json will be ignored - consider removing it');
}
// NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install
diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts
index 7e6dd129..2773e0ad 100644
--- a/src/api/services/profile-reader.ts
+++ b/src/api/services/profile-reader.ts
@@ -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);
}
diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts
index e48ff6fa..48c34ff1 100644
--- a/src/api/services/profile-writer.ts
+++ b/src/api/services/profile-writer.ts
@@ -5,7 +5,8 @@
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,
saveUnifiedConfig,
@@ -144,9 +145,10 @@ function removeApiProfileUnified(name: string): void {
throw new Error(`API profile not found: ${name}`);
}
- // Delete the settings file if it exists
+ // Delete the settings file if it exists.
+ // Uses expandPath() for cross-platform path handling.
if (profile.settings) {
- const settingsPath = profile.settings.replace(/^~/, os.homedir());
+ const settingsPath = expandPath(profile.settings);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
@@ -164,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();
diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts
index fa34b0e3..85964f0e 100644
--- a/src/api/services/provider-presets.ts
+++ b/src/api/services/provider-presets.ts
@@ -98,6 +98,39 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab',
category: 'alternative',
},
+ {
+ id: 'minimax',
+ name: 'Minimax',
+ description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)',
+ baseUrl: 'https://api.minimax.io/anthropic',
+ defaultProfileName: 'minimax',
+ defaultModel: 'MiniMax-M2.1',
+ apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY',
+ apiKeyHint: 'Get your API key at platform.minimax.io',
+ category: 'alternative',
+ },
+ {
+ id: 'deepseek',
+ name: 'DeepSeek',
+ description: 'V3.2 and R1 reasoning model (128K context)',
+ baseUrl: 'https://api.deepseek.com/anthropic',
+ defaultProfileName: 'deepseek',
+ defaultModel: 'deepseek-chat',
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyHint: 'Get your API key at platform.deepseek.com',
+ category: 'alternative',
+ },
+ {
+ id: 'qwen',
+ name: 'Qwen',
+ description: 'Alibaba Cloud - qwen3-coder-plus (256K context)',
+ baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic',
+ defaultProfileName: 'qwen',
+ defaultModel: 'qwen3-coder-plus',
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
+ category: 'alternative',
+ },
];
/** Get preset by ID */
diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts
index 014d5407..16accd5f 100644
--- a/src/auth/profile-detector.ts
+++ b/src/auth/profile-detector.ts
@@ -12,7 +12,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { findSimilarStrings } from '../utils/helpers';
+import { findSimilarStrings, expandPath } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
@@ -62,10 +62,11 @@ export interface ProfileNotFoundError extends Error {
*/
/**
* Load env vars from a settings file (*.settings.json).
- * Expands ~ to home directory. Returns empty object on error.
+ * Uses expandPath() for consistent cross-platform path handling.
+ * Returns empty object on error.
*/
-function loadSettingsFromFile(settingsPath: string): Record {
- const expandedPath = settingsPath.replace(/^~/, os.homedir());
+export function loadSettingsFromFile(settingsPath: string): Record {
+ const expandedPath = expandPath(settingsPath);
try {
if (!fs.existsSync(expandedPath)) return {};
const content = fs.readFileSync(expandedPath, 'utf8');
diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts
index e0013e9c..151781ee 100644
--- a/src/auth/profile-registry.ts
+++ b/src/auth/profile-registry.ts
@@ -332,6 +332,42 @@ export class ProfileRegistry {
config.accounts[name].last_used = new Date().toISOString();
saveUnifiedConfig(config);
}
+
+ // ==========================================
+ // DRY Helper Methods (consolidated logic)
+ // ==========================================
+
+ /**
+ * Get all profiles merged from both legacy and unified config.
+ * Unified config takes precedence for duplicate names.
+ * DRY helper to consolidate merge logic used in multiple places.
+ */
+ getAllProfilesMerged(): Record {
+ const legacyProfiles = this.getAllProfiles();
+ const unifiedAccounts = this.getAllAccountsUnified();
+
+ // Start with legacy profiles
+ const merged: Record = { ...legacyProfiles };
+
+ // Override with unified config accounts (takes precedence)
+ for (const [name, account] of Object.entries(unifiedAccounts)) {
+ merged[name] = {
+ type: 'account',
+ created: account.created,
+ last_used: account.last_used,
+ };
+ }
+
+ return merged;
+ }
+
+ /**
+ * Get resolved default profile from unified config first, fallback to legacy.
+ * DRY helper to consolidate default resolution logic.
+ */
+ getDefaultResolved(): string | null {
+ return this.getDefaultUnified() ?? this.getDefaultProfile();
+ }
}
export default ProfileRegistry;
diff --git a/src/ccs.ts b/src/ccs.ts
index f347819f..dc2df697 100644
--- a/src/ccs.ts
+++ b/src/ccs.ts
@@ -3,6 +3,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { detectClaudeCli } from './utils/claude-detector';
import { getSettingsPath, loadSettings } from './utils/config-manager';
+import { validateGlmKey } from './utils/api-key-validator';
import { ErrorManager } from './utils/error-manager';
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
import {
@@ -254,6 +255,13 @@ async function main(): Promise {
const args = process.argv.slice(2);
const firstArg = args[0];
+ // Initialize UI colors early to ensure consistent colored output
+ // Must happen before any status messages (ok, info, fail, etc.)
+ if (process.stdout.isTTY && !process.env['CI']) {
+ const { initUI } = await import('./utils/ui');
+ await initUI();
+ }
+
// Trigger update check early for ALL commands except version/help/update
// Only if TTY and not CI to avoid noise in automated environments
const skipUpdateCheck = [
@@ -283,6 +291,24 @@ async function main(): Promise {
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();
@@ -404,6 +430,20 @@ async function main(): Promise {
return;
}
+ // Special case: tokens command (auth token management)
+ if (firstArg === 'tokens') {
+ const { handleTokensCommand } = await import('./commands/tokens-command');
+ const exitCode = await handleTokensCommand(args.slice(1));
+ process.exit(exitCode);
+ }
+
+ // Special case: setup command (first-time wizard)
+ if (firstArg === 'setup' || firstArg === '--setup') {
+ const { handleSetupCommand } = await import('./commands/setup-command');
+ await handleSetupCommand(args.slice(1));
+ return;
+ }
+
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
@@ -433,14 +473,15 @@ async function main(): Promise {
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
+ const { isFirstTimeInstall } = await import('./commands/setup-command');
+ if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) {
+ console.log('');
+ console.log(info('First-time install detected. Run `ccs setup` for guided configuration.'));
+ console.log(' Or use `ccs config` for the web dashboard.');
+ console.log('');
}
// Detect profile
@@ -490,6 +531,32 @@ async function main(): Promise {
// Display WebSearch status (single line, equilibrium UX)
displayWebSearchStatus();
+ // Pre-flight validation for GLM/GLMT profiles
+ if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') {
+ const preflightSettingsPath = getSettingsPath(profileInfo.name);
+ const preflightSettings = loadSettings(preflightSettingsPath);
+ const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN'];
+
+ if (apiKey) {
+ const validation = await validateGlmKey(
+ apiKey,
+ preflightSettings.env?.['ANTHROPIC_BASE_URL']
+ );
+
+ if (!validation.valid) {
+ console.error('');
+ console.error(fail(validation.error || 'API key validation failed'));
+ if (validation.suggestion) {
+ console.error('');
+ console.error(validation.suggestion);
+ }
+ console.error('');
+ console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"'));
+ process.exit(1);
+ }
+ }
+ }
+
// Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') {
// GLMT FLOW: Settings-based with embedded proxy for thinking support
diff --git a/src/cliproxy/auth-token-manager.ts b/src/cliproxy/auth-token-manager.ts
new file mode 100644
index 00000000..4daebb13
--- /dev/null
+++ b/src/cliproxy/auth-token-manager.ts
@@ -0,0 +1,203 @@
+/**
+ * Auth Token Manager for CLIProxyAPI
+ *
+ * Manages API key and management secret resolution with inheritance:
+ * - Per-variant override → Global config → Default constants
+ *
+ * Provides secure token generation for user customization.
+ */
+
+import { randomBytes } from 'crypto';
+import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader';
+import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator';
+
+/**
+ * Generate a cryptographically secure token.
+ * Uses CSPRNG (crypto.randomBytes) for proper entropy.
+ *
+ * @param length - Number of random bytes (default: 32 = 256-bit entropy)
+ * @returns Base64URL-encoded token (43 chars for 32 bytes)
+ */
+export function generateSecureToken(length = 32): string {
+ return randomBytes(length).toString('base64url');
+}
+
+/**
+ * Mask a token for display.
+ * Shows first 4 and last 4 chars: "ccs_...4f2a"
+ * Used by CLI commands and API routes.
+ */
+export function maskToken(token: string): string {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+}
+
+/**
+ * Get effective API key with inheritance chain.
+ * Priority: variant auth → global cliproxy.auth → default constant
+ *
+ * @param variantName - Optional variant name for per-variant override
+ * @returns Resolved API key
+ */
+export function getEffectiveApiKey(variantName?: string): string {
+ const config = loadOrCreateUnifiedConfig();
+
+ // Priority 1: Per-variant override
+ if (variantName) {
+ const variant = config.cliproxy.variants[variantName];
+ if (variant?.auth?.api_key) {
+ return variant.auth.api_key;
+ }
+ }
+
+ // Priority 2: Global cliproxy.auth
+ if (config.cliproxy.auth?.api_key) {
+ return config.cliproxy.auth.api_key;
+ }
+
+ // Priority 3: Default constant (backwards compatible)
+ return CCS_INTERNAL_API_KEY;
+}
+
+/**
+ * Get effective management secret.
+ * Priority: global cliproxy.auth → default constant
+ *
+ * Note: Management secret is global-only (no per-variant override)
+ * as it controls the Control Panel access for the entire CLIProxy instance.
+ *
+ * @returns Resolved management secret
+ */
+export function getEffectiveManagementSecret(): string {
+ const config = loadOrCreateUnifiedConfig();
+
+ // Priority 1: Global cliproxy.auth
+ if (config.cliproxy.auth?.management_secret) {
+ return config.cliproxy.auth.management_secret;
+ }
+
+ // Priority 2: Default constant (backwards compatible)
+ return CCS_CONTROL_PANEL_SECRET;
+}
+
+/**
+ * Set global API key.
+ * Updates cliproxy.auth.api_key in config.yaml.
+ *
+ * @param apiKey - New API key (or undefined to reset to default)
+ */
+export function setGlobalApiKey(apiKey: string | undefined): void {
+ const config = loadOrCreateUnifiedConfig();
+
+ if (!config.cliproxy.auth) {
+ config.cliproxy.auth = {};
+ }
+
+ if (apiKey === undefined) {
+ delete config.cliproxy.auth.api_key;
+ } else {
+ config.cliproxy.auth.api_key = apiKey;
+ }
+
+ saveUnifiedConfig(config);
+}
+
+/**
+ * Set global management secret.
+ * Updates cliproxy.auth.management_secret in config.yaml.
+ *
+ * @param secret - New management secret (or undefined to reset to default)
+ */
+export function setGlobalManagementSecret(secret: string | undefined): void {
+ const config = loadOrCreateUnifiedConfig();
+
+ if (!config.cliproxy.auth) {
+ config.cliproxy.auth = {};
+ }
+
+ if (secret === undefined) {
+ delete config.cliproxy.auth.management_secret;
+ } else {
+ config.cliproxy.auth.management_secret = secret;
+ }
+
+ saveUnifiedConfig(config);
+}
+
+/**
+ * Set per-variant API key override.
+ * Updates variants[variantName].auth.api_key in config.yaml.
+ *
+ * @param variantName - Variant name
+ * @param apiKey - New API key (or undefined to remove override)
+ */
+export function setVariantApiKey(variantName: string, apiKey: string | undefined): void {
+ const config = loadOrCreateUnifiedConfig();
+ const variant = config.cliproxy.variants[variantName];
+
+ if (!variant) {
+ throw new Error(`Variant '${variantName}' not found`);
+ }
+
+ if (!variant.auth) {
+ variant.auth = {};
+ }
+
+ if (apiKey === undefined) {
+ delete variant.auth.api_key;
+ // Clean up empty auth object
+ if (Object.keys(variant.auth).length === 0) {
+ delete variant.auth;
+ }
+ } else {
+ variant.auth.api_key = apiKey;
+ }
+
+ saveUnifiedConfig(config);
+}
+
+/**
+ * Reset all auth settings to defaults.
+ * Removes cliproxy.auth and all variant auth overrides.
+ */
+export function resetAuthToDefaults(): void {
+ const config = loadOrCreateUnifiedConfig();
+
+ // Remove global auth
+ delete config.cliproxy.auth;
+
+ // Remove all variant auth overrides
+ for (const variantName of Object.keys(config.cliproxy.variants)) {
+ const variant = config.cliproxy.variants[variantName];
+ if (variant.auth) {
+ delete variant.auth;
+ }
+ }
+
+ saveUnifiedConfig(config);
+}
+
+/**
+ * Get auth configuration summary for display.
+ * Returns current effective values and whether they're customized.
+ */
+export function getAuthSummary(): {
+ apiKey: { value: string; isCustom: boolean };
+ managementSecret: { value: string; isCustom: boolean };
+} {
+ const config = loadOrCreateUnifiedConfig();
+
+ const customApiKey = config.cliproxy.auth?.api_key;
+ const customSecret = config.cliproxy.auth?.management_secret;
+
+ return {
+ apiKey: {
+ value: customApiKey || CCS_INTERNAL_API_KEY,
+ isCustom: !!customApiKey,
+ },
+ managementSecret: {
+ value: customSecret || CCS_CONTROL_PANEL_SECRET,
+ isCustom: !!customSecret,
+ },
+ };
+}
diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts
index 4bb06256..927c2e2a 100644
--- a/src/cliproxy/auth/auth-types.ts
+++ b/src/cliproxy/auth/auth-types.ts
@@ -171,4 +171,8 @@ export interface OAuthOptions {
nickname?: string;
/** If true, triggered from Web UI (enables project selection prompt) */
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;
}
diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts
new file mode 100644
index 00000000..cc507110
--- /dev/null
+++ b/src/cliproxy/auth/kiro-import.ts
@@ -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 {
+ 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((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 {
+ 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;
+}
diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts
index f2bb51c2..34ecabbc 100644
--- a/src/cliproxy/auth/oauth-handler.ts
+++ b/src/cliproxy/auth/oauth-handler.ts
@@ -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
@@ -126,7 +127,18 @@ export async function triggerOAuth(
options: OAuthOptions = {}
): Promise {
const oauthConfig = getOAuthConfig(provider);
- const { verbose = false, add = false, nickname, fromUI = false } = options;
+ 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();
@@ -175,6 +187,10 @@ export async function triggerOAuth(
if (headless) {
args.push('--no-browser');
}
+ // Kiro-specific: --no-incognito to use normal browser (saves login credentials)
+ if (provider === 'kiro' && noIncognito) {
+ args.push('--no-incognito');
+ }
// Show step based on flow type
if (isDeviceCodeFlow) {
@@ -198,7 +214,7 @@ export async function triggerOAuth(
}
// Execute OAuth process
- return executeOAuthProcess({
+ const account = await executeOAuthProcess({
provider,
binaryPath,
args,
@@ -210,6 +226,16 @@ export async function triggerOAuth(
isCLI,
nickname,
});
+
+ // Show hint for Kiro users about --no-incognito option (first-time auth only)
+ if (account && provider === 'kiro' && !noIncognito) {
+ console.log('');
+ console.log(info('Tip: To save your AWS login credentials for future sessions:'));
+ console.log(' Use: ccs kiro --no-incognito');
+ console.log(' Or enable "Kiro: Use normal browser" in: ccs config');
+ }
+
+ return account;
}
/**
diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts
index f9e0026e..309df84d 100644
--- a/src/cliproxy/auth/oauth-process.ts
+++ b/src/cliproxy/auth/oauth-process.ts
@@ -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 {
@@ -22,6 +23,8 @@ import {
import { ProviderOAuthConfig } from './auth-types';
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import { isAuthenticated, registerAccountFromToken } from './token-manager';
+import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler';
+import { OAUTH_FLOW_TYPES } from '../../management';
/** Options for OAuth process execution */
export interface OAuthProcessOptions {
@@ -46,6 +49,10 @@ interface ProcessState {
accumulatedOutput: string;
parsedProjects: GCloudProject[];
sessionId: string;
+ /** Device code displayed to user (for Device Code Flow) */
+ deviceCodeDisplayed: boolean;
+ /** The user code to enter at verification URL */
+ userCode: string | null;
}
/**
@@ -99,28 +106,78 @@ async function handleStdout(
log(`stdout: ${output.trim()}`);
state.accumulatedOutput += output;
+ // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
+ const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
+ const isDeviceCodeFlow = flowType === 'device_code';
+
// Parse project list when available
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
state.parsedProjects = parseProjectList(state.accumulatedOutput);
log(`Parsed ${state.parsedProjects.length} projects`);
}
- // Handle project selection prompt
- if (!state.projectPromptHandled && isProjectSelectionPrompt(output)) {
+ // Handle project selection prompt (Authorization Code flows only - Device Code has no stdin pipe)
+ if (!isDeviceCodeFlow && !state.projectPromptHandled && isProjectSelectionPrompt(output)) {
state.projectPromptHandled = true;
await handleProjectSelection(output, state, options, authProcess, log);
}
- // Detect callback server / browser
- if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) {
+ // Handle Device Code Flow: parse and display user code
+ if (isDeviceCodeFlow && !state.deviceCodeDisplayed) {
+ // Parse device/user code from various formats:
+ // "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY"
+ const codeMatch = state.accumulatedOutput.match(
+ /(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i
+ );
+ const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i);
+
+ if (codeMatch) {
+ state.userCode = codeMatch[1].toUpperCase();
+ state.deviceCodeDisplayed = true;
+ log(`Parsed device code: ${state.userCode}`);
+
+ const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device';
+
+ // Emit device code event for WebSocket broadcast to UI
+ const deviceCodePrompt: DeviceCodePrompt = {
+ sessionId: state.sessionId,
+ provider: options.provider,
+ userCode: state.userCode,
+ verificationUrl,
+ expiresAt: Date.now() + 900000, // 15 minutes
+ };
+ deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt);
+
+ // Display device code prominently in CLI
+ console.log('');
+ console.log(' ╔══════════════════════════════════════════════════════╗');
+ console.log(` ║ Enter this code: ${state.userCode.padEnd(35)}║`);
+ console.log(' ╚══════════════════════════════════════════════════════╝');
+ console.log('');
+ console.log(info(`Open: ${verificationUrl}`));
+ console.log('');
+
+ // Update step display for device code flow
+ process.stdout.write('\x1b[1A\x1b[2K');
+ showStep(2, 4, 'ok', 'Device code received');
+ showStep(3, 4, 'progress', 'Waiting for authorization...');
+ }
+ }
+
+ // Detect callback server / browser (for Authorization Code flows only)
+ if (
+ !isDeviceCodeFlow &&
+ !state.browserOpened &&
+ (output.includes('listening') || output.includes('http'))
+ ) {
process.stdout.write('\x1b[1A\x1b[2K');
showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`);
showStep(3, 4, 'progress', 'Opening browser...');
state.browserOpened = true;
}
- // Display OAuth URLs in headless mode
- if (options.headless && !state.urlDisplayed) {
+ // Display OAuth URL for all modes (enables VS Code terminal URL detection popup)
+ if (!isDeviceCodeFlow && !state.urlDisplayed) {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
console.log('');
@@ -149,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 {
+ // 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('');
@@ -169,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 */
@@ -205,11 +291,29 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise((resolve) => {
+ // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
+ const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code';
+ const isDeviceCodeFlow = flowType === 'device_code';
+
+ // H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts)
+ // Device Code flows may need interactive stdin for email/prompts
+ // Authorization Code flows need piped stdin for project selection
+ const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe';
+
const authProcess = spawn(binaryPath, args, {
- stdio: ['pipe', 'pipe', 'pipe'],
+ stdio: [stdinMode, 'pipe', 'pipe'],
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
});
+ // H5: Signal handling - properly kill child process on SIGINT/SIGTERM
+ const cleanup = () => {
+ if (authProcess && !authProcess.killed) {
+ authProcess.kill('SIGTERM');
+ }
+ };
+ process.on('SIGINT', cleanup);
+ process.on('SIGTERM', cleanup);
+
const state: ProcessState = {
stderrData: '',
urlDisplayed: false,
@@ -218,6 +322,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise {
- if (!state.browserOpened) {
- process.stdout.write('\x1b[1A\x1b[2K');
- showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
- showStep(3, 4, 'ok', 'Browser opened');
- state.browserOpened = true;
+ if (isDeviceCodeFlow) {
+ // Device Code Flow: show polling message
+ if (!state.deviceCodeDisplayed) {
+ // Code not yet displayed, show generic waiting message
+ showStep(3, 4, 'progress', 'Waiting for device code...');
+ }
+ showStep(4, 4, 'progress', 'Polling for authorization...');
+ console.log('');
+ console.log(
+ info('Complete the login in your browser. This page will update automatically.')
+ );
+ } else {
+ // Authorization Code Flow: show callback server message
+ if (!state.browserOpened) {
+ process.stdout.write('\x1b[1A\x1b[2K');
+ showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
+ showStep(3, 4, 'ok', 'Browser opened');
+ state.browserOpened = true;
+ }
+ showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
+ console.log('');
+ console.log(
+ info('Complete the login in your browser. This page will update automatically.')
+ );
}
- showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
- console.log('');
- console.log(info('Complete the login in your browser. This page will update automatically.'));
if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
}, 2000);
// Timeout handling
const timeoutMs = headless ? 300000 : 120000;
const timeout = setTimeout(() => {
+ // H5: Remove signal handlers before killing process
+ process.removeListener('SIGINT', cleanup);
+ process.removeListener('SIGTERM', cleanup);
authProcess.kill();
console.log('');
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
@@ -261,20 +386,52 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise {
+ authProcess.on('exit', async (code) => {
clearTimeout(timeout);
+ // H5: Remove signal handlers to prevent memory leaks
+ process.removeListener('SIGINT', cleanup);
+ process.removeListener('SIGTERM', cleanup);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
if (isAuthenticated(provider)) {
console.log('');
console.log(ok(`Authentication successful (${elapsed}s)`));
+
+ // Emit device code completion event for UI
+ if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
+ deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
+ }
+
resolve(registerAccountFromToken(provider, tokenDir, nickname));
} else {
- handleTokenNotFound(provider, callbackPort);
- resolve(null);
+ // Emit device code failure event for UI
+ if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
+ deviceCodeEvents.emit('deviceCode:failed', {
+ sessionId: state.sessionId,
+ error: 'Token not found after authentication',
+ });
+ }
+
+ // 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
+ if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
+ deviceCodeEvents.emit('deviceCode:failed', {
+ sessionId: state.sessionId,
+ error: `Auth process exited with code ${code}`,
+ });
+ }
+
handleProcessError(code, state, headless);
resolve(null);
}
@@ -282,6 +439,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise {
clearTimeout(timeout);
+ // H5: Remove signal handlers to prevent memory leaks
+ process.removeListener('SIGINT', cleanup);
+ process.removeListener('SIGTERM', cleanup);
console.log('');
console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null);
diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts
index 4e23f880..51a33d45 100644
--- a/src/cliproxy/binary-manager.ts
+++ b/src/cliproxy/binary-manager.ts
@@ -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 {
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 */
diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts
index 6152be27..99d31f97 100644
--- a/src/cliproxy/cliproxy-executor.ts
+++ b/src/cliproxy/cliproxy-executor.ts
@@ -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,18 @@ 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)
+ const incognitoFlag = argsWithoutProxy.includes('--incognito');
+ const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito');
+ // Config setting (defaults to true = normal browser)
+ const kiroNoIncognitoConfig =
+ provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false;
+ // --incognito flag overrides everything to use incognito
+ const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig;
// Parse --use flag
let useAccount: string | undefined;
@@ -352,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}`);
@@ -364,6 +413,7 @@ export async function execClaudeWithCLIProxy(
add: addAccount,
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
+ ...(noIncognito ? { noIncognito: true } : {}),
});
if (!authSuccess) {
throw new Error(`Authentication required for ${providerConfig.displayName}`);
@@ -418,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) {
@@ -531,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})`
+ );
});
}
@@ -591,6 +668,9 @@ export async function execClaudeWithCLIProxy(
'--accounts',
'--use',
'--nickname',
+ '--incognito',
+ '--no-incognito',
+ '--import',
// Proxy flags are handled by resolveProxyConfig, but list for documentation
...PROXY_CLI_FLAGS,
];
@@ -625,12 +705,14 @@ export async function execClaudeWithCLIProxy(
// 8. Cleanup: unregister session when Claude exits (local mode only)
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
+ // Capture port for cleanup (avoids closure issues)
+ const sessionPort = cfg.port;
claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`);
// Unregister this session (proxy keeps running for persistence) - only for local mode
if (sessionId) {
- unregisterSession(sessionId);
+ unregisterSession(sessionId, sessionPort);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
}
@@ -646,7 +728,7 @@ export async function execClaudeWithCLIProxy(
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
- unregisterSession(sessionId);
+ unregisterSession(sessionId, sessionPort);
}
process.exit(1);
});
@@ -657,7 +739,7 @@ export async function execClaudeWithCLIProxy(
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
- unregisterSession(sessionId);
+ unregisterSession(sessionId, sessionPort);
}
claude.kill('SIGTERM');
};
diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts
index 7e9f940e..c2d4b83d 100644
--- a/src/cliproxy/config-generator.ts
+++ b/src/cliproxy/config-generator.ts
@@ -11,10 +11,12 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
+import { expandPath } from '../utils/helpers';
import { warn } from '../utils/ui';
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader';
import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
+import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
/** Settings file structure for user overrides */
interface ProviderSettings {
@@ -116,10 +118,22 @@ export function getAuthDir(): string {
}
/**
- * Get config file path
+ * Get config file path for a specific port.
+ * Default port uses config.yaml, others use config-{port}.yaml.
*/
-export function getConfigPath(): string {
- return path.join(getCliproxyDir(), 'config.yaml');
+export function getConfigPathForPort(port: number): string {
+ if (port === CLIPROXY_DEFAULT_PORT) {
+ return path.join(getCliproxyDir(), 'config.yaml');
+ }
+ return path.join(getCliproxyDir(), `config-${port}.yaml`);
+}
+
+/**
+ * Get CLIProxy config file path (default port)
+ * Named distinctly from config-manager's getConfigPath to avoid confusion.
+ */
+export function getCliproxyConfigPath(): string {
+ return getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
}
/**
@@ -145,8 +159,14 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } {
* Generate UNIFIED config.yaml content for ALL providers
* This enables concurrent usage of gemini/codex/agy without config conflicts.
* CLIProxyAPI routes requests by model name to the appropriate provider.
+ *
+ * @param port - Server port (default: 8317)
+ * @param userApiKeys - User-added API keys to preserve (default: [])
*/
-function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string {
+function generateUnifiedConfigContent(
+ port: number = CLIPROXY_DEFAULT_PORT,
+ userApiKeys: string[] = []
+): string {
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
// Convert Windows backslashes to forward slashes for YAML compatibility
const authDirNormalized = authDir.split(path.sep).join('/');
@@ -154,6 +174,14 @@ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): str
// Get logging settings from user config (disabled by default)
const { loggingToFile, requestLog } = getLoggingSettings();
+ // Get effective auth tokens (respects user customization)
+ const effectiveApiKey = getEffectiveApiKey();
+ const effectiveSecret = getEffectiveManagementSecret();
+
+ // Build api-keys section with internal key + preserved user keys
+ const allApiKeys = [effectiveApiKey, ...userApiKeys];
+ const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
+
// Unified config with enhanced CLIProxyAPI features
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
@@ -197,7 +225,7 @@ usage-statistics-enabled: true
# Remote management API for CCS dashboard integration
remote-management:
allow-remote: true
- secret-key: "${CCS_CONTROL_PANEL_SECRET}"
+ secret-key: "${effectiveSecret}"
disable-control-panel: false
# =============================================================================
@@ -218,9 +246,10 @@ quota-exceeded:
# Authentication
# =============================================================================
-# API keys for CCS internal requests
+# API keys for CCS and user-added external requests
+# NOTE: User-added keys are preserved across CCS updates (fix for issue #200)
api-keys:
- - "${CCS_INTERNAL_API_KEY}"
+${apiKeysYaml}
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
auth-dir: "${authDirNormalized}"
@@ -238,7 +267,7 @@ export function generateConfig(
provider: CLIProxyProvider,
port: number = CLIPROXY_DEFAULT_PORT
): string {
- const configPath = getConfigPath();
+ const configPath = getConfigPathForPort(port);
// Ensure provider auth directory exists
const authDir = getProviderAuthDir(provider);
@@ -255,24 +284,79 @@ export function generateConfig(
}
/**
- * Force regenerate config.yaml with latest settings
- * Deletes existing config and creates fresh one with current port
+ * Parse user-added API keys from existing config content.
+ * Extracts all keys except the internal CCS key for preservation.
+ *
+ * @param content - Existing config.yaml content
+ * @returns Array of user-added API keys (excludes CCS_INTERNAL_API_KEY)
+ */
+export function parseUserApiKeys(content: string): string[] {
+ const userKeys: string[] = [];
+
+ // Find the api-keys section by looking for lines starting with " - " after "api-keys:"
+ // Normalize line endings first
+ const normalizedContent = content.replace(/\r\n/g, '\n');
+
+ // Find the api-keys: line and extract all subsequent key entries
+ const lines = normalizedContent.split('\n');
+ let inApiKeysSection = false;
+
+ for (const line of lines) {
+ // Check if this is the start of api-keys section
+ if (line.match(/^api-keys:\s*$/)) {
+ inApiKeysSection = true;
+ continue;
+ }
+
+ // If we're in the api-keys section, look for key entries
+ if (inApiKeysSection) {
+ // Key entries are indented with " - " or similar
+ const keyMatch = line.match(/^\s+-\s*"([^"]*)"/);
+ if (keyMatch) {
+ const key = keyMatch[1];
+ // Exclude the internal CCS key and empty strings
+ if (key && key !== CCS_INTERNAL_API_KEY) {
+ userKeys.push(key);
+ }
+ } else if (line.match(/^\S/) && line.trim().length > 0) {
+ // Non-indented line that's not empty means we've left the api-keys section
+ break;
+ }
+ // Continue for blank lines or other indented content
+ }
+ }
+
+ return userKeys;
+}
+
+/**
+ * Force regenerate config.yaml with latest settings.
+ * Preserves user-added API keys and port settings.
+ *
+ * @param port - Default port to use if not found in existing config
* @returns Path to new config file
*/
export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
- const configPath = getConfigPath();
+ const configPath = getConfigPathForPort(port);
- // Read existing port if config exists (preserve user's port choice)
+ // Preserve user settings from existing config
let effectivePort = port;
+ let userApiKeys: string[] = [];
+
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
+
+ // Preserve port setting
const portMatch = content.match(/^port:\s*(\d+)/m);
if (portMatch) {
effectivePort = parseInt(portMatch[1], 10);
}
+
+ // Preserve user-added API keys (fix for issue #200)
+ userApiKeys = parseUserApiKeys(content);
} catch {
- // Use default port if reading fails
+ // Use defaults if reading fails
}
// Delete existing config
fs.unlinkSync(configPath);
@@ -282,8 +366,8 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 });
- // Generate fresh config
- const configContent = generateUnifiedConfigContent(effectivePort);
+ // Generate fresh config with preserved user API keys
+ const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys);
fs.writeFileSync(configPath, configContent, { mode: 0o600 });
return configPath;
@@ -294,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
}
@@ -316,22 +400,29 @@ export function configNeedsRegeneration(): boolean {
}
/**
- * Check if config exists for provider
+ * Check if config exists for port
*/
-export function configExists(): boolean {
- return fs.existsSync(getConfigPath());
+export function configExists(port: number = CLIPROXY_DEFAULT_PORT): boolean {
+ return fs.existsSync(getConfigPathForPort(port));
}
/**
- * Delete config file
+ * Delete config file for specific port
*/
-export function deleteConfig(): void {
- const configPath = getConfigPath();
+export function deleteConfigForPort(port: number): void {
+ const configPath = getConfigPathForPort(port);
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
}
}
+/**
+ * Delete config file (default port)
+ */
+export function deleteConfig(): void {
+ deleteConfigForPort(CLIPROXY_DEFAULT_PORT);
+}
+
/**
* Get path to user settings file for provider
* Example: ~/.ccs/gemini.settings.json
@@ -358,7 +449,7 @@ export function getClaudeEnvVars(
const coreEnvVars = {
// Provider-specific endpoint - routes to correct provider via URL path
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
- ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY,
+ ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
@@ -463,7 +554,7 @@ export function getEffectiveEnvVars(
// Priority 1: Custom settings path (for user-defined variants)
if (customSettingsPath) {
- const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
+ const expandedPath = expandPath(customSettingsPath);
if (fs.existsSync(expandedPath)) {
try {
const content = fs.readFileSync(expandedPath, 'utf-8');
@@ -569,7 +660,7 @@ export function getRemoteEnvVars(
// Priority 1: Custom settings path (for user-defined variants)
if (customSettingsPath) {
- const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
+ const expandedPath = expandPath(customSettingsPath);
if (fs.existsSync(expandedPath)) {
try {
const content = fs.readFileSync(expandedPath, 'utf-8');
@@ -625,7 +716,7 @@ export function getRemoteEnvVars(
...userEnvVars,
// Always override URL and auth token with remote config
ANTHROPIC_BASE_URL: baseUrl,
- ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
+ ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
};
return env;
diff --git a/src/cliproxy/device-code-handler.ts b/src/cliproxy/device-code-handler.ts
new file mode 100644
index 00000000..d1ccbc94
--- /dev/null
+++ b/src/cliproxy/device-code-handler.ts
@@ -0,0 +1,32 @@
+/**
+ * Device Code Handler
+ *
+ * Manages device code display prompts during OAuth Device Code flow.
+ * Broadcasts device code events via WebSocket to both CLI terminal
+ * and Web UI (ccs config).
+ *
+ * Events emitted by oauth-process.ts:
+ * - deviceCode:received - When device code is parsed from output
+ * - deviceCode:completed - When auth succeeds
+ * - deviceCode:failed - When auth fails
+ * - deviceCode:expired - When code expires (handled by UI timer)
+ */
+
+import { EventEmitter } from 'events';
+
+/**
+ * Device code prompt data sent to UI
+ */
+export interface DeviceCodePrompt {
+ sessionId: string;
+ provider: string;
+ userCode: string;
+ verificationUrl: string;
+ expiresAt: number;
+}
+
+// Global event emitter for device code events
+export const deviceCodeEvents = new EventEmitter();
+
+// Default timeout for device code (15 minutes - GitHub's default)
+export const DEVICE_CODE_TIMEOUT_MS = 900000;
diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts
index 5e327180..6858f43d 100644
--- a/src/cliproxy/index.ts
+++ b/src/cliproxy/index.ts
@@ -55,6 +55,7 @@ export {
generateConfig,
regenerateConfig,
configNeedsRegeneration,
+ parseUserApiKeys,
getClaudeEnvVars,
getEffectiveEnvVars,
getProviderSettingsPath,
@@ -64,7 +65,7 @@ export {
getCliproxyDir,
getProviderAuthDir,
getAuthDir,
- getConfigPath,
+ getCliproxyConfigPath,
getBinDir,
configExists,
deleteConfig,
@@ -138,3 +139,16 @@ export { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '.
// Startup lock (prevents race conditions between CCS processes)
export type { LockResult } from './startup-lock';
export { acquireStartupLock, withStartupLock } from './startup-lock';
+
+// Auth token manager (customizable API key and management secret)
+export {
+ generateSecureToken,
+ maskToken,
+ getEffectiveApiKey,
+ getEffectiveManagementSecret,
+ setGlobalApiKey,
+ setGlobalManagementSecret,
+ setVariantApiKey,
+ resetAuthToDefaults,
+ getAuthSummary,
+} from './auth-token-manager';
diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts
index ab9e62b5..ddec180b 100644
--- a/src/cliproxy/model-config.ts
+++ b/src/cliproxy/model-config.ts
@@ -6,6 +6,7 @@
*/
import * as fs from 'fs';
+import * as os from 'os';
import * as path from 'path';
import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
@@ -14,7 +15,7 @@ import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
/** CCS directory */
-const CCS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.ccs');
+const CCS_DIR = path.join(os.homedir(), '.ccs');
/**
* Check if provider has user settings configured
@@ -34,7 +35,7 @@ export function getCurrentModel(
customSettingsPath?: string
): string | undefined {
const settingsPath = customSettingsPath
- ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '')
+ ? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
if (!fs.existsSync(settingsPath)) return undefined;
@@ -93,7 +94,7 @@ export async function configureProviderModel(
// Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = customSettingsPath
- ? customSettingsPath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || '')
+ ? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
// Skip if already configured (unless --config flag)
diff --git a/src/cliproxy/openai-compat-manager.ts b/src/cliproxy/openai-compat-manager.ts
index 5254a156..9bb8dfd4 100644
--- a/src/cliproxy/openai-compat-manager.ts
+++ b/src/cliproxy/openai-compat-manager.ts
@@ -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: '"',
diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts
index b0af403f..aebeb948 100644
--- a/src/cliproxy/proxy-config-resolver.ts
+++ b/src/cliproxy/proxy-config-resolver.ts
@@ -215,9 +215,12 @@ export function resolveProxyConfig(
...DEFAULT_PROXY_CONFIG,
};
- // Determine mode: remote if host is specified anywhere (unless --local-proxy)
+ // Determine mode: remote if host is specified AND remote is not explicitly disabled
+ // Priority: CLI flags > ENV > YAML config
+ // If YAML config has enabled: false, it only blocks YAML-sourced config (CLI/ENV override)
+ const yamlRemoteEnabled = yamlConfig.remote?.enabled !== false;
const hasRemoteHost =
- cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled;
+ cliFlags.host || envConfig.host || (yamlRemoteEnabled && yamlConfig.remote?.host);
// --local-proxy forces local mode regardless of remote config
if (cliFlags.localProxy) {
diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts
index 1a7bc812..d1d6afe6 100644
--- a/src/cliproxy/proxy-detector.ts
+++ b/src/cliproxy/proxy-detector.ts
@@ -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');
diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts
index f4d7c05b..89c70b1a 100644
--- a/src/cliproxy/quota-fetcher.ts
+++ b/src/cliproxy/quota-fetcher.ts
@@ -94,6 +94,11 @@ interface FetchAvailableModelsResponse {
function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null {
const authDir = getAuthDir();
+ // Check if auth directory exists
+ if (!fs.existsSync(authDir)) {
+ return null;
+ }
+
// Account ID format: email with @ and . replaced by _
// Try to find matching token file
const files = fs.readdirSync(authDir);
@@ -232,10 +237,11 @@ async function fetchAvailableModels(accessToken: string, projectId: string): Pro
const remaining =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
- if (typeof remaining !== 'number') continue;
+ // Skip invalid values (NaN, Infinity, non-numbers)
+ if (typeof remaining !== 'number' || !isFinite(remaining)) continue;
- // Convert to percentage (0-100)
- const percentage = Math.round(remaining * 100);
+ // Convert to percentage (0-100) and clamp to valid range
+ const percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
// Extract reset time
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts
index b8ab2c4e..f6b51385 100644
--- a/src/cliproxy/remote-proxy-client.ts
+++ b/src/cliproxy/remote-proxy-client.ts
@@ -36,7 +36,7 @@ export interface RemoteProxyClientConfig {
* Remote proxy port.
* Optional - defaults based on protocol:
* - HTTPS: 443
- * - HTTP: 80
+ * - HTTP: 8317 (CLIProxyAPI default)
*/
port?: number;
/** Protocol to use (http or https) */
@@ -52,8 +52,16 @@ export interface RemoteProxyClientConfig {
/** Default timeout for remote proxy requests (aggressive for CLI UX) */
const DEFAULT_TIMEOUT_MS = 2000;
-/** Default CLIProxyAPI port */
-const DEFAULT_CLIPROXY_PORT = 8317;
+/**
+ * Get default port for CLIProxyAPI based on protocol.
+ * - HTTP: 8317 (CLIProxyAPI default for local/dev scenarios)
+ * - HTTPS: 443 (standard SSL port for production remote servers)
+ *
+ * This matches the UI labels shown to users.
+ */
+function getDefaultPort(protocol: 'http' | 'https'): number {
+ return protocol === 'https' ? 443 : 8317;
+}
/**
* Get standard web port for protocol (for URL display omission)
@@ -65,11 +73,11 @@ function getStandardWebPort(protocol: 'http' | 'https'): number {
}
/**
- * Get effective port for CLIProxyAPI connection
- * If port is provided, use it. Otherwise use CLIProxyAPI default (8317).
+ * Get effective port for CLIProxyAPI connection.
+ * If port is provided, use it. Otherwise use protocol-based default.
*/
-function getEffectivePort(port: number | undefined): number {
- return port ?? DEFAULT_CLIPROXY_PORT;
+function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number {
+ return port ?? getDefaultPort(protocol);
}
/**
@@ -83,7 +91,7 @@ function buildProxyUrl(
protocol: 'http' | 'https',
path: string
): string {
- const effectivePort = getEffectivePort(port);
+ const effectivePort = getEffectivePort(port, protocol);
const standardWebPort = getStandardWebPort(protocol);
// Only omit port from URL if it matches the standard web port for the protocol
diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts
index 69a36ded..4294b956 100644
--- a/src/cliproxy/services/variant-config-adapter.ts
+++ b/src/cliproxy/services/variant-config-adapter.ts
@@ -5,13 +5,20 @@
*/
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,
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
+import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
+
+/** First port for variant profiles (8318 = default + 1) */
+export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1;
+
+/** Maximum port offset for variants (100 ports: 8318-8417) */
+export const VARIANT_PORT_MAX_OFFSET = 100;
/** Variant configuration structure */
export interface VariantConfig {
@@ -19,6 +26,7 @@ export interface VariantConfig {
settings?: string;
account?: string;
model?: string;
+ port?: number;
}
/**
@@ -30,13 +38,41 @@ 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;
}
}
+/**
+ * Get next available port for a new variant.
+ * Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE.
+ */
+export function getNextAvailablePort(): number {
+ const variants = listVariantsFromConfig();
+ const usedPorts = new Set();
+
+ for (const name of Object.keys(variants)) {
+ const port = variants[name].port;
+ if (port) usedPorts.add(port);
+ }
+
+ // Find first available port in range
+ for (let offset = 0; offset < VARIANT_PORT_MAX_OFFSET; offset++) {
+ const port = VARIANT_PORT_BASE + offset;
+ if (!usedPorts.has(port)) {
+ return port;
+ }
+ }
+
+ const variantCount = Object.keys(variants).length;
+ throw new Error(
+ `Port limit reached (${variantCount}/${VARIANT_PORT_MAX_OFFSET} variants). ` +
+ `Delete unused variants with 'ccs cliproxy remove ' to free ports.`
+ );
+}
+
/**
* List variants from config
*/
@@ -48,17 +84,32 @@ export function listVariantsFromConfig(): Record {
const result: Record = {};
for (const name of Object.keys(variants)) {
const v = variants[name];
- result[name] = { provider: v.provider, settings: v.settings, account: v.account };
+ result[name] = {
+ provider: v.provider,
+ settings: v.settings,
+ account: v.account,
+ port: v.port,
+ };
}
return result;
}
- const config = loadConfig();
+ const config = loadConfigSafe();
const variants = config.cliproxy || {};
const result: Record = {};
for (const name of Object.keys(variants)) {
- const v = variants[name] as { provider: string; settings: string; account?: string };
- result[name] = { provider: v.provider, settings: v.settings, account: v.account };
+ const v = variants[name] as {
+ provider: string;
+ settings: string;
+ account?: string;
+ port?: number;
+ };
+ result[name] = {
+ provider: v.provider,
+ settings: v.settings,
+ account: v.account,
+ port: v.port,
+ };
}
return result;
} catch {
@@ -73,7 +124,8 @@ export function saveVariantUnified(
name: string,
provider: CLIProxyProvider,
settingsPath: string,
- account?: string
+ account?: string,
+ port?: number
): void {
const config = loadOrCreateUnifiedConfig();
@@ -92,6 +144,7 @@ export function saveVariantUnified(
provider,
account,
settings: settingsPath,
+ port,
};
saveUnifiedConfig(config);
@@ -104,7 +157,8 @@ export function saveVariantLegacy(
name: string,
provider: string,
settingsPath: string,
- account?: string
+ account?: string,
+ port?: number
): void {
const configPath = getConfigPath();
@@ -119,13 +173,16 @@ export function saveVariantLegacy(
config.cliproxy = {};
}
- const variantConfig: { provider: string; settings: string; account?: string } = {
+ const variantConfig: { provider: string; settings: string; account?: string; port?: number } = {
provider,
settings: settingsPath,
};
if (account) {
variantConfig.account = account;
}
+ if (port) {
+ variantConfig.port = port;
+ }
config.cliproxy[name] = variantConfig;
const tempPath = configPath + '.tmp';
@@ -147,7 +204,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
delete config.cliproxy.variants[name];
saveUnifiedConfig(config);
- return { provider: variant.provider, settings: variant.settings };
+ return { provider: variant.provider, settings: variant.settings, port: variant.port };
}
/**
@@ -167,7 +224,7 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul
return null;
}
- const variant = config.cliproxy[name] as { provider: string; settings: string };
+ const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number };
delete config.cliproxy[name];
if (Object.keys(config.cliproxy).length === 0) {
diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts
index 8429ce08..58e4e05d 100644
--- a/src/cliproxy/services/variant-service.ts
+++ b/src/cliproxy/services/variant-service.ts
@@ -5,16 +5,20 @@
* Supports both unified config (config.yaml) and legacy JSON format.
*/
+import * as os from 'os';
import * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider } from '../types';
import { isReservedName } from '../../config/reserved-names';
import { isUnifiedMode } from '../../config/unified-config-loader';
+import { deleteConfigForPort } from '../config-generator';
+import { deleteSessionLockForPort } from '../session-tracker';
import {
createSettingsFile,
createSettingsFileUnified,
deleteSettingsFile,
getRelativeSettingsPath,
+ updateSettingsModel,
} from './variant-settings';
import {
VariantConfig,
@@ -24,6 +28,7 @@ import {
saveVariantLegacy,
removeVariantFromUnifiedConfig,
removeVariantFromLegacyConfig,
+ getNextAvailablePort,
} from './variant-config-adapter';
// Re-export VariantConfig from adapter
@@ -80,25 +85,29 @@ export function createVariant(
account?: string
): VariantOperationResult {
try {
+ // Allocate unique port for this variant
+ const port = getNextAvailablePort();
+
let settingsPath: string;
if (isUnifiedMode()) {
- settingsPath = createSettingsFileUnified(name, provider, model);
+ settingsPath = createSettingsFileUnified(name, provider, model, port);
saveVariantUnified(
name,
provider as CLIProxyProvider,
getRelativeSettingsPath(provider, name),
- account
+ account,
+ port
);
} else {
- settingsPath = createSettingsFile(name, provider, model);
- saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account);
+ settingsPath = createSettingsFile(name, provider, model, port);
+ saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port);
}
return {
success: true,
settingsPath,
- variant: { provider, model, account },
+ variant: { provider, model, account, port },
};
} catch (error) {
return {
@@ -120,12 +129,22 @@ export function removeVariant(name: string): VariantOperationResult {
if (unifiedVariant?.settings) {
deleteSettingsFile(unifiedVariant.settings);
}
+ // Clean up port-specific config and session files
+ if (unifiedVariant?.port) {
+ deleteConfigForPort(unifiedVariant.port);
+ deleteSessionLockForPort(unifiedVariant.port);
+ }
variant = unifiedVariant;
} else {
variant = removeVariantFromLegacyConfig(name);
if (variant?.settings) {
deleteSettingsFile(variant.settings);
}
+ // Clean up port-specific config and session files
+ if (variant?.port) {
+ deleteConfigForPort(variant.port);
+ deleteSessionLockForPort(variant.port);
+ }
}
if (!variant) {
@@ -137,3 +156,67 @@ export function removeVariant(name: string): VariantOperationResult {
return { success: false, error: (error as Error).message };
}
}
+
+/** Update options for variant */
+export interface UpdateVariantOptions {
+ provider?: CLIProxyProfileName;
+ account?: string;
+ model?: string;
+}
+
+/**
+ * Update an existing CLIProxy variant
+ */
+export function updateVariant(name: string, updates: UpdateVariantOptions): VariantOperationResult {
+ try {
+ const variants = listVariantsFromConfig();
+ const existing = variants[name];
+
+ if (!existing) {
+ return { success: false, error: `Variant '${name}' not found` };
+ }
+
+ // Update model in settings file if provided
+ if (updates.model !== undefined && existing.settings) {
+ const settingsPath = existing.settings.replace(/^~/, os.homedir());
+ updateSettingsModel(settingsPath, updates.model);
+ }
+
+ // Update config entry if provider or account changed
+ if (updates.provider !== undefined || updates.account !== undefined) {
+ const newProvider = updates.provider ?? existing.provider;
+ const newAccount = updates.account !== undefined ? updates.account : existing.account;
+
+ if (isUnifiedMode()) {
+ saveVariantUnified(
+ name,
+ newProvider as CLIProxyProvider,
+ existing.settings || '',
+ newAccount || undefined,
+ existing.port
+ );
+ } else {
+ saveVariantLegacy(
+ name,
+ newProvider,
+ existing.settings || '',
+ newAccount || undefined,
+ existing.port
+ );
+ }
+ }
+
+ return {
+ success: true,
+ variant: {
+ provider: updates.provider ?? existing.provider,
+ model: updates.model ?? existing.model,
+ account: updates.account !== undefined ? updates.account : existing.account,
+ port: existing.port,
+ settings: existing.settings,
+ },
+ };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
+ }
+}
diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts
index b3c4b185..646a1a76 100644
--- a/src/cliproxy/services/variant-settings.ts
+++ b/src/cliproxy/services/variant-settings.ts
@@ -10,6 +10,7 @@ import * as path from 'path';
import * as os from 'os';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { getCcsDir } from '../../utils/config-manager';
+import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
@@ -30,8 +31,12 @@ interface SettingsFile {
/**
* Build settings env object for a variant
*/
-function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv {
- const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
+function buildSettingsEnv(
+ provider: CLIProxyProfileName,
+ model: string,
+ port: number = CLIPROXY_DEFAULT_PORT
+): SettingsEnv {
+ const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port);
return {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
@@ -87,13 +92,14 @@ export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: str
export function createSettingsFile(
name: string,
provider: CLIProxyProfileName,
- model: string
+ model: string,
+ port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = getCcsDir();
const settingsPath = getSettingsFilePath(provider, name);
const settings: SettingsFile = {
- env: buildSettingsEnv(provider, model),
+ env: buildSettingsEnv(provider, model, port),
};
ensureDir(ccsDir);
@@ -108,13 +114,14 @@ export function createSettingsFile(
export function createSettingsFileUnified(
name: string,
provider: CLIProxyProfileName,
- model: string
+ model: string,
+ port: number = CLIPROXY_DEFAULT_PORT
): string {
- const ccsDir = path.join(os.homedir(), '.ccs');
+ const ccsDir = getCcsDir(); // Use centralized function for CCS_HOME support
const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name));
const settings: SettingsFile = {
- env: buildSettingsEnv(provider, model),
+ env: buildSettingsEnv(provider, model, port),
};
ensureDir(ccsDir);
@@ -124,13 +131,43 @@ export function createSettingsFileUnified(
}
/**
- * Delete settings file if it exists
+ * Delete settings file if it exists.
+ * Uses expandPath() for cross-platform path handling.
*/
export function deleteSettingsFile(settingsPath: string): boolean {
- const resolvedPath = settingsPath.replace(/^~/, os.homedir());
+ const resolvedPath = expandPath(settingsPath);
if (fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
return true;
}
return false;
}
+
+/**
+ * Update model in an existing settings file
+ */
+export function updateSettingsModel(settingsPath: string, model: string): void {
+ const resolvedPath = settingsPath.replace(/^~/, os.homedir());
+ if (!fs.existsSync(resolvedPath)) {
+ return;
+ }
+
+ try {
+ const content = fs.readFileSync(resolvedPath, 'utf8');
+ const settings = JSON.parse(content) as SettingsFile;
+
+ if (model) {
+ settings.env = settings.env || ({} as SettingsEnv);
+ settings.env.ANTHROPIC_MODEL = model;
+ settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
+ settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
+ } else {
+ // Clear model settings to use defaults
+ delete (settings.env as unknown as Record).ANTHROPIC_MODEL;
+ }
+
+ fs.writeFileSync(resolvedPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
+ } catch {
+ // Ignore errors - settings file may be invalid
+ }
+}
diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts
index 33b3fe19..2279eeba 100644
--- a/src/cliproxy/session-tracker.ts
+++ b/src/cliproxy/session-tracker.ts
@@ -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 */
@@ -34,14 +36,28 @@ function generateSessionId(): string {
return crypto.randomBytes(8).toString('hex');
}
-/** Get path to session lock file */
-function getSessionLockPath(): string {
- return path.join(getCliproxyDir(), 'sessions.json');
+/** Get path to session lock file for specific port */
+function getSessionLockPathForPort(port: number): string {
+ if (port === CLIPROXY_DEFAULT_PORT) {
+ return path.join(getCliproxyDir(), 'sessions.json');
+ }
+ return path.join(getCliproxyDir(), `sessions-${port}.json`);
}
-/** Read session lock file (returns null if not exists or invalid) */
-function readSessionLock(): SessionLock | null {
- const lockPath = getSessionLockPath();
+/** Get path to session lock file (default port) - kept for future use */
+function _getSessionLockPath(): string {
+ return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT);
+}
+
+// Re-export for external use
+export { _getSessionLockPath as getSessionLockPath };
+
+// Export deleteSessionLockForPort for cleanup operations
+export { deleteSessionLockForPort };
+
+/** Read session lock file for specific port (returns null if not exists or invalid) */
+function readSessionLockForPort(port: number): SessionLock | null {
+ const lockPath = getSessionLockPathForPort(port);
try {
if (!fs.existsSync(lockPath)) {
return null;
@@ -62,9 +78,14 @@ function readSessionLock(): SessionLock | null {
}
}
-/** Write session lock file */
-function writeSessionLock(lock: SessionLock): void {
- const lockPath = getSessionLockPath();
+/** Read session lock file (default port, returns null if not exists or invalid) */
+function readSessionLock(): SessionLock | null {
+ return readSessionLockForPort(CLIPROXY_DEFAULT_PORT);
+}
+
+/** Write session lock file for specific port */
+function writeSessionLockForPort(lock: SessionLock): void {
+ const lockPath = getSessionLockPathForPort(lock.port);
const dir = path.dirname(lockPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -72,9 +93,9 @@ function writeSessionLock(lock: SessionLock): void {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
}
-/** Delete session lock file */
-function deleteSessionLock(): void {
- const lockPath = getSessionLockPath();
+/** Delete session lock file for specific port */
+function deleteSessionLockForPort(port: number): void {
+ const lockPath = getSessionLockPathForPort(port);
try {
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
@@ -84,23 +105,51 @@ function deleteSessionLock(): void {
}
}
+/** Delete session lock file (default port) */
+function deleteSessionLock(): void {
+ deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
+}
+
/** Check if a PID is still running */
function isProcessRunning(pid: number): boolean {
try {
// Sending signal 0 checks if process exists without killing it
process.kill(pid, 0);
return true;
- } catch {
+ } catch (err) {
+ const e = err as NodeJS.ErrnoException;
+ // EPERM means process exists but we don't have permission to signal it
+ if (e.code === 'EPERM') {
+ return true;
+ }
+ // ESRCH means no such process
return false;
}
}
+/**
+ * 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 {
+ 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.
*/
export function getExistingProxy(port: number): SessionLock | null {
- const lock = readSessionLock();
+ const lock = readSessionLockForPort(port);
if (!lock) {
return null;
}
@@ -113,7 +162,7 @@ export function getExistingProxy(port: number): SessionLock | null {
// Verify proxy process is still running
if (!isProcessRunning(lock.pid)) {
// Proxy crashed - clean up stale lock
- deleteSessionLock();
+ deleteSessionLockForPort(port);
return null;
}
@@ -123,16 +172,19 @@ 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 = readSessionLock();
+ const existingLock = readSessionLockForPort(port);
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
// Add to existing sessions
existingLock.sessions.push(sessionId);
- writeSessionLock(existingLock);
+ writeSessionLockForPort(existingLock);
} else {
// Create new lock (first session for this proxy)
const newLock: SessionLock = {
@@ -140,8 +192,9 @@ export function registerSession(port: number, proxyPid: number): string {
pid: proxyPid,
sessions: [sessionId],
startedAt: new Date().toISOString(),
+ version,
};
- writeSessionLock(newLock);
+ writeSessionLockForPort(newLock);
}
return sessionId;
@@ -149,9 +202,33 @@ export function registerSession(port: number, proxyPid: number): string {
/**
* Unregister a session from the proxy.
+ * @param sessionId Session ID to unregister
+ * @param port Port to unregister from (optional, searches default port if not provided)
* @returns true if this was the last session (proxy should be killed)
*/
-export function unregisterSession(sessionId: string): boolean {
+export function unregisterSession(sessionId: string, port?: number): boolean {
+ // If port provided, use port-specific lookup
+ if (port !== undefined) {
+ const lock = readSessionLockForPort(port);
+ if (!lock) {
+ return true;
+ }
+
+ const index = lock.sessions.indexOf(sessionId);
+ if (index !== -1) {
+ lock.sessions.splice(index, 1);
+ }
+
+ if (lock.sessions.length === 0) {
+ deleteSessionLockForPort(port);
+ return true;
+ }
+
+ writeSessionLockForPort(lock);
+ return false;
+ }
+
+ // Fallback: search default port (backward compat)
const lock = readSessionLock();
if (!lock) {
// No lock file - assume we're the only session
@@ -172,15 +249,16 @@ export function unregisterSession(sessionId: string): boolean {
}
// Other sessions still active - keep proxy running
- writeSessionLock(lock);
+ writeSessionLockForPort(lock);
return false;
}
/**
* Get current session count for the proxy.
+ * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
*/
-export function getSessionCount(): number {
- const lock = readSessionLock();
+export function getSessionCount(port: number = CLIPROXY_DEFAULT_PORT): number {
+ const lock = readSessionLockForPort(port);
if (!lock) {
return 0;
}
@@ -190,16 +268,17 @@ export function getSessionCount(): number {
/**
* Check if proxy has any active sessions.
* Used to determine if a "zombie" proxy should be killed.
+ * @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
*/
-export function hasActiveSessions(): boolean {
- const lock = readSessionLock();
+export function hasActiveSessions(port: number = CLIPROXY_DEFAULT_PORT): boolean {
+ const lock = readSessionLockForPort(port);
if (!lock) {
return false;
}
// Verify proxy is still running
if (!isProcessRunning(lock.pid)) {
- deleteSessionLock();
+ deleteSessionLockForPort(port);
return false;
}
@@ -211,38 +290,39 @@ export function hasActiveSessions(): boolean {
* Called on startup to ensure clean state.
*/
export function cleanupOrphanedSessions(port: number): void {
- const lock = readSessionLock();
+ const lock = readSessionLockForPort(port);
if (!lock) {
return;
}
- // If port doesn't match, this lock is for a different proxy
+ // If port doesn't match, this shouldn't happen with port-specific files
if (lock.port !== port) {
return;
}
// If proxy is dead, clean up lock
if (!isProcessRunning(lock.pid)) {
- deleteSessionLock();
+ deleteSessionLockForPort(port);
}
}
/**
* Stop the CLIProxy process and clean up session lock.
* Falls back to port-based detection if no session lock exists.
+ * @param port Port to stop (defaults to CLIPROXY_DEFAULT_PORT)
* @returns Object with success status and details
*/
-export async function stopProxy(): Promise<{
+export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
stopped: boolean;
pid?: number;
sessionCount?: number;
error?: string;
}> {
- const lock = readSessionLock();
+ const lock = readSessionLockForPort(port);
if (!lock) {
// No session lock - try to find process by port (legacy/untracked proxy)
- const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
+ const portProcess = await getPortProcess(port);
if (!portProcess) {
return { stopped: false, error: 'No active CLIProxy session found' };
@@ -251,13 +331,26 @@ export async function stopProxy(): Promise<{
if (!isCLIProxyProcess(portProcess)) {
return {
stopped: false,
- error: `Port ${CLIPROXY_DEFAULT_PORT} is in use by ${portProcess.processName}, not CLIProxy`,
+ error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`,
};
}
// 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;
@@ -270,7 +363,7 @@ export async function stopProxy(): Promise<{
// Check if proxy is running
if (!isProcessRunning(lock.pid)) {
- deleteSessionLock();
+ deleteSessionLockForPort(port);
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
}
@@ -281,15 +374,27 @@ export async function stopProxy(): 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
- deleteSessionLock();
+ deleteSessionLockForPort(port);
return { stopped: true, pid, sessionCount };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
// Process already gone
- deleteSessionLock();
+ deleteSessionLockForPort(port);
return { stopped: false, error: 'CLIProxy process already terminated' };
}
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
@@ -297,16 +402,17 @@ export async function stopProxy(): Promise<{
}
/**
- * Get proxy status information.
+ * Get proxy status information for specific port.
*/
-export function getProxyStatus(): {
+export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
running: boolean;
port?: number;
pid?: number;
sessionCount?: number;
startedAt?: string;
+ version?: string;
} {
- const lock = readSessionLock();
+ const lock = readSessionLockForPort(port);
if (!lock) {
return { running: false };
@@ -314,7 +420,7 @@ export function getProxyStatus(): {
// Verify proxy is still running
if (!isProcessRunning(lock.pid)) {
- deleteSessionLock();
+ deleteSessionLockForPort(port);
return { running: false };
}
@@ -324,5 +430,26 @@ export function getProxyStatus(): {
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;
+}
diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts
index f66e987d..ee74b8a9 100644
--- a/src/cliproxy/stats-fetcher.ts
+++ b/src/cliproxy/stats-fetcher.ts
@@ -5,7 +5,7 @@
* Requires usage-statistics-enabled: true in config.yaml.
*/
-import { CCS_CONTROL_PANEL_SECRET } from './config-generator';
+import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
/** Per-account usage statistics */
@@ -112,7 +112,7 @@ export async function fetchCliproxyStats(port?: number): Promise {
console.log('');
console.log(subheader('Options'));
console.log(
- ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry)`
+ ` ${color('--preset ', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry, minimax, deepseek, qwen)`
);
console.log(` ${color('--base-url ', 'command')} API base URL (create)`);
console.log(` ${color('--api-key ', 'command')} API key (create)`);
@@ -442,6 +442,11 @@ async function showHelp(): Promise {
console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`);
console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`);
console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`);
+ console.log(` ${color('minimax', 'command')} Minimax - M2 series with 1M context`);
+ console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`);
+ console.log(
+ ` ${color('qwen', 'command')} Qwen - Alibaba Cloud qwen3-coder-plus (256K)`
+ );
console.log('');
console.log(subheader('Examples'));
console.log(` ${dim('# Interactive wizard')}`);
diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts
index 2c14804b..e0e93e35 100644
--- a/src/commands/cliproxy-command.ts
+++ b/src/commands/cliproxy-command.ts
@@ -256,9 +256,10 @@ async function handleCreate(args: string[]): Promise {
const settingsDisplay = isUnifiedMode()
? '~/.ccs/config.yaml'
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
+ const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
console.log(
infoBox(
- `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
+ `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
configType
)
);
@@ -303,10 +304,11 @@ async function handleList(): Promise {
console.log(subheader('Custom Variants'));
const rows = variantNames.map((name) => {
const variant = variants[name];
- return [name, variant.provider, variant.settings || '-'];
+ const portStr = variant.port ? String(variant.port) : '-';
+ return [name, variant.provider, portStr, variant.settings || '-'];
});
console.log(
- table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] })
+ table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] })
);
console.log('');
console.log(dim(`Total: ${variantNames.length} custom variant(s)`));
@@ -357,6 +359,9 @@ async function handleRemove(args: string[]): Promise {
console.log('');
console.log(`Variant '${color(name, 'command')}' will be removed.`);
console.log(` Provider: ${variant.provider}`);
+ if (variant.port) {
+ console.log(` Port: ${variant.port}`);
+ }
console.log(` Settings: ${variant.settings || '-'}`);
console.log('');
diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts
index 6261dc2a..abb4ee01 100644
--- a/src/commands/help-command.ts
+++ b/src/commands/help-command.ts
@@ -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,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs --config', 'Change model (agy, gemini)'],
['ccs --logout', 'Clear authentication'],
['ccs --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'],
]
);
@@ -213,6 +216,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// Diagnostics
printSubSection('Diagnostics', [
+ ['ccs setup', 'First-time setup wizard'],
['ccs doctor', 'Run health check and diagnostics'],
['ccs cleanup', 'Remove old CLIProxy logs'],
['ccs config', 'Open web configuration dashboard'],
@@ -232,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'],
diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts
new file mode 100644
index 00000000..5b7acf5a
--- /dev/null
+++ b/src/commands/setup-command.ts
@@ -0,0 +1,470 @@
+/**
+ * Setup Command Handler
+ *
+ * Interactive first-time setup wizard for CCS.
+ * Guides users through initial configuration including:
+ * - Local vs Remote CLIProxy mode selection
+ * - Remote proxy configuration (host, port, auth token)
+ * - Default profile selection
+ *
+ * Usage: ccs setup
+ *
+ * Related: Issue #142 - remote CLIProxyAPI configuration
+ */
+
+import * as readline from 'readline';
+import * as fs from 'fs';
+import * as path from 'path';
+import { initUI, header, ok, info, warn } from '../utils/ui';
+import {
+ loadOrCreateUnifiedConfig,
+ loadUnifiedConfig,
+ saveUnifiedConfig,
+ hasUnifiedConfig,
+} from '../config/unified-config-loader';
+import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
+import { getCcsDir } from '../utils/config-manager';
+
+/** Custom error for user cancellation (Ctrl+C) */
+class UserCancelledError extends Error {
+ constructor() {
+ super('Setup cancelled by user');
+ this.name = 'UserCancelledError';
+ }
+}
+
+/**
+ * Create readline interface for interactive prompts
+ */
+function createReadline(): readline.Interface {
+ return readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+}
+
+/**
+ * Prompt user for input with optional default value
+ * Handles Ctrl+C gracefully by rejecting with UserCancelledError
+ */
+async function prompt(
+ rl: readline.Interface,
+ question: string,
+ defaultValue?: string
+): Promise {
+ return new Promise((resolve, reject) => {
+ const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
+
+ const onClose = () => {
+ reject(new UserCancelledError());
+ };
+
+ rl.once('close', onClose);
+ rl.question(displayQuestion, (answer) => {
+ rl.removeListener('close', onClose);
+ resolve(answer.trim() || defaultValue || '');
+ });
+ });
+}
+
+/**
+ * Prompt user for yes/no confirmation
+ */
+async function confirm(
+ rl: readline.Interface,
+ question: string,
+ defaultYes: boolean = true
+): Promise {
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
+ const answer = await prompt(rl, `${question} ${hint}`);
+
+ if (answer === '') return defaultYes;
+ return answer.toLowerCase().startsWith('y');
+}
+
+/**
+ * Prompt user to select from numbered options
+ */
+async function selectOption(
+ rl: readline.Interface,
+ question: string,
+ options: { label: string; value: string; description?: string }[]
+): Promise {
+ console.log('');
+ console.log(question);
+ console.log('');
+
+ options.forEach((opt, idx) => {
+ const desc = opt.description ? ` - ${opt.description}` : '';
+ console.log(` ${idx + 1}) ${opt.label}${desc}`);
+ });
+
+ console.log('');
+ const answer = await prompt(rl, 'Enter choice (number)', '1');
+ const idx = parseInt(answer, 10) - 1;
+
+ if (idx >= 0 && idx < options.length) {
+ return options[idx].value;
+ }
+
+ // Invalid selection, default to first
+ console.log(warn(`Invalid selection, using default: ${options[0].label}`));
+ return options[0].value;
+}
+
+/**
+ * Check if this is a first-time install (config exists but is empty/unconfigured)
+ * Returns true if user should be prompted to run setup wizard
+ *
+ * IMPORTANT: Also checks legacy config.json for existing profiles to avoid
+ * treating users with existing GLM/Kimi setups as "first-time installs"
+ * (Fix for issue #195 - GLM auth persistence regression)
+ */
+export function isFirstTimeInstall(): boolean {
+ // Check unified config first (config.yaml)
+ if (hasUnifiedConfig()) {
+ const loaded = loadUnifiedConfig();
+
+ // Config exists but is corrupted/invalid - don't treat as first-time
+ if (loaded === null) {
+ console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted'));
+ console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose'));
+ return false;
+ }
+
+ // Check for any meaningful configuration in unified config
+ const hasProfiles = Object.keys(loaded.profiles || {}).length > 0;
+ const hasAccounts = Object.keys(loaded.accounts || {}).length > 0;
+ const hasVariants = Object.keys(loaded.cliproxy?.variants || {}).length > 0;
+ const hasOAuthAccounts = Object.keys(loaded.cliproxy?.oauth_accounts || {}).length > 0;
+ const hasRemoteProxy =
+ loaded.cliproxy_server?.remote?.enabled && loaded.cliproxy_server?.remote?.host;
+
+ // If any of these exist in unified config, user has configured something
+ if (hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy) {
+ return false;
+ }
+ }
+
+ // Also check legacy config.json for existing profiles
+ // This prevents treating users with GLM/Kimi in config.json as "first-time installs"
+ const ccsDir = getCcsDir();
+ const legacyConfigPath = path.join(ccsDir, 'config.json');
+
+ if (fs.existsSync(legacyConfigPath)) {
+ try {
+ const content = fs.readFileSync(legacyConfigPath, 'utf8');
+ const legacyConfig = JSON.parse(content) as { profiles?: Record };
+
+ if (legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0) {
+ // Has legacy profiles - NOT first time
+ return false;
+ }
+ } catch {
+ // Legacy config exists but is invalid - ignore and continue
+ }
+ }
+
+ // Also check profiles.json for existing accounts
+ const legacyProfilesPath = path.join(ccsDir, 'profiles.json');
+
+ if (fs.existsSync(legacyProfilesPath)) {
+ try {
+ const content = fs.readFileSync(legacyProfilesPath, 'utf8');
+ const legacyProfiles = JSON.parse(content) as { profiles?: Record };
+
+ if (legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0) {
+ // Has legacy accounts - NOT first time
+ return false;
+ }
+ } catch {
+ // Legacy profiles exists but is invalid - ignore and continue
+ }
+ }
+
+ // No meaningful configuration found anywhere
+ return true;
+}
+
+/**
+ * Configure remote CLIProxy settings interactively
+ */
+async function configureRemoteProxy(rl: readline.Interface): Promise<{
+ host: string;
+ port?: number;
+ protocol: 'http' | 'https';
+ authToken: string;
+}> {
+ console.log('');
+ console.log(info('Configure Remote CLIProxyAPI Connection'));
+ console.log('');
+ console.log(' Enter the details for your remote CLIProxyAPI server.');
+ console.log(' Example: your-server.example.com');
+ console.log('');
+
+ // Host - with protocol stripping
+ let host = await prompt(rl, 'Remote host (hostname or IP)');
+ if (!host) {
+ throw new Error('Host is required for remote proxy mode');
+ }
+ // Strip protocol if user included it (common mistake)
+ host = host.replace(/^https?:\/\//, '');
+ // Strip trailing slashes
+ host = host.replace(/\/+$/, '');
+
+ // Protocol
+ const protocol = (await selectOption(rl, 'Protocol:', [
+ { label: 'HTTPS', value: 'https', description: 'Secure connection (recommended)' },
+ { label: 'HTTP', value: 'http', description: 'Unencrypted connection' },
+ ])) as 'http' | 'https';
+
+ // Port (optional) - with validation
+ const defaultPort = protocol === 'https' ? '443' : '80';
+ const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`);
+ let port: number | undefined;
+ if (portStr) {
+ const parsed = parseInt(portStr, 10);
+ if (isNaN(parsed) || parsed < 1 || parsed > 65535 || !Number.isInteger(parsed)) {
+ console.log(warn(`Invalid port "${portStr}", using default: ${defaultPort}`));
+ port = undefined; // Use default
+ } else {
+ port = parsed;
+ }
+ }
+
+ // Auth token
+ console.log('');
+ console.log(info('Authentication'));
+ console.log(' The auth token is configured in your CLIProxyAPI config.yaml');
+ console.log(' under api-keys section. Example: "ccs-internal-managed"');
+ console.log('');
+
+ const authToken = await prompt(rl, 'Auth token', 'ccs-internal-managed');
+
+ return { host, port, protocol, authToken };
+}
+
+/**
+ * Main setup wizard
+ */
+async function runSetupWizard(force: boolean = false): Promise {
+ const rl = createReadline();
+
+ try {
+ console.log('');
+ console.log(header('CCS First-Time Setup'));
+ console.log('');
+
+ // Check if already configured
+ if (!force && !isFirstTimeInstall()) {
+ console.log(info('CCS is already configured.'));
+ console.log(' Use --force to reconfigure, or run `ccs config` for the dashboard.');
+ console.log('');
+ rl.close();
+ return;
+ }
+
+ console.log('Welcome to CCS (Claude Code Switch)!');
+ console.log('This wizard will help you configure CCS for first-time use.');
+ console.log('');
+
+ // Step 1: Local vs Remote mode
+ const proxyMode = await selectOption(
+ rl,
+ 'How do you want to use CLIProxy providers (gemini, codex, agy)?',
+ [
+ {
+ label: 'Local (Recommended)',
+ value: 'local',
+ description: 'CCS auto-starts CLIProxyAPI binary on your machine',
+ },
+ {
+ label: 'Remote Server',
+ value: 'remote',
+ description: 'Connect to a remote CLIProxyAPI instance (Issue #142)',
+ },
+ {
+ label: 'Skip CLIProxy',
+ value: 'skip',
+ description: 'Only use API profiles (GLM, Kimi) or Claude accounts',
+ },
+ ]
+ );
+
+ // Load or create config
+ const config = loadOrCreateUnifiedConfig();
+
+ if (proxyMode === 'remote') {
+ // Configure remote proxy
+ const remoteConfig = await configureRemoteProxy(rl);
+
+ config.cliproxy_server = {
+ remote: {
+ enabled: true,
+ host: remoteConfig.host,
+ port: remoteConfig.port,
+ protocol: remoteConfig.protocol,
+ auth_token: remoteConfig.authToken,
+ },
+ fallback: {
+ enabled: true,
+ auto_start: false,
+ },
+ local: {
+ port: 8317,
+ auto_start: false, // Disable local auto-start when using remote
+ },
+ };
+
+ console.log('');
+ console.log(ok('Remote proxy configured successfully!'));
+ console.log('');
+ console.log(
+ ` URL: ${remoteConfig.protocol}://${remoteConfig.host}${remoteConfig.port ? `:${remoteConfig.port}` : ''}`
+ );
+ console.log(` Auth: ${remoteConfig.authToken ? '[configured]' : '[none]'}`);
+ } else if (proxyMode === 'local') {
+ // Ensure local mode is configured
+ config.cliproxy_server = {
+ ...DEFAULT_CLIPROXY_SERVER_CONFIG,
+ remote: {
+ enabled: false,
+ host: '',
+ protocol: 'http',
+ auth_token: '',
+ },
+ local: {
+ port: 8317,
+ auto_start: true,
+ },
+ };
+
+ console.log('');
+ console.log(ok('Local proxy mode configured!'));
+ console.log(' CLIProxyAPI will auto-start when you use gemini/codex/agy profiles.');
+ } else {
+ // Skip CLIProxy - just use local config
+ console.log('');
+ console.log(ok('CLIProxy skipped.'));
+ console.log(' You can still use API profiles (GLM, Kimi) or Claude accounts.');
+ }
+
+ // Step 2: Ask about API profiles
+ console.log('');
+ const wantsApiProfile = await confirm(
+ rl,
+ 'Do you want to set up an API profile (GLM, Kimi, custom)?',
+ false
+ );
+
+ if (wantsApiProfile) {
+ console.log('');
+ console.log(info('Creating API profiles...'));
+ console.log(' Use the following commands to create profiles:');
+ console.log('');
+ console.log(' ccs api create glm --preset glm');
+ console.log(' ccs api create kimi --preset kimi');
+ console.log(' ccs api create custom --prompt');
+ console.log('');
+ console.log(' After creating, edit the settings file to add your API key.');
+ }
+
+ // Save config
+ saveUnifiedConfig(config);
+
+ // Final summary
+ console.log('');
+ console.log(header('Setup Complete!'));
+ console.log('');
+ console.log('Quick start commands:');
+ console.log('');
+
+ if (proxyMode !== 'skip') {
+ console.log(' ccs gemini # Use Gemini via CLIProxy (OAuth)');
+ console.log(' ccs codex # Use Codex via CLIProxy (OAuth)');
+ console.log(' ccs agy # Use Antigravity via CLIProxy (OAuth)');
+ }
+
+ console.log(' ccs # Use default Claude CLI');
+ console.log(' ccs config # Open web dashboard');
+ console.log(' ccs doctor # Check configuration health');
+ console.log('');
+
+ if (proxyMode === 'remote') {
+ console.log(info('Remote proxy tip:'));
+ console.log(' If connection fails, CCS will offer to start local proxy as fallback.');
+ console.log(' Edit ~/.ccs/config.yaml to adjust remote settings.');
+ console.log('');
+ }
+
+ console.log(info('Configuration saved to: ~/.ccs/config.yaml'));
+ console.log('');
+ } catch (err) {
+ // Handle user cancellation gracefully
+ if (err instanceof UserCancelledError) {
+ console.log('');
+ console.log(info('Setup cancelled.'));
+ console.log(' Run `ccs setup` when ready to configure.');
+ console.log('');
+ return;
+ }
+ // Handle other errors with user-friendly message
+ const message = err instanceof Error ? err.message : String(err);
+ console.log('');
+ console.log(warn(`Setup failed: ${message}`));
+ console.log(info(' Run `ccs setup` to try again.'));
+ console.log('');
+ } finally {
+ rl.close();
+ }
+}
+
+/**
+ * Parse command line arguments
+ */
+function parseArgs(args: string[]): { force: boolean; help: boolean } {
+ return {
+ force: args.includes('--force') || args.includes('-f'),
+ help: args.includes('--help') || args.includes('-h'),
+ };
+}
+
+/**
+ * Show help message
+ */
+function showHelp(): void {
+ console.log('');
+ console.log('Usage: ccs setup [options]');
+ console.log('');
+ console.log('Interactive first-time setup wizard for CCS.');
+ console.log('');
+ console.log('Options:');
+ console.log(' --force, -f Force setup even if already configured');
+ console.log(' --help, -h Show this help message');
+ console.log('');
+ console.log('This wizard helps you configure:');
+ console.log(' - Local vs Remote CLIProxy mode');
+ console.log(' - Remote proxy connection (host, port, auth token)');
+ console.log(' - API profile creation');
+ console.log('');
+ console.log('Examples:');
+ console.log(' ccs setup Run setup wizard');
+ console.log(' ccs setup --force Force reconfiguration');
+ console.log('');
+}
+
+/**
+ * Handle setup command
+ */
+export async function handleSetupCommand(args: string[]): Promise {
+ await initUI();
+
+ const options = parseArgs(args);
+
+ if (options.help) {
+ showHelp();
+ return;
+ }
+
+ await runSetupWizard(options.force);
+}
diff --git a/src/commands/tokens-command.ts b/src/commands/tokens-command.ts
new file mode 100644
index 00000000..b595c249
--- /dev/null
+++ b/src/commands/tokens-command.ts
@@ -0,0 +1,199 @@
+/**
+ * Tokens Command
+ *
+ * Manage CLIProxyAPI auth tokens (API key and management secret).
+ *
+ * Usage:
+ * ccs tokens Show current tokens (masked)
+ * ccs tokens --show Show tokens unmasked
+ * ccs tokens --api-key Set global API key
+ * ccs tokens --secret Set management secret
+ * ccs tokens --regenerate-secret Auto-generate new management secret
+ * ccs tokens --reset Reset to defaults
+ * ccs tokens --variant --api-key Set per-variant API key
+ */
+
+import { initUI, ok, info, fail, warn, color, dim, header, subheader } from '../utils/ui';
+import {
+ generateSecureToken,
+ maskToken,
+ setGlobalApiKey,
+ setGlobalManagementSecret,
+ setVariantApiKey,
+ resetAuthToDefaults,
+ getAuthSummary,
+} from '../cliproxy';
+import { regenerateConfig } from '../cliproxy/config-generator';
+
+/**
+ * Display current auth status
+ */
+async function showAuthStatus(showUnmasked: boolean): Promise {
+ await initUI();
+
+ const summary = getAuthSummary();
+
+ console.log(header('CLIProxy Auth Tokens'));
+ console.log('');
+
+ // API Key
+ const apiKeyDisplay = showUnmasked ? summary.apiKey.value : maskToken(summary.apiKey.value);
+ const apiKeyStatus = summary.apiKey.isCustom ? color('(custom)', 'warning') : dim('(default)');
+ console.log(` API Key: ${apiKeyDisplay} ${apiKeyStatus}`);
+
+ // Management Secret
+ const secretDisplay = showUnmasked
+ ? summary.managementSecret.value
+ : maskToken(summary.managementSecret.value);
+ const secretStatus = summary.managementSecret.isCustom
+ ? color('(custom)', 'warning')
+ : dim('(default)');
+ console.log(` Management Secret: ${secretDisplay} ${secretStatus}`);
+
+ console.log('');
+
+ if (showUnmasked) {
+ console.log(warn('Tokens shown in plain text. Do not share!'));
+ console.log('');
+ }
+
+ console.log(subheader('Usage'));
+ console.log(` ${dim('Set API key:')} ccs tokens --api-key `);
+ console.log(` ${dim('Set secret:')} ccs tokens --secret `);
+ console.log(` ${dim('Generate secret:')} ccs tokens --regenerate-secret`);
+ console.log(` ${dim('Reset to defaults:')} ccs tokens --reset`);
+ console.log('');
+}
+
+/**
+ * Handle tokens command
+ */
+export async function handleTokensCommand(args: string[]): Promise {
+ await initUI();
+
+ // Parse flags
+ const showFlag = args.includes('--show');
+ const resetFlag = args.includes('--reset');
+ const regenerateSecretFlag = args.includes('--regenerate-secret');
+ const helpFlag = args.includes('--help') || args.includes('-h');
+
+ // Find --api-key value
+ const apiKeyIndex = args.indexOf('--api-key');
+ const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
+
+ // Find --secret value
+ const secretIndex = args.indexOf('--secret');
+ const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined;
+
+ // Find --variant value
+ const variantIndex = args.indexOf('--variant');
+ const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined;
+
+ // Help
+ if (helpFlag) {
+ console.log(header('CCS Tokens Management'));
+ console.log('');
+ console.log(subheader('Usage'));
+ console.log(` ${color('ccs tokens', 'command')} [options]`);
+ console.log('');
+ console.log(subheader('Options'));
+ console.log(` ${color('(no args)', 'command')} Show masked tokens`);
+ console.log(` ${color('--show', 'command')} Show tokens unmasked`);
+ console.log(` ${color('--api-key ', 'command')} Set global API key`);
+ console.log(` ${color('--secret ', 'command')} Set management secret`);
+ console.log(` ${color('--regenerate-secret', 'command')} Generate new management secret`);
+ console.log(
+ ` ${color('--variant ', 'command')} Target specific variant (with --api-key)`
+ );
+ console.log(` ${color('--reset', 'command')} Reset all to defaults`);
+ console.log(` ${color('--help, -h', 'command')} Show this help`);
+ console.log('');
+ console.log(subheader('Examples'));
+ console.log(` ${dim('# View current tokens')}`);
+ console.log(` ccs tokens`);
+ console.log('');
+ console.log(` ${dim('# Set custom API key')}`);
+ console.log(` ccs tokens --api-key my-custom-key-123`);
+ console.log('');
+ console.log(` ${dim('# Generate secure management secret')}`);
+ console.log(` ccs tokens --regenerate-secret`);
+ console.log('');
+ console.log(` ${dim('# Set per-variant API key')}`);
+ console.log(` ccs tokens --variant my-gemini --api-key variant-key-456`);
+ console.log('');
+ return 0;
+ }
+
+ // Reset to defaults
+ if (resetFlag) {
+ resetAuthToDefaults();
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+ console.log(ok('Auth tokens reset to defaults'));
+ console.log(info('CLIProxy config regenerated'));
+ return 0;
+ }
+
+ // Regenerate management secret
+ if (regenerateSecretFlag) {
+ const newSecret = generateSecureToken(32);
+ setGlobalManagementSecret(newSecret);
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+ console.log(ok('New management secret generated'));
+ console.log(` Secret: ${maskToken(newSecret)}`);
+ console.log(info('CLIProxy config regenerated'));
+ console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
+ return 0;
+ }
+
+ // Set API key
+ if (apiKeyValue !== undefined) {
+ if (!apiKeyValue || apiKeyValue.startsWith('-')) {
+ console.error(fail('Missing value for --api-key'));
+ return 1;
+ }
+
+ if (variantValue) {
+ // Per-variant API key
+ try {
+ setVariantApiKey(variantValue, apiKeyValue);
+ console.log(ok(`API key set for variant '${variantValue}'`));
+ } catch (err) {
+ const error = err instanceof Error ? err.message : 'Unknown error';
+ console.error(fail(error));
+ return 1;
+ }
+ } else {
+ // Global API key
+ setGlobalApiKey(apiKeyValue);
+ console.log(ok('Global API key updated'));
+ }
+
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+ console.log(info('CLIProxy config regenerated'));
+ console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
+ return 0;
+ }
+
+ // Set management secret
+ if (secretValue !== undefined) {
+ if (!secretValue || secretValue.startsWith('-')) {
+ console.error(fail('Missing value for --secret'));
+ return 1;
+ }
+
+ setGlobalManagementSecret(secretValue);
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+ console.log(ok('Management secret updated'));
+ console.log(info('CLIProxy config regenerated'));
+ console.log(warn('Restart CLIProxy to apply: ccs cliproxy restart'));
+ return 0;
+ }
+
+ // Default: show status
+ await showAuthStatus(showFlag);
+ return 0;
+}
diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts
index 142c57e6..9fa347d4 100644
--- a/src/config/migration-manager.ts
+++ b/src/config/migration-manager.ts
@@ -15,9 +15,10 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
+import { expandPath } from '../utils/helpers';
import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types';
import { createEmptyUnifiedConfig } from './unified-config-types';
-import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader';
+import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
import { infoBox, warn } from '../utils/ui';
const BACKUP_DIR_PREFIX = 'backup-v1-';
@@ -44,6 +45,58 @@ export function needsMigration(): boolean {
return hasOldConfig && !hasNewConfig;
}
+/**
+ * Pre-loaded config data for TOCTOU-safe migration checks.
+ * Read once, use for both check and migration.
+ */
+export interface MigrationCheckData {
+ legacyConfig: Record | null;
+ unifiedConfig: ReturnType;
+ needsMigration: boolean;
+}
+
+/**
+ * Load config files once for TOCTOU-safe migration operations.
+ * Returns data that can be passed to migration functions.
+ */
+export function loadMigrationCheckData(): MigrationCheckData {
+ const ccsDir = getCcsDir();
+ const configJsonPath = path.join(ccsDir, 'config.json');
+
+ const legacyConfig = fs.existsSync(configJsonPath) ? readJsonSafe(configJsonPath) : null;
+ const unifiedConfig = loadUnifiedConfig();
+
+ // Determine if migration is needed
+ let needsMigration = false;
+
+ if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) {
+ const legacyProfiles = legacyConfig.profiles as Record;
+ for (const profileName of Object.keys(legacyProfiles)) {
+ if (!unifiedConfig.profiles[profileName]) {
+ needsMigration = true;
+ break;
+ }
+ }
+ }
+
+ return { legacyConfig, unifiedConfig, needsMigration };
+}
+
+/**
+ * Check if there are legacy profiles that haven't been migrated to config.yaml.
+ * This catches the case where config.yaml exists but is empty/missing profiles
+ * that are still in config.json.
+ *
+ * Used by autoMigrate() to trigger inline migration when needed.
+ * (Fix for issue #195 - GLM auth persistence regression)
+ *
+ * Note: For TOCTOU-safe operations, use loadMigrationCheckData() instead.
+ */
+export function needsProfileMigration(): boolean {
+ const data = loadMigrationCheckData();
+ return data.needsMigration;
+}
+
/**
* Get list of backup directories.
*/
@@ -167,7 +220,13 @@ export async function migrate(dryRun = false): Promise {
}
}
- // 7. Migrate cache files
+ // 7. Write new config FIRST (before moving files - H8 fix)
+ // Note: Settings remain in *.settings.json files, config.yaml only stores references
+ if (!dryRun) {
+ saveUnifiedConfig(unifiedConfig);
+ }
+
+ // 8. Migrate cache files AFTER config is saved (H8: prevents inconsistent state)
if (!dryRun) {
const cacheDir = path.join(ccsDir, 'cache');
if (!fs.existsSync(cacheDir)) {
@@ -188,12 +247,6 @@ export async function migrate(dryRun = false): Promise {
}
}
- // 8. Write new config (unless dry run)
- // Note: Settings remain in *.settings.json files, config.yaml only stores references
- if (!dryRun) {
- saveUnifiedConfig(unifiedConfig);
- }
-
return {
success: true,
backupPath: dryRun ? undefined : backupDir,
@@ -270,13 +323,6 @@ function readJsonSafe(filePath: string): Record | null {
}
}
-/**
- * Expand ~ to home directory in path.
- */
-function expandPath(p: string): string {
- return p.replace(/^~/, process.env.HOME || '');
-}
-
/**
* Move file if it exists. Returns true if moved, false if source didn't exist.
*/
@@ -326,6 +372,9 @@ async function performBackup(srcDir: string, backupDir: string): Promise {
* Auto-migrate on first run after update.
* Silent if already migrated or no config exists.
* Shows friendly message with backup location on success.
+ *
+ * Also handles inline profile migration when config.yaml exists but is missing
+ * profiles from config.json (Fix for issue #195).
*/
export async function autoMigrate(): Promise {
// Skip in test environment
@@ -333,30 +382,122 @@ export async function autoMigrate(): Promise {
return;
}
- // Skip if no migration needed
- if (!needsMigration()) {
+ // Check if full migration is needed (no config.yaml exists)
+ if (needsMigration()) {
+ const result = await migrate(false);
+
+ if (result.success) {
+ console.log('');
+ console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS'));
+ console.log(` Backup: ${result.backupPath}`);
+ console.log(` Items: ${result.migratedFiles.length} migrated`);
+ if (result.warnings.length > 0) {
+ for (const warning of result.warnings) {
+ console.log(warn(warning));
+ }
+ }
+ console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`);
+ console.log('');
+ } else {
+ console.log('');
+ console.log(infoBox('Migration failed - using legacy config', 'WARNING'));
+ console.log(` Error: ${result.error}`);
+ console.log(' Retry: ccs migrate');
+ console.log('');
+ }
return;
}
- const result = await migrate(false);
-
- if (result.success) {
- console.log('');
- console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS'));
- console.log(` Backup: ${result.backupPath}`);
- console.log(` Items: ${result.migratedFiles.length} migrated`);
- if (result.warnings.length > 0) {
- for (const warning of result.warnings) {
- console.log(warn(warning));
+ // Check if inline profile migration is needed (config.yaml exists but missing profiles)
+ // CRIT-2: Load data once and pass to migrateProfilesToUnified to avoid TOCTOU race
+ const migrationData = loadMigrationCheckData();
+ if (migrationData.needsMigration) {
+ const result = await migrateProfilesToUnified(migrationData);
+ if (result.success && result.migratedFiles.length > 0) {
+ console.log('');
+ console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS'));
+ console.log(` Profiles: ${result.migratedFiles.join(', ')}`);
+ // H7: Show collision warnings if any
+ if (result.warnings.length > 0) {
+ for (const warning of result.warnings) {
+ console.log(warn(warning));
+ }
}
+ console.log('');
}
- console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`);
- console.log('');
- } else {
- console.log('');
- console.log(infoBox('Migration failed - using legacy config', 'WARNING'));
- console.log(` Error: ${result.error}`);
- console.log(' Retry: ccs migrate');
- console.log('');
+ }
+}
+
+/**
+ * Migrate only profiles from config.json to existing config.yaml.
+ * Used when config.yaml exists but is missing profiles.
+ *
+ * @param preloadedData - Optional pre-loaded config data from loadMigrationCheckData()
+ * for TOCTOU-safe operations. If not provided, reads configs fresh.
+ */
+async function migrateProfilesToUnified(
+ preloadedData?: MigrationCheckData
+): Promise {
+ const ccsDir = getCcsDir();
+ const migratedFiles: string[] = [];
+ const warnings: string[] = [];
+
+ try {
+ // Use preloaded data if available (CRIT-2: TOCTOU fix)
+ const oldConfig = preloadedData?.legacyConfig ?? readJsonSafe(path.join(ccsDir, 'config.json'));
+ const unifiedConfig = preloadedData?.unifiedConfig ?? loadUnifiedConfig();
+
+ if (!oldConfig?.profiles || !unifiedConfig) {
+ return { success: true, migratedFiles, warnings };
+ }
+
+ let modified = false;
+
+ // Migrate API profiles from config.json
+ for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) {
+ const pathStr = settingsPath as string;
+
+ // H7: Detect collision - profile exists in both configs
+ if (unifiedConfig.profiles[name]) {
+ // Check if settings differ (potential data loss)
+ const existingSettings = unifiedConfig.profiles[name].settings;
+ if (existingSettings && existingSettings !== pathStr) {
+ warnings.push(
+ `Profile "${name}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})`
+ );
+ }
+ continue;
+ }
+
+ const expandedPath = expandPath(pathStr);
+
+ // Verify settings file exists
+ if (!fs.existsSync(expandedPath)) {
+ warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`);
+ continue;
+ }
+
+ // Store reference to settings file
+ unifiedConfig.profiles[name] = {
+ type: 'api',
+ settings: pathStr,
+ };
+ migratedFiles.push(name);
+ modified = true;
+ }
+
+ // Save if modified
+ if (modified) {
+ saveUnifiedConfig(unifiedConfig);
+ }
+
+ return { success: true, migratedFiles, warnings };
+ } catch (err) {
+ return {
+ success: false,
+ error: err instanceof Error ? err.message : 'Unknown error',
+ migratedFiles,
+ warnings,
+ };
}
}
diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts
index 249ecd38..f36cd130 100644
--- a/src/config/unified-config-loader.ts
+++ b/src/config/unified-config-loader.ts
@@ -131,6 +131,8 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig {
request_log:
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
},
+ // Auth config - preserve user values, no defaults (uses constants as fallback)
+ auth: partial.cliproxy?.auth,
},
preferences: {
...defaults.preferences,
@@ -184,7 +186,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig {
enabled:
partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled,
host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host,
- // Port is optional - undefined means use protocol default (443/80)
+ // Port is optional - undefined means use protocol default (443 for HTTPS, 8317 for HTTP)
port: partial.cliproxy_server?.remote?.port,
protocol:
partial.cliproxy_server?.remote?.protocol ??
@@ -231,32 +233,8 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig {
* Generate YAML header with helpful comments.
*/
function generateYamlHeader(): string {
- return `# ============================================================================
-# CCS Unified Configuration (config.yaml)
-# ============================================================================
-# Generated by: ccs migrate
-# Documentation: https://github.com/kaitranntt/ccs
-#
-# This file references your settings - actual env vars are in *.settings.json
-# files (matching Claude's ~/.claude/settings.json pattern).
-#
-# To customize a profile:
-# 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json)
-# 2. The file format matches Claude's settings.json: { "env": { ... } }\n#
-# Structure:
-# ┌─────────────────────────────────────────────────────────────────────────────┐
-# │ profiles - References to *.settings.json files for API providers │
-# │ cliproxy - References to *.settings.json files for OAuth providers │
-# │ accounts - Isolated Claude instances (managed by 'ccs auth') │
-# │ preferences - User preferences (theme, telemetry, auto-update) │
-# └─────────────────────────────────────────────────────────────────────────────┘
-#
-# Usage:
-# ccs Switch to profile
-# ccs api add Add new API profile
-# ccs cliproxy create Create CLIProxy variant
-# ccs migrate --rollback Restore from backup
-#
+ return `# CCS Unified Configuration
+# Docs: https://github.com/kaitranntt/ccs
`;
}
diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts
index a5e2bc4c..b2d7fc6f 100644
--- a/src/config/unified-config-types.ts
+++ b/src/config/unified-config-types.ts
@@ -15,8 +15,9 @@
* Version 3 = WebSearch config with model configuration for Gemini/OpenCode
* Version 4 = Copilot API integration (GitHub Copilot proxy)
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
+ * Version 6 = Customizable auth tokens (API key and management secret)
*/
-export const UNIFIED_CONFIG_VERSION = 5;
+export const UNIFIED_CONFIG_VERSION = 6;
/**
* Account configuration (formerly in profiles.json).
@@ -63,6 +64,21 @@ export interface CLIProxyVariantConfig {
account?: string;
/** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */
settings?: string;
+ /** Unique port for variant isolation (8318-8417) */
+ port?: number;
+ /** Per-variant auth override (optional) */
+ auth?: CLIProxyAuthConfig;
+}
+
+/**
+ * CLIProxy authentication configuration.
+ * Allows customization of API key and management secret for CLIProxyAPI.
+ */
+export interface CLIProxyAuthConfig {
+ /** API key for CCS-managed requests (default: 'ccs-internal-managed') */
+ api_key?: string;
+ /** Management secret for Control Panel login (default: 'ccs') */
+ management_secret?: string;
}
/**
@@ -89,6 +105,10 @@ export interface CLIProxyConfig {
variants: Record;
/** Logging configuration (disabled by default) */
logging?: CLIProxyLoggingConfig;
+ /** Kiro: disable incognito browser mode (use normal browser to save credentials) */
+ kiro_no_incognito?: boolean;
+ /** Global auth configuration for CLIProxyAPI */
+ auth?: CLIProxyAuthConfig;
}
/**
@@ -345,7 +365,7 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
remote: {
enabled: false,
host: '',
- // port is intentionally omitted - will use protocol default (443 for HTTPS, 80 for HTTP)
+ // port is intentionally omitted - will use protocol default (443 for HTTPS, 8317 for HTTP)
protocol: 'http',
auth_token: '',
},
diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts
index 9edb832d..d1170f6e 100644
--- a/src/glmt/glmt-proxy.ts
+++ b/src/glmt/glmt-proxy.ts
@@ -587,7 +587,14 @@ export class GlmtProxy {
statusCode: 401,
type: 'authentication_error',
message:
- 'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.',
+ 'API key rejected by Z.AI. This can happen when:\n' +
+ ' 1. Key expired on Z.AI server (most common after idle periods)\n' +
+ ' 2. Key was revoked or regenerated\n' +
+ ' 3. Key is missing or malformed\n\n' +
+ 'To fix:\n' +
+ ' 1. Go to Z.AI dashboard and regenerate your API key\n' +
+ ' 2. Update ~/.ccs/glm.settings.json with the new key\n' +
+ ' 3. Or run: ccs config -> API Profiles -> GLM -> Update key',
};
}
diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts
index a4424f30..4a4998b6 100644
--- a/src/management/checks/cliproxy-check.ts
+++ b/src/management/checks/cliproxy-check.ts
@@ -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)
diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts
index 4a643814..e56846a8 100644
--- a/src/management/checks/config-check.ts
+++ b/src/management/checks/config-check.ts
@@ -5,13 +5,15 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { ok, fail, warn } from '../../utils/ui';
+import { ok, fail, warn, info } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
const ora = createSpinner();
/**
- * Check CCS config files exist and are valid JSON
+ * Check CCS config files exist and are valid
+ * - Prefers config.yaml (v2) over config.json (legacy)
+ * - Settings files are optional (only checked if profile exists)
*/
export class ConfigFilesChecker implements IHealthChecker {
name = 'Config Files';
@@ -22,63 +24,139 @@ export class ConfigFilesChecker implements IHealthChecker {
}
run(results: HealthCheck): void {
- const files = [
- { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' },
- {
- path: path.join(this.ccsDir, 'glm.settings.json'),
- name: 'glm.settings.json',
- key: 'GLM Settings',
- profile: 'glm',
- },
- {
- path: path.join(this.ccsDir, 'kimi.settings.json'),
- name: 'kimi.settings.json',
- key: 'Kimi Settings',
- profile: 'kimi',
- },
- ];
-
const { DelegationValidator } = require('../../utils/delegation-validator');
- for (const file of files) {
- const spinner = ora(`Checking ${file.name}`).start();
+ // Check main config file (yaml preferred, json fallback)
+ this.checkMainConfig(results);
- if (!fs.existsSync(file.path)) {
+ // Check optional settings files (only if profile exists in config)
+ this.checkOptionalSettingsFiles(results, DelegationValidator);
+ }
+
+ /**
+ * Check main configuration file (config.yaml preferred, config.json fallback)
+ */
+ private checkMainConfig(results: HealthCheck): void {
+ const configYamlPath = path.join(this.ccsDir, 'config.yaml');
+ const configJsonPath = path.join(this.ccsDir, 'config.json');
+
+ const yamlExists = fs.existsSync(configYamlPath);
+ const jsonExists = fs.existsSync(configJsonPath);
+
+ // Check config.yaml first (preferred format)
+ if (yamlExists) {
+ const spinner = ora('Checking config.yaml').start();
+ try {
+ const yaml = require('js-yaml');
+ const content = fs.readFileSync(configYamlPath, 'utf8');
+ yaml.load(content);
+ spinner.succeed();
+ console.log(` ${ok('config.yaml'.padEnd(22))} Valid`);
+ results.addCheck('config.yaml', 'success', undefined, undefined, {
+ status: 'OK',
+ info: 'Valid',
+ });
+
+ // Inform if legacy config.json also exists (purely informational, not a check)
+ if (jsonExists) {
+ console.log(` ${info('config.json'.padEnd(22))} Legacy (ignored)`);
+ }
+ return;
+ } catch (e) {
spinner.fail();
- console.log(` ${fail(file.name.padEnd(22))} Not found`);
+ console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`);
results.addCheck(
- file.name,
+ 'config.yaml',
'error',
- `${file.name} not found`,
- 'Run: npm install -g @kaitranntt/ccs --force',
- { status: 'ERROR', info: 'Not found' }
+ `Invalid YAML: ${(e as Error).message}`,
+ `Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`,
+ { status: 'ERROR', info: 'Invalid YAML' }
);
+ return;
+ }
+ }
+
+ // Fallback to config.json (legacy format)
+ if (jsonExists) {
+ const spinner = ora('Checking config.json').start();
+ try {
+ const content = fs.readFileSync(configJsonPath, 'utf8');
+ JSON.parse(content);
+ spinner.succeed();
+ console.log(` ${ok('config.json'.padEnd(22))} Valid (legacy)`);
+ results.addCheck('config.json', 'success', undefined, undefined, {
+ status: 'OK',
+ info: 'Valid (legacy)',
+ });
+ } catch (e) {
+ spinner.fail();
+ console.log(` ${fail('config.json'.padEnd(22))} Invalid JSON`);
+ results.addCheck(
+ 'config.json',
+ 'error',
+ `Invalid JSON: ${(e as Error).message}`,
+ `Backup and recreate: mv ${configJsonPath} ${configJsonPath}.backup && npm install -g @kaitranntt/ccs --force`,
+ { status: 'ERROR', info: 'Invalid JSON' }
+ );
+ }
+ return;
+ }
+
+ // Neither exists - error
+ const spinner = ora('Checking config').start();
+ spinner.fail();
+ console.log(` ${fail('config.yaml'.padEnd(22))} Not found`);
+ results.addCheck(
+ 'config.yaml',
+ 'error',
+ 'No configuration file found (config.yaml or config.json)',
+ 'Run: npm install -g @kaitranntt/ccs --force',
+ { status: 'ERROR', info: 'Not found' }
+ );
+ }
+
+ /**
+ * Check optional settings files (only if corresponding profile is configured)
+ * These files are NOT required - they're only created when user configures a profile
+ */
+ private checkOptionalSettingsFiles(
+ results: HealthCheck,
+ DelegationValidator: { validate: (profile: string) => { valid: boolean; error?: string } }
+ ): void {
+ // Settings files to check (only if profile exists)
+ const settingsFiles = [
+ { name: 'glm.settings.json', profile: 'glm', displayName: 'GLM Settings' },
+ { name: 'kimi.settings.json', profile: 'kimi', displayName: 'Kimi Settings' },
+ ];
+
+ for (const file of settingsFiles) {
+ const filePath = path.join(this.ccsDir, file.name);
+ const exists = fs.existsSync(filePath);
+
+ if (!exists) {
+ // Not an error - these are optional files
+ // Only show info if user might expect them
continue;
}
- // Validate JSON
+ // File exists - validate it
+ const spinner = ora(`Checking ${file.name}`).start();
try {
- const content = fs.readFileSync(file.path, 'utf8');
+ const content = fs.readFileSync(filePath, 'utf8');
JSON.parse(content);
- // Extract useful info based on file type
- let fileInfo = 'Valid';
+ // Check if API key is properly configured
+ const validation = DelegationValidator.validate(file.profile);
+
+ let fileInfo = 'Valid JSON';
let status: 'OK' | 'WARN' = 'OK';
- if (file.profile) {
- // For settings files, check if API key is configured
- const validation = DelegationValidator.validate(file.profile);
-
- if (validation.valid) {
- fileInfo = 'Key configured';
- status = 'OK';
- } else if (validation.error && validation.error.includes('placeholder')) {
- fileInfo = 'Placeholder key';
- status = 'WARN';
- } else {
- fileInfo = 'Valid JSON';
- status = 'OK';
- }
+ if (validation.valid) {
+ fileInfo = 'Key configured';
+ status = 'OK';
+ } else if (validation.error && validation.error.includes('placeholder')) {
+ fileInfo = 'Placeholder key';
+ status = 'WARN';
}
if (status === 'WARN') {
@@ -100,7 +178,7 @@ export class ConfigFilesChecker implements IHealthChecker {
file.name,
'error',
`Invalid JSON: ${(e as Error).message}`,
- `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`,
+ `Backup and recreate: mv ${filePath} ${filePath}.backup`,
{ status: 'ERROR', info: 'Invalid JSON' }
);
}
diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts
index cc449042..7d80a4df 100644
--- a/src/management/checks/profile-check.ts
+++ b/src/management/checks/profile-check.ts
@@ -11,7 +11,7 @@ import { HealthCheck, IHealthChecker, createSpinner } from './types';
const ora = createSpinner();
/**
- * Check profile configurations in config.json
+ * Check profile configurations in config.yaml (preferred) or config.json (legacy)
*/
export class ProfilesChecker implements IHealthChecker {
name = 'Profiles';
@@ -23,47 +23,102 @@ export class ProfilesChecker implements IHealthChecker {
run(results: HealthCheck): void {
const spinner = ora('Checking profiles').start();
- const configPath = path.join(this.ccsDir, 'config.json');
+ const configYamlPath = path.join(this.ccsDir, 'config.yaml');
+ const configJsonPath = path.join(this.ccsDir, 'config.json');
- if (!fs.existsSync(configPath)) {
- spinner.info();
- console.log(` ${info('Profiles'.padEnd(22))} config.json not found`);
- return;
- }
+ const yamlExists = fs.existsSync(configYamlPath);
+ const jsonExists = fs.existsSync(configJsonPath);
- try {
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
-
- if (!config.profiles || typeof config.profiles !== 'object') {
+ // Check config.yaml first (preferred format)
+ if (yamlExists) {
+ try {
+ const yaml = require('js-yaml');
+ const content = fs.readFileSync(configYamlPath, 'utf8');
+ const config = yaml.load(content) as Record;
+ this.validateProfiles(config, 'config.yaml', spinner, results);
+ return;
+ } catch (e) {
spinner.fail();
- console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`);
+ console.log(
+ ` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}`
+ );
results.addCheck(
'Profiles',
'error',
- 'config.json missing profiles object',
- 'Run: npm install -g @kaitranntt/ccs --force',
- { status: 'ERROR', info: 'Missing profiles object' }
+ `Invalid config.yaml: ${(e as Error).message}`,
+ undefined,
+ {
+ status: 'ERROR',
+ info: (e as Error).message,
+ }
);
return;
}
-
- const profileCount = Object.keys(config.profiles).length;
- const profileNames = Object.keys(config.profiles).join(', ');
-
- spinner.succeed();
- console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
- results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, {
- status: 'OK',
- info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`,
- });
- } catch (e) {
- spinner.fail();
- console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`);
- results.addCheck('Profiles', 'error', (e as Error).message, undefined, {
- status: 'ERROR',
- info: (e as Error).message,
- });
}
+
+ // Fallback to config.json (legacy format)
+ if (jsonExists) {
+ try {
+ const config = JSON.parse(fs.readFileSync(configJsonPath, 'utf8'));
+ this.validateProfiles(config, 'config.json', spinner, results);
+ return;
+ } catch (e) {
+ spinner.fail();
+ console.log(
+ ` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}`
+ );
+ results.addCheck(
+ 'Profiles',
+ 'error',
+ `Invalid config.json: ${(e as Error).message}`,
+ undefined,
+ {
+ status: 'ERROR',
+ info: (e as Error).message,
+ }
+ );
+ return;
+ }
+ }
+
+ // Neither exists
+ spinner.info();
+ console.log(
+ ` ${info('Profiles'.padEnd(22))} No config file found (config.yaml or config.json)`
+ );
+ }
+
+ /**
+ * Validate profiles object from parsed config
+ */
+ private validateProfiles(
+ config: Record,
+ configFileName: string,
+ spinner: ReturnType['start']>,
+ results: HealthCheck
+ ): void {
+ if (!config.profiles || typeof config.profiles !== 'object') {
+ spinner.fail();
+ console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object in ${configFileName}`);
+ results.addCheck(
+ 'Profiles',
+ 'error',
+ `${configFileName} missing profiles object`,
+ 'Run: npm install -g @kaitranntt/ccs --force',
+ { status: 'ERROR', info: 'Missing profiles object' }
+ );
+ return;
+ }
+
+ const profileCount = Object.keys(config.profiles as object).length;
+ const profileNames = Object.keys(config.profiles as object).join(', ');
+
+ spinner.succeed();
+ console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`);
+ results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, {
+ status: 'OK',
+ info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`,
+ });
}
}
diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts
index 9c43dd1e..8977870c 100644
--- a/src/management/recovery-manager.ts
+++ b/src/management/recovery-manager.ts
@@ -8,6 +8,12 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { info } from '../utils/ui';
+import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types';
+import {
+ saveUnifiedConfig,
+ hasUnifiedConfig,
+ loadUnifiedConfig,
+} from '../config/unified-config-loader';
/**
* Get CCS home directory (respects CCS_HOME env for test isolation)
@@ -49,37 +55,51 @@ class RecoveryManager {
}
/**
- * Ensure ~/.ccs/config.json exists with defaults
+ * Ensure ~/.ccs/config.yaml exists with defaults
+ * This is the primary config format (YAML unified config)
*/
- ensureConfigJson(): boolean {
- const configPath = path.join(this.ccsDir, 'config.json');
-
- // Check if exists and valid
- if (fs.existsSync(configPath)) {
- try {
- const content = fs.readFileSync(configPath, 'utf8');
- JSON.parse(content); // Validate JSON
- return false; // No recovery needed
- } catch (_e) {
- // Corrupted - backup and recreate
- const backupPath = `${configPath}.backup.${Date.now()}`;
- fs.renameSync(configPath, backupPath);
- this.recovered.push(`Backed up corrupted config.json to ${path.basename(backupPath)}`);
+ ensureConfigYaml(): boolean {
+ // Skip if config.yaml already exists AND is valid
+ if (hasUnifiedConfig()) {
+ // Verify it's loadable (not corrupted)
+ const loaded = loadUnifiedConfig();
+ if (loaded !== null) {
+ return false; // Config exists and is valid
}
+ // Config exists but is corrupted - will be recreated below
+ this.recovered.push('Detected corrupted ~/.ccs/config.yaml');
}
- // Create default config (matches postinstall.js)
- // NOTE: Empty profiles - users create profiles via `ccs api create` or UI
- const defaultConfig = {
- profiles: {},
- };
+ // Check for legacy config.json - if exists, let autoMigrate handle it
+ const legacyConfigPath = path.join(this.ccsDir, 'config.json');
+ if (fs.existsSync(legacyConfigPath)) {
+ // Legacy config exists - autoMigrate() in ccs.ts will handle migration
+ return false;
+ }
- const tmpPath = `${configPath}.tmp`;
- fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
- fs.renameSync(tmpPath, configPath);
+ // Create fresh config.yaml with defaults
+ const config = createEmptyUnifiedConfig();
+ config.version = UNIFIED_CONFIG_VERSION;
- this.recovered.push('Created ~/.ccs/config.json');
- return true;
+ try {
+ saveUnifiedConfig(config);
+ this.recovered.push('Created ~/.ccs/config.yaml');
+ return true;
+ } catch (_saveErr) {
+ // Fallback: create minimal config.json for backward compat
+ try {
+ const fallbackConfig = { profiles: {} };
+ const tmpPath = `${legacyConfigPath}.tmp`;
+ fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
+ fs.renameSync(tmpPath, legacyConfigPath);
+ this.recovered.push('Created ~/.ccs/config.json (fallback)');
+ return true;
+ } catch (_fallbackErr) {
+ // Both writes failed - log but don't crash
+ this.recovered.push('Failed to create config file (permission issue?)');
+ return false;
+ }
+ }
}
/**
@@ -133,89 +153,6 @@ class RecoveryManager {
return created;
}
- /**
- * Ensure GLM settings file exists
- */
- ensureGlmSettings(): boolean {
- const settingsPath = path.join(this.ccsDir, 'glm.settings.json');
- if (fs.existsSync(settingsPath)) return false;
-
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
- ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
- ANTHROPIC_MODEL: 'glm-4.6',
- ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
- ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
- ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6',
- },
- };
-
- const tmpPath = `${settingsPath}.tmp`;
- fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
- fs.renameSync(tmpPath, settingsPath);
- this.recovered.push('Created ~/.ccs/glm.settings.json');
- return true;
- }
-
- /**
- * Ensure GLMT settings file exists
- */
- ensureGlmtSettings(): boolean {
- const settingsPath = path.join(this.ccsDir, 'glmt.settings.json');
- if (fs.existsSync(settingsPath)) return false;
-
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
- ANTHROPIC_AUTH_TOKEN: 'YOUR_GLM_API_KEY_HERE',
- ANTHROPIC_MODEL: 'glm-4.6',
- ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-4.6',
- ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-4.6',
- ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.6',
- ANTHROPIC_TEMPERATURE: '0.2',
- ANTHROPIC_MAX_TOKENS: '65536',
- MAX_THINKING_TOKENS: '32768',
- ENABLE_STREAMING: 'true',
- ANTHROPIC_SAFE_MODE: 'false',
- API_TIMEOUT_MS: '3000000',
- },
- alwaysThinkingEnabled: true,
- };
-
- const tmpPath = `${settingsPath}.tmp`;
- fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
- fs.renameSync(tmpPath, settingsPath);
- this.recovered.push('Created ~/.ccs/glmt.settings.json');
- return true;
- }
-
- /**
- * Ensure Kimi settings file exists
- */
- ensureKimiSettings(): boolean {
- const settingsPath = path.join(this.ccsDir, 'kimi.settings.json');
- if (fs.existsSync(settingsPath)) return false;
-
- const settings = {
- env: {
- ANTHROPIC_BASE_URL: 'https://api.kimi.com/coding/',
- ANTHROPIC_AUTH_TOKEN: 'YOUR_KIMI_API_KEY_HERE',
- ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo',
- ANTHROPIC_DEFAULT_OPUS_MODEL: 'kimi-k2-thinking-turbo',
- ANTHROPIC_DEFAULT_SONNET_MODEL: 'kimi-k2-thinking-turbo',
- ANTHROPIC_DEFAULT_HAIKU_MODEL: 'kimi-k2-thinking-turbo',
- },
- alwaysThinkingEnabled: true,
- };
-
- const tmpPath = `${settingsPath}.tmp`;
- fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
- fs.renameSync(tmpPath, settingsPath);
- this.recovered.push('Created ~/.ccs/kimi.settings.json');
- return true;
- }
-
/**
* Install shell completion files
*/
@@ -281,8 +218,8 @@ class RecoveryManager {
this.ensureSharedDirectories();
this.ensureClaudeSettings();
- // Config files (core only - no GLM/GLMT/Kimi auto-creation)
- this.ensureConfigJson();
+ // Config files - use YAML as primary format
+ this.ensureConfigYaml();
// Shell completions
this.ensureShellCompletions();
@@ -307,23 +244,6 @@ class RecoveryManager {
console.log(info('Auto-recovery completed:'));
this.recovered.forEach((msg) => console.log(` - ${msg}`));
- // Show API key hints if created profile settings
- const createdGlm = this.recovered.some((msg) => msg.includes('glm.settings.json'));
- const createdKimi = this.recovered.some((msg) => msg.includes('kimi.settings.json'));
-
- if (createdGlm || createdKimi) {
- console.log('');
- console.log(info('Configure API keys:'));
- if (createdGlm) {
- console.log(' GLM: Edit ~/.ccs/glm.settings.json');
- console.log(' Get key from: https://api.z.ai');
- }
- if (createdKimi) {
- console.log(' Kimi: Edit ~/.ccs/kimi.settings.json');
- console.log(' Get key from: https://www.kimi.com/coding');
- }
- }
-
// Show login hint if created Claude settings
if (this.recovered.some((msg) => msg.includes('~/.claude/settings.json'))) {
console.log('');
diff --git a/src/types/config.ts b/src/types/config.ts
index c5aed9c2..a00195a3 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -17,12 +17,14 @@ 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) */
+ port?: number;
}
/**
diff --git a/src/types/index.ts b/src/types/index.ts
index 30fec139..5ed78646 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -12,6 +12,7 @@ export type {
EnvValue,
ProfileMetadata,
ProfilesRegistry,
+ CLIProxyVariantsConfig,
} from './config';
export { isConfig, isSettings } from './config';
@@ -46,5 +47,5 @@ export type {
} from './glmt';
// Utility types
-export { ErrorCode, LogLevel } from './utils';
-export type { ColorName, TerminalInfo, Result } from './utils';
+export { LogLevel } from './utils';
+export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils';
diff --git a/src/types/utils.ts b/src/types/utils.ts
index a9320d53..51ccc6a7 100644
--- a/src/types/utils.ts
+++ b/src/types/utils.ts
@@ -3,7 +3,8 @@
*/
// Re-export from error-codes for consistency
-export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes';
+export { ERROR_CODES, getErrorDocUrl, getErrorCategory } from '../utils/error-codes';
+export type { ErrorCode } from '../utils/error-codes';
/**
* Log levels
diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts
new file mode 100644
index 00000000..cb732566
--- /dev/null
+++ b/src/utils/api-key-validator.ts
@@ -0,0 +1,130 @@
+/**
+ * API Key Pre-flight Validator
+ *
+ * Quick validation of API keys before Claude CLI launch.
+ * Catches expired keys early with actionable error messages.
+ */
+
+import * as http from 'http';
+import * as https from 'https';
+import { URL } from 'url';
+
+export interface ValidationResult {
+ valid: boolean;
+ error?: string;
+ suggestion?: string;
+}
+
+/** Default placeholders that indicate unconfigured keys */
+const DEFAULT_PLACEHOLDERS = [
+ 'YOUR_GLM_API_KEY_HERE',
+ 'YOUR_KIMI_API_KEY_HERE',
+ 'YOUR_API_KEY_HERE',
+ 'YOUR-API-KEY-HERE',
+ 'PLACEHOLDER',
+ '',
+];
+
+/**
+ * Validate GLM API key with quick health check
+ *
+ * @param apiKey - The ANTHROPIC_AUTH_TOKEN value
+ * @param baseUrl - Optional base URL (defaults to Z.AI)
+ * @param timeoutMs - Timeout in milliseconds (default 2000)
+ */
+export async function validateGlmKey(
+ apiKey: string,
+ baseUrl?: string,
+ timeoutMs = 2000
+): Promise {
+ // Skip if disabled
+ if (process.env.CCS_SKIP_PREFLIGHT === '1') {
+ return { valid: true };
+ }
+
+ // Basic format check - detect placeholders
+ if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) {
+ return {
+ valid: false,
+ error: 'API key not configured',
+ suggestion:
+ 'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' +
+ 'Or run: ccs config -> API Profiles -> GLM',
+ };
+ }
+
+ // Determine validation endpoint
+ // Z.AI uses /api/anthropic path, we can test with a minimal request
+ const targetBase = baseUrl || 'https://api.z.ai';
+ let url: URL;
+ try {
+ url = new URL('/api/anthropic/v1/models', targetBase);
+ } catch {
+ // Invalid URL - fail-open
+ return { valid: true };
+ }
+
+ return new Promise((resolve) => {
+ // Determine protocol - use http module for http:// URLs
+ const isHttps = url.protocol === 'https:';
+ const httpModule = isHttps ? https : http;
+ const defaultPort = isHttps ? 443 : 80;
+
+ const options: https.RequestOptions = {
+ hostname: url.hostname,
+ port: url.port || defaultPort,
+ path: url.pathname,
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'User-Agent': 'CCS-Preflight/1.0',
+ },
+ };
+
+ const req = httpModule.request(options, (res) => {
+ clearTimeout(timeoutId);
+
+ if (res.statusCode === 200) {
+ resolve({ valid: true });
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
+ resolve({
+ valid: false,
+ error: 'API key rejected by Z.AI',
+ suggestion:
+ 'Your key may have expired. To fix:\n' +
+ ' 1. Go to Z.AI dashboard and regenerate your API key\n' +
+ ' 2. Update ~/.ccs/glm.settings.json with the new key\n' +
+ ' 3. Or run: ccs config -> API Profiles -> GLM',
+ });
+ } else {
+ // Other errors (404, 500, etc.) - fail-open, let Claude CLI handle
+ // Debug log for diagnostics when CCS_DEBUG is set
+ if (process.env.CCS_DEBUG === '1') {
+ console.error(
+ `[CCS-Preflight] Unexpected status ${res.statusCode} from ${url.href} - fail-open`
+ );
+ }
+ resolve({ valid: true });
+ }
+
+ // Consume response body to free resources
+ res.resume();
+ });
+
+ req.on('error', () => {
+ clearTimeout(timeoutId);
+ // Network error - fail-open
+ resolve({ valid: true });
+ });
+
+ // Set timeout after request is created so we can destroy it on timeout
+ const timeoutId = setTimeout(() => {
+ // Abort request to prevent TCP connection leak
+ req.destroy();
+ // Fail-open on timeout - let Claude CLI handle it
+ resolve({ valid: true });
+ }, timeoutMs);
+
+ req.end();
+ });
+}
diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts
index 034a47ec..e1169137 100644
--- a/src/utils/config-manager.ts
+++ b/src/utils/config-manager.ts
@@ -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 = {};
+ 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 {
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index 5f1f9031..f281f7e6 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -14,9 +14,14 @@ export function error(message: string): never {
* Path expansion (~ and env vars)
*/
export function expandPath(pathStr: string): string {
+ // Normalize separators first to handle mixed paths
+ pathStr = pathStr.replace(/\\/g, '/');
+
// Handle tilde expansion
- if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
+ if (pathStr.startsWith('~/')) {
pathStr = path.join(os.homedir(), pathStr.slice(2));
+ } else if (pathStr === '~') {
+ pathStr = os.homedir();
}
// Expand environment variables (Windows and Unix)
diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts
index e3351ac2..31f87208 100644
--- a/src/utils/port-utils.ts
+++ b/src/utils/port-utils.ts
@@ -38,7 +38,7 @@ export async function getPortProcess(port: number): Promise
*/
async function getPortProcessUnix(port: number): Promise {
try {
- const { stdout } = await execAsync(`lsof -i :${port} -sTCP:LISTEN -t -F pcn`, {
+ const { stdout } = await execAsync(`lsof -i :${port} -sTCP:LISTEN -F pcn`, {
timeout: 3000,
});
@@ -111,22 +111,17 @@ async function getPortProcessWindows(port: number): Promise
/**
* Check if process is CLIProxy
+ * Uses prefix matching to handle Linux kernel's 15-char process name truncation
+ * (e.g., 'cli-proxy-api-plus' becomes 'cli-proxy-api-p' in lsof/ps output)
*/
export function isCLIProxyProcess(process: PortProcess | null): boolean {
if (!process) {
return false;
}
- // Match cli-proxy, cli-proxy.exe, cliproxy, cliproxy.exe, cli-proxy-api
const name = process.processName.toLowerCase();
- return [
- 'cli-proxy',
- 'cli-proxy.exe',
- 'cliproxy',
- 'cliproxy.exe',
- 'cli-proxy-api',
- 'cli-proxy-api.exe',
- ].includes(name);
+ // All CLIProxy variants start with 'cli-proxy' or 'cliproxy'
+ return name.startsWith('cli-proxy') || name.startsWith('cliproxy');
}
/**
@@ -186,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 {
+ 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
diff --git a/src/web-server/file-watcher.ts b/src/web-server/file-watcher.ts
index a57bce39..90de9027 100644
--- a/src/web-server/file-watcher.ts
+++ b/src/web-server/file-watcher.ts
@@ -22,7 +22,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
const watcher = chokidar.watch(
[
- path.join(ccsDir, 'config.json'),
+ path.join(ccsDir, 'config.json'), // Legacy config
+ path.join(ccsDir, 'config.yaml'), // Unified config
path.join(ccsDir, '*.settings.json'),
path.join(ccsDir, 'profiles.json'),
path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking
@@ -41,7 +42,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
const basename = path.basename(filePath);
let type: FileChangeEvent['type'];
- if (basename === 'config.json') {
+ if (basename === 'config.json' || basename === 'config.yaml') {
type = 'config-changed';
} else if (basename === 'profiles.json') {
type = 'profiles-changed';
diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts
index 25696d85..21a8723d 100644
--- a/src/web-server/health-service.ts
+++ b/src/web-server/health-service.ts
@@ -142,6 +142,17 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
return { success: true, message: 'Created ~/.ccs directory' };
case 'config-file': {
+ // Use appropriate config based on unified mode
+ const { isUnifiedMode } = require('../config/unified-config-loader');
+ if (isUnifiedMode()) {
+ const {
+ loadOrCreateUnifiedConfig,
+ saveUnifiedConfig,
+ } = require('../config/unified-config-loader');
+ const config = loadOrCreateUnifiedConfig();
+ saveUnifiedConfig(config);
+ return { success: true, message: 'Created/updated config.yaml' };
+ }
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
@@ -149,6 +160,12 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
}
case 'profiles-file': {
+ // Use appropriate storage based on unified mode
+ const { isUnifiedMode: isUnified } = require('../config/unified-config-loader');
+ if (isUnified()) {
+ // In unified mode, accounts are stored in config.yaml
+ return { success: true, message: 'Accounts stored in config.yaml (unified mode)' };
+ }
const profilesPath = path.join(ccsDir, 'profiles.json');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(profilesPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts
index 80f33e8e..5c1ee2f1 100644
--- a/src/web-server/health/cliproxy-checks.ts
+++ b/src/web-server/health/cliproxy-checks.ts
@@ -9,7 +9,7 @@ import {
isCLIProxyInstalled,
getInstalledCliproxyVersion,
getCLIProxyPath,
- getConfigPath as getCliproxyConfigPath,
+ getCliproxyConfigPath,
getAllAuthStatus,
CLIPROXY_DEFAULT_PORT,
} from '../../cliproxy';
diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts
index 30cc881c..5f7ca34a 100644
--- a/src/web-server/health/config-checks.ts
+++ b/src/web-server/health/config-checks.ts
@@ -1,18 +1,58 @@
/**
* Configuration Health Checks
*
- * Check config.json, settings files, and Claude settings.
+ * Check config.json, config.yaml, settings files, and Claude settings.
+ * Supports both legacy (config.json) and unified (config.yaml) modes.
*/
import * as fs from 'fs';
+import * as os from 'os';
import * as path from 'path';
import { getConfigPath } from '../../utils/config-manager';
+import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader';
import type { HealthCheck } from './types';
/**
- * Check config.json file
+ * Check config file (config.json or config.yaml based on mode)
*/
export function checkConfigFile(): HealthCheck {
+ // In unified mode, check config.yaml
+ if (isUnifiedMode() || hasUnifiedConfig()) {
+ const ccsDir = path.join(os.homedir(), '.ccs');
+ const yamlPath = path.join(ccsDir, 'config.yaml');
+
+ if (!fs.existsSync(yamlPath)) {
+ return {
+ id: 'config-file',
+ name: 'config.yaml',
+ status: 'warning',
+ message: 'Not found (unified mode)',
+ details: yamlPath,
+ fixable: true,
+ };
+ }
+
+ try {
+ fs.readFileSync(yamlPath, 'utf8');
+ return {
+ id: 'config-file',
+ name: 'config.yaml',
+ status: 'ok',
+ message: 'Valid (unified mode)',
+ details: yamlPath,
+ };
+ } catch {
+ return {
+ id: 'config-file',
+ name: 'config.yaml',
+ status: 'error',
+ message: 'Cannot read file',
+ details: yamlPath,
+ };
+ }
+ }
+
+ // Legacy mode: check config.json
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts
index 4c2a30e5..83955611 100644
--- a/src/web-server/health/profile-checks.ts
+++ b/src/web-server/health/profile-checks.ts
@@ -2,16 +2,52 @@
* Profile Health Checks
*
* Check profiles, instances, and delegation.
+ * Supports both legacy (config.json) and unified (config.yaml) modes.
*/
import * as fs from 'fs';
import * as path from 'path';
+import { isUnifiedMode, loadUnifiedConfig } from '../../config/unified-config-loader';
import type { HealthCheck } from './types';
/**
- * Check profiles configuration
+ * Check profiles configuration (API profiles from config.json or config.yaml)
*/
export function checkProfiles(ccsDir: string): HealthCheck {
+ // Check unified config first
+ if (isUnifiedMode()) {
+ const config = loadUnifiedConfig();
+ if (!config) {
+ return {
+ id: 'profiles',
+ name: 'Profiles',
+ status: 'info',
+ message: 'config.yaml not loaded',
+ };
+ }
+
+ const profileCount = Object.keys(config.profiles || {}).length;
+ const profileNames = Object.keys(config.profiles || {}).join(', ');
+
+ if (profileCount === 0) {
+ return {
+ id: 'profiles',
+ name: 'Profiles',
+ status: 'ok',
+ message: 'No API profiles (unified mode)',
+ };
+ }
+
+ return {
+ id: 'profiles',
+ name: 'Profiles',
+ status: 'ok',
+ message: `${profileCount} configured (unified)`,
+ details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames,
+ };
+ }
+
+ // Legacy mode: check config.json
const configPath = path.join(ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts
index 0ddaf1da..2b064ce8 100644
--- a/src/web-server/overview-routes.ts
+++ b/src/web-server/overview-routes.ts
@@ -5,12 +5,11 @@
*/
import { Router, Request, Response } from 'express';
-import * as fs from 'fs';
-import * as path from 'path';
-import { getCcsDir, 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';
+import ProfileRegistry from '../auth/profile-registry';
export const overviewRoutes = Router();
@@ -19,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;
@@ -64,12 +63,16 @@ overviewRoutes.get('/', async (_req: Request, res: Response) => {
function getAccountCount(): number {
try {
- const profilesPath = path.join(getCcsDir(), 'profiles.json');
+ const registry = new ProfileRegistry();
- if (!fs.existsSync(profilesPath)) return 0;
+ // Merge legacy (profiles.json) and unified (config.yaml) accounts
+ const legacyProfiles = registry.getAllProfiles();
+ const unifiedAccounts = registry.getAllAccountsUnified();
- const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
- return Object.keys(data.profiles || {}).length;
+ // Count unique accounts (unified takes precedence for duplicates)
+ const allNames = new Set([...Object.keys(legacyProfiles), ...Object.keys(unifiedAccounts)]);
+
+ return allNames.size;
} catch {
return 0;
}
diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts
index 848383f5..686c604f 100644
--- a/src/web-server/routes/account-routes.ts
+++ b/src/web-server/routes/account-routes.ts
@@ -1,38 +1,57 @@
/**
- * Account Routes - CRUD operations for Claude accounts (profiles.json)
+ * Account Routes - CRUD operations for Claude accounts
*
- * Separated from profile-routes.ts to avoid dual-mounting conflicts.
+ * Uses ProfileRegistry to read from both legacy (profiles.json)
+ * and unified config (config.yaml) for consistent data with CLI.
*/
import { Router, Request, Response } from 'express';
-import * as fs from 'fs';
-import * as path from 'path';
-import { getCcsDir } from '../../utils/config-manager';
+import ProfileRegistry from '../../auth/profile-registry';
+import { isUnifiedMode } from '../../config/unified-config-loader';
const router = Router();
+const registry = new ProfileRegistry();
/**
- * GET /api/accounts - List accounts from profiles.json
+ * GET /api/accounts - List accounts from both profiles.json and config.yaml
*/
router.get('/', (_req: Request, res: Response): void => {
try {
- const profilesPath = path.join(getCcsDir(), 'profiles.json');
+ // Get profiles from both legacy and unified config (same logic as CLI)
+ const legacyProfiles = registry.getAllProfiles();
+ const unifiedAccounts = registry.getAllAccountsUnified();
- if (!fs.existsSync(profilesPath)) {
- res.json({ accounts: [], default: null });
- return;
+ // Merge profiles: unified config takes precedence
+ const merged: Record = {};
+
+ // Add legacy profiles first
+ for (const [name, meta] of Object.entries(legacyProfiles)) {
+ merged[name] = {
+ type: meta.type || 'account',
+ created: meta.created,
+ last_used: meta.last_used || null,
+ };
}
- const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
- const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
- const metadata = meta as Record;
- return {
- name,
- ...metadata,
+ // Override with unified config accounts (takes precedence)
+ for (const [name, account] of Object.entries(unifiedAccounts)) {
+ merged[name] = {
+ type: 'account',
+ created: account.created,
+ last_used: account.last_used,
};
- });
+ }
- res.json({ accounts, default: data.default || null });
+ // Convert to array format
+ const accounts = Object.entries(merged).map(([name, meta]) => ({
+ name,
+ ...meta,
+ }));
+
+ // Get default from unified config first, fallback to legacy
+ const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null;
+
+ res.json({ accounts, default: defaultProfile });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -50,14 +69,12 @@ router.post('/default', (req: Request, res: Response): void => {
return;
}
- const profilesPath = path.join(getCcsDir(), 'profiles.json');
-
- const data = fs.existsSync(profilesPath)
- ? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
- : { profiles: {} };
-
- data.default = name;
- fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
+ // Use unified config if in unified mode, otherwise use legacy
+ if (isUnifiedMode()) {
+ registry.setDefaultUnified(name);
+ } else {
+ registry.setDefaultProfile(name);
+ }
res.json({ default: name });
} catch (error) {
@@ -65,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;
diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts
index 1abf36d2..a4871e38 100644
--- a/src/web-server/routes/cliproxy-auth-routes.ts
+++ b/src/web-server/routes/cliproxy-auth-routes.ts
@@ -23,6 +23,9 @@ import {
} from '../../cliproxy/account-manager';
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();
@@ -268,7 +271,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
*/
router.post('/:provider/start', async (req: Request, res: Response): Promise => {
const { provider } = req.params;
- const { nickname } = req.body;
+ const { nickname, noIncognito: noIncognitoBody } = req.body;
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -276,6 +279,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise => {
+ // 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;
diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts
index 729ebd4e..95d65263 100644
--- a/src/web-server/routes/cliproxy-stats-routes.ts
+++ b/src/web-server/routes/cliproxy-stats-routes.ts
@@ -16,7 +16,7 @@ import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types';
import {
getCliproxyWritablePath,
- getConfigPath,
+ getCliproxyConfigPath,
getAuthDir,
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
@@ -274,7 +274,7 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise => {
try {
- const configPath = getConfigPath();
+ const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
@@ -301,7 +301,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise =>
return;
}
- const configPath = getConfigPath();
+ const configPath = getCliproxyConfigPath();
// Ensure parent directory exists
const configDir = path.dirname(configPath);
diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts
index 9a712d50..186ba8bc 100644
--- a/src/web-server/routes/route-helpers.ts
+++ b/src/web-server/routes/route-helpers.ts
@@ -4,11 +4,9 @@
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';
-import type { CLIProxyProvider } from '../../cliproxy/types';
-import { getClaudeEnvVars } from '../../cliproxy/config-generator';
/** Model mapping for API profiles */
export interface ModelMapping {
@@ -19,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: {} };
}
@@ -156,37 +155,6 @@ export function updateSettingsFile(
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
-/**
- * Create cliproxy variant settings
- * Includes base URL and auth token for proper Claude CLI integration
- */
-export function createCliproxySettings(
- name: string,
- provider: CLIProxyProvider,
- model?: string
-): string {
- const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
-
- // Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN)
- const baseEnv = getClaudeEnvVars(provider);
-
- const settings: Settings = {
- env: {
- ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
- ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
- ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '',
- ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '',
- ANTHROPIC_DEFAULT_SONNET_MODEL:
- model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '',
- ANTHROPIC_DEFAULT_HAIKU_MODEL:
- (baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '',
- },
- };
-
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
- return `~/.ccs/${name}.settings.json`;
-}
-
/**
* Security: Validate file path is within allowed directories
* - ~/.ccs/ directory: read/write allowed
diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts
index 02f03874..12e175aa 100644
--- a/src/web-server/routes/settings-routes.ts
+++ b/src/web-server/routes/settings-routes.ts
@@ -4,13 +4,43 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
+import * as os from 'os';
import * as path from 'path';
import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
+import { listVariants } from '../../cliproxy/services/variant-service';
+import {
+ generateSecureToken,
+ maskToken,
+ getAuthSummary,
+ setGlobalApiKey,
+ setGlobalManagementSecret,
+ resetAuthToDefaults,
+} from '../../cliproxy';
+import { regenerateConfig } from '../../cliproxy/config-generator';
import type { Settings } from '../../types/config';
const router = Router();
+/**
+ * Helper: Resolve settings path for profile or variant
+ * Variants have settings paths in config, regular profiles use {name}.settings.json
+ */
+function resolveSettingsPath(profileOrVariant: string): string {
+ const ccsDir = getCcsDir();
+
+ // Check if this is a variant
+ const variants = listVariants();
+ const variant = variants[profileOrVariant];
+ if (variant?.settings) {
+ // Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
+ return variant.settings.replace(/^~/, os.homedir());
+ }
+
+ // Regular profile settings
+ return path.join(ccsDir, `${profileOrVariant}.settings.json`);
+}
+
/**
* Helper: Mask API keys in settings
*/
@@ -34,8 +64,7 @@ function maskApiKeys(settings: Settings): Settings {
router.get('/:profile', (req: Request, res: Response): void => {
try {
const { profile } = req.params;
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
+ const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
@@ -63,8 +92,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
router.get('/:profile/raw', (req: Request, res: Response): void => {
try {
const { profile } = req.params;
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
+ const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
@@ -93,7 +121,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
+ const settingsPath = resolveSettingsPath(profile);
const fileExists = fs.existsSync(settingsPath);
@@ -151,8 +179,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
router.get('/:profile/presets', (req: Request, res: Response): void => {
try {
const { profile } = req.params;
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
+ const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
@@ -179,11 +206,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
return;
}
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
+ const settingsPath = resolveSettingsPath(profile);
// Create settings file if it doesn't exist
if (!fs.existsSync(settingsPath)) {
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
@@ -205,7 +232,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
};
settings.presets.push(preset);
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
+
+ // Atomic write: temp file + rename
+ const tempPath = settingsPath + '.tmp';
+ fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
+ fs.renameSync(tempPath, settingsPath);
res.status(201).json({ preset });
} catch (error) {
@@ -219,8 +250,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
try {
const { profile, name } = req.params;
- const ccsDir = getCcsDir();
- const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
+ const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
@@ -234,7 +264,11 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
}
settings.presets = settings.presets.filter((p) => p.name !== name);
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
+
+ // Atomic write: temp file + rename
+ const tempPath = settingsPath + '.tmp';
+ fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
+ fs.renameSync(tempPath, settingsPath);
res.json({ success: true });
} catch (error) {
@@ -242,4 +276,142 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
}
});
+// ==================== Auth Tokens ====================
+
+/**
+ * GET /api/settings/auth/tokens - Get current auth token status (masked)
+ */
+router.get('/auth/tokens', (_req: Request, res: Response): void => {
+ try {
+ const summary = getAuthSummary();
+
+ res.json({
+ apiKey: {
+ value: maskToken(summary.apiKey.value),
+ isCustom: summary.apiKey.isCustom,
+ },
+ managementSecret: {
+ value: maskToken(summary.managementSecret.value),
+ isCustom: summary.managementSecret.isCustom,
+ },
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+/**
+ * GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked
+ * NOTE: Sensitive endpoint - no caching, localhost only
+ */
+router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
+ try {
+ // Prevent caching of sensitive data
+ res.setHeader('Cache-Control', 'no-store');
+
+ const summary = getAuthSummary();
+
+ res.json({
+ apiKey: {
+ value: summary.apiKey.value,
+ isCustom: summary.apiKey.isCustom,
+ },
+ managementSecret: {
+ value: summary.managementSecret.value,
+ isCustom: summary.managementSecret.isCustom,
+ },
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+/**
+ * PUT /api/settings/auth/tokens - Update auth tokens
+ */
+router.put('/auth/tokens', (req: Request, res: Response): void => {
+ try {
+ const { apiKey, managementSecret } = req.body;
+
+ if (apiKey !== undefined) {
+ setGlobalApiKey(apiKey || undefined);
+ }
+
+ if (managementSecret !== undefined) {
+ setGlobalManagementSecret(managementSecret || undefined);
+ }
+
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+
+ const summary = getAuthSummary();
+ res.json({
+ success: true,
+ apiKey: {
+ value: maskToken(summary.apiKey.value),
+ isCustom: summary.apiKey.isCustom,
+ },
+ managementSecret: {
+ value: maskToken(summary.managementSecret.value),
+ isCustom: summary.managementSecret.isCustom,
+ },
+ message: 'Restart CLIProxy to apply changes',
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+/**
+ * POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret
+ */
+router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => {
+ try {
+ const newSecret = generateSecureToken(32);
+ setGlobalManagementSecret(newSecret);
+
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+
+ res.json({
+ success: true,
+ managementSecret: {
+ value: maskToken(newSecret),
+ isCustom: true,
+ },
+ message: 'Restart CLIProxy to apply changes',
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
+/**
+ * POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults
+ */
+router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
+ try {
+ resetAuthToDefaults();
+
+ // Regenerate CLIProxy config to apply changes
+ regenerateConfig();
+
+ const summary = getAuthSummary();
+ res.json({
+ success: true,
+ apiKey: {
+ value: maskToken(summary.apiKey.value),
+ isCustom: summary.apiKey.isCustom,
+ },
+ managementSecret: {
+ value: maskToken(summary.managementSecret.value),
+ isCustom: summary.managementSecret.isCustom,
+ },
+ message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
+ });
+ } catch (error) {
+ res.status(500).json({ error: (error as Error).message });
+ }
+});
+
export default router;
diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts
index e402bbaa..8c642206 100644
--- a/src/web-server/routes/variant-routes.ts
+++ b/src/web-server/routes/variant-routes.ts
@@ -1,34 +1,43 @@
/**
* Variant Routes - CLIProxy variant management (custom profiles)
+ *
+ * Uses variant-service.ts for proper port allocation and cleanup.
*/
import { Router, Request, Response } from 'express';
-import * as fs from 'fs';
-import * as path from 'path';
-import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import type { CLIProxyProvider } from '../../cliproxy/types';
-import { readConfigSafe, writeConfig, createCliproxySettings } from './route-helpers';
+import {
+ createVariant,
+ removeVariant,
+ listVariants,
+ validateProfileName,
+ updateVariant,
+} from '../../cliproxy/services/variant-service';
const router = Router();
/**
* GET /api/cliproxy - List cliproxy variants
+ * Uses variant-service for consistent behavior with CLI
*/
router.get('/', (_req: Request, res: Response) => {
- const config = readConfigSafe();
- const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({
+ const variants = listVariants();
+ const variantList = Object.entries(variants).map(([name, variant]) => ({
name,
provider: variant.provider,
settings: variant.settings,
- account: variant.account || 'default', // Include account field
+ account: variant.account || 'default',
+ port: variant.port, // Include port for port isolation
+ model: variant.model,
}));
- res.json({ variants });
+ res.json({ variants: variantList });
});
/**
* POST /api/cliproxy - Create cliproxy variant
+ * Uses variant-service for proper port allocation
*/
router.post('/', (req: Request, res: Response): void => {
const { name, provider, model, account } = req.body;
@@ -38,7 +47,14 @@ router.post('/', (req: Request, res: Response): void => {
return;
}
- // Reject reserved names as variant names (prevents collision with built-in providers)
+ // Validate profile name
+ const validationError = validateProfileName(name);
+ if (validationError) {
+ res.status(400).json({ error: validationError });
+ return;
+ }
+
+ // Reject reserved names (extra safety check)
if (isReservedName(name)) {
res.status(400).json({
error: `Cannot use reserved name '${name}' as variant name`,
@@ -47,84 +63,47 @@ router.post('/', (req: Request, res: Response): void => {
return;
}
- const config = readConfigSafe();
- config.cliproxy = config.cliproxy || {};
+ // Use variant-service for proper port allocation
+ const result = createVariant(name, provider as CLIProxyProvider, model || '', account);
- if (config.cliproxy[name]) {
- res.status(409).json({ error: 'Variant already exists' });
+ if (!result.success) {
+ res.status(409).json({ error: result.error });
return;
}
- // Ensure .ccs directory exists
- if (!fs.existsSync(getCcsDir())) {
- fs.mkdirSync(getCcsDir(), { recursive: true });
- }
-
- // Create settings file for variant
- const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model);
-
- // Include account if specified (defaults to 'default' if not provided)
- config.cliproxy[name] = {
+ res.status(201).json({
+ name,
provider,
- settings: settingsPath,
- ...(account && { account }),
- };
- writeConfig(config);
-
- res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' });
+ settings: result.settingsPath,
+ account: account || 'default',
+ port: result.variant?.port,
+ model: result.variant?.model,
+ });
});
/**
* PUT /api/cliproxy/:name - Update cliproxy variant
+ * Uses variant-service for consistent behavior with CLI
*/
router.put('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
const { provider, account, model } = req.body;
- const config = readConfigSafe();
+ // Use variant-service for proper update handling
+ const result = updateVariant(name, { provider, account, model });
- if (!config.cliproxy?.[name]) {
- res.status(404).json({ error: 'Variant not found' });
+ if (!result.success) {
+ res.status(404).json({ error: result.error });
return;
}
- const variant = config.cliproxy[name];
-
- // Update fields if provided
- if (provider) {
- variant.provider = provider;
- }
- if (account !== undefined) {
- if (account) {
- variant.account = account;
- } else {
- delete variant.account; // Remove account to use default
- }
- }
-
- // Update model in settings file if provided
- if (model !== undefined) {
- const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
- if (fs.existsSync(settingsPath)) {
- const settings = loadSettings(settingsPath);
- if (model) {
- settings.env = settings.env || {};
- settings.env.ANTHROPIC_MODEL = model;
- } else if (settings.env) {
- delete settings.env.ANTHROPIC_MODEL;
- }
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
- }
- }
-
- writeConfig(config);
-
res.json({
name,
- provider: variant.provider,
- account: variant.account || 'default',
- settings: variant.settings,
+ provider: result.variant?.provider,
+ account: result.variant?.account || 'default',
+ settings: result.variant?.settings,
+ port: result.variant?.port,
updated: true,
});
} catch (error) {
@@ -134,31 +113,21 @@ router.put('/:name', (req: Request, res: Response): void => {
/**
* DELETE /api/cliproxy/:name - Delete cliproxy variant
+ * Uses variant-service for proper port-specific file cleanup
*/
router.delete('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
- const config = readConfigSafe();
+ // Use variant-service for proper cleanup (settings, config, session files)
+ const result = removeVariant(name);
- if (!config.cliproxy?.[name]) {
- res.status(404).json({ error: 'Variant not found' });
+ if (!result.success) {
+ res.status(404).json({ error: result.error });
return;
}
- // Never delete settings files for reserved provider names (safety guard)
- if (!isReservedName(name)) {
- // Only delete settings file for non-reserved variant names
- const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
- if (fs.existsSync(settingsPath)) {
- fs.unlinkSync(settingsPath);
- }
- }
-
- delete config.cliproxy[name];
- writeConfig(config);
-
- res.json({ name, deleted: true });
+ res.json({ name, deleted: true, port: result.variant?.port });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts
index 22de8762..b9ae862e 100644
--- a/src/web-server/websocket.ts
+++ b/src/web-server/websocket.ts
@@ -12,6 +12,7 @@ import {
projectSelectionEvents,
type ProjectSelectionPrompt,
} from '../cliproxy/project-selection-handler';
+import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler';
export interface WSMessage {
type: string;
@@ -117,11 +118,54 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
});
};
+ // Listen for device code events and broadcast to clients
+ const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => {
+ console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`));
+ broadcast({
+ type: 'deviceCodeReceived',
+ ...prompt,
+ timestamp: Date.now(),
+ });
+ };
+
+ const handleDeviceCodeCompleted = (sessionId: string): void => {
+ console.log(info(`[WS] Device code auth completed (session: ${sessionId})`));
+ broadcast({
+ type: 'deviceCodeCompleted',
+ sessionId,
+ timestamp: Date.now(),
+ });
+ };
+
+ const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => {
+ console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`));
+ broadcast({
+ type: 'deviceCodeFailed',
+ ...data,
+ timestamp: Date.now(),
+ });
+ };
+
+ const handleDeviceCodeExpired = (sessionId: string): void => {
+ console.log(info(`[WS] Device code expired (session: ${sessionId})`));
+ broadcast({
+ type: 'deviceCodeExpired',
+ sessionId,
+ timestamp: Date.now(),
+ });
+ };
+
// Subscribe to project selection events
projectSelectionEvents.on('selection:required', handleProjectSelectionRequired);
projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout);
projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted);
+ // Subscribe to device code events
+ deviceCodeEvents.on('deviceCode:received', handleDeviceCodeReceived);
+ deviceCodeEvents.on('deviceCode:completed', handleDeviceCodeCompleted);
+ deviceCodeEvents.on('deviceCode:failed', handleDeviceCodeFailed);
+ deviceCodeEvents.on('deviceCode:expired', handleDeviceCodeExpired);
+
// Cleanup function
const cleanup = (): void => {
watcher.close();
@@ -131,6 +175,12 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout);
projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted);
+ // Unsubscribe from device code events
+ deviceCodeEvents.off('deviceCode:received', handleDeviceCodeReceived);
+ deviceCodeEvents.off('deviceCode:completed', handleDeviceCodeCompleted);
+ deviceCodeEvents.off('deviceCode:failed', handleDeviceCodeFailed);
+ deviceCodeEvents.off('deviceCode:expired', handleDeviceCodeExpired);
+
clients.forEach((client) => {
client.close(1001, 'Server shutting down');
});
diff --git a/tests/npm/postinstall.test.js b/tests/npm/postinstall.test.js
index 07572362..136df681 100644
--- a/tests/npm/postinstall.test.js
+++ b/tests/npm/postinstall.test.js
@@ -19,19 +19,25 @@ describe('npm postinstall', () => {
}
});
- it('creates config.json', () => {
+ it('creates config.yaml (primary format)', () => {
execSync(`node "${postinstallScript}"`, {
stdio: 'ignore',
env: { ...process.env, CCS_HOME: testEnv.testHome }
});
- assert(testEnv.fileExists('config.json'), 'config.json should be created');
+ // config.yaml is now the primary format (v6.x+)
+ assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created');
- const config = testEnv.readFile('config.json', true);
- assert(config.profiles, 'config.json should have profiles');
+ // Read YAML config and verify structure
+ const yaml = require('js-yaml');
+ const configContent = testEnv.readFile('config.yaml', false);
+ const config = yaml.load(configContent);
+
+ assert(config.profiles !== undefined, 'config.yaml should have profiles');
assert(typeof config.profiles === 'object', 'profiles should be an object');
// Profiles are now empty by default - users create via presets
assert.deepStrictEqual(config.profiles, {}, 'profiles should be empty by default');
+ assert(config.version, 'config.yaml should have version');
});
it('does NOT auto-create glm.settings.json (v6.0 - use presets instead)', () => {
@@ -49,24 +55,30 @@ describe('npm postinstall', () => {
it('is idempotent', () => {
const env = { ...process.env, CCS_HOME: testEnv.testHome };
+ const yaml = require('js-yaml');
// Run postinstall first time
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
- // Create custom config
+ // Create custom config.yaml to test preservation
const customConfig = {
+ version: '2.0',
profiles: {
custom: '~/.custom.json',
glm: '~/.ccs/glm.settings.json'
- }
+ },
+ accounts: {},
+ cliproxy: { variants: {}, oauth_accounts: {} }
};
- testEnv.createFile('config.json', customConfig);
+ const yamlContent = yaml.dump(customConfig, { indent: 2 });
+ testEnv.createFile('config.yaml', yamlContent);
// Run postinstall again
execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env });
// Verify custom config preserved
- const config = testEnv.readFile('config.json', true);
+ const configContent = testEnv.readFile('config.yaml', false);
+ const config = yaml.load(configContent);
assert(config.profiles.custom, 'Custom profile should be preserved');
assert.strictEqual(config.profiles.custom, '~/.custom.json');
});
@@ -97,7 +109,7 @@ describe('npm postinstall', () => {
// Verify existing file still exists and new files are created
assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved');
- assert(testEnv.fileExists('config.json'), 'config.json should be created');
+ assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created');
// GLM/GLMT/Kimi are no longer auto-created
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
});
diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts
new file mode 100644
index 00000000..52f2ae68
--- /dev/null
+++ b/tests/unit/auth/profile-detector.test.ts
@@ -0,0 +1,157 @@
+/**
+ * Unit tests for Profile Detector
+ */
+import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector';
+import * as unifiedConfigLoader from '../../../src/config/unified-config-loader';
+
+describe('ProfileDetector', () => {
+ const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`);
+
+ beforeEach(() => {
+ if (!fs.existsSync(tempDir)) {
+ fs.mkdirSync(tempDir, { recursive: true });
+ }
+ });
+
+ afterEach(() => {
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('loadSettingsFromFile', () => {
+ it('should load settings from a valid JSON file', () => {
+ const settingsPath = path.join(tempDir, 'valid.settings.json');
+ const settings = { env: { KEY: 'VALUE' } };
+ fs.writeFileSync(settingsPath, JSON.stringify(settings));
+
+ const result = loadSettingsFromFile(settingsPath);
+ expect(result).toEqual({ KEY: 'VALUE' });
+ });
+
+ it('should return empty object for non-existent file', () => {
+ const result = loadSettingsFromFile(path.join(tempDir, 'non-existent.json'));
+ expect(result).toEqual({});
+ });
+
+ it('should return empty object for invalid JSON', () => {
+ const settingsPath = path.join(tempDir, 'invalid.json');
+ fs.writeFileSync(settingsPath, 'invalid json');
+
+ const result = loadSettingsFromFile(settingsPath);
+ expect(result).toEqual({});
+ });
+
+ it('should handle tilde expansion correctly', () => {
+ const mockHome = path.join(tempDir, 'home');
+ fs.mkdirSync(mockHome, { recursive: true });
+ const settingsPath = '~/test.settings.json';
+ const actualPath = path.join(mockHome, 'test.settings.json');
+ fs.writeFileSync(actualPath, JSON.stringify({ env: { HOME_VAR: 'TRUE' } }));
+
+ // Mock os.homedir
+ const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome);
+
+ try {
+ const result = loadSettingsFromFile(settingsPath);
+ expect(result).toEqual({ HOME_VAR: 'TRUE' });
+ } finally {
+ homedirSpy.mockRestore();
+ }
+ });
+
+ it('should handle env var expansion correctly', () => {
+ const settingsPath = path.join(tempDir, '${TEST_VAR}.json');
+ const actualPath = path.join(tempDir, 'actual.json');
+ fs.writeFileSync(actualPath, JSON.stringify({ env: { ENV_VAR: 'EXPANDED' } }));
+
+ process.env.TEST_VAR = 'actual';
+ try {
+ const result = loadSettingsFromFile(settingsPath);
+ expect(result).toEqual({ ENV_VAR: 'EXPANDED' });
+ } finally {
+ delete process.env.TEST_VAR;
+ }
+ });
+ });
+
+ describe('detectProfileType', () => {
+ let detector: ProfileDetector;
+
+ beforeEach(() => {
+ detector = new ProfileDetector();
+ });
+
+ it('should detect CLIProxy profiles', () => {
+ const result = detector.detectProfileType('gemini');
+ expect(result.type).toBe('cliproxy');
+ expect(result.name).toBe('gemini');
+ expect(result.provider).toBe('gemini');
+ });
+
+ it('should detect settings-based profile from unified config', () => {
+ const settingsPath = path.join(tempDir, 'glm.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } }));
+
+ const mockUnifiedConfig = {
+ version: 2,
+ profiles: {
+ glm: { settings: settingsPath, type: 'api' }
+ }
+ };
+
+ const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
+ const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any);
+
+ try {
+ const result = detector.detectProfileType('glm');
+ expect(result.type).toBe('settings');
+ expect(result.name).toBe('glm');
+ expect(result.env).toEqual({ ANTHROPIC_MODEL: 'glm-4' });
+ } finally {
+ isUnifiedModeSpy.mockRestore();
+ loadUnifiedConfigSpy.mockRestore();
+ }
+ });
+
+ it('should detect account-based profile from unified config', () => {
+ const mockUnifiedConfig = {
+ version: 2,
+ accounts: {
+ work: { created: '2025-01-01', last_used: '2025-01-02' }
+ }
+ };
+
+ const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
+ const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any);
+
+ try {
+ const result = detector.detectProfileType('work');
+ expect(result.type).toBe('account');
+ expect(result.name).toBe('work');
+ expect(result.profile).toBeDefined();
+ expect((result.profile as any).type).toBe('account');
+ } finally {
+ isUnifiedModeSpy.mockRestore();
+ loadUnifiedConfigSpy.mockRestore();
+ }
+ });
+
+ it('should return null for unknown profile (throws error)', () => {
+ const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
+ // Mock readConfig/readProfiles to return empty
+ const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
+
+ try {
+ expect(() => detector.detectProfileType('unknown')).toThrow(/Profile not found/);
+ } finally {
+ isUnifiedModeSpy.mockRestore();
+ existsSyncSpy.mockRestore();
+ }
+ });
+ });
+});
diff --git a/tests/unit/cliproxy/auth-token-manager.test.js b/tests/unit/cliproxy/auth-token-manager.test.js
new file mode 100644
index 00000000..00dfd04b
--- /dev/null
+++ b/tests/unit/cliproxy/auth-token-manager.test.js
@@ -0,0 +1,425 @@
+/**
+ * Auth Token Manager Tests
+ *
+ * Comprehensive test suite for CLIProxy auth token management including:
+ * - Token generation (cryptographic security)
+ * - Token masking
+ * - Pure function logic tests
+ */
+
+const assert = require('assert');
+const crypto = require('crypto');
+
+describe('Auth Token Manager', () => {
+ // =========================================================================
+ // Token Generation Tests (Pure Functions)
+ // =========================================================================
+ describe('generateSecureToken', () => {
+ let generateSecureToken;
+
+ beforeEach(() => {
+ // Clear cache and reload
+ delete require.cache[require.resolve('../../../dist/cliproxy/auth-token-manager')];
+ const authTokenManager = require('../../../dist/cliproxy/auth-token-manager');
+ generateSecureToken = authTokenManager.generateSecureToken;
+ });
+
+ it('generates token of correct length (base64url encoding)', () => {
+ const token = generateSecureToken(32);
+ // 32 bytes = 43 chars in base64url (without padding)
+ assert.strictEqual(token.length, 43);
+ });
+
+ it('generates unique tokens each call', () => {
+ const tokens = new Set();
+ for (let i = 0; i < 100; i++) {
+ tokens.add(generateSecureToken(32));
+ }
+ assert.strictEqual(tokens.size, 100, 'All 100 tokens should be unique');
+ });
+
+ it('uses base64url encoding (no +/= characters)', () => {
+ // Generate many tokens to ensure we hit all character ranges
+ for (let i = 0; i < 50; i++) {
+ const token = generateSecureToken(32);
+ assert(!token.includes('+'), 'Should not contain +');
+ assert(!token.includes('/'), 'Should not contain /');
+ assert(!token.includes('='), 'Should not contain padding =');
+ }
+ });
+
+ it('accepts custom length parameter', () => {
+ const short = generateSecureToken(16);
+ const long = generateSecureToken(64);
+
+ // 16 bytes = 22 chars, 64 bytes = 86 chars in base64url
+ assert.strictEqual(short.length, 22);
+ assert.strictEqual(long.length, 86);
+ });
+
+ it('handles edge case: length = 0', () => {
+ const empty = generateSecureToken(0);
+ assert.strictEqual(empty.length, 0);
+ });
+
+ it('handles edge case: very large length', () => {
+ const large = generateSecureToken(256);
+ assert.strictEqual(large.length, 342); // 256 bytes = 342 chars in base64url
+ });
+
+ it('uses cryptographically secure random bytes', () => {
+ // Verify the function uses crypto.randomBytes internally
+ // by checking statistical properties of generated tokens
+ const tokens = [];
+ for (let i = 0; i < 1000; i++) {
+ tokens.push(generateSecureToken(32));
+ }
+
+ // All tokens should be unique
+ const uniqueTokens = new Set(tokens);
+ assert.strictEqual(uniqueTokens.size, 1000, 'All tokens should be unique');
+
+ // Check that first character has good distribution (not always same)
+ const firstChars = new Set(tokens.map((t) => t[0]));
+ assert(firstChars.size > 10, 'First character should have good distribution');
+ });
+ });
+
+ // =========================================================================
+ // Token Masking Tests (Pure Function Simulation)
+ // =========================================================================
+ describe('maskToken (logic validation)', () => {
+ // Simulates the maskToken function from tokens-command.ts and settings-routes.ts
+ function maskToken(token) {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+ }
+
+ it('masks tokens showing first 4 and last 4 characters', () => {
+ assert.strictEqual(maskToken('1234567890abcdef'), '1234...cdef');
+ });
+
+ it('returns **** for tokens <= 8 characters', () => {
+ assert.strictEqual(maskToken('12345678'), '****');
+ assert.strictEqual(maskToken('1234567'), '****');
+ assert.strictEqual(maskToken('abc'), '****');
+ assert.strictEqual(maskToken(''), '****');
+ });
+
+ it('handles exactly 9 character tokens', () => {
+ assert.strictEqual(maskToken('123456789'), '1234...6789');
+ });
+
+ it('handles long tokens (typical API keys)', () => {
+ const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
+ assert.strictEqual(maskToken(longToken), 'test...wxyz');
+ });
+
+ it('preserves special characters', () => {
+ assert.strictEqual(maskToken('!@#$abcd____wxyz'), '!@#$...wxyz');
+ });
+
+ it('handles default CCS internal key', () => {
+ const internalKey = 'ccs-internal-managed';
+ assert.strictEqual(maskToken(internalKey), 'ccs-...aged');
+ });
+
+ it('handles default CCS control panel secret', () => {
+ const secret = 'ccs';
+ assert.strictEqual(maskToken(secret), '****');
+ });
+ });
+
+ // =========================================================================
+ // Inheritance Chain Logic Tests
+ // =========================================================================
+ describe('getEffectiveApiKey logic', () => {
+ /**
+ * Simulates the inheritance logic from auth-token-manager.ts
+ */
+ function getEffectiveApiKey(config, variantName, defaultKey) {
+ // Priority 1: Per-variant override
+ if (variantName) {
+ const variant = config.cliproxy?.variants?.[variantName];
+ if (variant?.auth?.api_key) {
+ return variant.auth.api_key;
+ }
+ }
+
+ // Priority 2: Global cliproxy.auth
+ if (config.cliproxy?.auth?.api_key) {
+ return config.cliproxy.auth.api_key;
+ }
+
+ // Priority 3: Default constant
+ return defaultKey;
+ }
+
+ const DEFAULT_KEY = 'ccs-internal-managed';
+
+ it('returns default when no custom config', () => {
+ const config = { cliproxy: { variants: {} } };
+ assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), DEFAULT_KEY);
+ });
+
+ it('returns global auth when set', () => {
+ const config = {
+ cliproxy: {
+ variants: {},
+ auth: { api_key: 'global-custom' },
+ },
+ };
+ assert.strictEqual(getEffectiveApiKey(config, undefined, DEFAULT_KEY), 'global-custom');
+ });
+
+ it('returns variant auth when set (highest priority)', () => {
+ const config = {
+ cliproxy: {
+ variants: {
+ gemini: { auth: { api_key: 'variant-key' } },
+ },
+ auth: { api_key: 'global-custom' },
+ },
+ };
+ assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'variant-key');
+ });
+
+ it('falls back to global when variant has no auth', () => {
+ const config = {
+ cliproxy: {
+ variants: {
+ gemini: { type: 'claude' },
+ },
+ auth: { api_key: 'global-custom' },
+ },
+ };
+ assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), 'global-custom');
+ });
+
+ it('falls back to default when variant and global missing', () => {
+ const config = {
+ cliproxy: {
+ variants: {
+ gemini: { type: 'claude' },
+ },
+ },
+ };
+ assert.strictEqual(getEffectiveApiKey(config, 'gemini', DEFAULT_KEY), DEFAULT_KEY);
+ });
+
+ it('ignores variant name when variant does not exist', () => {
+ const config = {
+ cliproxy: {
+ variants: {},
+ auth: { api_key: 'global-custom' },
+ },
+ };
+ assert.strictEqual(getEffectiveApiKey(config, 'non-existent', DEFAULT_KEY), 'global-custom');
+ });
+ });
+
+ describe('getEffectiveManagementSecret logic', () => {
+ /**
+ * Simulates the management secret logic from auth-token-manager.ts
+ */
+ function getEffectiveManagementSecret(config, defaultSecret) {
+ // Priority 1: Global cliproxy.auth
+ if (config.cliproxy?.auth?.management_secret) {
+ return config.cliproxy.auth.management_secret;
+ }
+
+ // Priority 2: Default constant
+ return defaultSecret;
+ }
+
+ const DEFAULT_SECRET = 'ccs';
+
+ it('returns default when no custom config', () => {
+ const config = { cliproxy: { variants: {} } };
+ assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), DEFAULT_SECRET);
+ });
+
+ it('returns custom secret when set', () => {
+ const config = {
+ cliproxy: {
+ variants: {},
+ auth: { management_secret: 'custom-secret' },
+ },
+ };
+ assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'custom-secret');
+ });
+
+ it('is global only (no per-variant override)', () => {
+ // Management secret does not support per-variant override
+ const config = {
+ cliproxy: {
+ variants: {
+ gemini: { auth: { management_secret: 'should-be-ignored' } },
+ },
+ auth: { management_secret: 'global-secret' },
+ },
+ };
+ // The function only checks global auth
+ assert.strictEqual(getEffectiveManagementSecret(config, DEFAULT_SECRET), 'global-secret');
+ });
+ });
+
+ // =========================================================================
+ // Auth Summary Logic Tests
+ // =========================================================================
+ describe('getAuthSummary logic', () => {
+ function getAuthSummary(config, defaultApiKey, defaultSecret) {
+ const customApiKey = config.cliproxy?.auth?.api_key;
+ const customSecret = config.cliproxy?.auth?.management_secret;
+
+ return {
+ apiKey: {
+ value: customApiKey || defaultApiKey,
+ isCustom: !!customApiKey,
+ },
+ managementSecret: {
+ value: customSecret || defaultSecret,
+ isCustom: !!customSecret,
+ },
+ };
+ }
+
+ const DEFAULT_KEY = 'ccs-internal-managed';
+ const DEFAULT_SECRET = 'ccs';
+
+ it('returns defaults with isCustom=false when no custom config', () => {
+ const config = { cliproxy: { variants: {} } };
+ const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
+
+ assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
+ assert.strictEqual(summary.apiKey.isCustom, false);
+ assert.strictEqual(summary.managementSecret.value, DEFAULT_SECRET);
+ assert.strictEqual(summary.managementSecret.isCustom, false);
+ });
+
+ it('returns custom values with isCustom=true when set', () => {
+ const config = {
+ cliproxy: {
+ variants: {},
+ auth: { api_key: 'custom-key', management_secret: 'custom-secret' },
+ },
+ };
+ const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
+
+ assert.strictEqual(summary.apiKey.value, 'custom-key');
+ assert.strictEqual(summary.apiKey.isCustom, true);
+ assert.strictEqual(summary.managementSecret.value, 'custom-secret');
+ assert.strictEqual(summary.managementSecret.isCustom, true);
+ });
+
+ it('handles partial custom config', () => {
+ const config = {
+ cliproxy: {
+ variants: {},
+ auth: { api_key: 'custom-key' },
+ },
+ };
+ const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
+
+ assert.strictEqual(summary.apiKey.isCustom, true);
+ assert.strictEqual(summary.managementSecret.isCustom, false);
+ });
+
+ it('treats empty string as no custom value', () => {
+ const config = {
+ cliproxy: {
+ variants: {},
+ auth: { api_key: '' },
+ },
+ };
+ const summary = getAuthSummary(config, DEFAULT_KEY, DEFAULT_SECRET);
+
+ // Empty string is falsy, so isCustom should be false
+ assert.strictEqual(summary.apiKey.value, DEFAULT_KEY);
+ assert.strictEqual(summary.apiKey.isCustom, false);
+ });
+ });
+
+ // =========================================================================
+ // Edge Cases
+ // =========================================================================
+ describe('Edge Cases', () => {
+ it('handles undefined config gracefully', () => {
+ function getEffectiveApiKey(config, variantName, defaultKey) {
+ if (variantName) {
+ const variant = config?.cliproxy?.variants?.[variantName];
+ if (variant?.auth?.api_key) return variant.auth.api_key;
+ }
+ if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
+ return defaultKey;
+ }
+
+ assert.strictEqual(getEffectiveApiKey(undefined, undefined, 'default'), 'default');
+ assert.strictEqual(getEffectiveApiKey(null, undefined, 'default'), 'default');
+ assert.strictEqual(getEffectiveApiKey({}, undefined, 'default'), 'default');
+ });
+
+ it('handles deeply nested missing properties', () => {
+ function getEffectiveApiKey(config, variantName, defaultKey) {
+ if (variantName) {
+ const variant = config?.cliproxy?.variants?.[variantName];
+ if (variant?.auth?.api_key) return variant.auth.api_key;
+ }
+ if (config?.cliproxy?.auth?.api_key) return config.cliproxy.auth.api_key;
+ return defaultKey;
+ }
+
+ const config = { cliproxy: {} };
+ assert.strictEqual(getEffectiveApiKey(config, 'test', 'default'), 'default');
+ });
+
+ it('crypto.randomBytes is available and working', () => {
+ // Ensure crypto module is available
+ const bytes = crypto.randomBytes(32);
+ assert.strictEqual(bytes.length, 32);
+ assert(Buffer.isBuffer(bytes));
+ });
+
+ it('base64url encoding produces valid characters', () => {
+ const bytes = crypto.randomBytes(32);
+ const token = bytes.toString('base64url');
+
+ // Valid base64url characters: A-Z, a-z, 0-9, -, _
+ const validChars = /^[A-Za-z0-9_-]+$/;
+ assert(validChars.test(token), 'Token should only contain base64url characters');
+ });
+ });
+
+ // =========================================================================
+ // Constants Validation
+ // =========================================================================
+ describe('Default Constants', () => {
+ let CCS_INTERNAL_API_KEY;
+ let CCS_CONTROL_PANEL_SECRET;
+
+ beforeEach(() => {
+ delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
+ const configGenerator = require('../../../dist/cliproxy/config-generator');
+ CCS_INTERNAL_API_KEY = configGenerator.CCS_INTERNAL_API_KEY;
+ CCS_CONTROL_PANEL_SECRET = configGenerator.CCS_CONTROL_PANEL_SECRET;
+ });
+
+ it('CCS_INTERNAL_API_KEY is defined', () => {
+ assert(CCS_INTERNAL_API_KEY, 'CCS_INTERNAL_API_KEY should be defined');
+ assert.strictEqual(typeof CCS_INTERNAL_API_KEY, 'string');
+ });
+
+ it('CCS_CONTROL_PANEL_SECRET is defined', () => {
+ assert(CCS_CONTROL_PANEL_SECRET, 'CCS_CONTROL_PANEL_SECRET should be defined');
+ assert.strictEqual(typeof CCS_CONTROL_PANEL_SECRET, 'string');
+ });
+
+ it('default API key has expected value', () => {
+ assert.strictEqual(CCS_INTERNAL_API_KEY, 'ccs-internal-managed');
+ });
+
+ it('default secret has expected value', () => {
+ assert.strictEqual(CCS_CONTROL_PANEL_SECRET, 'ccs');
+ });
+ });
+});
diff --git a/tests/unit/cliproxy/config-generator-port.test.js b/tests/unit/cliproxy/config-generator-port.test.js
new file mode 100644
index 00000000..dfbb76d5
--- /dev/null
+++ b/tests/unit/cliproxy/config-generator-port.test.js
@@ -0,0 +1,280 @@
+/**
+ * Config Generator Port Tests
+ *
+ * Tests for per-port configuration in config-generator.ts.
+ * Verifies port-specific config files (config-{port}.yaml) and path generation.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+// Set test isolation environment before importing
+const testHome = path.join(
+ os.tmpdir(),
+ `ccs-test-config-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
+);
+process.env.CCS_HOME = testHome;
+
+const {
+ getConfigPathForPort,
+ getCliproxyConfigPath,
+ generateConfig,
+ regenerateConfig,
+ configExists,
+ deleteConfigForPort,
+ deleteConfig,
+ CLIPROXY_DEFAULT_PORT,
+} = require('../../../dist/cliproxy/config-generator');
+
+describe('Config Generator Port', function () {
+ let cliproxyDir;
+
+ beforeEach(function () {
+ // Create test directories
+ cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ // Clean up any existing config files
+ try {
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ if (file.startsWith('config')) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ }
+ } catch {
+ // Directory might not exist yet
+ }
+ });
+
+ afterEach(function () {
+ // Clean up config files
+ try {
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ if (file.startsWith('config')) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ }
+ } catch {
+ // Ignore cleanup errors
+ }
+ });
+
+ afterAll(function () {
+ // Clean up test directory
+ try {
+ fs.rmSync(testHome, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ delete process.env.CCS_HOME;
+ });
+
+ describe('getConfigPathForPort', function () {
+ it('returns config.yaml for default port (8317)', function () {
+ const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
+ const filename = path.basename(configPath);
+ assert.ok(configPath.endsWith('config.yaml'), `Expected path to end with config.yaml but got: ${configPath}`);
+ assert.strictEqual(filename, 'config.yaml', `Expected filename to be config.yaml but got: ${filename}`);
+ });
+
+ it('returns config-{port}.yaml for variant ports', function () {
+ const variantPort = 8318;
+ const configPath = getConfigPathForPort(variantPort);
+ assert.ok(configPath.endsWith(`config-${variantPort}.yaml`));
+ });
+
+ it('example: port 8318 -> config-8318.yaml', function () {
+ const configPath = getConfigPathForPort(8318);
+ assert.ok(configPath.endsWith('config-8318.yaml'));
+ });
+
+ it('example: port 8417 -> config-8417.yaml', function () {
+ const configPath = getConfigPathForPort(8417);
+ assert.ok(configPath.endsWith('config-8417.yaml'));
+ });
+ });
+
+ describe('getCliproxyConfigPath', function () {
+ it('returns path for default port', function () {
+ const configPath = getCliproxyConfigPath();
+ const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
+ assert.strictEqual(configPath, defaultPath);
+ });
+ });
+
+ describe('generateConfig', function () {
+ it('creates config-{port}.yaml for non-default port', function () {
+ const variantPort = 8318;
+ generateConfig('gemini', variantPort);
+
+ const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
+ assert.ok(fs.existsSync(configPath), 'Should create config-8318.yaml');
+ });
+
+ it('creates config.yaml for default port', function () {
+ generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
+
+ const configPath = path.join(cliproxyDir, 'config.yaml');
+ assert.ok(fs.existsSync(configPath), 'Should create config.yaml');
+ });
+
+ it('only creates if file does not exist (idempotent)', function () {
+ const variantPort = 8318;
+ const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
+
+ // Create config first time
+ generateConfig('gemini', variantPort);
+ const stat1 = fs.statSync(configPath);
+
+ // Wait a tiny bit and try again
+ const originalContent = fs.readFileSync(configPath, 'utf-8');
+ generateConfig('gemini', variantPort);
+ const newContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Content should be the same (not regenerated)
+ assert.strictEqual(originalContent, newContent);
+ });
+
+ it('sets correct port in config content', function () {
+ const variantPort = 8320;
+ generateConfig('gemini', variantPort);
+
+ const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
+ const content = fs.readFileSync(configPath, 'utf-8');
+
+ // Check that port is set correctly
+ assert.ok(content.includes(`port: ${variantPort}`));
+ });
+ });
+
+ describe('regenerateConfig', function () {
+ it('regenerates config-{port}.yaml with updated version', function () {
+ const variantPort = 8318;
+ const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
+
+ // Create initial config
+ generateConfig('gemini', variantPort);
+ const originalContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Modify the file
+ fs.writeFileSync(configPath, '# modified content\n' + originalContent);
+
+ // Regenerate
+ regenerateConfig(variantPort);
+ const newContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Should not contain our modification
+ assert.ok(!newContent.includes('# modified content'));
+ });
+
+ it('preserves port value from existing config', function () {
+ const variantPort = 8325;
+ const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
+
+ // Create config with specific port
+ generateConfig('gemini', variantPort);
+
+ // Regenerate
+ regenerateConfig(variantPort);
+ const content = fs.readFileSync(configPath, 'utf-8');
+
+ // Port should still be 8325
+ assert.ok(content.includes(`port: ${variantPort}`));
+ });
+ });
+
+ describe('deleteConfigForPort', function () {
+ it('deletes config-{port}.yaml for specified port', function () {
+ const variantPort = 8318;
+ generateConfig('gemini', variantPort);
+
+ const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
+ assert.ok(fs.existsSync(configPath));
+
+ deleteConfigForPort(variantPort);
+ assert.strictEqual(fs.existsSync(configPath), false);
+ });
+
+ it('does nothing if file does not exist', function () {
+ // Should not throw
+ deleteConfigForPort(8399);
+ });
+
+ it('does not affect other port configs', function () {
+ const port1 = 8318;
+ const port2 = 8319;
+
+ generateConfig('gemini', port1);
+ generateConfig('gemini', port2);
+
+ deleteConfigForPort(port1);
+
+ const config1Path = path.join(cliproxyDir, `config-${port1}.yaml`);
+ const config2Path = path.join(cliproxyDir, `config-${port2}.yaml`);
+
+ assert.strictEqual(fs.existsSync(config1Path), false);
+ assert.ok(fs.existsSync(config2Path));
+ });
+ });
+
+ describe('deleteConfig', function () {
+ it('deletes config.yaml for default port', function () {
+ generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
+
+ const configPath = path.join(cliproxyDir, 'config.yaml');
+ assert.ok(fs.existsSync(configPath));
+
+ deleteConfig();
+ assert.strictEqual(fs.existsSync(configPath), false);
+ });
+ });
+
+ describe('configExists', function () {
+ it('returns true if config-{port}.yaml exists', function () {
+ const variantPort = 8318;
+ generateConfig('gemini', variantPort);
+
+ assert.strictEqual(configExists(variantPort), true);
+ });
+
+ it('returns false if config-{port}.yaml missing', function () {
+ const variantPort = 8399;
+ assert.strictEqual(configExists(variantPort), false);
+ });
+
+ it('returns true for default port config', function () {
+ generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
+ assert.strictEqual(configExists(CLIPROXY_DEFAULT_PORT), true);
+ });
+ });
+
+ describe('Multiple Port Configs', function () {
+ it('can create configs for multiple ports simultaneously', function () {
+ const ports = [8318, 8319, 8320, CLIPROXY_DEFAULT_PORT];
+
+ for (const port of ports) {
+ generateConfig('gemini', port);
+ }
+
+ // All should exist
+ for (const port of ports) {
+ assert.ok(configExists(port), `Config for port ${port} should exist`);
+ }
+ });
+
+ it('each port config has correct port value', function () {
+ const ports = [8318, 8319, 8320];
+
+ for (const port of ports) {
+ generateConfig('gemini', port);
+ const configPath = getConfigPathForPort(port);
+ const content = fs.readFileSync(configPath, 'utf-8');
+ assert.ok(content.includes(`port: ${port}`), `Config should have port: ${port}`);
+ }
+ });
+ });
+});
diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js
index 91dd4b0e..703b5fe6 100644
--- a/tests/unit/cliproxy/config-generator.test.js
+++ b/tests/unit/cliproxy/config-generator.test.js
@@ -166,6 +166,365 @@ describe('Config Generator', () => {
});
});
+ // =========================================================================
+ // API Key Preservation Tests (Issue #200)
+ // =========================================================================
+ describe('parseUserApiKeys', () => {
+ let parseUserApiKeys;
+
+ beforeEach(() => {
+ // Clear cache and reload module
+ delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
+ const configGenerator = require('../../../dist/cliproxy/config-generator');
+ parseUserApiKeys = configGenerator.parseUserApiKeys;
+ });
+
+ it('extracts user-added API keys from config content', () => {
+ const configContent = `
+# CLIProxyAPI config generated by CCS v4
+port: 8317
+
+api-keys:
+ - "ccs-internal-managed"
+ - "user-custom-key-12345"
+ - "another-user-key-abcde"
+
+auth-dir: "/home/user/.ccs/cliproxy/auth"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['user-custom-key-12345', 'another-user-key-abcde']);
+ });
+
+ it('excludes the internal CCS key', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - "my-custom-key"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.strictEqual(userKeys.length, 1);
+ assert.strictEqual(userKeys[0], 'my-custom-key');
+ assert(!userKeys.includes('ccs-internal-managed'), 'Should not include internal key');
+ });
+
+ it('returns empty array when only internal key exists', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, []);
+ });
+
+ it('returns empty array when no api-keys section exists', () => {
+ const configContent = `
+port: 8317
+debug: false
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, []);
+ });
+
+ it('handles single user key', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - "single-user-key"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['single-user-key']);
+ });
+
+ it('handles multiple user keys', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - "key1"
+ - "key2"
+ - "key3"
+ - "key4"
+ - "key5"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['key1', 'key2', 'key3', 'key4', 'key5']);
+ });
+
+ it('handles keys with special characters', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - "sk_live_1234567890abcdef"
+ - "api-key-with-dashes"
+ - "key_with_underscores_123"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, [
+ 'sk_live_1234567890abcdef',
+ 'api-key-with-dashes',
+ 'key_with_underscores_123',
+ ]);
+ });
+
+ it('preserves key order', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - "zebra-key"
+ - "alpha-key"
+ - "middle-key"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['zebra-key', 'alpha-key', 'middle-key']);
+ });
+
+ it('handles empty string key (edge case)', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - ""
+ - "valid-key"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ // Empty strings should be filtered out (only truthy values)
+ assert.deepStrictEqual(userKeys, ['valid-key']);
+ });
+
+ it('handles config with Windows line endings', () => {
+ const configContent =
+ 'api-keys:\r\n - "ccs-internal-managed"\r\n - "user-key"\r\n\r\nauth-dir: "/test"\r\n';
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['user-key']);
+ });
+
+ it('handles different indentation styles', () => {
+ const configContent = `
+api-keys:
+ - "ccs-internal-managed"
+ - "user-key-with-4-spaces"
+
+auth-dir: "/test"
+`;
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['user-key-with-4-spaces']);
+ });
+
+ it('handles tabs in indentation', () => {
+ const configContent = 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"';
+
+ const userKeys = parseUserApiKeys(configContent);
+
+ assert.deepStrictEqual(userKeys, ['user-key-with-tab']);
+ });
+ });
+
+ describe('regenerateConfig API key preservation', () => {
+ const fs = require('fs');
+ const os = require('os');
+ const path = require('path');
+
+ let testDir;
+ let originalCcsHome;
+ let regenerateConfig;
+ let getCliproxyConfigPath;
+
+ beforeEach(() => {
+ // Create a temporary test directory
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
+ originalCcsHome = process.env.CCS_HOME;
+ process.env.CCS_HOME = testDir;
+
+ // Clear cache and reload module
+ delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
+ delete require.cache[require.resolve('../../../dist/utils/config-manager')];
+ const configGenerator = require('../../../dist/cliproxy/config-generator');
+ regenerateConfig = configGenerator.regenerateConfig;
+ getCliproxyConfigPath = configGenerator.getCliproxyConfigPath;
+ });
+
+ afterEach(() => {
+ // Restore environment
+ process.env.CCS_HOME = originalCcsHome;
+
+ // Clean up test directory
+ if (testDir && fs.existsSync(testDir)) {
+ fs.rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ it('preserves user API keys during regeneration', () => {
+ // Create initial config with user keys
+ const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ const initialConfig = `# CLIProxyAPI config generated by CCS v3
+port: 8317
+
+api-keys:
+ - "ccs-internal-managed"
+ - "user-custom-key-12345"
+ - "another-user-key"
+
+auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
+`;
+ fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
+
+ // Regenerate config (simulating CCS update)
+ regenerateConfig();
+
+ // Read regenerated config
+ const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
+
+ // Verify user keys are preserved
+ assert(newConfig.includes('user-custom-key-12345'), 'Should preserve first user key');
+ assert(newConfig.includes('another-user-key'), 'Should preserve second user key');
+ assert(newConfig.includes('ccs-internal-managed'), 'Should include internal key');
+ });
+
+ it('preserves port setting during regeneration', () => {
+ // Create initial config with custom port
+ const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ const initialConfig = `# CLIProxyAPI config generated by CCS v3
+port: 9999
+
+api-keys:
+ - "ccs-internal-managed"
+
+auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
+`;
+ fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
+
+ // Regenerate config
+ regenerateConfig();
+
+ // Read regenerated config
+ const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
+
+ // Verify port is preserved
+ assert(newConfig.includes('port: 9999'), 'Should preserve custom port');
+ });
+
+ it('creates fresh config when none exists', () => {
+ // Ensure clean state
+ const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
+ const configPath = path.join(cliproxyDir, 'config.yaml');
+
+ assert(!fs.existsSync(configPath), 'Config should not exist initially');
+
+ // Generate config
+ regenerateConfig();
+
+ // Verify config was created
+ assert(fs.existsSync(configPath), 'Config should be created');
+
+ const config = fs.readFileSync(configPath, 'utf-8');
+ assert(config.includes('ccs-internal-managed'), 'Should include internal key');
+ assert(config.includes('port: 8317'), 'Should use default port');
+ });
+
+ it('handles corrupted config gracefully', () => {
+ // Create corrupted config
+ const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), 'invalid yaml content: [[[');
+
+ // Should not throw
+ assert.doesNotThrow(() => regenerateConfig());
+
+ // Should create valid config
+ const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
+ assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config');
+ });
+
+ it('handles empty config file gracefully', () => {
+ // Create empty config
+ const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), '');
+
+ // Should not throw
+ assert.doesNotThrow(() => regenerateConfig());
+
+ // Should create valid config with defaults
+ const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
+ assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config');
+ assert(newConfig.includes('port: 8317'), 'Should use default port');
+ });
+
+ it('preserves multiple user keys in correct order', () => {
+ // Create config with multiple user keys
+ const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ const initialConfig = `# CLIProxyAPI config generated by CCS v3
+port: 8317
+
+api-keys:
+ - "ccs-internal-managed"
+ - "key-first"
+ - "key-second"
+ - "key-third"
+
+auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
+`;
+ fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
+
+ // Regenerate config
+ regenerateConfig();
+
+ // Read regenerated config
+ const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
+
+ // Verify order is preserved (first > second > third)
+ const firstPos = newConfig.indexOf('key-first');
+ const secondPos = newConfig.indexOf('key-second');
+ const thirdPos = newConfig.indexOf('key-third');
+
+ assert(firstPos < secondPos, 'First key should come before second');
+ assert(secondPos < thirdPos, 'Second key should come before third');
+ });
+ });
+
describe('Cross-platform path handling', () => {
it('normalizes paths regardless of current platform', () => {
// The fix should work consistently on Windows, macOS, and Linux
diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js
index 2a7d0851..ce905846 100644
--- a/tests/unit/cliproxy/proxy-config-resolver.test.js
+++ b/tests/unit/cliproxy/proxy-config-resolver.test.js
@@ -255,6 +255,58 @@ describe('proxy-config-resolver', () => {
expect(config.remoteOnly).toBe(true);
expect(config.fallbackEnabled).toBe(false);
});
+
+ // Tests for YAML enabled:false handling (Bug fix validation)
+ describe('YAML remote.enabled handling', () => {
+ it('should block remote mode when YAML remote.enabled is false', () => {
+ const { config } = resolveProxyConfig([], {
+ remote: { enabled: false, host: 'yaml-host.example.com' },
+ });
+ expect(config.mode).toBe('local');
+ // Host from YAML should NOT be used when enabled: false
+ });
+
+ it('should allow remote mode when YAML remote.enabled is true', () => {
+ const { config } = resolveProxyConfig([], {
+ remote: { enabled: true, host: 'yaml-host.example.com' },
+ });
+ expect(config.mode).toBe('remote');
+ expect(config.host).toBe('yaml-host.example.com');
+ });
+
+ it('should allow remote mode when YAML remote.enabled is undefined (backwards compat)', () => {
+ const { config } = resolveProxyConfig([], {
+ remote: { host: 'yaml-host.example.com' },
+ });
+ expect(config.mode).toBe('remote');
+ expect(config.host).toBe('yaml-host.example.com');
+ });
+
+ it('should allow CLI --proxy-host to override YAML enabled:false', () => {
+ const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], {
+ remote: { enabled: false, host: 'yaml-host' },
+ });
+ expect(config.mode).toBe('remote');
+ expect(config.host).toBe('cli-host');
+ });
+
+ it('should allow ENV CCS_PROXY_HOST to override YAML enabled:false', () => {
+ process.env.CCS_PROXY_HOST = 'env-host';
+ const { config } = resolveProxyConfig([], {
+ remote: { enabled: false, host: 'yaml-host' },
+ });
+ expect(config.mode).toBe('remote');
+ expect(config.host).toBe('env-host');
+ });
+
+ it('should force local mode with --local-proxy even when YAML enabled:true', () => {
+ const { config } = resolveProxyConfig(['--local-proxy'], {
+ remote: { enabled: true, host: 'yaml-host' },
+ });
+ expect(config.mode).toBe('local');
+ expect(config.forceLocal).toBe(true);
+ });
+ });
});
describe('hasProxyFlags', () => {
diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts
index 536ff1d8..58defd72 100644
--- a/tests/unit/cliproxy/remote-proxy-client.test.ts
+++ b/tests/unit/cliproxy/remote-proxy-client.test.ts
@@ -137,4 +137,35 @@ describe('remote-proxy-client', () => {
expect(expectedUrl).toBe('https://secure.example.com:443/v1/models');
});
});
+
+ describe('port defaults by protocol', () => {
+ // Document expected default ports based on protocol
+ // HTTP: 8317 (CLIProxyAPI default for local/dev scenarios)
+ // HTTPS: 443 (standard SSL port for production remote servers)
+
+ it('should document HTTP default port as 8317', () => {
+ // HTTP connections default to CLIProxyAPI port 8317
+ // This matches local development scenarios
+ const expectedHttpDefault = 8317;
+ expect(expectedHttpDefault).toBe(8317);
+ });
+
+ it('should document HTTPS default port as 443', () => {
+ // HTTPS connections default to standard SSL port 443
+ // This matches production remote server scenarios
+ const expectedHttpsDefault = 443;
+ expect(expectedHttpsDefault).toBe(443);
+ });
+
+ it('should allow port to be optional in config', () => {
+ // Port is optional - when undefined, defaults based on protocol
+ const configWithoutPort: RemoteProxyClientConfig = {
+ host: 'example.com',
+ protocol: 'https',
+ // port is intentionally undefined
+ };
+ expect(configWithoutPort.port).toBeUndefined();
+ expect(configWithoutPort.protocol).toBe('https');
+ });
+ });
});
diff --git a/tests/unit/cliproxy/session-tracker-port.test.js b/tests/unit/cliproxy/session-tracker-port.test.js
new file mode 100644
index 00000000..1826a2aa
--- /dev/null
+++ b/tests/unit/cliproxy/session-tracker-port.test.js
@@ -0,0 +1,420 @@
+/**
+ * Session Tracker Port-Specific Tests
+ *
+ * Tests for per-port session tracking in session-tracker.ts.
+ * Verifies port-specific session files (sessions-{port}.json) and cleanup.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+// Set test isolation environment before importing
+const testHome = path.join(
+ os.tmpdir(),
+ `ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
+);
+process.env.CCS_HOME = testHome;
+
+const {
+ getExistingProxy,
+ registerSession,
+ unregisterSession,
+ cleanupOrphanedSessions,
+ stopProxy,
+ getProxyStatus,
+ getSessionLockPath,
+ deleteSessionLockForPort,
+} = require('../../../dist/cliproxy/session-tracker');
+const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
+
+describe('Session Tracker Port-Specific', function () {
+ const variantPort1 = 8318;
+ const variantPort2 = 8319;
+ let cliproxyDir;
+
+ beforeEach(function () {
+ // Create test directories
+ cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+
+ // Clean up any existing session files
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ if (file.startsWith('sessions')) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ }
+ });
+
+ afterEach(function () {
+ // Clean up session files
+ try {
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ if (file.startsWith('sessions')) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ }
+ } catch {
+ // Ignore cleanup errors
+ }
+ });
+
+ afterAll(function () {
+ // Clean up test directory
+ try {
+ fs.rmSync(testHome, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ delete process.env.CCS_HOME;
+ });
+
+ describe('Session Lock Path', function () {
+ it('returns sessions.json for default port', function () {
+ const lockPath = getSessionLockPath();
+ assert.ok(lockPath.endsWith('sessions.json'));
+ assert.ok(!lockPath.includes('sessions-'));
+ });
+ });
+
+ describe('Port-Specific Session Files', function () {
+ it('creates sessions-{port}.json for variant ports', function () {
+ registerSession(variantPort1, process.pid);
+
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ assert.ok(fs.existsSync(lockPath), `Should create sessions-${variantPort1}.json`);
+ });
+
+ it('creates sessions.json for default port', function () {
+ registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
+
+ const lockPath = path.join(cliproxyDir, 'sessions.json');
+ assert.ok(fs.existsSync(lockPath), 'Should create sessions.json for default port');
+ });
+
+ it('keeps separate session files for different ports', function () {
+ // Register sessions on different ports
+ registerSession(variantPort1, process.pid);
+ registerSession(variantPort2, process.pid);
+ registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
+
+ // All three should exist
+ assert.ok(
+ fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort1}.json`)),
+ 'Should have port 8318 sessions'
+ );
+ assert.ok(
+ fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort2}.json`)),
+ 'Should have port 8319 sessions'
+ );
+ assert.ok(
+ fs.existsSync(path.join(cliproxyDir, 'sessions.json')),
+ 'Should have default port sessions'
+ );
+ });
+ });
+
+ describe('registerSession with Port', function () {
+ it('stores correct port in session lock file', function () {
+ registerSession(variantPort1, process.pid);
+
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
+
+ assert.strictEqual(lock.port, variantPort1);
+ });
+
+ it('stores correct PID in session lock file', function () {
+ registerSession(variantPort1, process.pid);
+
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
+
+ assert.strictEqual(lock.pid, process.pid);
+ });
+
+ it('appends to existing sessions array for same port', function () {
+ const session1 = registerSession(variantPort1, process.pid);
+ const session2 = registerSession(variantPort1, process.pid);
+
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
+
+ assert.strictEqual(lock.sessions.length, 2);
+ assert.ok(lock.sessions.includes(session1));
+ assert.ok(lock.sessions.includes(session2));
+ });
+ });
+
+ describe('unregisterSession with Port', function () {
+ it('removes session from port-specific file', function () {
+ const session1 = registerSession(variantPort1, process.pid);
+ const session2 = registerSession(variantPort1, process.pid);
+
+ // Unregister first session with port
+ unregisterSession(session1, variantPort1);
+
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
+
+ assert.strictEqual(lock.sessions.length, 1);
+ assert.strictEqual(lock.sessions[0], session2);
+ });
+
+ it('deletes lock file when last session removed', function () {
+ const session = registerSession(variantPort1, process.pid);
+
+ const shouldKill = unregisterSession(session, variantPort1);
+
+ assert.strictEqual(shouldKill, true);
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ assert.strictEqual(fs.existsSync(lockPath), false);
+ });
+
+ it('returns true when last session', function () {
+ const session = registerSession(variantPort1, process.pid);
+ const shouldKill = unregisterSession(session, variantPort1);
+ assert.strictEqual(shouldKill, true);
+ });
+
+ it('returns false when sessions remain', function () {
+ const session1 = registerSession(variantPort1, process.pid);
+ registerSession(variantPort1, process.pid);
+
+ const shouldKill = unregisterSession(session1, variantPort1);
+ assert.strictEqual(shouldKill, false);
+ });
+
+ it('searches default port for backward compat (fallback)', function () {
+ // Register on default port
+ const session = registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
+
+ // Unregister without port (should search default)
+ const shouldKill = unregisterSession(session);
+
+ assert.strictEqual(shouldKill, true);
+ const lockPath = path.join(cliproxyDir, 'sessions.json');
+ assert.strictEqual(fs.existsSync(lockPath), false);
+ });
+ });
+
+ describe('getExistingProxy with Port', function () {
+ it('returns lock for running proxy on specified port', function () {
+ registerSession(variantPort1, process.pid);
+
+ const lock = getExistingProxy(variantPort1);
+ assert.notStrictEqual(lock, null);
+ assert.strictEqual(lock.port, variantPort1);
+ assert.strictEqual(lock.pid, process.pid);
+ });
+
+ it('returns null if lock file missing', function () {
+ const lock = getExistingProxy(variantPort1);
+ assert.strictEqual(lock, null);
+ });
+
+ it('returns null if port mismatch', function () {
+ // Create lock with different port number in file
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ fs.writeFileSync(
+ lockPath,
+ JSON.stringify({
+ port: 9999, // Wrong port
+ pid: process.pid,
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ const lock = getExistingProxy(variantPort1);
+ assert.strictEqual(lock, null);
+ });
+
+ it('cleans up stale lock if PID not running', function () {
+ // Create lock with dead PID
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ fs.writeFileSync(
+ lockPath,
+ JSON.stringify({
+ port: variantPort1,
+ pid: 999999999, // Dead PID
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ const lock = getExistingProxy(variantPort1);
+ assert.strictEqual(lock, null);
+ assert.strictEqual(fs.existsSync(lockPath), false);
+ });
+ });
+
+ describe('deleteSessionLockForPort', function () {
+ it('removes sessions-{port}.json for specified port', function () {
+ registerSession(variantPort1, process.pid);
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ assert.ok(fs.existsSync(lockPath));
+
+ deleteSessionLockForPort(variantPort1);
+ assert.strictEqual(fs.existsSync(lockPath), false);
+ });
+
+ it('does nothing if file does not exist', function () {
+ // Should not throw
+ deleteSessionLockForPort(variantPort1);
+ });
+
+ it('does not affect other port sessions', function () {
+ registerSession(variantPort1, process.pid);
+ registerSession(variantPort2, process.pid);
+
+ deleteSessionLockForPort(variantPort1);
+
+ const lock1Path = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ const lock2Path = path.join(cliproxyDir, `sessions-${variantPort2}.json`);
+
+ assert.strictEqual(fs.existsSync(lock1Path), false);
+ assert.ok(fs.existsSync(lock2Path));
+ });
+ });
+
+ describe('cleanupOrphanedSessions with Port', function () {
+ it('deletes lock if PID not running', function () {
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ fs.writeFileSync(
+ lockPath,
+ JSON.stringify({
+ port: variantPort1,
+ pid: 999999999, // Dead PID
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ cleanupOrphanedSessions(variantPort1);
+ assert.strictEqual(fs.existsSync(lockPath), false);
+ });
+
+ it('keeps lock if PID still running', function () {
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ fs.writeFileSync(
+ lockPath,
+ JSON.stringify({
+ port: variantPort1,
+ pid: process.pid, // Our process - running
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ cleanupOrphanedSessions(variantPort1);
+ assert.ok(fs.existsSync(lockPath));
+ });
+ });
+
+ describe('stopProxy with Port', function () {
+ it('stops proxy on specified port', async function () {
+ // Create lock with dead PID (we can't actually stop a real process in tests)
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ fs.writeFileSync(
+ lockPath,
+ JSON.stringify({
+ port: variantPort1,
+ pid: 999999999, // Dead PID
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ const result = await stopProxy(variantPort1);
+ assert.strictEqual(result.stopped, false);
+ assert.ok(result.error.includes('not running'));
+ });
+
+ it('cleans up session lock after stop', async function () {
+ const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
+ fs.writeFileSync(
+ lockPath,
+ JSON.stringify({
+ port: variantPort1,
+ pid: 999999999, // Dead PID
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ await stopProxy(variantPort1);
+ assert.strictEqual(fs.existsSync(lockPath), false);
+ });
+
+ it('handles already-stopped proxy gracefully', async function () {
+ const result = await stopProxy(variantPort1);
+ assert.strictEqual(result.stopped, false);
+ assert.strictEqual(result.error, 'No active CLIProxy session found');
+ });
+ });
+
+ describe('getProxyStatus with Port', function () {
+ it('returns correct status for variant port', function () {
+ registerSession(variantPort1, process.pid);
+
+ const status = getProxyStatus(variantPort1);
+ assert.strictEqual(status.running, true);
+ assert.strictEqual(status.port, variantPort1);
+ assert.strictEqual(status.pid, process.pid);
+ assert.strictEqual(status.sessionCount, 1);
+ });
+
+ it('returns not running for empty variant port', function () {
+ const status = getProxyStatus(variantPort1);
+ assert.strictEqual(status.running, false);
+ });
+ });
+
+ describe('Concurrent Variant Sessions', function () {
+ it('manages multiple variant ports independently', function () {
+ // Start sessions on different ports
+ const session1 = registerSession(variantPort1, process.pid);
+ const session2 = registerSession(variantPort2, process.pid);
+
+ // Both should be tracked
+ assert.strictEqual(getProxyStatus(variantPort1).running, true);
+ assert.strictEqual(getProxyStatus(variantPort2).running, true);
+
+ // Unregister one should not affect other
+ unregisterSession(session1, variantPort1);
+
+ assert.strictEqual(getProxyStatus(variantPort1).running, false);
+ assert.strictEqual(getProxyStatus(variantPort2).running, true);
+
+ // Clean up
+ unregisterSession(session2, variantPort2);
+ });
+
+ it('allows same session workflow on different ports', function () {
+ // Simulate concurrent variant usage
+ const port1Session1 = registerSession(variantPort1, process.pid);
+ const port1Session2 = registerSession(variantPort1, process.pid);
+ const port2Session1 = registerSession(variantPort2, process.pid);
+
+ assert.strictEqual(getProxyStatus(variantPort1).sessionCount, 2);
+ assert.strictEqual(getProxyStatus(variantPort2).sessionCount, 1);
+
+ // Unregister from port1
+ const shouldKill1 = unregisterSession(port1Session1, variantPort1);
+ assert.strictEqual(shouldKill1, false); // Still has session2
+
+ const shouldKill2 = unregisterSession(port1Session2, variantPort1);
+ assert.strictEqual(shouldKill2, true); // Last session on port1
+
+ // Port2 should still be running
+ assert.strictEqual(getProxyStatus(variantPort2).running, true);
+
+ // Clean up
+ unregisterSession(port2Session1, variantPort2);
+ });
+ });
+});
diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js
index 463a26cd..ac4d7de2 100644
--- a/tests/unit/cliproxy/session-tracker.test.js
+++ b/tests/unit/cliproxy/session-tracker.test.js
@@ -28,23 +28,39 @@ const {
describe('Session Tracker', function () {
const testPort = 18317;
let sessionLockPath;
+ let cliproxyDir;
beforeEach(function () {
// Create test directories
- const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
+ cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
- sessionLockPath = path.join(cliproxyDir, 'sessions.json');
+ // Use port-specific session file for non-default ports
+ sessionLockPath = path.join(cliproxyDir, `sessions-${testPort}.json`);
- // Clean up any existing lock file
- if (fs.existsSync(sessionLockPath)) {
- fs.unlinkSync(sessionLockPath);
+ // Clean up any existing lock files
+ try {
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ if (file.startsWith('sessions')) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ }
+ } catch {
+ // Directory might not exist yet
}
});
afterEach(function () {
- // Clean up lock file
- if (fs.existsSync(sessionLockPath)) {
- fs.unlinkSync(sessionLockPath);
+ // Clean up lock files
+ try {
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ if (file.startsWith('sessions')) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ }
+ } catch {
+ // Ignore cleanup errors
}
});
@@ -164,7 +180,7 @@ describe('Session Tracker', function () {
describe('unregisterSession', function () {
it('should return true when no lock exists', function () {
- const result = unregisterSession('nonexistent');
+ const result = unregisterSession('nonexistent', testPort);
assert.strictEqual(result, true);
});
@@ -174,7 +190,7 @@ describe('Session Tracker', function () {
const session2 = registerSession(testPort, process.pid);
// Unregister first
- const shouldKill = unregisterSession(session1);
+ const shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
@@ -188,7 +204,7 @@ describe('Session Tracker', function () {
const session1 = registerSession(testPort, process.pid);
// Unregister it
- const shouldKill = unregisterSession(session1);
+ const shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, true, 'should kill - last session');
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
@@ -199,38 +215,40 @@ describe('Session Tracker', function () {
registerSession(testPort, process.pid);
// Try to unregister wrong session
- const shouldKill = unregisterSession('wrong-session-id');
+ const shouldKill = unregisterSession('wrong-session-id', testPort);
// Should return false since a session still exists
assert.strictEqual(shouldKill, false);
});
});
- describe('getSessionCount', function () {
+ describe('getSessionCount (default port)', function () {
it('should return 0 when no lock exists', function () {
assert.strictEqual(getSessionCount(), 0);
});
- it('should return correct count', function () {
+ it('should return correct count (uses getProxyStatus for port-specific)', function () {
registerSession(testPort, process.pid);
- assert.strictEqual(getSessionCount(), 1);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
registerSession(testPort, process.pid);
- assert.strictEqual(getSessionCount(), 2);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
registerSession(testPort, process.pid);
- assert.strictEqual(getSessionCount(), 3);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
});
});
- describe('hasActiveSessions', function () {
+ describe('hasActiveSessions (default port)', function () {
it('should return false when no lock exists', function () {
assert.strictEqual(hasActiveSessions(), false);
});
- it('should return true when sessions exist and proxy running', function () {
+ it('should return true when sessions exist on default port', function () {
+ // Note: hasActiveSessions() checks default port only
+ // For port-specific checks, use getProxyStatus(port).running
registerSession(testPort, process.pid);
- assert.strictEqual(hasActiveSessions(), true);
+ assert.strictEqual(getProxyStatus(testPort).running, true);
});
it('should return false and cleanup when proxy is dead', function () {
@@ -243,7 +261,9 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
- assert.strictEqual(hasActiveSessions(), false);
+ // getProxyStatus will clean up stale lock
+ const status = getProxyStatus(testPort);
+ assert.strictEqual(status.running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
});
@@ -300,7 +320,7 @@ describe('Session Tracker', function () {
describe('stopProxy', function () {
it('should return error when no lock exists', async function () {
- const result = await stopProxy();
+ const result = await stopProxy(testPort);
assert.strictEqual(result.stopped, false);
assert.strictEqual(result.error, 'No active CLIProxy session found');
});
@@ -315,7 +335,7 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
- const result = await stopProxy();
+ const result = await stopProxy(testPort);
assert.strictEqual(result.stopped, false);
assert.ok(result.error.includes('not running'));
assert.strictEqual(fs.existsSync(sessionLockPath), false);
@@ -327,7 +347,7 @@ describe('Session Tracker', function () {
// Note: We can't actually test killing our own process,
// but we can verify the structure is correct before it attempts kill
- const status = getProxyStatus();
+ const status = getProxyStatus(testPort);
assert.strictEqual(status.running, true);
assert.strictEqual(status.pid, process.pid);
assert.strictEqual(status.sessionCount, 1);
@@ -336,7 +356,7 @@ describe('Session Tracker', function () {
describe('getProxyStatus', function () {
it('should return not running when no lock exists', function () {
- const result = getProxyStatus();
+ const result = getProxyStatus(testPort);
assert.strictEqual(result.running, false);
assert.strictEqual(result.port, undefined);
assert.strictEqual(result.pid, undefined);
@@ -352,7 +372,7 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
- const result = getProxyStatus();
+ const result = getProxyStatus(testPort);
assert.strictEqual(result.running, true);
assert.strictEqual(result.port, testPort);
assert.strictEqual(result.pid, process.pid);
@@ -369,22 +389,22 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
- const result = getProxyStatus();
+ const result = getProxyStatus(testPort);
assert.strictEqual(result.running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
it('should return correct session count after registrations', function () {
registerSession(testPort, process.pid);
- let status = getProxyStatus();
+ let status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 1);
registerSession(testPort, process.pid);
- status = getProxyStatus();
+ status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 2);
registerSession(testPort, process.pid);
- status = getProxyStatus();
+ status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 3);
});
});
@@ -393,30 +413,30 @@ describe('Session Tracker', function () {
it('should handle complete multi-terminal workflow', function () {
// Terminal 1 starts - first session
const session1 = registerSession(testPort, process.pid);
- assert.strictEqual(getSessionCount(), 1);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
// Terminal 2 starts - joins existing
const session2 = registerSession(testPort, process.pid);
- assert.strictEqual(getSessionCount(), 2);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
// Terminal 3 starts - joins existing
const session3 = registerSession(testPort, process.pid);
- assert.strictEqual(getSessionCount(), 3);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
// Terminal 1 exits - should NOT kill proxy
- let shouldKill = unregisterSession(session1);
+ let shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, false);
- assert.strictEqual(getSessionCount(), 2);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
// Terminal 3 exits - should NOT kill proxy
- shouldKill = unregisterSession(session3);
+ shouldKill = unregisterSession(session3, testPort);
assert.strictEqual(shouldKill, false);
- assert.strictEqual(getSessionCount(), 1);
+ assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
// Terminal 2 exits - SHOULD kill proxy (last session)
- shouldKill = unregisterSession(session2);
+ shouldKill = unregisterSession(session2, testPort);
assert.strictEqual(shouldKill, true);
- assert.strictEqual(getSessionCount(), 0);
+ assert.strictEqual(getProxyStatus(testPort).running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
});
diff --git a/tests/unit/cliproxy/variant-port-allocation.test.js b/tests/unit/cliproxy/variant-port-allocation.test.js
new file mode 100644
index 00000000..363f856d
--- /dev/null
+++ b/tests/unit/cliproxy/variant-port-allocation.test.js
@@ -0,0 +1,299 @@
+/**
+ * Variant Port Allocation Tests
+ *
+ * Tests for port allocation logic in variant-config-adapter.ts.
+ * Verifies unique port assignment (8318-8417) for CLIProxy variants.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+// Set test isolation environment before importing
+const testHome = path.join(
+ os.tmpdir(),
+ `ccs-test-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
+);
+process.env.CCS_HOME = testHome;
+
+const {
+ getNextAvailablePort,
+ VARIANT_PORT_BASE,
+ VARIANT_PORT_MAX_OFFSET,
+ listVariantsFromConfig,
+ saveVariantLegacy,
+ removeVariantFromLegacyConfig,
+} = require('../../../dist/cliproxy/services/variant-config-adapter');
+const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
+
+describe('Variant Port Allocation', function () {
+ let configPath;
+
+ beforeEach(function () {
+ // Create test directories
+ const ccsDir = path.join(testHome, '.ccs');
+ fs.mkdirSync(ccsDir, { recursive: true });
+ configPath = path.join(ccsDir, 'config.json');
+
+ // Start with empty config
+ fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
+ });
+
+ afterEach(function () {
+ // Clean up config file
+ if (fs.existsSync(configPath)) {
+ fs.unlinkSync(configPath);
+ }
+ });
+
+ afterAll(function () {
+ // Clean up test directory
+ try {
+ fs.rmSync(testHome, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ delete process.env.CCS_HOME;
+ });
+
+ describe('Constants', function () {
+ it('VARIANT_PORT_BASE equals CLIPROXY_DEFAULT_PORT + 1 (8318)', function () {
+ assert.strictEqual(VARIANT_PORT_BASE, CLIPROXY_DEFAULT_PORT + 1);
+ assert.strictEqual(VARIANT_PORT_BASE, 8318);
+ });
+
+ it('VARIANT_PORT_MAX_OFFSET equals 100 (ports 8318-8417)', function () {
+ assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
+ });
+ });
+
+ describe('getNextAvailablePort - Basic Allocation', function () {
+ it('returns VARIANT_PORT_BASE (8318) when no variants exist', function () {
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, VARIANT_PORT_BASE);
+ assert.strictEqual(port, 8318);
+ });
+
+ it('returns next available port when some ports used', function () {
+ // Create variant on first port
+ const settingsPath = path.join(testHome, '.ccs', 'test1.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('test1', 'gemini', settingsPath, undefined, 8318);
+
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, 8319);
+ });
+
+ it('skips used ports and returns first gap', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create variants on ports 8318 and 8320 (leaving 8319 as gap)
+ const settingsPath1 = path.join(ccsDir, 'test1.settings.json');
+ const settingsPath2 = path.join(ccsDir, 'test2.settings.json');
+ fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
+ fs.writeFileSync(settingsPath2, JSON.stringify({ env: {} }));
+
+ saveVariantLegacy('test1', 'gemini', settingsPath1, undefined, 8318);
+ saveVariantLegacy('test2', 'gemini', settingsPath2, undefined, 8320);
+
+ // Should return 8319 (the gap)
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, 8319);
+ });
+
+ it('allocates sequential ports for multiple variants', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ for (let i = 0; i < 5; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, 8318 + i);
+
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
+ }
+ });
+ });
+
+ describe('getNextAvailablePort - Boundary Conditions', function () {
+ it('returns last port (8417) when 99 ports used', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create 99 variants (ports 8318-8416)
+ for (let i = 0; i < 99; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
+ }
+
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, 8417); // Last available port
+ });
+
+ it('throws when all 100 ports exhausted', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create 100 variants (ports 8318-8417)
+ for (let i = 0; i < 100; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
+ }
+
+ assert.throws(
+ () => getNextAvailablePort(),
+ /Port limit reached/,
+ 'Should throw error when all ports exhausted'
+ );
+ });
+
+ it('error message includes variant count and recovery hint', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create 100 variants
+ for (let i = 0; i < 100; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
+ }
+
+ try {
+ getNextAvailablePort();
+ assert.fail('Should have thrown error');
+ } catch (err) {
+ assert.ok(err.message.includes('100/100'), 'Should include variant count');
+ assert.ok(
+ err.message.includes('ccs cliproxy remove'),
+ 'Should include recovery hint'
+ );
+ }
+ });
+ });
+
+ describe('getNextAvailablePort - Port Reuse', function () {
+ it('reuses port after variant deletion', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create variant on port 8318
+ const settingsPath = path.join(ccsDir, 'test.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
+
+ // Next port should be 8319
+ let nextPort = getNextAvailablePort();
+ assert.strictEqual(nextPort, 8319);
+
+ // Remove the variant
+ removeVariantFromLegacyConfig('test');
+
+ // Now 8318 should be available again
+ nextPort = getNextAvailablePort();
+ assert.strictEqual(nextPort, 8318);
+ });
+
+ it('allocates lowest available port (not most recently freed)', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create 3 variants on ports 8318, 8319, 8320
+ for (let i = 0; i < 3; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
+ }
+
+ // Remove middle variant (port 8319)
+ removeVariantFromLegacyConfig('variant1');
+
+ // Next allocation should use 8319 (the gap), not 8321
+ const nextPort = getNextAvailablePort();
+ assert.strictEqual(nextPort, 8319);
+ });
+ });
+
+ describe('getNextAvailablePort - Legacy Variant Handling', function () {
+ it('ignores variants without port field in usage calculation', function () {
+ // Create variant without port (legacy format)
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy_variant: {
+ provider: 'gemini',
+ settings: '/path/to/settings.json',
+ // No port field
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ // Should return first port since legacy variant has no port
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, 8318);
+ });
+
+ it('handles mixed legacy and modern variants', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create legacy variant without port
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy: {
+ provider: 'gemini',
+ settings: path.join(ccsDir, 'legacy.settings.json'),
+ // No port field
+ },
+ modern: {
+ provider: 'gemini',
+ settings: path.join(ccsDir, 'modern.settings.json'),
+ port: 8318,
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ // Should skip 8318 (used by modern) and return 8319
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, 8319);
+ });
+ });
+
+ describe('listVariantsFromConfig', function () {
+ it('returns empty object when no variants exist', function () {
+ const variants = listVariantsFromConfig();
+ assert.deepStrictEqual(variants, {});
+ });
+
+ it('includes port field for each variant', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+ const settingsPath = path.join(ccsDir, 'test.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+
+ saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
+
+ const variants = listVariantsFromConfig();
+ assert.strictEqual(variants.test.port, 8318);
+ assert.strictEqual(variants.test.provider, 'gemini');
+ });
+
+ it('returns undefined port for legacy variants', function () {
+ // Create legacy variant without port
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy: {
+ provider: 'gemini',
+ settings: '/path/to/settings.json',
+ // No port field
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ const variants = listVariantsFromConfig();
+ assert.strictEqual(variants.legacy.port, undefined);
+ assert.strictEqual(variants.legacy.provider, 'gemini');
+ });
+ });
+});
diff --git a/tests/unit/cliproxy/variant-port-edge-cases.test.js b/tests/unit/cliproxy/variant-port-edge-cases.test.js
new file mode 100644
index 00000000..6bad40ec
--- /dev/null
+++ b/tests/unit/cliproxy/variant-port-edge-cases.test.js
@@ -0,0 +1,501 @@
+/**
+ * Variant Port Edge Case Tests
+ *
+ * Tests for edge cases and error handling in variant port isolation.
+ * Covers port exhaustion, race conditions, stale session cleanup,
+ * legacy migration, permission errors, and config corruption.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+// Set test isolation environment before importing
+const testHome = path.join(
+ os.tmpdir(),
+ `ccs-test-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`
+);
+process.env.CCS_HOME = testHome;
+
+const {
+ getNextAvailablePort,
+ VARIANT_PORT_BASE,
+ VARIANT_PORT_MAX_OFFSET,
+ listVariantsFromConfig,
+ saveVariantLegacy,
+ removeVariantFromLegacyConfig,
+} = require('../../../dist/cliproxy/services/variant-config-adapter');
+const {
+ getExistingProxy,
+ registerSession,
+ unregisterSession,
+ cleanupOrphanedSessions,
+ deleteSessionLockForPort,
+ getProxyStatus,
+} = require('../../../dist/cliproxy/session-tracker');
+const {
+ deleteConfigForPort,
+ configExists,
+ generateConfig,
+} = require('../../../dist/cliproxy/config-generator');
+
+describe('Variant Port Edge Cases', function () {
+ let configPath;
+ let cliproxyDir;
+
+ beforeEach(function () {
+ // Create test directories
+ const ccsDir = path.join(testHome, '.ccs');
+ cliproxyDir = path.join(ccsDir, 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+ configPath = path.join(ccsDir, 'config.json');
+
+ // Start with empty config
+ fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
+ });
+
+ afterEach(function () {
+ // Clean up config and session files
+ try {
+ if (fs.existsSync(configPath)) {
+ fs.unlinkSync(configPath);
+ }
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ } catch {
+ // Ignore cleanup errors
+ }
+ });
+
+ afterAll(function () {
+ // Clean up test directory
+ try {
+ fs.rmSync(testHome, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ delete process.env.CCS_HOME;
+ });
+
+ describe('Port Exhaustion', function () {
+ it('throws after 100 variants created', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create 100 variants
+ for (let i = 0; i < 100; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
+ }
+
+ assert.throws(() => getNextAvailablePort(), /Port limit reached/);
+ });
+
+ it('error message shows 100/100 and recovery hint', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ for (let i = 0; i < 100; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
+ }
+
+ try {
+ getNextAvailablePort();
+ assert.fail('Should have thrown');
+ } catch (err) {
+ assert.ok(err.message.includes('100/100'));
+ assert.ok(err.message.includes('ccs cliproxy remove'));
+ }
+ });
+
+ it('frees port after variant removal', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create variant on port 8318
+ const settingsPath = path.join(ccsDir, 'test.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('test', 'gemini', settingsPath, undefined, VARIANT_PORT_BASE);
+
+ // Next should be 8319
+ assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE + 1);
+
+ // Remove variant
+ removeVariantFromLegacyConfig('test');
+
+ // 8318 should be free again
+ assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE);
+ });
+ });
+
+ describe('Stale Session Cleanup', function () {
+ it('cleans orphaned session lock on variant delete', function () {
+ const port = 8318;
+
+ // Create session file
+ registerSession(port, process.pid);
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+ assert.ok(fs.existsSync(sessionPath));
+
+ // Simulate variant delete cleanup
+ deleteSessionLockForPort(port);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+
+ it('cleans stale lock when PID not running', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Create lock with dead PID
+ fs.writeFileSync(
+ sessionPath,
+ JSON.stringify({
+ port,
+ pid: 999999999, // Dead PID
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ // getExistingProxy should clean it up
+ const lock = getExistingProxy(port);
+ assert.strictEqual(lock, null);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+ });
+
+ describe('Legacy Variant Migration', function () {
+ it('variant without port gets undefined in listing', function () {
+ // Create legacy variant without port
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy: {
+ provider: 'gemini',
+ settings: '/path/to/settings.json',
+ // No port field
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ const variants = listVariantsFromConfig();
+ assert.strictEqual(variants.legacy.port, undefined);
+ });
+
+ it('legacy variant does not block port allocation', function () {
+ // Create legacy variant without port
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy: {
+ provider: 'gemini',
+ settings: '/path/to/settings.json',
+ // No port field - doesn't count toward port usage
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ // First port should still be available
+ const port = getNextAvailablePort();
+ assert.strictEqual(port, VARIANT_PORT_BASE);
+ });
+ });
+
+ describe('Config Corruption', function () {
+ it('handles malformed sessions-{port}.json gracefully', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Write invalid JSON
+ fs.writeFileSync(sessionPath, '{ invalid json }');
+
+ // getExistingProxy should return null, not throw
+ const lock = getExistingProxy(port);
+ assert.strictEqual(lock, null);
+ });
+
+ it('handles missing config-{port}.yaml gracefully', function () {
+ const port = 8318;
+ // configExists should return false, not throw
+ assert.strictEqual(configExists(port), false);
+ });
+
+ it('handles sessions file with missing required fields', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Write JSON missing required fields
+ fs.writeFileSync(sessionPath, JSON.stringify({ port: 8318 }));
+
+ // getExistingProxy should return null (invalid structure)
+ const lock = getExistingProxy(port);
+ assert.strictEqual(lock, null);
+ });
+
+ it('handles sessions file with wrong data types', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Write JSON with wrong types
+ fs.writeFileSync(
+ sessionPath,
+ JSON.stringify({
+ port: 'not-a-number',
+ pid: 'also-not-a-number',
+ sessions: 'not-an-array',
+ })
+ );
+
+ const lock = getExistingProxy(port);
+ assert.strictEqual(lock, null);
+ });
+ });
+
+ describe('Cleanup on Crash', function () {
+ it('getExistingProxy cleans stale lock if PID dead', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Create lock with dead PID (simulating crashed proxy)
+ fs.writeFileSync(
+ sessionPath,
+ JSON.stringify({
+ port,
+ pid: 999999999,
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ const lock = getExistingProxy(port);
+ assert.strictEqual(lock, null);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+
+ it('cleanupOrphanedSessions removes stale lock', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Create lock with dead PID
+ fs.writeFileSync(
+ sessionPath,
+ JSON.stringify({
+ port,
+ pid: 999999999,
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ cleanupOrphanedSessions(port);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+
+ it('variant delete cleans session lock even if proxy crashed', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Create lock with dead PID
+ fs.writeFileSync(
+ sessionPath,
+ JSON.stringify({
+ port,
+ pid: 999999999,
+ sessions: ['session1'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ // deleteSessionLockForPort is called during variant removal
+ deleteSessionLockForPort(port);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+ });
+
+ describe('Variant Lifecycle Integration', function () {
+ it('creates variant with unique port and separate files', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+ const port = getNextAvailablePort();
+
+ // Create variant
+ const settingsPath = path.join(ccsDir, 'test.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
+
+ // Generate config
+ generateConfig('gemini', port);
+
+ // Verify files exist
+ assert.ok(configExists(port));
+
+ // Start session
+ const sessionId = registerSession(port, process.pid);
+ const status = getProxyStatus(port);
+ assert.strictEqual(status.running, true);
+ assert.strictEqual(status.port, port);
+
+ // Clean up session
+ unregisterSession(sessionId, port);
+ });
+
+ it('removes variant and cleans up all port files', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+ const port = 8318;
+
+ // Create variant
+ const settingsPath = path.join(ccsDir, 'test.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
+
+ // Generate config and session
+ generateConfig('gemini', port);
+ registerSession(port, process.pid);
+
+ // Verify files exist
+ assert.ok(configExists(port));
+ assert.strictEqual(getProxyStatus(port).running, true);
+
+ // Remove variant (simulating full cleanup)
+ removeVariantFromLegacyConfig('test');
+ deleteConfigForPort(port);
+ deleteSessionLockForPort(port);
+
+ // Verify cleanup
+ assert.strictEqual(configExists(port), false);
+ assert.strictEqual(getProxyStatus(port).running, false);
+ });
+
+ it('port reuse after deletion does not have stale data', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+
+ // Create variant A
+ const settingsA = path.join(ccsDir, 'variantA.settings.json');
+ fs.writeFileSync(settingsA, JSON.stringify({ env: { KEY: 'A' } }));
+ const portA = getNextAvailablePort();
+ saveVariantLegacy('variantA', 'gemini', settingsA, undefined, portA);
+ generateConfig('gemini', portA);
+ registerSession(portA, process.pid);
+
+ // Remove variant A with full cleanup
+ removeVariantFromLegacyConfig('variantA');
+ deleteConfigForPort(portA);
+ deleteSessionLockForPort(portA);
+
+ // Create variant B - should get same port
+ const settingsB = path.join(ccsDir, 'variantB.settings.json');
+ fs.writeFileSync(settingsB, JSON.stringify({ env: { KEY: 'B' } }));
+ const portB = getNextAvailablePort();
+ assert.strictEqual(portB, portA); // Port should be reused
+
+ // New session should start fresh
+ saveVariantLegacy('variantB', 'gemini', settingsB, undefined, portB);
+ generateConfig('gemini', portB);
+ const sessionB = registerSession(portB, process.pid);
+
+ const status = getProxyStatus(portB);
+ assert.strictEqual(status.running, true);
+ assert.strictEqual(status.sessionCount, 1);
+
+ // Clean up
+ unregisterSession(sessionB, portB);
+ });
+ });
+
+ describe('Multiple Concurrent Variants', function () {
+ it('creates 3 variants with different ports', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+ const ports = [];
+
+ for (let i = 0; i < 3; i++) {
+ const port = getNextAvailablePort();
+ ports.push(port);
+
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
+ }
+
+ // Verify all ports are different
+ const uniquePorts = new Set(ports);
+ assert.strictEqual(uniquePorts.size, 3);
+
+ // Verify sequential assignment
+ assert.strictEqual(ports[0], VARIANT_PORT_BASE);
+ assert.strictEqual(ports[1], VARIANT_PORT_BASE + 1);
+ assert.strictEqual(ports[2], VARIANT_PORT_BASE + 2);
+ });
+
+ it('each has separate config file', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+ const ports = [8318, 8319, 8320];
+
+ for (let i = 0; i < 3; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
+ generateConfig('gemini', ports[i]);
+ }
+
+ // Verify separate config files
+ for (const port of ports) {
+ assert.ok(configExists(port), `Config for port ${port} should exist`);
+ }
+ });
+
+ it('each has separate sessions file when running', function () {
+ const ports = [8318, 8319, 8320];
+
+ for (const port of ports) {
+ registerSession(port, process.pid);
+ }
+
+ // Verify separate session files
+ for (const port of ports) {
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+ assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
+ }
+
+ // Clean up
+ for (const port of ports) {
+ deleteSessionLockForPort(port);
+ }
+ });
+
+ it('removing one does not affect others', function () {
+ const ccsDir = path.join(testHome, '.ccs');
+ const ports = [8318, 8319, 8320];
+
+ // Create 3 variants
+ for (let i = 0; i < 3; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
+ generateConfig('gemini', ports[i]);
+ registerSession(ports[i], process.pid);
+ }
+
+ // Remove middle variant
+ removeVariantFromLegacyConfig('variant1');
+ deleteConfigForPort(ports[1]);
+ deleteSessionLockForPort(ports[1]);
+
+ // Verify others still exist
+ assert.ok(configExists(ports[0]));
+ assert.ok(!configExists(ports[1])); // Removed
+ assert.ok(configExists(ports[2]));
+
+ assert.strictEqual(getProxyStatus(ports[0]).running, true);
+ assert.strictEqual(getProxyStatus(ports[1]).running, false); // Removed
+ assert.strictEqual(getProxyStatus(ports[2]).running, true);
+
+ // Clean up remaining
+ deleteSessionLockForPort(ports[0]);
+ deleteSessionLockForPort(ports[2]);
+ });
+ });
+});
diff --git a/tests/unit/cliproxy/variant-port-integration.test.js b/tests/unit/cliproxy/variant-port-integration.test.js
new file mode 100644
index 00000000..bfb03828
--- /dev/null
+++ b/tests/unit/cliproxy/variant-port-integration.test.js
@@ -0,0 +1,579 @@
+/**
+ * Variant Port Isolation Integration Tests
+ *
+ * Tests for PR #184: feat(cliproxy): add variant port isolation
+ * Maps directly to the PR test plan:
+ * - [x] Create multiple variants with `ccs cliproxy create`
+ * - [x] Verify each variant gets unique port in config
+ * - [x] Run multiple variants concurrently
+ * - [x] Verify `ccs cliproxy list` shows port column
+ * - [x] Remove variant and verify cleanup of port-specific files
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+// Set test isolation environment before importing
+const testHome = path.join(
+ os.tmpdir(),
+ `ccs-test-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`
+);
+process.env.CCS_HOME = testHome;
+
+const {
+ getNextAvailablePort,
+ VARIANT_PORT_BASE,
+ VARIANT_PORT_MAX_OFFSET,
+ listVariantsFromConfig,
+ saveVariantLegacy,
+ removeVariantFromLegacyConfig,
+} = require('../../../dist/cliproxy/services/variant-config-adapter');
+const {
+ getExistingProxy,
+ registerSession,
+ unregisterSession,
+ getProxyStatus,
+ deleteSessionLockForPort,
+} = require('../../../dist/cliproxy/session-tracker');
+const {
+ generateConfig,
+ configExists,
+ deleteConfigForPort,
+ getConfigPathForPort,
+ CLIPROXY_DEFAULT_PORT,
+} = require('../../../dist/cliproxy/config-generator');
+
+describe('PR #184: Variant Port Isolation Integration', function () {
+ let configPath;
+ let cliproxyDir;
+ let ccsDir;
+
+ beforeEach(function () {
+ // Create test directories
+ ccsDir = path.join(testHome, '.ccs');
+ cliproxyDir = path.join(ccsDir, 'cliproxy');
+ fs.mkdirSync(cliproxyDir, { recursive: true });
+ configPath = path.join(ccsDir, 'config.json');
+
+ // Start with empty config
+ fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
+ });
+
+ afterEach(function () {
+ // Clean up all test files
+ try {
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
+ const files = fs.readdirSync(cliproxyDir);
+ for (const file of files) {
+ fs.unlinkSync(path.join(cliproxyDir, file));
+ }
+ } catch {
+ // Ignore cleanup errors
+ }
+ });
+
+ afterAll(function () {
+ try {
+ fs.rmSync(testHome, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ delete process.env.CCS_HOME;
+ });
+
+ // ==========================================================================
+ // PR Test Plan: Create multiple variants with ccs cliproxy create
+ // ==========================================================================
+ describe('Test Plan: Create multiple variants', function () {
+ it('creates 3 variants with unique ports via createVariant flow', function () {
+ const variants = [];
+
+ for (let i = 0; i < 3; i++) {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: { VARIANT_ID: i } }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
+ generateConfig('gemini', port);
+ variants.push({ name: `variant${i}`, port });
+ }
+
+ // Verify all variants created
+ const storedVariants = listVariantsFromConfig();
+ assert.strictEqual(Object.keys(storedVariants).length, 3);
+
+ // Verify unique ports
+ const ports = variants.map((v) => v.port);
+ const uniquePorts = new Set(ports);
+ assert.strictEqual(uniquePorts.size, 3, 'All variants should have unique ports');
+
+ // Verify sequential port assignment
+ assert.strictEqual(variants[0].port, VARIANT_PORT_BASE);
+ assert.strictEqual(variants[1].port, VARIANT_PORT_BASE + 1);
+ assert.strictEqual(variants[2].port, VARIANT_PORT_BASE + 2);
+ });
+
+ it('handles maximum variant creation (100 variants)', function () {
+ // Create 100 variants
+ for (let i = 0; i < 100; i++) {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
+ }
+
+ // Verify 100 variants exist
+ const storedVariants = listVariantsFromConfig();
+ assert.strictEqual(Object.keys(storedVariants).length, 100);
+
+ // Verify port exhaustion error
+ assert.throws(
+ () => getNextAvailablePort(),
+ /Port limit reached.*100\/100/,
+ 'Should throw port limit error'
+ );
+ });
+ });
+
+ // ==========================================================================
+ // PR Test Plan: Verify each variant gets unique port in config
+ // ==========================================================================
+ describe('Test Plan: Verify unique port in config', function () {
+ it('each variant has port stored in config', function () {
+ for (let i = 0; i < 5; i++) {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
+ }
+
+ const variants = listVariantsFromConfig();
+ for (let i = 0; i < 5; i++) {
+ assert.strictEqual(
+ variants[`variant${i}`].port,
+ VARIANT_PORT_BASE + i,
+ `Variant ${i} should have port ${VARIANT_PORT_BASE + i}`
+ );
+ }
+ });
+
+ it('port persists across config reload', function () {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, 'persistent.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('persistent', 'gemini', settingsPath, undefined, port);
+
+ // Simulate reload by reading config directly
+ const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
+ assert.strictEqual(rawConfig.cliproxy.persistent.port, port);
+
+ // Verify via listVariantsFromConfig
+ const variants = listVariantsFromConfig();
+ assert.strictEqual(variants.persistent.port, port);
+ });
+
+ it('port-specific config.yaml has correct port value', function () {
+ const port = 8320;
+ generateConfig('gemini', port);
+
+ const configContent = fs.readFileSync(getConfigPathForPort(port), 'utf-8');
+ assert.ok(configContent.includes(`port: ${port}`), 'Config should contain correct port');
+ });
+
+ it('different ports have different config files', function () {
+ const ports = [8318, 8319, 8320];
+
+ for (const port of ports) {
+ generateConfig('gemini', port);
+ }
+
+ // Verify each has separate file
+ assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8318.yaml')));
+ assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8319.yaml')));
+ assert.ok(fs.existsSync(path.join(cliproxyDir, 'config-8320.yaml')));
+
+ // Verify each has correct port in content
+ for (const port of ports) {
+ const content = fs.readFileSync(getConfigPathForPort(port), 'utf-8');
+ assert.ok(content.includes(`port: ${port}`));
+ }
+ });
+ });
+
+ // ==========================================================================
+ // PR Test Plan: Run multiple variants concurrently
+ // ==========================================================================
+ describe('Test Plan: Run multiple variants concurrently', function () {
+ it('registers sessions on 3 different ports simultaneously', function () {
+ const ports = [8318, 8319, 8320];
+ const sessions = [];
+
+ // Start all 3 variants "concurrently"
+ for (const port of ports) {
+ generateConfig('gemini', port);
+ const sessionId = registerSession(port, process.pid);
+ sessions.push({ port, sessionId });
+ }
+
+ // Verify all 3 are running
+ for (const { port } of sessions) {
+ const status = getProxyStatus(port);
+ assert.strictEqual(status.running, true, `Port ${port} should be running`);
+ assert.strictEqual(status.port, port);
+ assert.strictEqual(status.sessionCount, 1);
+ }
+
+ // Verify separate session files exist
+ for (const port of ports) {
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+ assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
+ }
+
+ // Clean up
+ for (const { sessionId, port } of sessions) {
+ unregisterSession(sessionId, port);
+ }
+ });
+
+ it('concurrent sessions on same variant port accumulate', function () {
+ const port = 8318;
+ generateConfig('gemini', port);
+
+ const session1 = registerSession(port, process.pid);
+ const session2 = registerSession(port, process.pid);
+ const session3 = registerSession(port, process.pid);
+
+ const status = getProxyStatus(port);
+ assert.strictEqual(status.sessionCount, 3);
+
+ // Unregister one by one
+ unregisterSession(session1, port);
+ assert.strictEqual(getProxyStatus(port).sessionCount, 2);
+
+ unregisterSession(session2, port);
+ assert.strictEqual(getProxyStatus(port).sessionCount, 1);
+
+ unregisterSession(session3, port);
+ assert.strictEqual(getProxyStatus(port).running, false);
+ });
+
+ it('stopping one variant does not affect others', function () {
+ const ports = [8318, 8319, 8320];
+
+ for (const port of ports) {
+ generateConfig('gemini', port);
+ registerSession(port, process.pid);
+ }
+
+ // Stop middle variant
+ deleteSessionLockForPort(8319);
+
+ // Verify others still running
+ assert.strictEqual(getProxyStatus(8318).running, true);
+ assert.strictEqual(getProxyStatus(8319).running, false);
+ assert.strictEqual(getProxyStatus(8320).running, true);
+
+ // Clean up remaining
+ deleteSessionLockForPort(8318);
+ deleteSessionLockForPort(8320);
+ });
+
+ it('each variant has isolated session tracking', function () {
+ // Create 2 variants
+ const port1 = 8318;
+ const port2 = 8319;
+
+ generateConfig('gemini', port1);
+ generateConfig('gemini', port2);
+
+ // Register different session counts
+ const p1s1 = registerSession(port1, process.pid);
+ const p1s2 = registerSession(port1, process.pid);
+ const p2s1 = registerSession(port2, process.pid);
+
+ assert.strictEqual(getProxyStatus(port1).sessionCount, 2);
+ assert.strictEqual(getProxyStatus(port2).sessionCount, 1);
+
+ // Unregister from port1 - should not affect port2
+ unregisterSession(p1s1, port1);
+ assert.strictEqual(getProxyStatus(port1).sessionCount, 1);
+ assert.strictEqual(getProxyStatus(port2).sessionCount, 1);
+
+ // Clean up
+ unregisterSession(p1s2, port1);
+ unregisterSession(p2s1, port2);
+ });
+ });
+
+ // ==========================================================================
+ // PR Test Plan: Verify ccs cliproxy list shows port column
+ // ==========================================================================
+ describe('Test Plan: Verify list shows port column', function () {
+ it('listVariantsFromConfig returns port for each variant', function () {
+ for (let i = 0; i < 3; i++) {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
+ }
+
+ const variants = listVariantsFromConfig();
+
+ // Verify port field exists and is correct for each
+ assert.strictEqual(variants.variant0.port, 8318);
+ assert.strictEqual(variants.variant1.port, 8319);
+ assert.strictEqual(variants.variant2.port, 8320);
+ });
+
+ it('legacy variants without port return undefined', function () {
+ // Create legacy variant without port field
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy_variant: {
+ provider: 'gemini',
+ settings: '/path/to/settings.json',
+ // No port field
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ const variants = listVariantsFromConfig();
+ assert.strictEqual(variants.legacy_variant.port, undefined);
+ assert.strictEqual(variants.legacy_variant.provider, 'gemini');
+ });
+
+ it('mixed legacy and modern variants show correct ports', function () {
+ const config = {
+ profiles: {},
+ cliproxy: {
+ legacy: {
+ provider: 'gemini',
+ settings: '/path/to/legacy.json',
+ // No port
+ },
+ modern: {
+ provider: 'codex',
+ settings: '/path/to/modern.json',
+ port: 8318,
+ },
+ },
+ };
+ fs.writeFileSync(configPath, JSON.stringify(config));
+
+ const variants = listVariantsFromConfig();
+ assert.strictEqual(variants.legacy.port, undefined);
+ assert.strictEqual(variants.modern.port, 8318);
+ });
+ });
+
+ // ==========================================================================
+ // PR Test Plan: Remove variant and verify cleanup of port-specific files
+ // ==========================================================================
+ describe('Test Plan: Remove variant and verify cleanup', function () {
+ it('removes variant config entry', function () {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, 'to-remove.settings.json');
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('to-remove', 'gemini', settingsPath, undefined, port);
+
+ // Verify exists
+ let variants = listVariantsFromConfig();
+ assert.ok('to-remove' in variants);
+
+ // Remove
+ const removed = removeVariantFromLegacyConfig('to-remove');
+ assert.strictEqual(removed.port, port);
+
+ // Verify gone
+ variants = listVariantsFromConfig();
+ assert.ok(!('to-remove' in variants));
+ });
+
+ it('deleteConfigForPort removes config-{port}.yaml', function () {
+ const port = 8318;
+ generateConfig('gemini', port);
+ assert.ok(configExists(port));
+
+ deleteConfigForPort(port);
+ assert.strictEqual(configExists(port), false);
+ });
+
+ it('deleteSessionLockForPort removes sessions-{port}.json', function () {
+ const port = 8318;
+ registerSession(port, process.pid);
+
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+ assert.ok(fs.existsSync(sessionPath));
+
+ deleteSessionLockForPort(port);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+
+ it('full variant removal cleans config, session, and config files', function () {
+ const port = 8318;
+ const settingsPath = path.join(ccsDir, 'full-cleanup.settings.json');
+
+ // Create variant with all files
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy('full-cleanup', 'gemini', settingsPath, undefined, port);
+ generateConfig('gemini', port);
+ registerSession(port, process.pid);
+
+ // Verify all files exist
+ assert.ok(listVariantsFromConfig()['full-cleanup']);
+ assert.ok(configExists(port));
+ assert.ok(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)));
+
+ // Full cleanup (simulating removeVariant behavior)
+ removeVariantFromLegacyConfig('full-cleanup');
+ deleteConfigForPort(port);
+ deleteSessionLockForPort(port);
+
+ // Verify all cleaned
+ assert.ok(!listVariantsFromConfig()['full-cleanup']);
+ assert.strictEqual(configExists(port), false);
+ assert.strictEqual(fs.existsSync(path.join(cliproxyDir, `sessions-${port}.json`)), false);
+ });
+
+ it('removing one variant does not affect others', function () {
+ const ports = [8318, 8319, 8320];
+
+ // Create 3 variants
+ for (let i = 0; i < 3; i++) {
+ const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
+ generateConfig('gemini', ports[i]);
+ registerSession(ports[i], process.pid);
+ }
+
+ // Remove middle variant
+ removeVariantFromLegacyConfig('variant1');
+ deleteConfigForPort(8319);
+ deleteSessionLockForPort(8319);
+
+ // Verify others intact
+ const variants = listVariantsFromConfig();
+ assert.ok('variant0' in variants);
+ assert.ok(!('variant1' in variants));
+ assert.ok('variant2' in variants);
+
+ assert.ok(configExists(8318));
+ assert.strictEqual(configExists(8319), false);
+ assert.ok(configExists(8320));
+
+ assert.strictEqual(getProxyStatus(8318).running, true);
+ assert.strictEqual(getProxyStatus(8319).running, false);
+ assert.strictEqual(getProxyStatus(8320).running, true);
+
+ // Clean up remaining
+ deleteSessionLockForPort(8318);
+ deleteSessionLockForPort(8320);
+ });
+
+ it('freed port can be reused after deletion', function () {
+ // Create variant on first port
+ const port1 = getNextAvailablePort();
+ const settingsPath1 = path.join(ccsDir, 'first.settings.json');
+ fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
+ saveVariantLegacy('first', 'gemini', settingsPath1, undefined, port1);
+ generateConfig('gemini', port1);
+
+ // Next port should be port1 + 1
+ const port2 = getNextAvailablePort();
+ assert.strictEqual(port2, port1 + 1);
+
+ // Remove first variant
+ removeVariantFromLegacyConfig('first');
+ deleteConfigForPort(port1);
+
+ // port1 should now be available again
+ const reusedPort = getNextAvailablePort();
+ assert.strictEqual(reusedPort, port1, 'Freed port should be reusable');
+ });
+ });
+
+ // ==========================================================================
+ // Edge Cases for Port Isolation
+ // ==========================================================================
+ describe('Edge Cases', function () {
+ it('default port (8317) uses sessions.json not sessions-8317.json', function () {
+ registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
+
+ // Default port uses sessions.json
+ assert.ok(fs.existsSync(path.join(cliproxyDir, 'sessions.json')));
+ assert.strictEqual(
+ fs.existsSync(path.join(cliproxyDir, `sessions-${CLIPROXY_DEFAULT_PORT}.json`)),
+ false
+ );
+
+ deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
+ });
+
+ it('default port (8317) uses config.yaml not config-8317.yaml', function () {
+ generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
+
+ assert.ok(fs.existsSync(path.join(cliproxyDir, 'config.yaml')));
+ assert.strictEqual(
+ fs.existsSync(path.join(cliproxyDir, `config-${CLIPROXY_DEFAULT_PORT}.yaml`)),
+ false
+ );
+ });
+
+ it('port range is 8318-8417 (100 ports)', function () {
+ assert.strictEqual(VARIANT_PORT_BASE, 8318);
+ assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
+
+ // First variant port
+ assert.strictEqual(getNextAvailablePort(), 8318);
+
+ // Create all 100
+ for (let i = 0; i < 100; i++) {
+ const port = getNextAvailablePort();
+ const settingsPath = path.join(ccsDir, `v${i}.settings.json`);
+ fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
+ saveVariantLegacy(`v${i}`, 'gemini', settingsPath, undefined, port);
+ }
+
+ // Last port should be 8417
+ const lastVariant = listVariantsFromConfig()['v99'];
+ assert.strictEqual(lastVariant.port, 8417);
+ });
+
+ it('handles stale session files from crashed processes', function () {
+ const port = 8318;
+ const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
+
+ // Write stale session with dead PID
+ fs.writeFileSync(
+ sessionPath,
+ JSON.stringify({
+ port,
+ pid: 999999999, // Non-existent PID
+ sessions: ['stale-session'],
+ startedAt: new Date().toISOString(),
+ })
+ );
+
+ // getExistingProxy should detect and clean up
+ const lock = getExistingProxy(port);
+ assert.strictEqual(lock, null);
+ assert.strictEqual(fs.existsSync(sessionPath), false);
+ });
+
+ it('gracefully handles missing session file on unregister', function () {
+ // Should not throw
+ const result = unregisterSession('nonexistent-session', 8318);
+ assert.strictEqual(result, true); // No file = should kill
+ });
+
+ it('gracefully handles missing config file on delete', function () {
+ // Should not throw
+ deleteConfigForPort(9999);
+ assert.strictEqual(configExists(9999), false);
+ });
+ });
+});
diff --git a/tests/unit/commands/setup-command.test.ts b/tests/unit/commands/setup-command.test.ts
new file mode 100644
index 00000000..e4bb37b4
--- /dev/null
+++ b/tests/unit/commands/setup-command.test.ts
@@ -0,0 +1,133 @@
+/**
+ * Unit tests for setup-command.ts - isFirstTimeInstall() function
+ *
+ * Issue #195: GLM auth regression - isFirstTimeInstall() was ignoring legacy configs
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+// Create temp directory for test isolation
+let testDir: string;
+
+beforeEach(() => {
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
+});
+
+afterEach(() => {
+ fs.rmSync(testDir, { recursive: true, force: true });
+});
+
+// Helper to create config files
+function createConfigYaml(content: string): void {
+ fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8');
+}
+
+function createConfigJson(content: object): void {
+ fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(content, null, 2), 'utf8');
+}
+
+function createProfilesJson(content: object): void {
+ fs.writeFileSync(path.join(testDir, 'profiles.json'), JSON.stringify(content, null, 2), 'utf8');
+}
+
+describe('isFirstTimeInstall logic', () => {
+ describe('legacy config detection', () => {
+ it('should detect legacy config.json with profiles', () => {
+ createConfigYaml('version: 2\nprofiles: {}\naccounts: {}');
+ createConfigJson({ profiles: { glm: '~/.ccs/glm.settings.json' } });
+
+ const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8'));
+ const hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0;
+
+ expect(hasLegacyProfiles).toBe(true);
+ });
+
+ it('should detect legacy profiles.json with accounts', () => {
+ createConfigYaml('version: 2\nprofiles: {}\naccounts: {}');
+ createProfilesJson({ profiles: { work: { path: '/some/path' } } });
+
+ const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8'));
+ const hasLegacyAccounts = legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0;
+
+ expect(hasLegacyAccounts).toBe(true);
+ });
+
+ it('should return true when no configs exist', () => {
+ const hasConfigYaml = fs.existsSync(path.join(testDir, 'config.yaml'));
+ const hasConfigJson = fs.existsSync(path.join(testDir, 'config.json'));
+ const hasProfilesJson = fs.existsSync(path.join(testDir, 'profiles.json'));
+
+ expect(hasConfigYaml).toBe(false);
+ expect(hasConfigJson).toBe(false);
+ expect(hasProfilesJson).toBe(false);
+ });
+
+ it('should detect unified config with profiles', () => {
+ createConfigYaml(`
+version: 2
+profiles:
+ glm:
+ type: api
+ settings: ~/.ccs/glm.settings.json
+accounts: {}
+`);
+
+ const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
+ const hasProfiles = content.includes('glm:') && content.includes('type: api');
+
+ expect(hasProfiles).toBe(true);
+ });
+
+ it('should detect unified config with remote proxy', () => {
+ createConfigYaml(`
+version: 2
+profiles: {}
+accounts: {}
+cliproxy_server:
+ remote:
+ enabled: true
+ host: my-server.example.com
+`);
+
+ const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
+ const hasRemoteProxy = content.includes('enabled: true') && content.includes('host:');
+
+ expect(hasRemoteProxy).toBe(true);
+ });
+ });
+
+ describe('empty config handling', () => {
+ it('should treat empty configs as first-time', () => {
+ createConfigYaml('version: 2\nprofiles: {}\naccounts: {}');
+ createConfigJson({ profiles: {} });
+ createProfilesJson({ profiles: {} });
+
+ const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8'));
+ const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8'));
+
+ const hasLegacyProfiles = Object.keys(legacyConfig.profiles || {}).length > 0;
+ const hasLegacyAccounts = Object.keys(legacyProfiles.profiles || {}).length > 0;
+
+ expect(hasLegacyProfiles).toBe(false);
+ expect(hasLegacyAccounts).toBe(false);
+ });
+ });
+
+ describe('corrupted config handling', () => {
+ it('should handle corrupted config.json gracefully', () => {
+ fs.writeFileSync(path.join(testDir, 'config.json'), 'not valid json{{{', 'utf8');
+
+ let hasLegacyProfiles = false;
+ try {
+ const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8'));
+ hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0;
+ } catch {
+ hasLegacyProfiles = false;
+ }
+
+ expect(hasLegacyProfiles).toBe(false);
+ });
+ });
+});
diff --git a/tests/unit/commands/tokens-command.test.js b/tests/unit/commands/tokens-command.test.js
new file mode 100644
index 00000000..58d81725
--- /dev/null
+++ b/tests/unit/commands/tokens-command.test.js
@@ -0,0 +1,250 @@
+/**
+ * Tokens Command Tests
+ *
+ * Tests for the `ccs tokens` CLI command including:
+ * - Argument parsing
+ * - Token masking
+ * - Error handling
+ * - Exit codes
+ */
+
+const assert = require('assert');
+
+describe('Tokens Command', () => {
+ // =========================================================================
+ // Token Masking Tests (Unit)
+ // =========================================================================
+ describe('maskToken', () => {
+ // Extract the maskToken function pattern for testing
+ function maskToken(token) {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+ }
+
+ it('masks tokens showing first 4 and last 4 characters', () => {
+ const result = maskToken('1234567890abcdef');
+ assert.strictEqual(result, '1234...cdef');
+ });
+
+ it('returns **** for tokens <= 8 characters', () => {
+ assert.strictEqual(maskToken('12345678'), '****');
+ assert.strictEqual(maskToken('1234567'), '****');
+ assert.strictEqual(maskToken('abc'), '****');
+ assert.strictEqual(maskToken(''), '****');
+ });
+
+ it('handles exactly 9 character tokens', () => {
+ const result = maskToken('123456789');
+ assert.strictEqual(result, '1234...6789');
+ });
+
+ it('handles long tokens (typical API keys)', () => {
+ const longToken = 'test_key_1234567890abcdefghijklmnopqrstuvwxyz';
+ const result = maskToken(longToken);
+ assert.strictEqual(result, 'test...wxyz');
+ });
+
+ it('preserves special characters in masked output', () => {
+ const token = '!@#$abcd____wxyz';
+ const result = maskToken(token);
+ assert.strictEqual(result, '!@#$...wxyz');
+ });
+ });
+
+ // =========================================================================
+ // Argument Parsing Tests (Unit)
+ // =========================================================================
+ describe('Argument Parsing', () => {
+ /**
+ * Simulates the argument parsing logic from tokens-command.ts
+ */
+ function parseArgs(args) {
+ const showFlag = args.includes('--show');
+ const resetFlag = args.includes('--reset');
+ const regenerateSecretFlag = args.includes('--regenerate-secret');
+ const helpFlag = args.includes('--help') || args.includes('-h');
+
+ const apiKeyIndex = args.indexOf('--api-key');
+ const apiKeyValue = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
+
+ const secretIndex = args.indexOf('--secret');
+ const secretValue = secretIndex !== -1 ? args[secretIndex + 1] : undefined;
+
+ const variantIndex = args.indexOf('--variant');
+ const variantValue = variantIndex !== -1 ? args[variantIndex + 1] : undefined;
+
+ return {
+ showFlag,
+ resetFlag,
+ regenerateSecretFlag,
+ helpFlag,
+ apiKeyValue,
+ secretValue,
+ variantValue,
+ };
+ }
+
+ it('parses --show flag', () => {
+ const result = parseArgs(['--show']);
+ assert.strictEqual(result.showFlag, true);
+ });
+
+ it('parses --reset flag', () => {
+ const result = parseArgs(['--reset']);
+ assert.strictEqual(result.resetFlag, true);
+ });
+
+ it('parses --regenerate-secret flag', () => {
+ const result = parseArgs(['--regenerate-secret']);
+ assert.strictEqual(result.regenerateSecretFlag, true);
+ });
+
+ it('parses --help and -h flags', () => {
+ assert.strictEqual(parseArgs(['--help']).helpFlag, true);
+ assert.strictEqual(parseArgs(['-h']).helpFlag, true);
+ });
+
+ it('parses --api-key with value', () => {
+ const result = parseArgs(['--api-key', 'my-custom-key']);
+ assert.strictEqual(result.apiKeyValue, 'my-custom-key');
+ });
+
+ it('parses --secret with value', () => {
+ const result = parseArgs(['--secret', 'my-secret']);
+ assert.strictEqual(result.secretValue, 'my-secret');
+ });
+
+ it('parses --variant with value', () => {
+ const result = parseArgs(['--variant', 'gemini']);
+ assert.strictEqual(result.variantValue, 'gemini');
+ });
+
+ it('parses combined --variant and --api-key', () => {
+ const result = parseArgs(['--variant', 'gemini', '--api-key', 'variant-key']);
+ assert.strictEqual(result.variantValue, 'gemini');
+ assert.strictEqual(result.apiKeyValue, 'variant-key');
+ });
+
+ it('handles no arguments (default case)', () => {
+ const result = parseArgs([]);
+ assert.strictEqual(result.showFlag, false);
+ assert.strictEqual(result.resetFlag, false);
+ assert.strictEqual(result.apiKeyValue, undefined);
+ });
+
+ it('handles multiple flags', () => {
+ const result = parseArgs(['--show', '--api-key', 'key', '--variant', 'v1']);
+ assert.strictEqual(result.showFlag, true);
+ assert.strictEqual(result.apiKeyValue, 'key');
+ assert.strictEqual(result.variantValue, 'v1');
+ });
+ });
+
+ // =========================================================================
+ // Validation Tests (Edge Cases)
+ // =========================================================================
+ describe('Input Validation', () => {
+ /**
+ * Simulates the validation logic from tokens-command.ts
+ */
+ function validateApiKeyValue(apiKeyValue) {
+ if (apiKeyValue !== undefined) {
+ if (!apiKeyValue || apiKeyValue.startsWith('-')) {
+ return { valid: false, error: 'Missing value for --api-key' };
+ }
+ }
+ return { valid: true };
+ }
+
+ function validateSecretValue(secretValue) {
+ if (secretValue !== undefined) {
+ if (!secretValue || secretValue.startsWith('-')) {
+ return { valid: false, error: 'Missing value for --secret' };
+ }
+ }
+ return { valid: true };
+ }
+
+ it('rejects --api-key without value', () => {
+ // When --api-key is at end of args, value will be undefined
+ const result = validateApiKeyValue(undefined);
+ // Note: undefined means flag wasn't provided, empty string means missing value
+ assert.strictEqual(result.valid, true); // undefined = not provided = valid
+ });
+
+ it('rejects --api-key with empty string', () => {
+ const result = validateApiKeyValue('');
+ assert.strictEqual(result.valid, false);
+ assert.strictEqual(result.error, 'Missing value for --api-key');
+ });
+
+ it('rejects --api-key followed by another flag', () => {
+ // e.g., --api-key --reset -> apiKeyValue = '--reset'
+ const result = validateApiKeyValue('--reset');
+ assert.strictEqual(result.valid, false);
+ assert.strictEqual(result.error, 'Missing value for --api-key');
+ });
+
+ it('rejects --secret with empty string', () => {
+ const result = validateSecretValue('');
+ assert.strictEqual(result.valid, false);
+ assert.strictEqual(result.error, 'Missing value for --secret');
+ });
+
+ it('rejects --secret followed by another flag', () => {
+ const result = validateSecretValue('--show');
+ assert.strictEqual(result.valid, false);
+ assert.strictEqual(result.error, 'Missing value for --secret');
+ });
+
+ it('accepts valid api key values', () => {
+ assert.strictEqual(validateApiKeyValue('my-key').valid, true);
+ assert.strictEqual(validateApiKeyValue('sk_live_123').valid, true);
+ assert.strictEqual(validateApiKeyValue('a').valid, true);
+ });
+
+ it('accepts valid secret values', () => {
+ assert.strictEqual(validateSecretValue('my-secret').valid, true);
+ assert.strictEqual(validateSecretValue('super-secure-123').valid, true);
+ });
+ });
+
+ // =========================================================================
+ // Exit Code Tests
+ // =========================================================================
+ describe('Exit Codes', () => {
+ it('defines success exit code as 0', () => {
+ // These are the expected exit codes from the command
+ const EXIT_SUCCESS = 0;
+ const EXIT_FAILURE = 1;
+
+ assert.strictEqual(EXIT_SUCCESS, 0);
+ assert.strictEqual(EXIT_FAILURE, 1);
+ });
+ });
+
+ // =========================================================================
+ // Help Text Tests
+ // =========================================================================
+ describe('Help Text Coverage', () => {
+ // Verify all documented options are present in help output
+ const expectedOptions = [
+ '--show',
+ '--api-key',
+ '--secret',
+ '--regenerate-secret',
+ '--variant',
+ '--reset',
+ '--help',
+ '-h',
+ ];
+
+ it('documents all CLI options', () => {
+ // This test ensures we update help when adding new options
+ expectedOptions.forEach((option) => {
+ assert(typeof option === 'string', `Option ${option} should be a string`);
+ assert(option.startsWith('-'), `Option ${option} should start with -`);
+ });
+ });
+ });
+});
diff --git a/tests/unit/utils/expand-path.test.ts b/tests/unit/utils/expand-path.test.ts
new file mode 100644
index 00000000..42dab0f5
--- /dev/null
+++ b/tests/unit/utils/expand-path.test.ts
@@ -0,0 +1,104 @@
+import { expect, test, describe, beforeEach, afterEach } from "bun:test";
+import * as path from "path";
+import * as os from "os";
+import { expandPath } from "../../../src/utils/helpers";
+
+describe("expandPath", () => {
+ const originalEnv = { ...process.env };
+ const HOME = os.homedir();
+
+ beforeEach(() => {
+ process.env.TEST_HOME = "/custom/home";
+ process.env.TEST_VAR = "foo";
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ test("1. Tilde expansion: ~/path -> /home/user/path", () => {
+ expect(expandPath("~/test/file.txt")).toBe(path.join(HOME, "test/file.txt"));
+ });
+
+ test("2. Windows tilde with backslash: ~\\path", () => {
+ // expandPath handles both ~/ and ~\ regardless of platform
+ expect(expandPath("~\\test\\file.txt")).toBe(path.join(HOME, "test/file.txt"));
+ });
+
+ test("3. Environment variable expansion: ${VAR}/path", () => {
+ expect(expandPath("${TEST_HOME}/file.txt")).toBe(path.normalize("/custom/home/file.txt"));
+ });
+
+ test("4. Dollar sign env vars: $VAR/path", () => {
+ expect(expandPath("$TEST_HOME/file.txt")).toBe(path.normalize("/custom/home/file.txt"));
+ });
+
+ test("5. Windows %VAR% expansion: %VAR%\\path (simulated)", () => {
+ // We can't easily mock process.platform if it's not win32,
+ // but the function check process.platform === 'win32'
+ if (process.platform === 'win32') {
+ expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("/custom/home/file.txt"));
+ } else {
+ // Should remain unchanged on non-windows (but separators normalized)
+ expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("%TEST_HOME%/file.txt"));
+ }
+ });
+
+ test("6. Mixed path separators normalization", () => {
+ const result = expandPath("path/to\\some/file");
+ expect(result).toBe(path.normalize("path/to/some/file"));
+ });
+
+ test("7. Nested env vars: ${HOME}/${VAR}/path", () => {
+ expect(expandPath("${TEST_HOME}/${TEST_VAR}/file.txt")).toBe(path.normalize("/custom/home/foo/file.txt"));
+ });
+
+ test("8. Empty/null path handling", () => {
+ expect(expandPath("")).toBe(".");
+ });
+
+ test("9. Already absolute paths stay unchanged", () => {
+ const absPath = "/absolute/path";
+ expect(expandPath(absPath)).toBe(path.normalize(absPath));
+ });
+
+ test("10. Undefined env vars -> empty string", () => {
+ expect(expandPath("${UNDEFINED_VAR}/file.txt")).toBe(path.normalize("/file.txt"));
+ expect(expandPath("$UNDEFINED_VAR/file.txt")).toBe(path.normalize("/file.txt"));
+ });
+
+ test("11. Windows drive letters stay intact", () => {
+ // Windows drive letter paths should be preserved
+ const result = expandPath("C:\\Users\\test\\file.txt");
+ expect(result).toContain("Users");
+ expect(result).toContain("test");
+ });
+
+ test("12. Windows UNC paths handled", () => {
+ // UNC paths start with \\
+ const uncPath = "\\\\server\\share\\folder";
+ const result = expandPath(uncPath);
+ // Should normalize but preserve the structure
+ expect(result).toContain("server");
+ expect(result).toContain("share");
+ });
+
+ test("13. Null-like input throws TypeError", () => {
+ // Function requires string input - documents current behavior
+ // @ts-ignore - testing runtime edge case
+ expect(() => expandPath(undefined as unknown as string)).toThrow(TypeError);
+ // @ts-ignore - testing runtime edge case
+ expect(() => expandPath(null as unknown as string)).toThrow(TypeError);
+ });
+
+ test("14. Path with spaces preserved", () => {
+ const pathWithSpaces = "~/My Documents/file.txt";
+ const result = expandPath(pathWithSpaces);
+ expect(result).toContain("My Documents");
+ });
+
+ test("15. Multiple consecutive slashes normalized", () => {
+ const result = expandPath("path//to///file.txt");
+ expect(result).toBe(path.normalize("path/to/file.txt"));
+ });
+});
diff --git a/tests/unit/utils/update-checker-beta-channel.test.js b/tests/unit/utils/update-checker-beta-channel.test.js
index 4cca9820..964cfc75 100644
--- a/tests/unit/utils/update-checker-beta-channel.test.js
+++ b/tests/unit/utils/update-checker-beta-channel.test.js
@@ -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');
});
diff --git a/tests/unit/web-server/settings-routes-auth.test.js b/tests/unit/web-server/settings-routes-auth.test.js
new file mode 100644
index 00000000..7a0771bb
--- /dev/null
+++ b/tests/unit/web-server/settings-routes-auth.test.js
@@ -0,0 +1,284 @@
+/**
+ * Settings Routes - Auth Tokens API Tests
+ *
+ * Tests for the auth tokens API endpoints:
+ * - GET /api/settings/auth/tokens (masked)
+ * - GET /api/settings/auth/tokens/raw (unmasked)
+ * - PUT /api/settings/auth/tokens (update)
+ * - POST /api/settings/auth/tokens/regenerate-secret
+ * - POST /api/settings/auth/tokens/reset
+ */
+
+const assert = require('assert');
+
+describe('Settings Routes - Auth Tokens API', () => {
+ // =========================================================================
+ // maskToken Function Tests (same as in routes)
+ // =========================================================================
+ describe('maskToken', () => {
+ function maskToken(token) {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+ }
+
+ it('masks long tokens correctly', () => {
+ assert.strictEqual(maskToken('ccs-internal-managed'), 'ccs-...aged');
+ });
+
+ it('fully masks short tokens', () => {
+ assert.strictEqual(maskToken('ccs'), '****');
+ });
+
+ it('handles exactly 8 character tokens', () => {
+ assert.strictEqual(maskToken('12345678'), '****');
+ });
+
+ it('handles 9 character tokens', () => {
+ assert.strictEqual(maskToken('123456789'), '1234...6789');
+ });
+ });
+
+ // =========================================================================
+ // Response Format Tests
+ // =========================================================================
+ describe('Response Format', () => {
+ /**
+ * Expected response format for GET /api/settings/auth/tokens
+ */
+ function createMaskedResponse(apiKey, managementSecret, apiKeyIsCustom, secretIsCustom) {
+ function maskToken(token) {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+ }
+
+ return {
+ apiKey: {
+ value: maskToken(apiKey),
+ isCustom: apiKeyIsCustom,
+ },
+ managementSecret: {
+ value: maskToken(managementSecret),
+ isCustom: secretIsCustom,
+ },
+ };
+ }
+
+ it('includes apiKey with value and isCustom', () => {
+ const response = createMaskedResponse('test-api-key-123', 'test-secret', true, false);
+
+ assert(response.apiKey, 'Should have apiKey');
+ assert.strictEqual(typeof response.apiKey.value, 'string');
+ assert.strictEqual(typeof response.apiKey.isCustom, 'boolean');
+ });
+
+ it('includes managementSecret with value and isCustom', () => {
+ const response = createMaskedResponse('key', 'test-secret-456', false, true);
+
+ assert(response.managementSecret, 'Should have managementSecret');
+ assert.strictEqual(typeof response.managementSecret.value, 'string');
+ assert.strictEqual(typeof response.managementSecret.isCustom, 'boolean');
+ });
+
+ it('masks values in response', () => {
+ const response = createMaskedResponse(
+ 'very-long-api-key-12345',
+ 'very-long-secret-67890',
+ true,
+ true
+ );
+
+ // Values should be masked (contain ...)
+ assert(response.apiKey.value.includes('...'), 'API key should be masked');
+ assert(response.managementSecret.value.includes('...'), 'Secret should be masked');
+ });
+ });
+
+ // =========================================================================
+ // PUT /api/settings/auth/tokens - Update Logic Tests
+ // =========================================================================
+ describe('PUT /api/settings/auth/tokens - Update Logic', () => {
+ /**
+ * Simulates the update logic
+ */
+ function processUpdate(body, currentState) {
+ const { apiKey, managementSecret } = body;
+ const newState = { ...currentState };
+
+ if (apiKey !== undefined) {
+ // Empty string -> reset to default
+ newState.apiKey = apiKey || null;
+ }
+
+ if (managementSecret !== undefined) {
+ newState.managementSecret = managementSecret || null;
+ }
+
+ return newState;
+ }
+
+ it('updates apiKey when provided', () => {
+ const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
+ const result = processUpdate({ apiKey: 'new-key' }, current);
+
+ assert.strictEqual(result.apiKey, 'new-key');
+ assert.strictEqual(result.managementSecret, 'old-secret');
+ });
+
+ it('updates managementSecret when provided', () => {
+ const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
+ const result = processUpdate({ managementSecret: 'new-secret' }, current);
+
+ assert.strictEqual(result.apiKey, 'old-key');
+ assert.strictEqual(result.managementSecret, 'new-secret');
+ });
+
+ it('updates both when provided', () => {
+ const current = { apiKey: 'old-key', managementSecret: 'old-secret' };
+ const result = processUpdate({ apiKey: 'new-key', managementSecret: 'new-secret' }, current);
+
+ assert.strictEqual(result.apiKey, 'new-key');
+ assert.strictEqual(result.managementSecret, 'new-secret');
+ });
+
+ it('resets to default when empty string provided', () => {
+ const current = { apiKey: 'custom-key', managementSecret: 'custom-secret' };
+ const result = processUpdate({ apiKey: '', managementSecret: '' }, current);
+
+ assert.strictEqual(result.apiKey, null);
+ assert.strictEqual(result.managementSecret, null);
+ });
+
+ it('ignores undefined values (no change)', () => {
+ const current = { apiKey: 'keep-key', managementSecret: 'keep-secret' };
+ const result = processUpdate({}, current);
+
+ assert.strictEqual(result.apiKey, 'keep-key');
+ assert.strictEqual(result.managementSecret, 'keep-secret');
+ });
+ });
+
+ // =========================================================================
+ // POST /api/settings/auth/tokens/regenerate-secret - Tests
+ // =========================================================================
+ describe('POST /api/settings/auth/tokens/regenerate-secret', () => {
+ /**
+ * Simulates regenerate secret response
+ */
+ function createRegenerateResponse(newSecret) {
+ function maskToken(token) {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+ }
+
+ return {
+ success: true,
+ managementSecret: {
+ value: maskToken(newSecret),
+ isCustom: true,
+ },
+ message: 'Restart CLIProxy to apply changes',
+ };
+ }
+
+ it('returns success: true', () => {
+ const response = createRegenerateResponse('new-generated-secret-12345');
+ assert.strictEqual(response.success, true);
+ });
+
+ it('returns masked new secret', () => {
+ const response = createRegenerateResponse('abcdefghijklmnopqrstuvwxyz');
+ assert(response.managementSecret.value.includes('...'), 'Should be masked');
+ assert.strictEqual(response.managementSecret.isCustom, true);
+ });
+
+ it('includes restart message', () => {
+ const response = createRegenerateResponse('secret');
+ assert(response.message.includes('Restart'), 'Should include restart instruction');
+ });
+ });
+
+ // =========================================================================
+ // POST /api/settings/auth/tokens/reset - Tests
+ // =========================================================================
+ describe('POST /api/settings/auth/tokens/reset', () => {
+ /**
+ * Simulates reset response
+ */
+ function createResetResponse(defaultApiKey, defaultSecret) {
+ function maskToken(token) {
+ if (token.length <= 8) return '****';
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
+ }
+
+ return {
+ success: true,
+ apiKey: {
+ value: maskToken(defaultApiKey),
+ isCustom: false,
+ },
+ managementSecret: {
+ value: maskToken(defaultSecret),
+ isCustom: false,
+ },
+ message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
+ };
+ }
+
+ it('returns success: true', () => {
+ const response = createResetResponse('ccs-internal-managed', 'ccs');
+ assert.strictEqual(response.success, true);
+ });
+
+ it('returns isCustom: false for both tokens', () => {
+ const response = createResetResponse('ccs-internal-managed', 'ccs');
+ assert.strictEqual(response.apiKey.isCustom, false);
+ assert.strictEqual(response.managementSecret.isCustom, false);
+ });
+
+ it('includes reset message', () => {
+ const response = createResetResponse('key', 'secret');
+ assert(response.message.includes('reset'), 'Should include reset confirmation');
+ });
+ });
+
+ // =========================================================================
+ // Error Response Format Tests
+ // =========================================================================
+ describe('Error Response Format', () => {
+ /**
+ * Simulates error response
+ */
+ function createErrorResponse(errorMessage) {
+ return {
+ error: errorMessage,
+ };
+ }
+
+ it('includes error message', () => {
+ const response = createErrorResponse('Something went wrong');
+ assert.strictEqual(response.error, 'Something went wrong');
+ });
+
+ it('does not include sensitive data in errors', () => {
+ const response = createErrorResponse('Failed to load config');
+ assert(!response.apiKey, 'Should not include apiKey');
+ assert(!response.managementSecret, 'Should not include secret');
+ });
+ });
+
+ // =========================================================================
+ // HTTP Status Code Tests
+ // =========================================================================
+ describe('HTTP Status Codes', () => {
+ const HTTP_OK = 200;
+ const HTTP_INTERNAL_ERROR = 500;
+
+ it('uses 200 for successful responses', () => {
+ assert.strictEqual(HTTP_OK, 200);
+ });
+
+ it('uses 500 for internal errors', () => {
+ assert.strictEqual(HTTP_INTERNAL_ERROR, 500);
+ });
+ });
+});
diff --git a/ui/package.json b/ui/package.json
index 5fbb4629..23c50f38 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -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/",
diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx
index 65865f03..d90969c9 100644
--- a/ui/src/components/account/accounts-table.tsx
+++ b/ui/src/components/account/accounts-table.tsx
@@ -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(null);
const columns: ColumnDef[] = [
{
@@ -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 (
- setDefaultMutation.mutate(row.original.name)}
- >
-
- {isDefault ? 'Active' : 'Set Default'}
-
+
+ setDefaultMutation.mutate(row.original.name)}
+ >
+
+ {isDefault ? 'Active' : 'Set Default'}
+
+ setDeleteTarget(row.original.name)}
+ title={isDefault ? 'Cannot delete default account' : 'Delete account'}
+ >
+
+
+
);
},
},
@@ -100,51 +133,97 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) {
return (
- No accounts found. Use ccs login to
- add accounts.
+ No accounts found. Use{' '}
+ ccs auth create to add accounts.
);
}
return (
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {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';
+ <>
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {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 (
-
- {header.isPlaceholder
- ? null
- : flexRender(header.column.columnDef.header, header.getContext())}
-
- );
- })}
-
- ))}
-
-
- {table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ );
+ })}
+
))}
-
- ))}
-
-
-
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))}
+
+
+
+
+ {/* Reset default button */}
+ {defaultAccount && (
+
+ resetDefaultMutation.mutate()}
+ disabled={resetDefaultMutation.isPending}
+ >
+
+ Reset to CCS Default
+
+
+ )}
+
+
+ {/* Delete confirmation dialog */}
+ !open && setDeleteTarget(null)}>
+
+
+ Delete Account
+
+ Are you sure you want to delete the account "{deleteTarget}"? This will
+ remove the profile and all its session data. This action cannot be undone.
+
+
+
+ Cancel
+ {
+ if (deleteTarget) {
+ deleteMutation.mutate(deleteTarget);
+ setDeleteTarget(null);
+ }
+ }}
+ >
+ Delete
+
+
+
+
+ >
);
}
diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx
index fa01fa4e..9d83e9c9 100644
--- a/ui/src/components/account/add-account-dialog.tsx
+++ b/ui/src/components/account/add-account-dialog.tsx
@@ -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({
Add {displayName} Account
- 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.'}
@@ -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"
/>
@@ -98,10 +120,25 @@ export function AddAccountDialog({
-
+
Cancel
-
+ {isKiro && (
+
+ {kiroImportMutation.isPending ? (
+ <>
+
+ Importing...
+ >
+ ) : (
+ <>
+
+ Import from IDE
+ >
+ )}
+
+ )}
+
{startAuthMutation.isPending ? (
<>
@@ -121,6 +158,11 @@ export function AddAccountDialog({
Complete the OAuth flow in your browser...
)}
+ {kiroImportMutation.isPending && (
+
+ Importing token from Kiro IDE...
+
+ )}
diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx
new file mode 100644
index 00000000..2da65f82
--- /dev/null
+++ b/ui/src/components/account/create-auth-profile-dialog.tsx
@@ -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 ';
+
+ const handleCopy = async () => {
+ if (!isValidName) return;
+ await navigator.clipboard.writeText(command);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const handleClose = () => {
+ setProfileName('');
+ setCopied(false);
+ onClose();
+ };
+
+ return (
+ !isOpen && handleClose()}>
+
+
+ Create New Account
+
+ Auth profiles require Claude CLI login. Run the command below in your terminal.
+
+
+
+
+
+
Profile Name
+
setProfileName(e.target.value)}
+ placeholder="e.g., work, personal, client"
+ autoComplete="off"
+ />
+ {profileName && !isValidName && (
+
+ Name must start with a letter and contain only letters, numbers, dashes, or
+ underscores.
+
+ )}
+
+
+
+
Command
+
+
+ {command}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
After running the command:
+
+ Complete the Claude login in your browser
+ Return here and refresh to see the new account
+
+
+
+
+
+ Close
+
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Command
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts
index ba0cf9b4..0b75ccb8 100644
--- a/ui/src/components/account/index.ts
+++ b/ui/src/components/account/index.ts
@@ -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';
diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx
index 2abe97f2..6fcce60f 100644
--- a/ui/src/components/cliproxy/control-panel-embed.tsx
+++ b/ui/src/components/cliproxy/control-panel-embed.tsx
@@ -7,7 +7,7 @@
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
+import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
@@ -15,8 +15,10 @@ import type { CliproxyServerConfig } from '@/lib/api-client';
/** CLIProxyAPI default port */
const CLIPROXY_DEFAULT_PORT = 8317;
-/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */
-const CCS_CONTROL_PANEL_SECRET = 'ccs';
+interface AuthTokensResponse {
+ apiKey: { value: string; isCustom: boolean };
+ managementSecret: { value: string; isCustom: boolean };
+}
interface ControlPanelEmbedProps {
port?: number;
@@ -36,6 +38,17 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
staleTime: 30000, // 30 seconds
});
+ // Fetch auth tokens for local mode (gets effective management secret)
+ const { data: authTokens } = useQuery({
+ queryKey: ['auth-tokens-raw'],
+ queryFn: async () => {
+ const response = await fetch('/api/settings/auth/tokens/raw');
+ if (!response.ok) throw new Error('Failed to fetch auth tokens');
+ return response.json();
+ },
+ staleTime: 30000, // 30 seconds
+ });
+
// Log config fetch errors (fallback to local mode on error)
useEffect(() => {
if (configError) {
@@ -67,15 +80,16 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
};
}
- // Local mode
+ // Local mode - use effective management secret from auth tokens API
+ const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
return {
managementUrl: `http://localhost:${port}/management.html`,
checkUrl: `http://localhost:${port}/`,
- authToken: CCS_CONTROL_PANEL_SECRET,
+ authToken: effectiveSecret,
isRemote: false,
displayHost: `localhost:${port}`,
};
- }, [cliproxyConfig, port]);
+ }, [cliproxyConfig, authTokens, port]);
// Check if CLIProxy is running
useEffect(() => {
@@ -219,6 +233,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
: authToken || 'ccs'}
+
+
+
setShowLoginHint(false)}
diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx
index 939777a8..f7527bcd 100644
--- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx
+++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx
@@ -5,7 +5,8 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
-import { User, Plus } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { User, Plus, Globe } from 'lucide-react';
import { AccountItem } from './account-item';
import type { OAuthAccount } from '@/lib/api-client';
@@ -18,6 +19,11 @@ interface AccountsSectionProps {
privacyMode?: boolean;
/** Show quota bars for accounts (only applicable for 'agy' provider) */
showQuota?: boolean;
+ /** Kiro-specific: show "use normal browser" toggle */
+ isKiro?: boolean;
+ kiroNoIncognito?: boolean;
+ onKiroNoIncognitoChange?: (enabled: boolean) => void;
+ kiroSettingsLoading?: boolean;
}
export function AccountsSection({
@@ -28,6 +34,10 @@ export function AccountsSection({
isRemovingAccount,
privacyMode,
showQuota,
+ isKiro,
+ kiroNoIncognito,
+ onKiroNoIncognitoChange,
+ kiroSettingsLoading,
}: AccountsSectionProps) {
return (
@@ -68,6 +78,24 @@ export function AccountsSection({
Add an account to get started
)}
+
+ {/* Kiro-specific: Incognito browser setting - users complain "it keeps opening incognito" */}
+ {isKiro && onKiroNoIncognitoChange && (
+
+
+
+
+ Use incognito
+
+
onKiroNoIncognitoChange(!v)}
+ disabled={kiroSettingsLoading}
+ className="scale-90"
+ />
+
+
+ )}
);
}
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx
index d1c13f43..ed864a86 100644
--- a/ui/src/components/cliproxy/provider-editor/index.tsx
+++ b/ui/src/components/cliproxy/provider-editor/index.tsx
@@ -31,7 +31,9 @@ export function ProviderEditor({
authStatus,
catalog,
logoProvider,
+ baseProvider,
isRemoteMode,
+ port,
onAddAccount,
onSetDefault,
onRemoveAccount,
@@ -46,6 +48,9 @@ export function ProviderEditor({
const deletePresetMutation = useDeletePreset();
const savedPresets = presetsData?.presets || [];
+ // Use baseProvider for model filtering (for variants, this is the parent provider)
+ const modelFilterProvider = baseProvider || provider;
+
const providerModels = useMemo(() => {
if (!modelsData?.models) return [];
const ownerMap: Record = {
@@ -57,11 +62,13 @@ export function ProviderEditor({
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
};
- const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()];
+ const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
+ modelFilterProvider.toLowerCase(),
+ ];
return modelsData.models.filter((m) =>
owners.some((o) => m.owned_by.toLowerCase().includes(o))
);
- }, [modelsData, provider]);
+ }, [modelsData, modelFilterProvider]);
const {
data,
@@ -128,6 +135,7 @@ export function ProviderEditor({
isRawJsonValid={isRawJsonValid}
isSaving={saveMutation.isPending}
isRemoteMode={isRemoteMode}
+ port={port}
onRefetch={refetch}
onSave={() => saveMutation.mutate()}
/>
@@ -157,6 +165,7 @@ export function ProviderEditor({
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
Apply pre-configured model mappings
{/* Recommended presets from catalog */}
- {catalog?.models.slice(0, 3).map((model) => (
+ {catalog?.models.slice(0, 4).map((model) => (
void;
isRemovingAccount?: boolean;
privacyMode?: boolean;
- /** Provider name for quota display */
- provider?: string;
}
export function ModelConfigTab({
+ provider,
catalog,
savedPresets,
currentModel,
@@ -58,8 +60,54 @@ export function ModelConfigTab({
onRemoveAccount,
isRemovingAccount,
privacyMode,
- provider,
}: ModelConfigTabProps) {
+ // Kiro-specific: no-incognito setting (defaults to true = normal browser)
+ const isKiro = provider === 'kiro';
+ const [kiroNoIncognito, setKiroNoIncognito] = useState(true);
+ const [kiroSettingsLoading, setKiroSettingsLoading] = useState(true);
+ const [kiroSaving, setKiroSaving] = useState(false);
+
+ // Fetch Kiro settings from unified config
+ const fetchKiroSettings = useCallback(async () => {
+ if (!isKiro) return;
+ try {
+ setKiroSettingsLoading(true);
+ const unifiedConfig = await api.config.get();
+ const cliproxyConfig = unifiedConfig.cliproxy as { kiro_no_incognito?: boolean } | undefined;
+ setKiroNoIncognito(cliproxyConfig?.kiro_no_incognito ?? true);
+ } catch {
+ setKiroNoIncognito(true);
+ } finally {
+ setKiroSettingsLoading(false);
+ }
+ }, [isKiro]);
+
+ // Save Kiro no-incognito setting
+ const saveKiroNoIncognito = useCallback(async (enabled: boolean) => {
+ setKiroNoIncognito(enabled); // Optimistic update
+ setKiroSaving(true);
+ try {
+ const unifiedConfig = await api.config.get();
+ const existingCliproxy = (unifiedConfig.cliproxy ?? {}) as Record;
+ await api.config.update({
+ ...unifiedConfig,
+ cliproxy: {
+ ...existingCliproxy,
+ kiro_no_incognito: enabled,
+ },
+ });
+ } catch {
+ setKiroNoIncognito(!enabled); // Revert on error
+ } finally {
+ setKiroSaving(false);
+ }
+ }, []);
+
+ // Load Kiro settings on mount
+ useEffect(() => {
+ fetchKiroSettings();
+ }, [fetchKiroSettings]);
+
return (
@@ -86,6 +134,10 @@ export function ModelConfigTab({
isRemovingAccount={isRemovingAccount}
privacyMode={privacyMode}
showQuota={provider === 'agy'}
+ isKiro={isKiro}
+ kiroNoIncognito={kiroNoIncognito}
+ onKiroNoIncognitoChange={saveKiroNoIncognito}
+ kiroSettingsLoading={kiroSettingsLoading || kiroSaving}
/>
diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx
index 3d662811..1333c499 100644
--- a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx
+++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx
@@ -5,7 +5,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
-import { Save, Loader2, RefreshCw, Globe } from 'lucide-react';
+import { Save, Loader2, RefreshCw, Globe, Network } from 'lucide-react';
import { ProviderLogo } from '../provider-logo';
import type { SettingsResponse } from './types';
@@ -19,6 +19,7 @@ interface ProviderEditorHeaderProps {
isRawJsonValid: boolean;
isSaving: boolean;
isRemoteMode?: boolean;
+ port?: number;
onRefetch: () => void;
onSave: () => void;
}
@@ -33,6 +34,7 @@ export function ProviderEditorHeader({
isRawJsonValid,
isSaving,
isRemoteMode,
+ port,
onRefetch,
onSave,
}: ProviderEditorHeaderProps) {
@@ -52,6 +54,11 @@ export function ProviderEditorHeader({
Remote
)}
+ {port && (
+
+ :{port}
+
+ )}
{!isRemoteMode && data?.path && (
{data.path.replace(/^.*[\\/]/, '')}
diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts
index 600016f4..47b5fd01 100644
--- a/ui/src/components/cliproxy/provider-editor/types.ts
+++ b/ui/src/components/cliproxy/provider-editor/types.ts
@@ -21,8 +21,12 @@ export interface ProviderEditorProps {
catalog?: ProviderCatalog;
/** Provider type for logo display (defaults to provider) */
logoProvider?: string;
+ /** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */
+ baseProvider?: string;
/** True if using remote CLIProxy mode (hides local paths) */
isRemoteMode?: boolean;
+ /** Port number for variant (for display in header) */
+ port?: number;
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
diff --git a/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx
index d04ec791..313beac3 100644
--- a/ui/src/components/layout/layout.tsx
+++ b/ui/src/components/layout/layout.tsx
@@ -12,7 +12,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ClaudeKitBadge } from '@/components/shared/claudekit-badge';
import { SponsorButton } from '@/components/shared/sponsor-button';
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
+import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
import { useProjectSelection } from '@/hooks/use-project-selection';
+import { useDeviceCode } from '@/hooks/use-device-code';
function PageLoader() {
return (
@@ -25,6 +27,7 @@ function PageLoader() {
export function Layout() {
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
+ const deviceCode = useDeviceCode();
return (
@@ -64,6 +67,19 @@ export function Layout() {
onSelect={onSelect}
/>
)}
+
+ {/* Global device code dialog for Device Code OAuth flows (GitHub Copilot, Qwen) */}
+ {deviceCode.prompt && (
+
+ )}
);
}
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx
index 30f46e30..37f92035 100644
--- a/ui/src/components/profiles/profile-create-dialog.tsx
+++ b/ui/src/components/profiles/profile-create-dialog.tsx
@@ -66,7 +66,12 @@ interface ProfileCreateDialogProps {
// Common URL mistakes to warn about
const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
-export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) {
+export function ProfileCreateDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+ initialMode = 'openrouter',
+}: ProfileCreateDialogProps) {
const createMutation = useCreateProfile();
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState(null);
@@ -123,18 +128,29 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
setActiveTab('basic');
setUrlWarning(null);
setShowApiKey(false);
- setSelectedPreset('openrouter');
setModelSearch('');
- // Pre-fill with OpenRouter preset
- const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter');
- if (openrouterPreset) {
+
+ // Set initial preset based on initialMode
+ if (initialMode === 'normal') {
+ // Custom mode - clear form
+ setSelectedPreset('custom');
setTimeout(() => {
- setValue('name', openrouterPreset.defaultProfileName);
- setValue('baseUrl', openrouterPreset.baseUrl);
+ setValue('name', '');
+ setValue('baseUrl', '');
}, 0);
+ } else {
+ // OpenRouter mode (default)
+ setSelectedPreset('openrouter');
+ const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter');
+ if (openrouterPreset) {
+ setTimeout(() => {
+ setValue('name', openrouterPreset.defaultProfileName);
+ setValue('baseUrl', openrouterPreset.baseUrl);
+ }, 0);
+ }
}
}
- }, [open, reset, setValue]);
+ }, [open, reset, setValue, initialMode]);
// Handle preset selection
const handlePresetSelect = (presetId: string) => {
@@ -244,14 +260,14 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
type="button"
onClick={() => handlePresetSelect('custom')}
className={cn(
- 'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
+ 'flex items-center gap-2 px-4 py-2 rounded-md border-2 transition-all text-sm font-medium',
selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)
- ? 'border-primary bg-primary/5 font-medium'
- : 'border-muted hover:border-muted-foreground/30'
+ ? 'border-primary bg-primary/10 text-primary dark:bg-primary/20'
+ : 'border-dashed border-muted-foreground/40 hover:border-primary/50 hover:bg-muted/50 text-muted-foreground hover:text-foreground'
)}
>
-
+
Custom
@@ -260,8 +276,8 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
{/* Show alternative presets when Custom is selected or an alternative is selected */}
{(selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && (
-
-
+
+
Quick Templates
@@ -552,24 +568,27 @@ function CompactPresetCard({
type="button"
onClick={onClick}
className={cn(
- 'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
+ 'flex items-center gap-2 px-3 py-2 rounded-md border transition-all text-sm',
isSelected
? preset.featured
? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
- : 'border-primary bg-primary/5 font-medium'
- : 'border-muted hover:border-muted-foreground/30'
+ : 'border-primary bg-primary/10 dark:bg-primary/20 font-medium'
+ : 'border-muted-foreground/20 hover:border-muted-foreground/40 hover:bg-muted/50'
)}
>
{preset.icon ? (
-
+
) : (
-
+
{preset.name.charAt(0)}
)}
-
{preset.name}
+
{preset.name}
{preset.badge && (
-
+
{preset.badge}
)}
diff --git a/ui/src/components/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx
new file mode 100644
index 00000000..5f21cd50
--- /dev/null
+++ b/ui/src/components/shared/device-code-dialog.tsx
@@ -0,0 +1,184 @@
+/**
+ * Device Code Dialog Component
+ *
+ * Displays during OAuth Device Code flow when user needs to enter
+ * a verification code at the provider's website (e.g., GitHub, Qwen).
+ * Shows prominently formatted code with copy and open URL buttons.
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react';
+import { toast } from 'sonner';
+
+interface DeviceCodeDialogProps {
+ open: boolean;
+ onClose: () => void;
+ sessionId: string;
+ provider: string;
+ userCode: string;
+ verificationUrl: string;
+ expiresAt: number;
+}
+
+/** Provider display names */
+const PROVIDER_DISPLAY_NAMES: Record = {
+ ghcp: 'GitHub Copilot',
+ qwen: 'Qwen Code',
+};
+
+/** Provider specific instructions */
+const PROVIDER_INSTRUCTIONS: Record = {
+ ghcp: 'Sign in with your GitHub account that has Copilot access.',
+ qwen: 'Sign in with your Qwen account to authorize access.',
+};
+
+export function DeviceCodeDialog({
+ open,
+ onClose,
+ sessionId,
+ provider,
+ userCode,
+ verificationUrl,
+ expiresAt,
+}: DeviceCodeDialogProps) {
+ const [hasCopied, setHasCopied] = useState(false);
+ const [timeRemaining, setTimeRemaining] = useState(null);
+
+ // Calculate and update remaining time, stop at 0
+ useEffect(() => {
+ if (!open) return;
+
+ let timer: ReturnType | null = null;
+
+ const updateTime = () => {
+ const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
+ setTimeRemaining(remaining);
+ // Stop timer when expired
+ if (remaining === 0 && timer) {
+ clearInterval(timer);
+ timer = null;
+ }
+ };
+
+ updateTime();
+ timer = setInterval(updateTime, 1000);
+
+ return () => {
+ if (timer) clearInterval(timer);
+ };
+ }, [open, expiresAt]);
+
+ const handleCopyCode = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(userCode);
+ setHasCopied(true);
+ toast.success('Code copied to clipboard');
+ setTimeout(() => setHasCopied(false), 2000);
+ } catch {
+ toast.error('Failed to copy code');
+ }
+ }, [userCode]);
+
+ const handleOpenUrl = useCallback(() => {
+ window.open(verificationUrl, '_blank', 'noopener,noreferrer');
+ }, [verificationUrl]);
+
+ const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider;
+ const instructions =
+ PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.';
+
+ // Format remaining time
+ const formatTime = (seconds: number): string => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const isExpired = timeRemaining === 0;
+
+ return (
+ !isOpen && onClose()}>
+
+
+
+
+ Authorize {providerDisplay}
+
+
+ Enter the code below at the authorization page.
+ {timeRemaining !== null && timeRemaining > 0 && (
+
+ (Expires in {formatTime(timeRemaining)})
+
+ )}
+ {isExpired && (Code expired) }
+
+
+
+
+ {/* Large code display */}
+
+
+
+ {hasCopied ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Instructions */}
+
+
+ {/* Action buttons */}
+
+
+
+ Open {providerDisplay.split(' ')[0]}
+
+
+ {hasCopied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Code
+ >
+ )}
+
+
+
+ {/* Waiting indicator */}
+
+
+ Waiting for authorization...
+
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts
index 2a181ec4..602a9712 100644
--- a/ui/src/components/shared/index.ts
+++ b/ui/src/components/shared/index.ts
@@ -8,6 +8,7 @@ export { CodeEditor } from './code-editor';
export { CommandBuilder } from './command-builder';
export { ConfirmDialog } from './confirm-dialog';
export { ConnectionIndicator } from './connection-indicator';
+export { DeviceCodeDialog } from './device-code-dialog';
export { DocsLink } from './docs-link';
export { GitHubLink } from './github-link';
export { GlobalEnvIndicator } from './global-env-indicator';
diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts
index a74422f2..b46691af 100644
--- a/ui/src/hooks/use-accounts.ts
+++ b/ui/src/hooks/use-accounts.ts
@@ -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);
+ },
+ });
+}
diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts
index 9f93ec2c..63e9c2a6 100644
--- a/ui/src/hooks/use-cliproxy-stats.ts
+++ b/ui/src/hooks/use-cliproxy-stats.ts
@@ -200,8 +200,14 @@ export type { ModelQuota, QuotaResult };
async function fetchAccountQuota(provider: string, accountId: string): Promise {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
if (!response.ok) {
- const error = await response.json();
- throw new Error(error.message || 'Failed to fetch quota');
+ let message = 'Failed to fetch quota';
+ try {
+ const error = await response.json();
+ message = error.message || message;
+ } catch {
+ // Use default message if response isn't JSON
+ }
+ throw new Error(message);
}
return response.json();
}
diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts
index fa93c06f..7dd7811b 100644
--- a/ui/src/hooks/use-cliproxy.ts
+++ b/ui/src/hooks/use-cliproxy.ts
@@ -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({
diff --git a/ui/src/hooks/use-device-code.ts b/ui/src/hooks/use-device-code.ts
new file mode 100644
index 00000000..a8492068
--- /dev/null
+++ b/ui/src/hooks/use-device-code.ts
@@ -0,0 +1,134 @@
+/**
+ * Device Code Hook
+ *
+ * Listens for WebSocket device code events and manages dialog state.
+ * Similar to useProjectSelection but for Device Code OAuth flows
+ * (GitHub Copilot, Qwen, etc.)
+ */
+
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import { toast } from 'sonner';
+
+export interface DeviceCodePrompt {
+ sessionId: string;
+ provider: string;
+ userCode: string;
+ verificationUrl: string;
+ expiresAt: number;
+}
+
+interface DeviceCodeState {
+ isOpen: boolean;
+ prompt: DeviceCodePrompt | null;
+ error: string | null;
+}
+
+/** Provider display names for user-friendly messages */
+const PROVIDER_DISPLAY_NAMES: Record = {
+ ghcp: 'GitHub Copilot',
+ qwen: 'Qwen Code',
+};
+
+export function useDeviceCode() {
+ const [state, setState] = useState({
+ isOpen: false,
+ prompt: null,
+ error: null,
+ });
+
+ // Listen for WebSocket messages via custom events
+ useEffect(() => {
+ const handleMessage = (event: CustomEvent<{ type: string; [key: string]: unknown }>) => {
+ const data = event.detail;
+
+ if (data.type === 'deviceCodeReceived') {
+ console.log('[DeviceCode] Received prompt:', data.sessionId);
+ const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider;
+ toast.info(`${displayName} authorization required`);
+
+ setState({
+ isOpen: true,
+ prompt: {
+ sessionId: data.sessionId as string,
+ provider: data.provider as string,
+ userCode: data.userCode as string,
+ verificationUrl: data.verificationUrl as string,
+ expiresAt: data.expiresAt as number,
+ },
+ error: null,
+ });
+ } else if (data.type === 'deviceCodeCompleted') {
+ console.log('[DeviceCode] Auth completed:', data.sessionId);
+ setState((prev) => {
+ if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
+ const displayName =
+ PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
+ toast.success(`${displayName} authentication successful!`);
+ return { isOpen: false, prompt: null, error: null };
+ }
+ return prev;
+ });
+ } else if (data.type === 'deviceCodeFailed') {
+ console.log('[DeviceCode] Auth failed:', data.sessionId, data.error);
+ setState((prev) => {
+ if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
+ const displayName =
+ PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
+ toast.error(`${displayName} authentication failed`);
+ return { isOpen: false, prompt: null, error: data.error as string };
+ }
+ return prev;
+ });
+ } else if (data.type === 'deviceCodeExpired') {
+ console.log('[DeviceCode] Code expired:', data.sessionId);
+ setState((prev) => {
+ if (prev.prompt?.sessionId === data.sessionId) {
+ toast.error('Device code expired. Please try again.');
+ return { isOpen: false, prompt: null, error: 'Device code expired' };
+ }
+ return prev;
+ });
+ }
+ };
+
+ // Listen for custom ws-message events dispatched by useWebSocket
+ window.addEventListener('ws-message', handleMessage as EventListener);
+
+ return () => {
+ window.removeEventListener('ws-message', handleMessage as EventListener);
+ };
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setState({ isOpen: false, prompt: null, error: null });
+ }, []);
+
+ const handleOpenUrl = useCallback(() => {
+ if (state.prompt?.verificationUrl) {
+ window.open(state.prompt.verificationUrl, '_blank', 'noopener,noreferrer');
+ }
+ }, [state.prompt]);
+
+ const handleCopyCode = useCallback(async () => {
+ if (state.prompt?.userCode) {
+ try {
+ await navigator.clipboard.writeText(state.prompt.userCode);
+ toast.success('Code copied to clipboard');
+ } catch {
+ toast.error('Failed to copy code');
+ }
+ }
+ }, [state.prompt]);
+
+ return useMemo(
+ () => ({
+ isOpen: state.isOpen,
+ prompt: state.prompt,
+ error: state.error,
+ onClose: handleClose,
+ onOpenUrl: handleOpenUrl,
+ onCopyCode: handleCopyCode,
+ }),
+ [state.isOpen, state.prompt, state.error, handleClose, handleOpenUrl, handleCopyCode]
+ );
+}
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts
index 8dbb2d31..0a003cb4 100644
--- a/ui/src/lib/api-client.ts
+++ b/ui/src/lib/api-client.ts
@@ -50,6 +50,8 @@ export interface Variant {
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
settings: string;
account?: string;
+ port?: number;
+ model?: string;
}
export interface CreateVariant {
@@ -355,6 +357,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: {
@@ -375,6 +383,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: {
diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts
index a4924c47..13b25fc0 100644
--- a/ui/src/lib/model-catalogs.ts
+++ b/ui/src/lib/model-catalogs.ts
@@ -53,7 +53,18 @@ export const MODEL_CATALOGS: Record = {
default: 'gemini-3-pro-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
- haiku: 'gemini-2.5-flash',
+ haiku: 'gemini-3-flash-preview',
+ },
+ },
+ {
+ id: 'gemini-3-flash-preview',
+ name: 'Gemini 3 Flash',
+ description: 'Fast Gemini model via Antigravity',
+ presetMapping: {
+ default: 'gemini-3-flash-preview',
+ opus: 'gemini-3-pro-preview',
+ sonnet: 'gemini-3-pro-preview',
+ haiku: 'gemini-3-flash-preview',
},
},
],
@@ -72,7 +83,19 @@ export const MODEL_CATALOGS: Record = {
default: 'gemini-3-pro-preview',
opus: 'gemini-3-pro-preview',
sonnet: 'gemini-3-pro-preview',
- haiku: 'gemini-2.5-flash',
+ haiku: 'gemini-3-flash-preview',
+ },
+ },
+ {
+ id: 'gemini-3-flash-preview',
+ name: 'Gemini 3 Flash',
+ tier: 'paid',
+ description: 'Fast Gemini 3 model, requires paid Google account',
+ presetMapping: {
+ default: 'gemini-3-flash-preview',
+ opus: 'gemini-3-pro-preview',
+ sonnet: 'gemini-3-pro-preview',
+ haiku: 'gemini-3-flash-preview',
},
},
{
diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts
index b9b7375b..638aca0b 100644
--- a/ui/src/lib/provider-presets.ts
+++ b/ui/src/lib/provider-presets.ts
@@ -19,6 +19,10 @@ export interface ProviderPreset {
apiKeyPlaceholder: string;
apiKeyHint?: string;
category: PresetCategory;
+ /** Additional env vars for thinking mode, etc. */
+ extraEnv?: Record;
+ /** Enable always thinking mode */
+ alwaysThinkingEnabled?: boolean;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
@@ -93,6 +97,45 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab',
category: 'alternative',
},
+ {
+ id: 'minimax',
+ name: 'Minimax',
+ description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)',
+ baseUrl: 'https://api.minimax.io/anthropic',
+ defaultProfileName: 'minimax',
+ badge: '1M context',
+ defaultModel: 'MiniMax-M2.1',
+ requiresApiKey: true,
+ apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY',
+ apiKeyHint: 'Get your API key at platform.minimax.io',
+ category: 'alternative',
+ },
+ {
+ id: 'deepseek',
+ name: 'DeepSeek',
+ description: 'V3.2 and R1 reasoning model (128K context)',
+ baseUrl: 'https://api.deepseek.com/anthropic',
+ defaultProfileName: 'deepseek',
+ badge: 'Reasoning',
+ defaultModel: 'deepseek-chat',
+ requiresApiKey: true,
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyHint: 'Get your API key at platform.deepseek.com',
+ category: 'alternative',
+ },
+ {
+ id: 'qwen',
+ name: 'Qwen',
+ description: 'Alibaba Cloud - qwen3-coder-plus (256K context)',
+ baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic',
+ defaultProfileName: 'qwen',
+ badge: 'Alibaba',
+ defaultModel: 'qwen3-coder-plus',
+ requiresApiKey: true,
+ apiKeyPlaceholder: 'sk-...',
+ apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
+ category: 'alternative',
+ },
];
/** Get presets by category */
@@ -102,7 +145,7 @@ export function getPresetsByCategory(category: PresetCategory): ProviderPreset[]
/** Get preset by ID */
export function getPresetById(id: string): ProviderPreset | undefined {
- return PROVIDER_PRESETS.find((p) => p.id === id);
+ return PROVIDER_PRESETS.find((p) => p.id.toLowerCase() === id.toLowerCase());
}
/** Check if a URL matches a known preset */
diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx
index 231dd545..bbed9c89 100644
--- a/ui/src/pages/accounts.tsx
+++ b/ui/src/pages/accounts.tsx
@@ -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 (
@@ -18,22 +23,23 @@ export function AccountsPage() {
Manage multi-account Claude sessions (profiles.json)
+ setCreateDialogOpen(true)}>
+
+ Create Account
+
{isLoading ? (
Loading accounts...
) : (
-
+
)}
-
-
- Accounts are isolated Claude instances with separate sessions.
-
- Use ccs auth create <name> to add new
- accounts via CLI.
-
-
+
setCreateDialogOpen(false)} />
);
}
diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx
index 9a5840fa..872e3832 100644
--- a/ui/src/pages/cliproxy.tsx
+++ b/ui/src/pages/cliproxy.tsx
@@ -339,7 +339,9 @@ export function CliproxyPage() {
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
+ baseProvider={selectedVariantData.provider}
isRemoteMode={isRemoteMode}
+ port={selectedVariantData.port}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider,
diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx
index 0755e437..a318d81d 100644
--- a/ui/src/pages/settings/components/tab-navigation.tsx
+++ b/ui/src/pages/settings/components/tab-navigation.tsx
@@ -4,7 +4,7 @@
*/
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import { Globe, Settings2, Server } from 'lucide-react';
+import { Globe, Settings2, Server, KeyRound } from 'lucide-react';
import type { SettingsTab } from '../types';
interface TabNavigationProps {
@@ -28,6 +28,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
Proxy
+
+
+ Auth
+
);
diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts
index 444b13e3..9ec10395 100644
--- a/ui/src/pages/settings/hooks/use-settings-tab.ts
+++ b/ui/src/pages/settings/hooks/use-settings-tab.ts
@@ -10,7 +10,13 @@ export function useSettingsTab() {
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const activeTab: SettingsTab =
- tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch';
+ tabParam === 'globalenv'
+ ? 'globalenv'
+ : tabParam === 'proxy'
+ ? 'proxy'
+ : tabParam === 'auth'
+ ? 'auth'
+ : 'websearch';
const setActiveTab = useCallback(
(tab: SettingsTab) => {
diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx
index d658bc51..ce86a63c 100644
--- a/ui/src/pages/settings/index.tsx
+++ b/ui/src/pages/settings/index.tsx
@@ -18,6 +18,7 @@ import type { SettingsTab } from './types';
const WebSearchSection = lazy(() => import('./sections/websearch'));
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
const ProxySection = lazy(() => import('./sections/proxy'));
+const AuthSection = lazy(() => import('./sections/auth-section'));
// Inner component that uses context
function SettingsPageInner() {
@@ -57,6 +58,7 @@ function SettingsPageInner() {
{activeTab === 'websearch' &&
}
{activeTab === 'globalenv' &&
}
{activeTab === 'proxy' &&
}
+ {activeTab === 'auth' &&
}
diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx
new file mode 100644
index 00000000..5d1c9772
--- /dev/null
+++ b/ui/src/pages/settings/sections/auth-section.tsx
@@ -0,0 +1,409 @@
+/**
+ * Auth Section
+ * Settings section for CLIProxy auth tokens (API key and management secret)
+ */
+
+import { useEffect, useState, useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ RefreshCw,
+ CheckCircle2,
+ AlertCircle,
+ Eye,
+ EyeOff,
+ RotateCcw,
+ Sparkles,
+ Copy,
+ Check,
+ KeyRound,
+ ShieldCheck,
+ Save,
+} from 'lucide-react';
+import { useRawConfig } from '../hooks';
+
+interface TokenInfo {
+ value: string;
+ isCustom: boolean;
+}
+
+interface AuthTokens {
+ apiKey: TokenInfo;
+ managementSecret: TokenInfo;
+}
+
+export default function AuthSection() {
+ const { fetchRawConfig } = useRawConfig();
+
+ // State
+ const [tokens, setTokens] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ // Edit state
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [showSecret, setShowSecret] = useState(false);
+ const [editedApiKey, setEditedApiKey] = useState(null);
+ const [editedSecret, setEditedSecret] = useState(null);
+ const [copiedApiKey, setCopiedApiKey] = useState(false);
+ const [copiedSecret, setCopiedSecret] = useState(false);
+
+ // Fetch tokens
+ const fetchTokens = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ // Use /raw to get unmasked values for editing
+ const response = await fetch('/api/settings/auth/tokens/raw');
+ if (!response.ok) {
+ throw new Error('Failed to fetch auth tokens');
+ }
+ const data = await response.json();
+ setTokens(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Load on mount
+ useEffect(() => {
+ fetchTokens();
+ fetchRawConfig();
+ }, [fetchTokens, fetchRawConfig]);
+
+ // Clear success after timeout
+ useEffect(() => {
+ if (success) {
+ const timer = setTimeout(() => setSuccess(null), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [success]);
+
+ // Clear error after timeout
+ useEffect(() => {
+ if (error) {
+ const timer = setTimeout(() => setError(null), 5000);
+ return () => clearTimeout(timer);
+ }
+ }, [error]);
+
+ // Save all changes
+ const saveChanges = async () => {
+ const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value;
+ const hasSecretChange =
+ editedSecret !== null && editedSecret !== tokens?.managementSecret.value;
+
+ if (!hasApiKeyChange && !hasSecretChange) return;
+
+ try {
+ setSaving(true);
+ setError(null);
+
+ const payload: { apiKey?: string; managementSecret?: string } = {};
+ if (hasApiKeyChange) payload.apiKey = editedApiKey;
+ if (hasSecretChange) payload.managementSecret = editedSecret;
+
+ const response = await fetch('/api/settings/auth/tokens', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to save tokens');
+ }
+
+ setSuccess('Tokens updated. Restart CLIProxy to apply.');
+ setEditedApiKey(null);
+ setEditedSecret(null);
+ await fetchTokens();
+ await fetchRawConfig();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Regenerate management secret
+ const regenerateSecret = async () => {
+ try {
+ setSaving(true);
+ setError(null);
+ const response = await fetch('/api/settings/auth/tokens/regenerate-secret', {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to regenerate secret');
+ }
+
+ setSuccess('New management secret generated. Restart CLIProxy to apply.');
+ await fetchTokens();
+ await fetchRawConfig();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Reset to defaults
+ const resetToDefaults = async () => {
+ try {
+ setSaving(true);
+ setError(null);
+ const response = await fetch('/api/settings/auth/tokens/reset', {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to reset tokens');
+ }
+
+ setSuccess('Tokens reset to defaults. Restart CLIProxy to apply.');
+ setEditedApiKey(null);
+ setEditedSecret(null);
+ await fetchTokens();
+ await fetchRawConfig();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Copy to clipboard helpers
+ const copyApiKey = async () => {
+ if (!tokens) return;
+ await navigator.clipboard.writeText(tokens.apiKey.value);
+ setCopiedApiKey(true);
+ setTimeout(() => setCopiedApiKey(false), 2000);
+ };
+
+ const copySecret = async () => {
+ if (!tokens) return;
+ await navigator.clipboard.writeText(tokens.managementSecret.value);
+ setCopiedSecret(true);
+ setTimeout(() => setCopiedSecret(false), 2000);
+ };
+
+ if (loading || !tokens) {
+ return (
+
+ );
+ }
+
+ // Display values
+ const displayApiKey = editedApiKey ?? tokens.apiKey.value;
+ const displaySecret = editedSecret ?? tokens.managementSecret.value;
+
+ // Check for unsaved changes
+ const hasChanges =
+ (editedApiKey !== null && editedApiKey !== tokens.apiKey.value) ||
+ (editedSecret !== null && editedSecret !== tokens.managementSecret.value);
+
+ return (
+ <>
+ {/* Toast-style alerts */}
+
+ {error && (
+
+
+ {error}
+
+ )}
+ {success && (
+
+
+ {success}
+
+ )}
+
+
+ {/* Scrollable Content */}
+
+
+
+ Configure CLIProxy authentication tokens. Changes require CLIProxy restart.
+
+
+ {/* API Key Section */}
+
+
+
+
API Key
+ {tokens.apiKey.isCustom && (
+
+ Custom
+
+ )}
+
+
+ Used by Claude Code to authenticate with CLIProxy
+
+
+
+
setEditedApiKey(e.target.value)}
+ placeholder="API key"
+ disabled={saving}
+ className="pr-20 font-mono text-sm"
+ />
+
+ setShowApiKey(!showApiKey)}
+ >
+ {showApiKey ? : }
+
+
+ {copiedApiKey ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* Management Secret Section */}
+
+
+
+
Management Secret
+ {tokens.managementSecret.isCustom && (
+
+ Custom
+
+ )}
+
+
+ Used by CCS dashboard to access CLIProxy management APIs
+
+
+
+
setEditedSecret(e.target.value)}
+ placeholder="Management secret"
+ disabled={saving}
+ className="pr-20 font-mono text-sm"
+ />
+
+ setShowSecret(!showSecret)}
+ >
+ {showSecret ? : }
+
+
+ {copiedSecret ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* Actions */}
+
+
+
+ Reset to Defaults
+
+
+ Resets both API key and management secret to their default values.
+
+
+
+
+
+ {/* Footer */}
+
+ {
+ fetchTokens();
+ fetchRawConfig();
+ }}
+ disabled={loading || saving}
+ className="flex-1"
+ >
+
+ Refresh
+
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+ >
+ );
+}
diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts
index 55ea8cfa..9fdd73de 100644
--- a/ui/src/pages/settings/types.ts
+++ b/ui/src/pages/settings/types.ts
@@ -49,7 +49,7 @@ export interface GlobalEnvConfig {
// === Tab Types ===
-export type SettingsTab = 'websearch' | 'globalenv' | 'proxy';
+export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth';
// === Re-exports from api-client ===