From 4fc19c43902330da31b49b6296082fb13e22bebf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Feb 2026 09:48:39 +0700 Subject: [PATCH 01/31] feat(cliproxy): expose codex weekly reset schedule in quota views --- README.md | 1 + src/cliproxy/quota-fetcher-codex.ts | 97 ++++++++++++++++++- src/cliproxy/quota-types.ts | 22 +++++ src/commands/cliproxy/help-subcommand.ts | 2 +- src/commands/cliproxy/quota-subcommand.ts | 78 ++++++++++++++- .../unit/cliproxy/quota-fetcher-codex.test.ts | 70 ++++++++++++- .../shared/quota-tooltip-content.tsx | 45 ++++++++- ui/src/lib/api-client.ts | 22 +++++ 8 files changed, 329 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b6a62ffe..6b135b0f 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Re-creates symlinks for shared commands, skills, and settings. ```bash ccs cliproxy doctor # Check quota status for all agy accounts +ccs cliproxy quota # Show agy/codex/gemini quotas (Codex: 5h + weekly reset schedule) ``` **Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 88ac119a..3c860d5b 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; -import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types'; +import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types'; /** ChatGPT backend API base URL */ const CODEX_API_BASE = 'https://chatgpt.com/backend-api'; @@ -57,6 +57,99 @@ interface CodexWindowData { resetAfterSeconds?: number | null; } +type CodexWindowKind = + | 'usage-5h' + | 'usage-weekly' + | 'code-review-5h' + | 'code-review-weekly' + | 'code-review' + | 'unknown'; + +function getCodexWindowKind(label: string): CodexWindowKind { + const lower = (label || '').toLowerCase(); + const isCodeReview = lower.includes('code review') || lower.includes('code_review'); + const isPrimary = lower.includes('primary'); + const isSecondary = lower.includes('secondary'); + + if (isCodeReview) { + if (isPrimary) return 'code-review-5h'; + if (isSecondary) return 'code-review-weekly'; + return 'code-review'; + } + + if (isPrimary) return 'usage-5h'; + if (isSecondary) return 'usage-weekly'; + return 'unknown'; +} + +/** + * Build explicit 5h + weekly usage summary from raw Codex windows. + * Falls back to shortest/longest reset windows if API labels change. + */ +export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCoreUsageSummary { + if (!windows || windows.length === 0) { + return { fiveHour: null, weekly: null }; + } + + let fiveHourWindow: CodexQuotaWindow | null = null; + let weeklyWindow: CodexQuotaWindow | null = null; + const nonCodeReviewWindows: CodexQuotaWindow[] = []; + + for (const window of windows) { + const kind = getCodexWindowKind(window.label); + if (kind === 'usage-5h') { + if (!fiveHourWindow) fiveHourWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'usage-weekly') { + if (!weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'unknown') { + nonCodeReviewWindows.push(window); + } + } + + if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { + const withReset = nonCodeReviewWindows + .filter( + (w) => + typeof w.resetAfterSeconds === 'number' && + isFinite(w.resetAfterSeconds) && + w.resetAfterSeconds >= 0 + ) + .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); + + if (!fiveHourWindow) { + fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; + } + + if (!weeklyWindow) { + weeklyWindow = + withReset.length > 1 + ? withReset[withReset.length - 1] + : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; + } + } + + const mapWindow = (window: CodexQuotaWindow | null): CodexCoreUsageSummary['fiveHour'] => { + if (!window) return null; + return { + label: window.label, + remainingPercent: window.remainingPercent, + resetAfterSeconds: window.resetAfterSeconds, + resetAt: window.resetAt, + }; + }; + + return { + fiveHour: mapWindow(fiveHourWindow), + weekly: mapWindow(weeklyWindow), + }; +} + /** * Read auth data from Codex auth file */ @@ -295,6 +388,7 @@ export async function fetchCodexQuota( const data = (await response.json()) as CodexUsageResponse; const windows = buildCodexQuotaWindows(data); + const coreUsage = buildCodexCoreUsageSummary(windows); // Extract plan type const planTypeRaw = data.plan_type || data.planType; @@ -311,6 +405,7 @@ export async function fetchCodexQuota( return { success: true, windows, + coreUsage, planType, lastUpdated: Date.now(), accountId, diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index f8759bbe..17f32f72 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -27,6 +27,26 @@ export interface CodexQuotaWindow { resetAt: string | null; } +/** Core Codex usage window (5h/weekly) extracted from raw windows */ +export interface CodexCoreUsageWindow { + /** Source window label */ + label: string; + /** Percentage remaining (0-100) */ + remainingPercent: number; + /** Seconds until quota resets, null if unknown */ + resetAfterSeconds: number | null; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; +} + +/** Core Codex usage summary with explicit 5h and weekly windows */ +export interface CodexCoreUsageSummary { + /** Short-cycle usage limit window (typically 5h) */ + fiveHour: CodexCoreUsageWindow | null; + /** Long-cycle usage limit window (typically weekly) */ + weekly: CodexCoreUsageWindow | null; +} + /** * Codex quota fetch result */ @@ -35,6 +55,8 @@ export interface CodexQuotaResult { success: boolean; /** Quota windows (primary, secondary, code review) */ windows: CodexQuotaWindow[]; + /** Explicit core usage windows (5h + weekly) for easier reset display */ + coreUsage?: CodexCoreUsageSummary; /** Plan type: free, plus, team, or null if unknown */ planType: 'free' | 'plus' | 'team' | null; /** Timestamp of fetch */ diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 7971c701..a5c2ed4d 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -54,7 +54,7 @@ export async function showHelp(): Promise { ['default ', 'Set default account for rotation'], ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers'], + ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], ['quota --provider ', 'Filter by provider (agy|codex|gemini)'], ], ], diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index f426e490..8bdf4830 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -67,7 +67,13 @@ function formatResetTime(seconds: number): string { if (seconds <= 0) return 'now'; if (seconds < 60) return `in ${seconds}s`; if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`; - return `in ${Math.round(seconds / 3600)}h`; + if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`; + + const days = Math.floor(seconds / 86400); + const hours = Math.round((seconds % 86400) / 3600); + if (hours <= 0) return `in ${days}d`; + if (hours >= 24) return `in ${days + 1}d`; + return `in ${days}d ${hours}h`; } function formatResetTimeISO(isoTime: string): string { @@ -78,6 +84,40 @@ function formatResetTimeISO(isoTime: string): string { return formatResetTime(seconds); } +function formatAbsoluteResetTime(isoTime: string): string | null { + if (!isoTime) return null; + const resetDate = new Date(isoTime); + if (isNaN(resetDate.getTime())) return null; + const date = resetDate.toLocaleDateString(undefined, { + month: '2-digit', + day: '2-digit', + }); + const time = resetDate.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + return `${date} ${time}`; +} + +function formatCodexWindowReset( + window: Pick +): string | null { + if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) { + const relative = formatResetTime(Math.max(0, window.resetAfterSeconds)); + if (window.resetAfterSeconds >= 86400 && window.resetAt) { + const absolute = formatAbsoluteResetTime(window.resetAt); + return absolute ? `${relative} (${absolute})` : relative; + } + return relative; + } + + if (window.resetAt) { + return formatResetTimeISO(window.resetAt); + } + + return null; +} + type CodexWindowKind = | 'usage-5h' | 'usage-weekly' @@ -286,15 +326,45 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`); + const coreUsageSummary = quota.coreUsage ?? { + fiveHour: fiveHourWindow + ? { + label: fiveHourWindow.label, + remainingPercent: fiveHourWindow.remainingPercent, + resetAfterSeconds: fiveHourWindow.resetAfterSeconds, + resetAt: fiveHourWindow.resetAt, + } + : null, + weekly: weeklyWindow + ? { + label: weeklyWindow.label, + remainingPercent: weeklyWindow.remainingPercent, + resetAfterSeconds: weeklyWindow.resetAfterSeconds, + resetAt: weeklyWindow.resetAt, + } + : null, + }; + const resetParts: string[] = []; + const fiveHourReset = coreUsageSummary.fiveHour + ? formatCodexWindowReset(coreUsageSummary.fiveHour) + : null; + const weeklyReset = coreUsageSummary.weekly + ? formatCodexWindowReset(coreUsageSummary.weekly) + : null; + if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`); + if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`); + if (resetParts.length > 0) { + console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); + } + const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter( (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index ); for (const window of orderedWindows) { const bar = formatQuotaBar(window.remainingPercent); - const resetLabel = window.resetAfterSeconds - ? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`) - : ''; + const resetValue = formatCodexWindowReset(window); + const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : ''; console.log( ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` ); diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index 5c774f4b..b771ee4d 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect } from 'bun:test'; -import { buildCodexQuotaWindows } from '../../../src/cliproxy/quota-fetcher-codex'; +import { + buildCodexQuotaWindows, + buildCodexCoreUsageSummary, +} from '../../../src/cliproxy/quota-fetcher-codex'; describe('Codex Quota Fetcher', () => { describe('buildCodexQuotaWindows', () => { @@ -187,4 +190,69 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].remainingPercent).toBe(100); }); }); + + describe('buildCodexCoreUsageSummary', () => { + it('extracts 5h and weekly windows from labeled usage windows', () => { + const windows = buildCodexQuotaWindows({ + rate_limit: { + primary_window: { + used_percent: 35, + reset_after_seconds: 18000, + }, + secondary_window: { + used_percent: 60, + reset_after_seconds: 604800, + }, + }, + }); + + const summary = buildCodexCoreUsageSummary(windows); + + expect(summary.fiveHour?.label).toBe('Primary'); + expect(summary.fiveHour?.remainingPercent).toBe(65); + expect(summary.fiveHour?.resetAfterSeconds).toBe(18000); + expect(summary.weekly?.label).toBe('Secondary'); + expect(summary.weekly?.remainingPercent).toBe(40); + expect(summary.weekly?.resetAfterSeconds).toBe(604800); + }); + + it('falls back to shortest and longest reset windows when labels are unknown', () => { + const windows = [ + { + label: 'Window A', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 18000, + resetAt: '2026-02-15T15:00:00Z', + }, + { + label: 'Window B', + usedPercent: 45, + remainingPercent: 55, + resetAfterSeconds: 604800, + resetAt: '2026-02-21T10:00:00Z', + }, + { + label: 'Code Review (Primary)', + usedPercent: 10, + remainingPercent: 90, + resetAfterSeconds: 3600, + resetAt: '2026-02-15T11:00:00Z', + }, + ]; + + const summary = buildCodexCoreUsageSummary(windows); + + expect(summary.fiveHour?.label).toBe('Window A'); + expect(summary.fiveHour?.resetAfterSeconds).toBe(18000); + expect(summary.weekly?.label).toBe('Window B'); + expect(summary.weekly?.resetAfterSeconds).toBe(604800); + }); + + it('returns null summaries when no windows are available', () => { + const summary = buildCodexCoreUsageSummary([]); + expect(summary.fiveHour).toBeNull(); + expect(summary.weekly).toBeNull(); + }); + }); }); diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 492924c3..cdb834ad 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -68,6 +68,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (isCodexQuotaResult(quota)) { const { fiveHourWindow, weeklyWindow, codeReviewWindows, unknownWindows } = getCodexQuotaBreakdown(quota.windows); + const fiveHourResetAt = quota.coreUsage?.fiveHour?.resetAt ?? fiveHourWindow?.resetAt ?? null; + const weeklyResetAt = quota.coreUsage?.weekly?.resetAt ?? weeklyWindow?.resetAt ?? null; const orderedWindows = [fiveHourWindow, weeklyWindow, ...codeReviewWindows, ...unknownWindows] .filter((w): w is NonNullable => !!w) .filter( @@ -92,7 +94,11 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {w.remainingPercent}% ))} - + ); } @@ -132,3 +138,40 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { ); } + +function CodexResetIndicators({ + fiveHourResetTime, + weeklyResetTime, + fallbackResetTime, +}: { + fiveHourResetTime: string | null; + weeklyResetTime: string | null; + fallbackResetTime: string | null; +}) { + const hasSpecificReset = !!fiveHourResetTime || !!weeklyResetTime; + if (!hasSpecificReset && !fallbackResetTime) return null; + + return ( +
+ {fiveHourResetTime && ( +
+ + + 5h resets {formatResetTime(fiveHourResetTime)} + +
+ )} + {weeklyResetTime && ( +
+ + + Weekly resets {formatResetTime(weeklyResetTime)} + +
+ )} + {!hasSpecificReset && fallbackResetTime && ( + + )} +
+ ); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 5fefd08c..9f01191f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -183,12 +183,34 @@ export interface CodexQuotaWindow { resetAt: string | null; } +/** Core Codex usage window (5h/weekly) extracted from raw windows */ +export interface CodexCoreUsageWindow { + /** Source window label */ + label: string; + /** Percentage remaining (0-100) */ + remainingPercent: number; + /** Seconds until quota resets, null if unknown */ + resetAfterSeconds: number | null; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; +} + +/** Core Codex usage summary with explicit 5h and weekly windows */ +export interface CodexCoreUsageSummary { + /** Short-cycle usage limit window (typically 5h) */ + fiveHour: CodexCoreUsageWindow | null; + /** Long-cycle usage limit window (typically weekly) */ + weekly: CodexCoreUsageWindow | null; +} + /** Codex quota result */ export interface CodexQuotaResult { /** Whether fetch succeeded */ success: boolean; /** Quota windows (primary, secondary, code review) */ windows: CodexQuotaWindow[]; + /** Explicit core usage windows (5h + weekly) for easier reset display */ + coreUsage?: CodexCoreUsageSummary; /** Plan type: free, plus, team, or null if unknown */ planType: 'free' | 'plus' | 'team' | null; /** Timestamp of fetch */ From 8ab78f039c54237531ba1c7461fc34c5981ba991 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Feb 2026 02:52:44 +0000 Subject: [PATCH 02/31] chore(release): 7.45.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a43034c4..9c32852e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.45.0", + "version": "7.45.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 7d7054e2c096768c42fa4be1db5d111bb8e56b8a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Feb 2026 10:49:09 +0700 Subject: [PATCH 03/31] feat(targets): add multi-target CLI adapter system (Droid support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement target adapter pattern enabling CCS CLI to support multiple backend targets (Claude, Droid) via pluggable adapters. Core additions: - TargetAdapter interface for pluggable target implementations - ClaudeAdapter and DroidAdapter concrete implementations - Target registry (singleton Map-based storage) - Target resolver with precedence: --target flag > per-profile config > busybox detection - Droid config manager with atomic writes and file locking to ~/.factory/settings.json - Droid binary detector to validate runtime environment - Adapter dispatch integrated into ccs.ts main execution flow - ccsd busybox alias for seamless Droid invocation - --target flag documentation in help - Session tracking enriched with target metadata - Dashboard target badge for visual identification Testing: - 43 unit tests covering resolver, registry, config manager, and adapters - Full coverage of target detection logic and edge cases Documentation: - Refactored system-architecture.md into modular docs/system-architecture/ subdirectory - Updated code-standards.md with target adapter guidelines - Updated codebase-summary.md with architecture overview - Updated maintainability baseline (33.8% → 35.2%) This establishes extensible foundation for multi-target support without breaking existing Claude workflows. Droid adapter is production-ready but defaults to Claude for backward compatibility. --- bun.lock | 10 + docs/code-standards.md | 101 +++ docs/codebase-summary.md | 52 ++ docs/metrics/maintainability-baseline.json | 6 +- docs/system-architecture.md | 754 ------------------ docs/system-architecture/index.md | 402 ++++++++++ docs/system-architecture/provider-flows.md | 565 +++++++++++++ docs/system-architecture/target-adapters.md | 607 ++++++++++++++ package.json | 5 +- src/ccs.ts | 103 ++- src/cliproxy/session-tracker.ts | 2 + src/commands/help-command.ts | 6 + src/config/unified-config-types.ts | 8 + src/targets/claude-adapter.ts | 104 +++ src/targets/droid-adapter.ts | 98 +++ src/targets/droid-config-manager.ts | 242 ++++++ src/targets/droid-detector.ts | 104 +++ src/targets/index.ts | 30 + src/targets/target-adapter.ts | 66 ++ src/targets/target-registry.ts | 52 ++ src/targets/target-resolver.ts | 79 ++ src/web-server/usage/types.ts | 2 + .../unit/targets/droid-config-manager.test.ts | 274 +++++++ tests/unit/targets/target-registry.test.ts | 140 ++++ tests/unit/targets/target-resolver.test.ts | 96 +++ .../analytics/session-stats-card.tsx | 13 +- ui/src/hooks/use-usage.ts | 2 + 27 files changed, 3157 insertions(+), 766 deletions(-) delete mode 100644 docs/system-architecture.md create mode 100644 docs/system-architecture/index.md create mode 100644 docs/system-architecture/provider-flows.md create mode 100644 docs/system-architecture/target-adapters.md create mode 100644 src/targets/claude-adapter.ts create mode 100644 src/targets/droid-adapter.ts create mode 100644 src/targets/droid-config-manager.ts create mode 100644 src/targets/droid-detector.ts create mode 100644 src/targets/index.ts create mode 100644 src/targets/target-adapter.ts create mode 100644 src/targets/target-registry.ts create mode 100644 src/targets/target-resolver.ts create mode 100644 tests/unit/targets/droid-config-manager.test.ts create mode 100644 tests/unit/targets/target-registry.test.ts create mode 100644 tests/unit/targets/target-resolver.test.ts diff --git a/bun.lock b/bun.lock index fd68ef78..3332b893 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "listr2": "^3.14.0", "open": "^8.4.2", "ora": "^5.4.1", + "proper-lockfile": "^4.1.2", "ws": "^8.16.0", }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/express-session": "^1.18.2", "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.25", + "@types/proper-lockfile": "^4.1.4", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", @@ -397,10 +399,14 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], @@ -1091,6 +1097,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -1129,6 +1137,8 @@ "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], diff --git a/docs/code-standards.md b/docs/code-standards.md index 5eea1cac..94ef0f8d 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -40,6 +40,9 @@ Code standards, modularization patterns, and conventions for the CCS codebase. |------------|---------|-------------| | kebab-case | `cliproxy-executor.ts` | All TypeScript/TSX files | | kebab-case | `profile-detector.ts` | Multi-word file names | +| *-adapter.ts | `claude-adapter.ts`, `droid-adapter.ts` | TargetAdapter implementations | +| *-detector.ts | `droid-detector.ts` | Binary detection logic | +| *-manager.ts | `droid-config-manager.ts` | Config/state management | | PascalCase | `BinaryManager` | Class exports only | | camelCase | `detectProfile` | Function exports | @@ -157,6 +160,84 @@ Allowed when: --- +## Target Adapter Pattern + +The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, etc.) while preserving a unified profile system. + +### Pattern Overview + +**Each CLI target implements a `TargetAdapter` interface:** + +```typescript +interface TargetAdapter { + readonly type: TargetType; // 'claude' | 'droid' + readonly displayName: string; // Human-readable name + + detectBinary(): TargetBinaryInfo | null; // Find CLI on system + prepareCredentials(creds: TargetCredentials): Promise; // Deliver credentials + buildArgs(profile: string, userArgs: string[]): string[]; // Build CLI args + buildEnv(creds: TargetCredentials, type: string): Env; // Build env vars + exec(args: string[], env: Env): void; // Spawn CLI process + supportsProfileType(type: string): boolean; // Validate profile +} +``` + +### Key Differences Per Target + +| Aspect | Claude | Droid | +|--------|--------|-------| +| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) | +| **Spawn args** | `claude ` | `droid -m custom:ccs- ` | +| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings | +| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check | + +### Target Resolution Priority + +Resolves which adapter to use via `resolveTargetType()`: + +``` +1. --target flag (highest priority) + ↓ +2. Profile config: profileConfig.target field + ↓ +3. argv[0] detection (busybox pattern): + - ccsd → droid + - ccs → default + ↓ +4. Fallback: 'claude' (lowest priority) +``` + +### Registration Pattern + +At startup, adapters self-register into the runtime registry: + +```typescript +// In ccs.ts or initialization +registerTarget(new ClaudeAdapter()); +registerTarget(new DroidAdapter()); + +// Later, when executing +const targetType = resolveTargetType(args, profileConfig); +const adapter = getTarget(targetType); + +await adapter.prepareCredentials(credentials); +const spawnArgs = adapter.buildArgs(profile, userArgs); +adapter.exec(spawnArgs, adapter.buildEnv(credentials, profileType)); +``` + +### Adding a New Target + +To add support for a new CLI (e.g., `newcli`): + +1. Create `src/targets/newcli-adapter.ts` implementing `TargetAdapter` +2. Implement each required method (detection, credential delivery, spawning) +3. Create `src/targets/newcli-detector.ts` for binary detection logic +4. Export from `src/targets/index.ts` +5. Register in `ccs.ts`: `registerTarget(new NewCliAdapter())` +6. Update `TargetType` union to include `'newcli'` + +--- + ## Monster File Splitting Methodology When splitting large files (500+ lines), follow this process: @@ -328,6 +409,26 @@ Use ASCII box drawing for error displays: +=====================================+ ``` +### Cross-Platform Adapter Spawning + +When implementing target adapters, handle platform differences for binary spawning: + +```typescript +// Window shell detection (.cmd, .bat, .ps1 require shell) +const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath); + +if (needsShell) { + // Escape arguments and use shell: true + const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' '); + spawn(cmdString, { shell: true, stdio: 'inherit' }); +} else { + // Direct spawn (Unix-like, unshelled Windows executables) + spawn(binaryPath, args, { stdio: 'inherit' }); +} +``` + +This pattern is used in both `ClaudeAdapter` and `DroidAdapter` to ensure cross-platform consistency. + --- ## React Component Standards (UI) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 43fcb828..972b56c6 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -56,6 +56,16 @@ src/ │ ├── update-command.ts # Self-update logic │ └── version-command.ts # Version display │ +├── targets/ # Multi-target adapter system (NEW) +│ ├── index.ts # Barrel export +│ ├── target-adapter.ts # TargetAdapter interface contract +│ ├── target-registry.ts # Registry for runtime adapter lookup +│ ├── target-resolver.ts # Resolution logic (flag > config > argv[0]) +│ ├── claude-adapter.ts # Claude Code CLI implementation +│ ├── droid-adapter.ts # Factory Droid CLI implementation +│ ├── droid-detector.ts # Droid binary detection & version checks +│ └── droid-config-manager.ts # ~/.factory/settings.json management +│ ├── auth/ # Authentication module │ ├── index.ts # Barrel export │ ├── commands/ # Auth-specific CLI commands @@ -181,6 +191,7 @@ src/ | Category | Directories | Purpose | |----------|-------------|---------| | Core | `commands/`, `errors/` | CLI commands, error handling | +| Targets | `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, extensible) | | Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | | Config | `config/`, `types/` | Configuration & type definitions | | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations (7 CLIProxy providers: gemini, codex, agy, qwen, iflow, kiro, ghcp) | @@ -190,6 +201,47 @@ src/ | Services | `web-server/`, `api/` | HTTP server, API services | | Utilities | `utils/`, `management/` | Helpers, diagnostics | +### Target Adapter Module + +The targets module provides an extensible interface for dispatching profiles to different CLI implementations. + +**Key components:** + +1. **TargetAdapter Interface** - Contract that each CLI implementation must fulfill: + - `detectBinary()` - Find CLI binary on system (platform-specific) + - `prepareCredentials()` - Deliver credentials (env vars vs config file writes) + - `buildArgs()` - Construct target-specific argument list + - `buildEnv()` - Construct environment for target CLI + - `exec()` - Spawn target process (cross-platform) + - `supportsProfileType()` - Verify profile compatibility + +2. **Target Resolution** - Priority order: + - `--target ` flag (CLI argument) + - Per-profile `target` field (from config.yaml) + - `argv[0]` detection (busybox pattern: `ccsd` → droid) + - Default: `claude` + +3. **Implementations:** + - **ClaudeAdapter** - Wraps existing behavior; delivers credentials via environment variables + - **DroidAdapter** - New; writes to ~/.factory/settings.json and spawns with `-m custom:ccs-` flag + +4. **Registry** - Map-based lookup (O(1)) for registered adapters at runtime + +**Usage flow:** +``` +Profile resolution (existing) + ↓ +Target resolution (via resolver.ts) + ↓ +Get adapter from registry + ↓ +Prepare credentials (adapter.prepareCredentials) + ↓ +Build args & env (adapter.buildArgs, buildEnv) + ↓ +Spawn target CLI (adapter.exec) +``` + --- ## UI Source (`ui/src/`) diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index cb0c20a0..ce360ce9 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -1,9 +1,9 @@ { "sourceDirectory": "src", "largeFileThresholdLoc": 350, - "typeScriptFileCount": 341, - "locInSrc": 67708, - "processExitReferenceCount": 164, + "typeScriptFileCount": 347, + "locInSrc": 69998, + "processExitReferenceCount": 168, "synchronousFsApiReferenceCount": 850, "largeFileCountOver350Loc": 55 } diff --git a/docs/system-architecture.md b/docs/system-architecture.md deleted file mode 100644 index 96726327..00000000 --- a/docs/system-architecture.md +++ /dev/null @@ -1,754 +0,0 @@ -# CCS System Architecture - -Last Updated: 2026-02-04 - -High-level architecture documentation for the CCS (Claude Code Switch) system. - ---- - -## System Overview - -CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It consists of two main components: - -1. **CLI Application** (`src/`) - Node.js TypeScript CLI -2. **Dashboard UI** (`ui/`) - React web application served by Express - -CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types. - -``` -+===========================================================================+ -| CCS System | -+===========================================================================+ -| | -| +------------------+ +-----------------+ +----------------+ | -| | User Terminal | ---> | CCS CLI | ---> | Claude Code | | -| | (ccs command) | | (src/ccs.ts) | | CLI | | -| +------------------+ +-----------------+ +----------------+ | -| | | | -| v v | -| +------------------+ +-----------------+ +----------------+ | -| | Dashboard UI | <--> | Express | ---> | Provider APIs | | -| | (React SPA) | | Web Server | | (Claude/GLM/ | | -| +------------------+ +-----------------+ | Gemini/etc) | | -| | +----------------+ | -| v | -| +---------------------+ | -| | CLIProxyAPI | | -| | (Local or Remote) | | -| +---------------------+ | -| | -+===========================================================================+ -``` - ---- - -## Component Architecture - -### CLI Layer - -``` -+===========================================================================+ -| CLI Architecture | -+===========================================================================+ - - User Input (ccs [args]) - | - v - +-------------+ - | ccs.ts | Entry point, command routing - +-------------+ - | - +---> [Version/Help/Doctor/etc.] ---> Exit - | - v - +-------------+ - | Profile | Determines execution path - | Detection | - +-------------+ - | - +---> [Native Claude Account] ---> execClaude() - | | - +---> [CLIProxy Provider] ---> execClaudeWithCLIProxy() - | | - +---> [GLMT Profile] ---> execClaudeWithProxy() - | - v - +-------------+ - | Claude CLI | Underlying Anthropic CLI - +-------------+ -``` - -### Profile Mechanisms (Priority Order) - -``` - Profile Resolution - | - v - 1. CLIProxy Hardcoded ----+---> gemini, codex, agy, kiro, ghcp - (OAuth-based) | Zero-config OAuth providers - | (kiro: method-aware, ghcp: Device Code) - | - 2. CLIProxy Variants -----+---> config.cliproxy section - (User-defined) | Custom provider configurations - | - 3. Settings-based --------+---> config.profiles section - (API key profiles) | GLM, GLMT, Kimi, custom - | - 4. Account-based ---------+---> profiles.json - (Claude instances) | Isolated via CLAUDE_CONFIG_DIR - | - v - Profile Config -``` - ---- - -## Module Architecture - -### CLI Modules (`src/`) - -``` -+===========================================================================+ -| CLI Module Structure | -+===========================================================================+ - - +------------------+ +------------------+ +------------------+ - | commands/ | | auth/ | | config/ | - |------------------| |------------------| |------------------| - | doctor-command | | account-switcher | | unified-config- | - | env-command | | profile-detector | | loader | - | help-command | | commands/ | | migration-manager| - | install-command | +------------------+ +------------------+ - | sync-command | - | update-command | - +------------------+ - | | | - +-----------------------+------------------------+ - | - v - +------------------+ +------------------+ +------------------+ - | cliproxy/ | | copilot/ | | glmt/ | - |------------------| |------------------| |------------------| - | cliproxy-executor| | copilot-package- | | glmt-proxy | - | config-generator | | manager | | delta-accumulator| - | account-manager | | [copilot logic] | | pipeline/ | - | quota-manager | +------------------+ +------------------+ - | quota-fetcher | - | auth/ | - | binary/ | - | services/ | - +------------------+ - | | | - +-----------------------+------------------------+ - | - v - +------------------+ +------------------+ +------------------+ - | web-server/ | | utils/ | | errors/ | - |------------------| |------------------| |------------------| - | routes/ (15+) | | ui/ (boxes, | | error-handler | - | health/ | | colors, | | exit-codes | - | usage/ | | spinners) | | cleanup | - | services/ | | websearch/ | +------------------+ - | model-pricing | | shell-executor | - +------------------+ +------------------+ - - | - v - +------------------+ +------------------+ - | types/ | | management/ | - |------------------| |------------------| - | config.ts | | checks/ | - | cli.ts | | repair/ | - | delegation.ts | | [diagnostics] | - | glmt.ts | +------------------+ - +------------------+ -``` - -### UI Modules (`ui/src/`) - -``` -+===========================================================================+ -| UI Module Structure | -+===========================================================================+ - - +------------------+ - | pages/ | Route-level components (modular directories) - |------------------| - | analytics/ | 8 files - usage charts, cost tracking - | settings/ | 20 files - lazy-loaded tab sections - | api.tsx | - | cliproxy.tsx | - | copilot.tsx | - | health.tsx | - +------------------+ - | - v - +------------------+ +------------------+ +------------------+ - | components/ | | contexts/ | | hooks/ | - |------------------| |------------------| |------------------| - | account/ | | privacy-context | | use-accounts | - | analytics/ | | theme-context | | use-cliproxy | - | cliproxy/ | | websocket-context| | use-health | - | copilot/ | +------------------+ | use-profiles | - | health/ | | use-websocket | - | layout/ | +------------------+ - | monitoring/ | <-- auth-monitor/ (8 files), error-logs/ (6 files) - | profiles/ | - | setup/ | - | shared/ | - | ui/ (shadcn) | - +------------------+ - | - v - +------------------+ +------------------+ - | lib/ | | providers/ | - |------------------| |------------------| - | api.ts | | websocket- | - | model-catalogs | | provider | - | utils.ts | +------------------+ - +------------------+ -``` - ---- - -## Data Flow Architecture - -### CLI Execution Flow - -``` -+===========================================================================+ -| CLI Execution Flow | -+===========================================================================+ - - 1. Parse Arguments - | - v - 2. Detect Profile Type - | - +---> Native Claude ---> 3a. Load Account Settings - | | - | v - | 4a. Set CLAUDE_CONFIG_DIR - | | - | v - | 5a. Spawn Claude CLI - | - +---> CLIProxy -------> 3b. Ensure Binary Installed - | | - | v - | 4b. Generate Config - | | - | v - | 5b. Start CLIProxyAPI - | | - | v - | 6b. Set Proxy Env Vars - | | - | v - | 7b. Spawn Claude CLI - | - +---> GLMT -----------> 3c. Start Embedded Proxy - | - v - 4c. Spawn Claude CLI -``` - -### Dashboard Data Flow - -``` -+===========================================================================+ -| Dashboard Data Flow | -+===========================================================================+ - - Browser (React SPA) - | - | HTTP Requests + WebSocket - v - Express Server (src/web-server/) - | - +---> /api/accounts ---> auth/account-manager - | - +---> /api/profiles ---> config/unified-config-loader - | - +---> /api/cliproxy ---> cliproxy/ - | - +---> /api/health ----> management/checks/ - | - +---> /api/usage -----> usage/aggregator - | - v - WebSocket (Real-time) - | - +---> Health status updates - +---> Auth state changes - +---> Usage analytics -``` - ---- - -## Provider Integration Architecture - -### CLIProxyAPI Flow - -``` -+===========================================================================+ -| CLIProxyAPI Integration | -+===========================================================================+ - - Claude CLI - | - | ANTHROPIC_BASE_URL = localhost:XXXX - v - +------------------+ - | CLIProxyAPI | Local proxy binary (CLIProxyAPIPlus for kiro/ghcp) - | (binary) | - +------------------+ - | - +---> OAuth Authentication - | | - | +---> Authorization Code Flow (port-based) - | | - Gemini, Codex, Antigravity, Kiro (port 9876) - | | - Opens browser for user auth - | | - Callback to localhost:PORT - | | - | +---> Device Code Flow (no port needed) - | - GitHub Copilot (ghcp) - | - User enters code at github.com/login/device - | - Polls for token completion - | | - | v - | +------------------+ - | | OAuth Server | Browser-based auth - | +------------------+ - | - +---> Request Transformation - | | - | v - | Anthropic Format --> Provider Format - | - +---> Image Analysis Hook (v7.34) - | | - | v - | Vision Model Proxying (gemini, codex, agy, cliproxy) - | - Auto-injected via claude-hooks - | - Skip for Claude Sub accounts (native vision) - | - Fallback with deprecated block-image-read - | - +---> Provider APIs - | - +---> Google (Gemini) - +---> GitHub (Codex) - +---> Antigravity - +---> AWS Kiro (Claude-powered) - +---> GitHub Copilot (ghcp) - +---> OpenAI-compatible endpoints -``` - -### GLMT Proxy Flow - -``` -+===========================================================================+ -| GLMT Proxy Integration | -+===========================================================================+ - - Claude CLI - | - | ANTHROPIC_BASE_URL = localhost:XXXX - v - +------------------+ - | GLMT Proxy | Embedded Node.js proxy (src/glmt/) - | (glmt-proxy.ts)| - +------------------+ - | - v - +------------------+ - | Delta Accumulator| Stream transformation - +------------------+ - | - v - +------------------+ - | Pipeline | Request/Response transformation - +------------------+ - | - v - +------------------+ - | GLM API | Z.AI / Kimi API - +------------------+ -``` - -### Remote CLIProxy Flow (v7.1) - -``` -+===========================================================================+ -| Remote CLIProxy Architecture | -+===========================================================================+ - - Config Resolution (proxy-config-resolver.ts) - | - +---> Priority: CLI flags > ENV vars > config.yaml > defaults - | - v - +------------------+ - | ResolvedProxyConfig | - | mode: local|remote | - +------------------+ - | - +---> [mode = local] ---> Spawn local CLIProxyAPI binary - | | - | v - | localhost:8317 - | - +---> [mode = remote] ---> Connect to remote server - | - v - +------------------+ - | Health Check | remote-proxy-client.ts - | /v1/models | 2s timeout - +------------------+ - | - +---> [reachable] ---> Use remote - | | - | v - | protocol://host:port - | - +---> [unreachable] ---> Fallback decision - | - +-----------------------------+ - | - +---> [fallbackEnabled] ---> Start local - | - +---> [remoteOnly] ---> Fail with error - - CLI Flags: - --proxy-host Remote hostname/IP - --proxy-port Port (default: 8317 HTTP, 443 HTTPS) - --proxy-protocol http or https - --proxy-auth-token Bearer authentication - --local-proxy Force local mode - --remote-only Fail if remote unreachable - - Environment Variables: - CCS_PROXY_HOST Remote hostname - CCS_PROXY_PORT Remote port - CCS_PROXY_PROTOCOL Protocol (http/https) - CCS_PROXY_AUTH_TOKEN Auth token - CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false) -``` - -### Quota Management Flow (v7.14) - -``` -+===========================================================================+ -| Quota Management Architecture | -+===========================================================================+ - - Pre-Flight Check (before session start) - | - v - +------------------+ - | quota-manager.ts | Hybrid quota management - +------------------+ - | - +---> Get all active accounts for provider - | - +---> For each account: - | | - | v - | +------------------+ - | | quota-fetcher.ts | Provider-specific API calls - | +------------------+ - | | - | +---> Check isPaused flag --> Skip if paused - | | - | +---> Fetch quota from provider API - | | - Gemini: /models endpoint - | | - Codex: /api/v1/account - | | - Kiro: /api/usage - | | - | +---> Detect tier (free/paid/unknown) - | | - | +---> Check exhaustion status - | - +---> Select best account (not paused, not exhausted) - | - +---> Auto-failover to next account if current exhausted - - CLI Commands: - ccs cliproxy pause --> Set isPaused=true in account-manager - ccs cliproxy resume --> Set isPaused=false - ccs cliproxy status [account] --> Display quota + tier info - - Dashboard UI: - - Pause/Resume toggle per account - - Tier badge (free/paid/unknown) - - Quota usage display -``` - ---- - -## Configuration Architecture - -### Config File Hierarchy - -``` -+===========================================================================+ -| Configuration Hierarchy | -+===========================================================================+ - - ~/.ccs/ - | - +---> config.yaml # Main CCS config (unified) - | - +---> profiles.json # Claude account registry - | - +---> .settings.json # Per-profile settings - | - +---> cliproxy/ - | | - | +---> config.yaml # CLIProxy configuration - | +---> auth/ # OAuth tokens - | +---> bin/ # CLIProxy binary - | - +---> shared/ # Symlinked resources - | - +---> commands/ # Claude Code commands - +---> skills/ # Custom skills - +---> agents/ # Agent configurations -``` - -### Config Loading Order - -``` - 1. Environment Variables (highest priority) - | - v - 2. CLI Arguments - | - v - 3. Profile-specific settings (~/.ccs/.settings.json) - | - v - 4. Main config (~/.ccs/config.yaml) - | - v - 5. Default values (lowest priority) -``` - ---- - -## WebSocket Architecture - -### Real-time Communication - -``` -+===========================================================================+ -| WebSocket Communication | -+===========================================================================+ - - Dashboard (React) Server (Express) - | | - |<------ Connection Established ------>| - | | - |<------ health:update ----------------| Health status - | | - |<------ auth:status ------------------| Auth changes - | | - |<------ usage:update -----------------| Usage stats - | | - |------- action:refresh -------------->| User requests - | | -``` - ---- - -## Security Architecture - -### Authentication Flow - -``` -+===========================================================================+ -| Authentication Flow | -+===========================================================================+ - - OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY) - -------------------------------------------------------------- - - 1. User runs: ccs gemini - | - v - 2. Check token cache (~/.ccs/cliproxy/auth/) - | - +---> [Valid token] ---> Use cached token - | - +---> [No/Expired token] - | - v - 3. Open browser for OAuth (localhost:PORT callback) - v - 4. Callback with auth code - | - v - 5. Exchange for access token - | - v - 6. Cache token locally - - - Kiro OAuth - Method-Aware Flow (CLI + Dashboard parity) - ------------------------------------------------------- - - Supported methods: - - aws: Device Code (default, AWS org friendly) - - aws-authcode: Authorization Code via CLI flow - - google: Social OAuth via management API - - github: Social OAuth via management API (Dashboard flow) - - Key behavior: - - Device Code method uses /start route (no callback port) - - Callback/social methods use /start-url + status polling - - Some management flows return state first, auth_url later - - - OAuth Providers - Device Code Flow (GitHub Copilot/ghcp) - -------------------------------------------------------- - - 1. User runs: ccs ghcp - | - v - 2. Check token cache (~/.ccs/cliproxy/auth/) - | - +---> [Valid token] ---> Use cached token - | - +---> [No/Expired token] - | - v - 3. Request device code from GitHub - | - v - 4. Display user code + verification URL - | "Enter code XXXX-XXXX at github.com/login/device" - v - 5. Poll for token (user completes auth in browser) - | - v - 6. Receive and cache token locally - - - API Key Profiles (GLM, Kimi) - ---------------------------- - - 1. User configures API key in settings - | - v - 2. Key stored in ~/.ccs/.settings.json - | - v - 3. Key passed via ANTHROPIC_AUTH_TOKEN env var -``` - -### Security Boundaries - -``` - +------------------+ - | User Terminal | - +------------------+ - | - | Local only (no network exposure) - v - +------------------+ - | CCS CLI | - +------------------+ - | - | Localhost only (127.0.0.1) - v - +------------------+ - | CLIProxy/GLMT | Binds to localhost only - +------------------+ - | - | TLS encrypted - v - +------------------+ - | Provider APIs | External endpoints - +------------------+ -``` - ---- - -## Build and Distribution - -### Build Pipeline - -``` -+===========================================================================+ -| Build Pipeline | -+===========================================================================+ - - src/ (TypeScript) ui/src/ (React TSX) - | | - v v - TypeScript Compiler Vite Build - | | - v v - dist/ (JavaScript) dist/ui/ (Static assets) - | | - +---------------+---------------------+ - | - v - npm package (@kaitranntt/ccs) - | - v - npm registry / GitHub releases -``` - -### Package Contents - -``` - @kaitranntt/ccs - | - +---> dist/ # Compiled CLI - +---> dist/ui/ # Built dashboard - +---> lib/ # Native scripts - | +---> ccs # Bash bootstrap - | +---> ccs.ps1 # PowerShell bootstrap - +---> package.json -``` - ---- - -## Deployment Architecture - -### Local Installation - -``` - npm install -g @kaitranntt/ccs - | - v - Global node_modules - | - +---> Creates symlink: ccs --> dist/ccs.js - | - +---> First run creates: ~/.ccs/ -``` - -### Runtime Dependencies - -``` - +------------------+ +------------------+ - | Node.js 14+ | | Claude CLI | - | (required) | | (required) | - +------------------+ +------------------+ - - +------------------+ +------------------+ - | CLIProxyAPI | | Gemini CLI | - | (auto-managed) | | (optional) | - +------------------+ +------------------+ -``` - ---- - -## Related Documentation - -- [Codebase Summary](./codebase-summary.md) - Detailed directory structure -- [Code Standards](./code-standards.md) - Coding conventions -- [Project Roadmap](./project-roadmap.md) - Development phases -- [WebSearch](./websearch.md) - WebSearch feature details diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md new file mode 100644 index 00000000..38f64bf0 --- /dev/null +++ b/docs/system-architecture/index.md @@ -0,0 +1,402 @@ +# CCS System Architecture + +Last Updated: 2026-02-16 + +High-level architecture overview for the CCS (Claude Code Switch) system. + +--- + +## System Overview + +CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid) for credential delivery. + +The system consists of two main components: + +1. **CLI Application** (`src/`) - Node.js TypeScript CLI +2. **Dashboard UI** (`ui/`) - React web application served by Express + +CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types. + +``` ++===========================================================================+ +| CCS System | ++===========================================================================+ +| | +| +------------------+ +-----------------+ +----------------+ | +| | User Terminal | ---> | CCS CLI | ---> | Target CLI | | +| | (ccs command) | | (src/ccs.ts) | | (claude/droid) | | +| +------------------+ +-----------------+ +----------------+ | +| | | | +| v v | +| +------------------+ +-----------------+ +----------------+ | +| | Dashboard UI | <--> | Express | ---> | Provider APIs | | +| | (React SPA) | | Web Server | | (Claude/GLM/ | | +| +------------------+ +-----------------+ | Gemini/etc) | | +| | +----------------+ | +| v | +| +---------------------+ | +| | CLIProxyAPI | | +| | (Local or Remote) | | +| +---------------------+ | +| | ++===========================================================================+ +``` + +--- + +## Component Architecture + +### Multi-Target Adapter System + +CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration with different CLI implementations. + +**Key architecture:** + +``` +Profile Resolution (CLIProxy, GLMT, Account-based) + | + v +Target Resolution (--target flag > config > argv[0] > default) + | + v +Get Target Adapter (Claude or Droid) + | + +---> detectBinary() (find CLI on system) + | + +---> prepareCredentials() (write config or set env) + | + +---> buildArgs() (construct CLI arguments) + | + +---> buildEnv() (prepare environment variables) + | + v +Spawn Target Process +``` + +**Each target adapter implements different credential delivery:** + +- **Claude Adapter**: Env var delivery (existing behavior) + - `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` + - No config files needed + +- **Droid Adapter**: Config file delivery to `~/.factory/settings.json` + - Writes custom model entry: `custom:ccs-` + - Spawns: `droid -m custom:ccs- ` + - Model config includes baseUrl, apiKey, provider + +**Binary alias pattern (busybox-style):** + +``` +ccs → Target: claude (default) +ccsd → Target: droid (auto-selected via argv[0]) +``` + +For details on the adapter architecture, see [Target Adapters](./target-adapters.md). + +### CLI Layer + +``` ++===========================================================================+ +| CLI Architecture | ++===========================================================================+ + + User Input (ccs [--target ] [args]) + | + v + +-------------+ + | ccs.ts | Entry point, command routing + +-------------+ + | + +---> [Version/Help/Doctor/etc.] ---> Exit + | + v + +------------------+ + | Target Resolution | Determine which CLI to use + +------------------+ + | + v + +-------------+ + | Profile | Determines execution path + | Detection | + +-------------+ + | + +---> [Native Claude Account] ---> execClaude() + | | + +---> [CLIProxy Provider] ---> execClaudeWithCLIProxy() + | | + +---> [GLMT Profile] ---> execClaudeWithProxy() + | + v + +------------------+ + | Target Adapter | Get appropriate adapter + +------------------+ + | + v + +------------------+ + | Prepare Creds | Deliver credentials + +------------------+ + | + v + +------------------+ + | Target CLI | Claude Code or Droid + +------------------+ +``` + +--- + +## Data Flow Architecture + +### CLI Execution Flow + +``` ++===========================================================================+ +| CLI Execution Flow | ++===========================================================================+ + + 1. Parse Arguments + | + v + 2. Resolve Target Type + | + v + 3. Detect Profile Type + | + +---> Native Claude ---> 3a. Load Account Settings + | | + | v + | 4a. Set CLAUDE_CONFIG_DIR + | | + | v + | 5a. Get Claude Target Adapter + | + +---> CLIProxy -------> 3b. Ensure Binary Installed + | | + | v + | 4b. Generate Config + | | + | v + | 5b. Resolve Target Adapter + | | + | v + | 6b. Prepare Credentials + | | + | v + | 7b. Spawn via Adapter + | + +---> GLMT -----------> 3c. Start Embedded Proxy + | + v + 4c. Resolve Target Adapter + | + v + 5c. Spawn via Adapter +``` + +--- + +## Provider Integration Architecture + +For detailed provider flows (CLIProxyAPI, GLMT, quota management), see [Provider Flows](./provider-flows.md). + +--- + +## Configuration Architecture + +### Config File Hierarchy + +``` ++===========================================================================+ +| Configuration Hierarchy | ++===========================================================================+ + + ~/.ccs/ + | + +---> config.yaml # Main CCS config (unified) + | + +---> profiles.json # Claude account registry + | + +---> .settings.json # Per-profile settings + | + +---> cliproxy/ + | | + | +---> config.yaml # CLIProxy configuration + | +---> auth/ # OAuth tokens + | +---> bin/ # CLIProxy binary + | + +---> shared/ # Symlinked resources + | + +---> commands/ # Claude Code commands + +---> skills/ # Custom skills + +---> agents/ # Agent configurations + + ~/.factory/ (Droid CLI) + | + +---> settings.json # Droid config (custom models) +``` + +### Config Loading Order + +``` + 1. Environment Variables (highest priority) + | + v + 2. CLI Arguments (including --target) + | + v + 3. Profile-specific settings (~/.ccs/.settings.json) + | + v + 4. Main config (~/.ccs/config.yaml) + | + v + 5. Default values (lowest priority) +``` + +--- + +## WebSocket Architecture + +### Real-time Communication + +``` ++===========================================================================+ +| WebSocket Communication | ++===========================================================================+ + + Dashboard (React) Server (Express) + | | + |<------ Connection Established ------>| + | | + |<------ health:update ----------------| Health status + | | + |<------ auth:status ------------------| Auth changes + | | + |<------ usage:update -----------------| Usage stats + | | + |------- action:refresh -------------->| User requests + | | +``` + +--- + +## Security Architecture + +### Authentication Flow + +See [Provider Flows](./provider-flows.md) → Authentication Flow section. + +### Security Boundaries + +``` + +------------------+ + | User Terminal | + +------------------+ + | + | Local only (no network exposure) + v + +------------------+ + | CCS CLI | + +------------------+ + | + | Localhost only (127.0.0.1) + v + +------------------+ + | CLIProxy/GLMT | Binds to localhost only + +------------------+ + | + | TLS encrypted + v + +------------------+ + | Target CLI | Spawned locally (claude/droid) + +------------------+ + | + | TLS encrypted + v + +------------------+ + | Provider APIs | External endpoints + +------------------+ +``` + +--- + +## Build and Distribution + +### Build Pipeline + +``` ++===========================================================================+ +| Build Pipeline | ++===========================================================================+ + + src/ (TypeScript) ui/src/ (React TSX) + | | + v v + TypeScript Compiler Vite Build + | | + v v + dist/ (JavaScript) dist/ui/ (Static assets) + | | + +---------------+---------------------+ + | + v + npm package (@kaitranntt/ccs) + | + v + npm registry / GitHub releases +``` + +### Package Contents + +``` + @kaitranntt/ccs + | + +---> dist/ # Compiled CLI + +---> dist/ui/ # Built dashboard + +---> lib/ # Native scripts + | +---> ccs # Bash bootstrap + | +---> ccs.ps1 # PowerShell bootstrap + +---> package.json +``` + +--- + +## Deployment Architecture + +### Local Installation + +``` + npm install -g @kaitranntt/ccs + | + v + Global node_modules + | + +---> Creates symlink: ccs --> dist/ccs.js + | + +---> Binary alias: ccsd → ccs (auto-selects droid target) + | + +---> First run creates: ~/.ccs/ +``` + +### Runtime Dependencies + +``` + +------------------+ +------------------+ + | Node.js 14+ | | Claude CLI | + | (required) | | (required) | + +------------------+ +------------------+ + + +------------------+ +------------------+ + | CLIProxyAPI | | Droid CLI | + | (auto-managed) | | (optional) | + +------------------+ +------------------+ +``` + +--- + +## Related Documentation + +- [Codebase Summary](../codebase-summary.md) - Detailed directory structure +- [Code Standards](../code-standards.md) - Coding conventions & patterns +- [Target Adapters](./target-adapters.md) - Multi-CLI adapter architecture +- [Provider Flows](./provider-flows.md) - CLIProxy, GLMT, authentication flows +- [Project Roadmap](../project-roadmap.md) - Development phases diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md new file mode 100644 index 00000000..92f0ea64 --- /dev/null +++ b/docs/system-architecture/provider-flows.md @@ -0,0 +1,565 @@ +# Provider Integration Flows + +Last Updated: 2026-02-16 + +Detailed provider integration flows including CLIProxyAPI, GLMT proxy, remote CLIProxy, quota management, and authentication. + +--- + +## CLIProxyAPI Flow + +### Overview + +CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with multiple AI providers. CCS manages the binary and configuration automatically. + +``` ++===========================================================================+ +| CLIProxyAPI Integration | ++===========================================================================+ + + Claude CLI + | + | ANTHROPIC_BASE_URL = localhost:XXXX + v + +------------------+ + | CLIProxyAPI | Local proxy binary (CLIProxyAPIPlus for kiro/ghcp) + | (binary) | + +------------------+ + | + +---> OAuth Authentication + | | + | +---> Authorization Code Flow (port-based) + | | - Gemini, Codex, Antigravity, Kiro (port 9876) + | | - Opens browser for user auth + | | - Callback to localhost:PORT + | | + | +---> Device Code Flow (no port needed) + | - GitHub Copilot (ghcp) + | - User enters code at github.com/login/device + | - Polls for token completion + | | + | v + | +------------------+ + | | OAuth Server | Browser-based auth + | +------------------+ + | + +---> Request Transformation + | | + | v + | Anthropic Format --> Provider Format + | + +---> Image Analysis Hook (v7.34) + | | + | v + | Vision Model Proxying (gemini, codex, agy, clipproxy) + | - Auto-injected via claude-hooks + | - Skip for Claude Sub accounts (native vision) + | - Fallback with deprecated block-image-read + | + +---> Provider APIs + | + +---> Google (Gemini) + +---> GitHub (Codex) + +---> Antigravity (AGY) + +---> AWS Kiro (Claude-powered) + +---> GitHub Copilot (ghcp) + +---> OpenAI-compatible endpoints +``` + +### Supported Hardcoded Providers + +| Provider | ID | Auth Method | Port | Binary | +|----------|----|----|------|--------| +| Gemini | `gemini` | Authorization Code | 9876 | CLIProxyAPI | +| Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | +| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | +| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus | +| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus | + +### Hardcoded Provider Detection + +CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`. + +```typescript +// Profile name matching +const hardcodedProviders = ['gemini', 'codex', 'agy', 'kiro', 'ghcp']; + +if (hardcodedProviders.includes(profileName)) { + return execClaudeWithCLIProxy(claudeCli, profileName, args); +} +``` + +--- + +## GLMT Proxy Flow + +### Overview + +GLMT proxy enables seamless integration with GLM-compatible APIs (Z.AI, Kimi, OpenRouter, etc.) using a Node.js-based embedded proxy. + +``` ++===========================================================================+ +| GLMT Proxy Integration | ++===========================================================================+ + + Claude CLI + | + | ANTHROPIC_BASE_URL = localhost:XXXX + v + +------------------+ + | GLMT Proxy | Embedded Node.js proxy (src/glmt/) + | (glmt-proxy.ts)| + +------------------+ + | + v + +------------------+ + | Delta Accumulator| Stream transformation + +------------------+ + | + v + +------------------+ + | Pipeline | Request/Response transformation + +------------------+ + | + v + +------------------+ + | GLM API | Z.AI / Kimi API + +------------------+ +``` + +### Supported GLM Providers + +| Provider | Config Key | Endpoint | Auth | +|----------|------------|----------|------| +| Z.AI (GLM) | `glmt` | https://open.bigmodel.cn/api/paas/v4/ | API key | +| Kimi | `kimi` | https://api.moonshot.cn/v1/ | API key | +| OpenRouter | `openrouter` | https://openrouter.ai/api/v1/ | API key | + +### GLMT Profile Detection + +CCS detects GLMT profiles and routes through `execClaudeWithProxy()`: + +```typescript +// Settings-based profile detection +const settings = loadSettings(profileName); +if (settings.env?.ANTHROPIC_BASE_URL?.includes('glm') || + settings.env?.ANTHROPIC_BASE_URL?.includes('moonshot') || + settings.env?.ANTHROPIC_BASE_URL?.includes('openrouter')) { + return execClaudeWithProxy(claudeCli, profileName, args); +} +``` + +--- + +## Remote CLIProxy Flow (v7.1) + +### Overview + +Remote CLIProxy enables CCS to delegate authentication to a central proxy server instead of spawning a local binary. + +``` ++===========================================================================+ +| Remote CLIProxy Architecture (v7.1) | ++===========================================================================+ + + Config Resolution (proxy-config-resolver.ts) + | + +---> Priority: CLI flags > ENV vars > config.yaml > defaults + | + v + +------------------+ + | ResolvedProxyConfig | + | mode: local|remote | + +------------------+ + | + +---> [mode = local] ---> Spawn local CLIProxyAPI binary + | | + | v + | localhost:8317 + | + +---> [mode = remote] ---> Connect to remote server + | + v + +------------------+ + | Health Check | remote-proxy-client.ts + | /v1/models | 2s timeout + +------------------+ + | + +---> [reachable] ---> Use remote + | | + | v + | protocol://host:port + | + +---> [unreachable] ---> Fallback decision + | + +-----------------------------+ + | + +---> [fallbackEnabled] ---> Start local + | + +---> [remoteOnly] ---> Fail with error + + CLI Flags: + --proxy-host Remote hostname/IP + --proxy-port Port (default: 8317 HTTP, 443 HTTPS) + --proxy-protocol http or https + --proxy-auth-token Bearer authentication + --local-proxy Force local mode + --remote-only Fail if remote unreachable + + Environment Variables: + CCS_PROXY_HOST Remote hostname + CCS_PROXY_PORT Remote port + CCS_PROXY_PROTOCOL Protocol (http/https) + CCS_PROXY_AUTH_TOKEN Auth token + CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false) +``` + +### Configuration Resolution + +```typescript +// proxy-config-resolver.ts: Priority order +const resolved = { + ...DEFAULT_CONFIG, // 4. Defaults (lowest) + ...yamlConfig, // 3. config.yaml + ...envConfig, // 2. Environment variables + ...cliFlags, // 1. CLI flags (highest) +}; +``` + +### Health Check + +```typescript +// remote-proxy-client.ts +async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise { + try { + const url = `${config.protocol}://${config.host}:${config.port}/v1/models`; + const response = await fetch(url, { + headers: config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {}, + timeout: 2000, + }); + return response.ok; + } catch { + return false; + } +} +``` + +--- + +## Quota Management Flow (v7.14) + +### Overview + +Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account. + +``` ++===========================================================================+ +| Quota Management Architecture (v7.14) | ++===========================================================================+ + + Pre-Flight Check (before session start) + | + v + +------------------+ + | quota-manager.ts | Hybrid quota management + +------------------+ + | + +---> Get all active accounts for provider + | + +---> For each account: + | | + | v + | +------------------+ + | | quota-fetcher.ts | Provider-specific API calls + | +------------------+ + | | + | +---> Check isPaused flag --> Skip if paused + | | + | +---> Fetch quota from provider API + | | - Gemini: /models endpoint + | | - Codex: /api/v1/account + | | - Kiro: /api/usage + | | + | +---> Detect tier (free/paid/unknown) + | | + | +---> Check exhaustion status + | + +---> Select best account (not paused, not exhausted) + | + +---> Auto-failover to next account if current exhausted + + CLI Commands: + ccs cliproxy pause --> Set isPaused=true in account-manager + ccs cliproxy resume --> Set isPaused=false + ccs cliproxy status [account] --> Display quota + tier info + + Dashboard UI: + - Pause/Resume toggle per account + - Tier badge (free/paid/unknown) + - Quota usage display +``` + +### Account Selection Algorithm + +```typescript +// quota-manager.ts: Best account selection +function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null { + // Priority: + // 1. Not paused + // 2. Not exhausted + // 3. Paid tier over free tier + // 4. Highest remaining quota + + return accounts + .filter(acc => !acc.isPaused && !acc.isExhausted) + .sort((a, b) => { + if (a.tier !== b.tier) return (a.tier === 'paid' ? -1 : 1); + return (b.remainingQuota || 0) - (a.remainingQuota || 0); + })[0] || null; +} +``` + +--- + +## Authentication Flow + +### OAuth Providers - Authorization Code Flow + +**Providers**: Gemini, Codex, Antigravity, Kiro (aws method) + +``` ++===========================================================================+ +| OAuth - Authorization Code Flow (Port-based) | ++===========================================================================+ + + 1. User runs: ccs gemini + | + v + 2. Check token cache (~/.ccs/cliproxy/auth/) + | + +---> [Valid token] ---> Use cached token + | + +---> [No/Expired token] + | + v + 3. Start local OAuth server (localhost:9876) + | + v + 4. Open browser with OAuth request + | https://oauth-provider/authorize?redirect_uri=http://localhost:9876/callback + v + 5. User authorizes in browser + | + v + 6. OAuth provider redirects to localhost:9876/callback?code=XXXX + | + v + 7. Exchange auth code for access token + | + v + 8. Cache token locally (~/.ccs/cliproxy/auth/gemini.json) + | + v + 9. Proceed with Claude CLI +``` + +### OAuth Providers - Device Code Flow + +**Providers**: GitHub Copilot (ghcp) + +``` ++===========================================================================+ +| OAuth - Device Code Flow (No Port Needed) | ++===========================================================================+ + + 1. User runs: ccs ghcp + | + v + 2. Check token cache (~/.ccs/cliproxy/auth/) + | + +---> [Valid token] ---> Use cached token + | + +---> [No/Expired token] + | + v + 3. Request device code from GitHub + | + v + 4. Display user code + verification URL + | "Enter code XXXX-XXXX at github.com/login/device" + v + 5. User opens URL in browser and enters code + | + v + 6. Poll GitHub for token completion + | + v + 7. Receive and cache token locally + | + v + 8. Proceed with Claude CLI +``` + +### Kiro OAuth - Method-Aware Flow + +**Supported methods**: +- `aws`: Device Code (default, AWS org friendly) +- `aws-authcode`: Authorization Code via CLI flow +- `google`: Social OAuth via management API +- `github`: Social OAuth via management API (Dashboard flow) + +``` ++===========================================================================+ +| Kiro OAuth - Method-Aware Flow | ++===========================================================================+ + + Configuration: + ccs_profile: + target: claude + cliproxy: + provider: kiro + kiro_method: aws # or aws-authcode, google, github + + Flow: + Device Code (aws) + → /start endpoint (no callback port) + → Opens browser + → User enters code + → Poll /status + + Authorization Code (aws-authcode, google, github) + → /start-url endpoint + → Returns auth_url + → User visits URL + → Callback handled + → Poll /status for completion + + Key behavior: + - Device Code method uses /start route (no callback port) + - Callback/social methods use /start-url + status polling + - Some management flows return state first, auth_url later +``` + +### API Key Profiles (GLM, Kimi) + +``` ++===========================================================================+ +| API Key Profile (Non-OAuth) | ++===========================================================================+ + + 1. User configures API key in settings + | + v + 2. Key stored in ~/.ccs/.settings.json + | + v + 3. Profile detection: APIKeyProfile + | + v + 4. Key passed via ANTHROPIC_AUTH_TOKEN env var + | + v + 5. Target adapter (Claude/Droid) handles delivery + | + └─ Claude: env var + └─ Droid: config file (~/.factory/settings.json) +``` + +--- + +## Image Analysis Hook Flow (v7.34) + +### Overview + +Image Analysis Hook enables vision model proxying through CLIProxy with automatic injection for all profile types. + +``` ++===========================================================================+ +| Image Analysis Hook Flow (v7.34) | ++===========================================================================+ + + Claude CLI with image input + | + v + Hook Installer (ensureProfileHooks) + | + +---> Check ~/.claude/hooks/openai-vision-hook.cjs exists + | + +---> If missing: auto-install via image-analyzer-hook-installer + | + v + Hook Configuration + | + +---> Set ANTHROPIC_IMAGE_HOOK_URL + | (proxy endpoint URL) + | + v + Claude CLI processes image request + | + v + Hook intercepts image request + | + v + Vision Model Proxying (via CLIProxyAPI) + | + +---> Gemini, Codex, AGY support vision + | + +---> Kiro (Claude native vision) + | + +---> Skip for Claude Sub accounts (native vision) + | + v + Vision response returned to Claude CLI +``` + +### Hook Environment + +```typescript +// getImageAnalysisHookEnv() +{ + ANTHROPIC_IMAGE_HOOK_URL: 'http://localhost:8317/api/image-analysis', + // or for remote proxy: + ANTHROPIC_IMAGE_HOOK_URL: 'https://proxy.example.com:8317/api/image-analysis', +} +``` + +### Provider Support + +| Provider | Vision Support | Notes | +|----------|---|---| +| Gemini | ✓ | Via CLIProxy image analysis | +| Codex | ✓ | Via CLIProxy image analysis | +| Antigravity | ✓ | Via CLIProxy image analysis | +| Kiro | ✓ | Native Claude vision (no proxy needed) | +| Copilot | ✗ | Not supported | +| GLM/Kimi | ✗ | Requires direct API implementation | + +--- + +## Session Tracking + +All execution paths record session metadata including target CLI used: + +```typescript +{ + profileName: 'gemini', + profileType: 'clipproxy', + provider: 'google-gemini', + targetCli: 'claude', // NEW: which target was used + timestamp: '2026-02-16T10:40:00Z', + duration: 12345, + exitCode: 0, + model: 'claude-opus-4-6', +} +``` + +This enables analytics on target CLI usage and adoption. + +--- + +## Related Documentation + +- [System Architecture Index](./index.md) — Overall system design +- [Target Adapters](./target-adapters.md) — Multi-CLI adapter pattern +- [Codebase Summary](../codebase-summary.md) — Module structure +- [Code Standards](../code-standards.md) — Implementation guidelines diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md new file mode 100644 index 00000000..1edb7704 --- /dev/null +++ b/docs/system-architecture/target-adapters.md @@ -0,0 +1,607 @@ +# Target Adapters + +Last Updated: 2026-02-16 + +Detailed documentation of the target adapter pattern and implementations. + +--- + +## Overview + +The target adapter system enables CCS to dispatch credential-resolved profiles to different CLI implementations while maintaining a unified configuration and profile system. + +**Key insight**: Profile resolution (detecting provider, loading auth, building credentials) is target-agnostic. Only the final credential delivery and process spawning differ per target. + +--- + +## Target Adapter Interface + +Each CLI target implements the `TargetAdapter` contract: + +```typescript +export interface TargetAdapter { + readonly type: TargetType; // 'claude' | 'droid' + readonly displayName: string; // "Claude Code" | "Factory Droid" + + /** Detect if the target CLI binary exists on system */ + detectBinary(): TargetBinaryInfo | null; + + /** Prepare credentials for delivery to target CLI */ + prepareCredentials(creds: TargetCredentials): Promise; + + /** Build spawn arguments for the target CLI */ + buildArgs(profile: string, userArgs: string[]): string[]; + + /** Build environment variables for the target CLI */ + buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; + + /** Spawn the target CLI process (replaces current process flow) */ + exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void; + + /** Check if a profile type is supported by this target */ + supportsProfileType(profileType: string): boolean; +} +``` + +### Type Definitions + +```typescript +export type TargetType = 'claude' | 'droid'; + +export interface TargetCredentials { + baseUrl: string; // API endpoint + apiKey: string; // Auth token + model?: string; // Model ID + provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api'; + envVars?: NodeJS.ProcessEnv; // Additional env vars +} + +export interface TargetBinaryInfo { + path: string; // Full path to binary + needsShell: boolean; // Windows .cmd/.bat/.ps1? +} +``` + +--- + +## Target Resolution + +CCS resolves which adapter to use via priority-ordered checks: + +### Resolution Priority + +``` +1. --target flag (CLI argument) — highest priority + └─ ccs --target droid glm + +2. Per-profile config (from ~/.ccs/config.yaml or settings.json) + └─ profiles: + glm: + target: droid + +3. argv[0] detection (busybox pattern) — binary name mapping + └─ ccsd (symlink/batch file) → droid + └─ ccs (regular command) → default + +4. Fallback: 'claude' — lowest priority +``` + +### Implementation + +```typescript +// src/targets/target-resolver.ts + +export function resolveTargetType( + args: string[], + profileConfig?: { target?: TargetType } +): TargetType { + // 1. Check --target flag + const targetIdx = args.indexOf('--target'); + if (targetIdx !== -1 && args[targetIdx + 1]) { + const flagValue = args[targetIdx + 1]; + if (VALID_TARGETS.has(flagValue)) { + return flagValue as TargetType; + } + // Invalid target → error + console.error(`[X] Unknown target "${flagValue}". Available: claude, droid`); + process.exit(1); + } + + // 2. Check profile config + if (profileConfig?.target) { + return profileConfig.target; + } + + // 3. Check argv[0] (binary name) + const binName = path.basename(process.argv[1] || '').replace(/\.(cmd|bat)$/i, ''); + if (ARGV0_TARGET_MAP[binName]) { + return ARGV0_TARGET_MAP[binName]; + } + + // 4. Default to claude + return 'claude'; +} +``` + +--- + +## Claude Adapter + +### Implementation + +```typescript +// src/targets/claude-adapter.ts + +export class ClaudeAdapter implements TargetAdapter { + readonly type: TargetType = 'claude'; + readonly displayName = 'Claude Code'; + + detectBinary(): TargetBinaryInfo | null { + const info = getClaudeCliInfo(); + if (!info) return null; + return { path: info.path, needsShell: info.needsShell }; + } + + async prepareCredentials(_creds: TargetCredentials): Promise { + // No-op: Claude receives credentials via environment variables + } + + buildArgs(_profile: string, userArgs: string[]): string[] { + return userArgs; // Pass through user arguments unchanged + } + + buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv { + const webSearchEnv = getWebSearchHookEnv(); + + // For native profiles, strip stale proxy env to prevent interference + const baseEnv = + profileType === 'account' || profileType === 'default' + ? stripAnthropicEnv(process.env) + : process.env; + + const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; + + if (creds.envVars) { + Object.assign(env, creds.envVars); + } + + // Deliver credentials via environment variables + if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl; + if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey; + if (creds.model) env['ANTHROPIC_MODEL'] = creds.model; + + return env; + } + + exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { + const claudeCli = detectClaudeCli(); + if (!claudeCli) { + void ErrorManager.showClaudeNotFound(); + process.exit(1); + return; + } + + // Handle Windows shell requirements + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + + let child: ChildProcess; + if (needsShell) { + const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { shell: true, stdio: 'inherit', env }); + } else { + child = spawn(claudeCli, args, { stdio: 'inherit', env }); + } + + // Handle process termination + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); + } + + supportsProfileType(profileType: string): boolean { + // Claude supports all profile types + return true; + } +} +``` + +### Credential Delivery + +**Method**: Environment variables + +```bash +export ANTHROPIC_BASE_URL=https://api.anthropic.com +export ANTHROPIC_AUTH_TOKEN=sk-ant-... +export ANTHROPIC_MODEL=claude-opus-4-6 +export WEBSEARCH_HOOK_ENV=... # Image analysis, websearch +``` + +### Execution + +```bash +# Direct invocation +ccs gemini +→ claude "args..." + with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set + +# With --target override +ccs --target claude glm +→ claude "args..." + with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set +``` + +--- + +## Droid Adapter + +### Implementation + +```typescript +// src/targets/droid-adapter.ts + +export class DroidAdapter implements TargetAdapter { + readonly type: TargetType = 'droid'; + readonly displayName = 'Factory Droid'; + + detectBinary(): TargetBinaryInfo | null { + const info = getDroidBinaryInfo(); + if (!info) return null; + + // Non-blocking version compatibility check + checkDroidVersion(info.path); + return info; + } + + async prepareCredentials(creds: TargetCredentials): Promise { + const profile = creds.envVars?.['CCS_PROFILE_NAME'] || 'default'; + + // Write custom model entry to ~/.factory/settings.json + await upsertCcsModel(profile, { + model: creds.model || 'claude-opus-4-6', + displayName: `CCS ${profile}`, + baseUrl: creds.baseUrl, + apiKey: creds.apiKey, + provider: creds.provider || 'anthropic', + }); + } + + buildArgs(profile: string, userArgs: string[]): string[] { + // Droid uses -m syntax for model selection + return ['-m', `custom:ccs-${profile}`, ...userArgs]; + } + + buildEnv(_creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv { + // Droid reads from config file — minimal env needed + return { ...process.env }; + } + + exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { + const droidPath = detectDroidCli(); + if (!droidPath) { + console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); + process.exit(1); + return; + } + + // Handle Windows shell requirements + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath); + + let child: ChildProcess; + if (needsShell) { + const cmdString = [droidPath, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { shell: true, stdio: 'inherit', env }); + } else { + child = spawn(droidPath, args, { stdio: 'inherit', env }); + } + + // Handle process termination + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); + } + + supportsProfileType(profileType: string): boolean { + // Droid supports all profile types (like Claude) + return true; + } +} +``` + +### Credential Delivery + +**Method**: Config file (`~/.factory/settings.json`) + +```json +{ + "customModels": { + "ccs-gemini": { + "model": "claude-opus-4-6", + "displayName": "CCS gemini", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/", + "apiKey": "AIza...", + "provider": "openai" + }, + "ccs-glm": { + "model": "glm-4", + "displayName": "CCS glm", + "baseUrl": "https://open.bigmodel.cn/api/paas/v4/", + "apiKey": "your-glm-key", + "provider": "openai" + } + } +} +``` + +### Execution + +```bash +# Direct invocation +ccs gemini +→ droid -m custom:ccs-gemini "args..." + (credentials loaded from ~/.factory/settings.json) + +# With --target override +ccs --target droid glm +→ droid -m custom:ccs-glm "args..." + (credentials loaded from ~/.factory/settings.json) +``` + +### Binary Alias Pattern + +```bash +# Create symlink to auto-select droid target +ln -s /path/to/ccs /path/to/ccsd + +# Usage +ccsd glm +→ Target: droid (detected from argv[0]) +→ droid -m custom:ccs-glm "args..." +``` + +--- + +## Registry and Lookup + +The target registry is a simple map-based store for adapters: + +```typescript +// src/targets/target-registry.ts + +const adapters = new Map(); + +export function registerTarget(adapter: TargetAdapter): void { + adapters.set(adapter.type, adapter); +} + +export function getTarget(type: TargetType): TargetAdapter { + const adapter = adapters.get(type); + if (!adapter) { + throw new Error(`Unknown target "${type}"`); + } + return adapter; +} + +export function getDefaultTarget(): TargetAdapter { + return getTarget('claude'); +} +``` + +### Adapter Registration + +At startup, adapters self-register: + +```typescript +// src/ccs.ts (initialization) + +registerTarget(new ClaudeAdapter()); +registerTarget(new DroidAdapter()); +``` + +--- + +## Execution Flow + +### Step-by-Step + +``` +1. Parse command-line arguments + └─ args: ['--target', 'droid', 'glm'] + +2. Resolve target type + └─ resolveTargetType(args) → 'droid' + └─ stripTargetFlag(args) → ['glm'] + +3. Detect and resolve profile + └─ detectProfile(['glm']) → { profile: 'glm', ... } + └─ Load credentials from config/CLIProxy/env + +4. Build credentials object + └─ TargetCredentials { + baseUrl: '...', + apiKey: '...', + model: 'claude-opus-4-6', + envVars: { CCS_PROFILE_NAME: 'glm', ... } + } + +5. Get target adapter + └─ getTarget('droid') → DroidAdapter instance + +6. Prepare credentials + └─ adapter.prepareCredentials(creds) + └─ DroidAdapter: writes to ~/.factory/settings.json + +7. Build spawn arguments + └─ adapter.buildArgs('glm', []) → ['-m', 'custom:ccs-glm'] + +8. Build environment + └─ adapter.buildEnv(creds, profileType) → process.env + +9. Spawn target CLI + └─ adapter.exec(spawnArgs, env) + └─ exec spawn('droid', ['-m', 'custom:ccs-glm', ...]) + +10. Replace current process + └─ Child process inherits stdio + └─ Signal handlers propagate to child +``` + +--- + +## Adding a New Target + +To support a new CLI (e.g., MyAI CLI), follow this pattern: + +### 1. Create Adapter Class + +```typescript +// src/targets/myai-adapter.ts + +export class MyAiAdapter implements TargetAdapter { + readonly type: TargetType = 'myai'; + readonly displayName = 'MyAI CLI'; + + detectBinary(): TargetBinaryInfo | null { + const path = which.sync('myai', { nothrow: true }); + if (!path) return null; + return { path, needsShell: process.platform === 'win32' }; + } + + async prepareCredentials(creds: TargetCredentials): Promise { + // Write to ~/.myai/config or similar + } + + buildArgs(profile: string, userArgs: string[]): string[] { + return ['-p', profile, ...userArgs]; + } + + buildEnv(creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv { + return { + ...process.env, + MYAI_API_KEY: creds.apiKey, + MYAI_API_URL: creds.baseUrl, + }; + } + + exec(args: string[], env: NodeJS.ProcessEnv): void { + const myaiPath = this.detectBinary()?.path; + if (!myaiPath) { + console.error('[X] MyAI CLI not found'); + process.exit(1); + } + spawn(myaiPath, args, { stdio: 'inherit', env }); + } + + supportsProfileType(profileType: string): boolean { + return true; // or implement specific logic + } +} +``` + +### 2. Update Type Definition + +```typescript +// src/targets/target-adapter.ts + +export type TargetType = 'claude' | 'droid' | 'myai'; +``` + +### 3. Register in ccs.ts + +```typescript +registerTarget(new MyAiAdapter()); +``` + +### 4. Update Documentation + +- Add to [Codebase Summary](../codebase-summary.md) +- Update Code Standards adapter examples +- Document CLI-specific behavior + +--- + +## Cross-Platform Considerations + +### Windows Shell Detection + +Both adapters check for shell-requiring binaries: + +```typescript +const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath); + +if (needsShell) { + const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' '); + spawn(cmdString, { shell: true, stdio: 'inherit' }); +} else { + spawn(binaryPath, args, { stdio: 'inherit' }); +} +``` + +### Environment Variable Escaping + +Arguments passed to shell are escaped to prevent injection: + +```typescript +export function escapeShellArg(arg: string): string { + // Wrap in quotes and escape internal quotes + return `"${arg.replace(/"/g, '\\"')}"`; +} +``` + +### Signal Handling + +Both adapters propagate signals from parent to child: + +```typescript +process.on('SIGINT', () => child.kill('SIGINT')); +process.on('SIGTERM', () => child.kill('SIGTERM')); +``` + +This ensures CTRL+C and graceful shutdowns work correctly. + +--- + +## Testing Target Adapters + +### Unit Tests + +```typescript +describe('ClaudeAdapter', () => { + it('detects Claude CLI', () => { + const adapter = new ClaudeAdapter(); + const binary = adapter.detectBinary(); + expect(binary).not.toBeNull(); + }); + + it('builds env with credentials', () => { + const adapter = new ClaudeAdapter(); + const env = adapter.buildEnv({ + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-ant-...', + model: 'claude-opus-4-6', + }, 'clipproxy'); + + expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...'); + }); +}); +``` + +### Integration Tests + +```bash +# Test Claude adapter +ccs --target claude help + +# Test Droid adapter (if installed) +ccs --target droid help + +# Test argv[0] detection +ccsd help +``` + +--- + +## Related Documentation + +- [Codebase Summary](../codebase-summary.md) — Module structure +- [Code Standards](../code-standards.md) — Adapter pattern guidelines +- [System Architecture Index](./index.md) — Overall system design diff --git a/package.json b/package.json index 9c32852e..b30d55d8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "main": "dist/ccs.js", "types": "dist/ccs.d.ts", "bin": { - "ccs": "dist/ccs.js" + "ccs": "dist/ccs.js", + "ccsd": "dist/ccs.js" }, "files": [ "dist/", @@ -102,6 +103,7 @@ "listr2": "^3.14.0", "open": "^8.4.2", "ora": "^5.4.1", + "proper-lockfile": "^4.1.2", "ws": "^8.16.0" }, "devDependencies": { @@ -121,6 +123,7 @@ "@types/express-session": "^1.18.2", "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.25", + "@types/proper-lockfile": "^4.1.4", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", diff --git a/src/ccs.ts b/src/ccs.ts index 9b6ca3f2..05f54109 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -38,6 +38,16 @@ import { handleUpdateCommand } from './commands/update-command'; // Import extracted utility functions import { execClaude, escapeShellArg } from './utils/shell-executor'; +// Import target adapter system +import { + registerTarget, + getTarget, + ClaudeAdapter, + DroidAdapter, + type TargetCredentials, +} from './targets'; +import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; + // Version and Update check utilities import { getVersion } from './utils/version'; import { @@ -264,6 +274,10 @@ async function showCachedUpdateNotification(): Promise { } async function main(): Promise { + // Register target adapters + registerTarget(new ClaudeAdapter()); + registerTarget(new DroidAdapter()); + const args = process.argv.slice(2); // Initialize UI colors early to ensure consistent colored output @@ -599,15 +613,35 @@ async function main(): Promise { console.log(''); } - // Detect profile - const { profile, remainingArgs } = detectProfile(args); + // Detect profile (strip --target from args before profile detection) + const cleanArgs = stripTargetFlag(args); + const { profile, remainingArgs } = detectProfile(cleanArgs); - // Detect Claude CLI first (needed for all paths) - const claudeCli = detectClaudeCli(); - if (!claudeCli) { + // Resolve target CLI (--target flag > per-profile config > argv[0] > 'claude') + const resolvedTarget = resolveTargetType(args); + + // Detect Claude CLI (needed for claude target and CLIProxy flows) + const claudeCliRaw = detectClaudeCli(); + if (resolvedTarget === 'claude' && !claudeCliRaw) { await ErrorManager.showClaudeNotFound(); process.exit(1); } + // For claude target, claudeCli is guaranteed non-null after the check above. + // For non-claude targets, CLIProxy flows still need Claude CLI — warn if missing. + const claudeCli = claudeCliRaw || ''; + + // For non-claude targets, verify target binary exists + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + const binaryInfo = adapter.detectBinary(); + if (!binaryInfo) { + console.error(fail(`${adapter.displayName} CLI not found.`)); + if (resolvedTarget === 'droid') { + console.error(info('Install: npm i -g @factory/cli')); + } + process.exit(1); + } + } // Use ProfileDetector to determine profile type const ProfileDetectorModule = await import('./auth/profile-detector'); @@ -623,6 +657,14 @@ async function main(): Promise { const profileInfo = detector.detectProfileType(profile); if (profileInfo.type === 'cliproxy') { + // Guard: non-claude targets don't support CLIProxy flow yet + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } + // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); @@ -641,6 +683,13 @@ async function main(): Promise { profileName: profileInfo.name, }); } else if (profileInfo.type === 'copilot') { + // Guard: non-claude targets don't support Copilot flow + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + console.error(fail(`${adapter.displayName} does not support Copilot profiles`)); + process.exit(1); + } + // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); @@ -721,6 +770,12 @@ async function main(): Promise { // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { + // Guard: non-claude targets don't support GLMT proxy flow + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`)); + process.exit(1); + } // GLMT FLOW: Settings-based with embedded proxy for thinking support await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); } else { @@ -753,9 +808,34 @@ async function main(): Promise { ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', + apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '', + model: settingsEnv['ANTHROPIC_MODEL'], + }; + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv); + return; + } + execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); } } else if (profileInfo.type === 'account') { + // Guard: non-claude targets don't support account profiles + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + console.error(fail(`${adapter.displayName} does not support account-based profiles`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } + // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR const registry = new ProfileRegistry(); @@ -790,6 +870,19 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; + + // Dispatch through target adapter for non-claude targets + if (resolvedTarget !== 'claude') { + const adapter = getTarget(resolvedTarget); + const targetArgs = adapter.buildArgs('default', remainingArgs); + const targetEnv = adapter.buildEnv( + { profile: 'default', baseUrl: '', apiKey: '' }, + 'default' + ); + adapter.exec(targetArgs, targetEnv); + return; + } + execClaude(claudeCli, remainingArgs, envVars); } } catch (error) { diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index 0c3d201d..90ec06f9 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -31,6 +31,8 @@ interface SessionLock { version?: string; /** Backend type running (original vs plus) */ backend?: 'original' | 'plus'; + /** Target CLI used for this session (default: 'claude') */ + target?: string; } /** Generate unique session ID */ diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ecfdbd0a..bc84c08f 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -301,11 +301,17 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Flags printSubSection('Flags', [ ['--config-dir ', 'Use custom CCS config directory'], + ['--target ', 'Target CLI: claude (default), droid'], ['-h, --help', 'Show this help message'], ['-v, --version', 'Show version and installation info'], ['-sc, --shell-completion', 'Install shell auto-completion'], ]); + // Aliases + printSubSection('Aliases', [ + ['ccsd [args]', 'Shorthand for: ccs --target droid'], + ]); + // Configuration printConfigSection('Configuration', [ ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 6a7797d0..6e6368c0 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -9,6 +9,8 @@ * Into a single config.yaml structure. */ +import type { TargetType } from '../targets/target-adapter'; + /** * Unified config version. * Version 2 = YAML unified format @@ -59,6 +61,8 @@ export interface ProfileConfig { type: 'api'; /** Path to settings file (e.g., "~/.ccs/glm.settings.json") */ settings: string; + /** Target CLI to use for this profile (default: 'claude') */ + target?: TargetType; } /** @@ -85,6 +89,8 @@ export interface CLIProxyVariantConfig { port?: number; /** Per-variant auth override (optional) */ auth?: CLIProxyAuthConfig; + /** Target CLI to use for this variant (default: 'claude') */ + target?: TargetType; } /** @@ -130,6 +136,8 @@ export interface CompositeVariantConfig { port?: number; /** Per-variant auth override (optional) */ auth?: CLIProxyAuthConfig; + /** Target CLI to use for this composite variant (default: 'claude') */ + target?: TargetType; } /** diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts new file mode 100644 index 00000000..3b267de8 --- /dev/null +++ b/src/targets/claude-adapter.ts @@ -0,0 +1,104 @@ +/** + * Claude Adapter + * + * TargetAdapter implementation for Claude Code CLI. + * Wraps existing detection, spawning, and execution logic. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; +import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector'; +import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; +import { ErrorManager } from '../utils/error-manager'; +import { getWebSearchHookEnv } from '../utils/websearch-manager'; + +export class ClaudeAdapter implements TargetAdapter { + readonly type: TargetType = 'claude'; + readonly displayName = 'Claude Code'; + + detectBinary(): TargetBinaryInfo | null { + const info = getClaudeCliInfo(); + if (!info) return null; + return { path: info.path, needsShell: info.needsShell }; + } + + /** + * Claude uses env vars for credential delivery — no config file writes needed. + */ + async prepareCredentials(_creds: TargetCredentials): Promise { + // No-op: Claude receives credentials via environment variables + } + + buildArgs(_profile: string, userArgs: string[]): string[] { + return userArgs; + } + + buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv { + const webSearchEnv = getWebSearchHookEnv(); + + // For account/default profiles, strip ANTHROPIC_* from parent env to prevent + // stale proxy config from interfering with native Claude API routing. + const baseEnv = + profileType === 'account' || profileType === 'default' + ? stripAnthropicEnv(process.env) + : process.env; + + const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv }; + + if (creds.envVars) { + Object.assign(env, creds.envVars); + } + + if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl; + if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey; + if (creds.model) env['ANTHROPIC_MODEL'] = creds.model; + + return env; + } + + exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { + const claudeCli = detectClaudeCli(); + if (!claudeCli) { + void ErrorManager.showClaudeNotFound(); + process.exit(1); + return; + } + + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + + let child: ChildProcess; + if (needsShell) { + const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env, + }); + } else { + child = spawn(claudeCli, args, { + stdio: 'inherit', + windowsHide: true, + env, + }); + } + + child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal as NodeJS.Signals); + else process.exit(code || 0); + }); + + child.on('error', async () => { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + }); + } + + /** + * Claude supports all CCS profile types. + */ + supportsProfileType(_profileType: string): boolean { + return true; + } +} diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts new file mode 100644 index 00000000..067234f8 --- /dev/null +++ b/src/targets/droid-adapter.ts @@ -0,0 +1,98 @@ +/** + * Droid Adapter + * + * TargetAdapter implementation for Factory Droid CLI. + * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-`. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; +import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; +import { upsertCcsModel } from './droid-config-manager'; +import { escapeShellArg } from '../utils/shell-executor'; + +export class DroidAdapter implements TargetAdapter { + readonly type: TargetType = 'droid'; + readonly displayName = 'Factory Droid'; + + detectBinary(): TargetBinaryInfo | null { + const info = getDroidBinaryInfo(); + if (!info) return null; + + // Version compatibility check (non-blocking warning) + checkDroidVersion(info.path); + return info; + } + + /** + * Write CCS credentials to ~/.factory/settings.json as a custom model entry. + * This is the key difference from Claude — Droid reads config files, not env vars. + */ + async prepareCredentials(creds: TargetCredentials): Promise { + await upsertCcsModel(creds.profile, { + model: creds.model || 'claude-opus-4-6', + displayName: `CCS ${creds.profile}`, + baseUrl: creds.baseUrl, + apiKey: creds.apiKey, + provider: creds.provider || 'anthropic', + }); + } + + buildArgs(profile: string, userArgs: string[]): string[] { + return ['-m', `custom:ccs-${profile}`, ...userArgs]; + } + + /** + * Droid uses config file for credentials — minimal env needed. + */ + buildEnv(_creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv { + return { ...process.env }; + } + + exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { + const droidPath = detectDroidCli(); + if (!droidPath) { + console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); + process.exit(1); + return; + } + + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath); + + let child: ChildProcess; + if (needsShell) { + const cmdString = [droidPath, ...args].map(escapeShellArg).join(' '); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env, + }); + } else { + child = spawn(droidPath, args, { + stdio: 'inherit', + windowsHide: true, + env, + }); + } + + child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal as NodeJS.Signals); + else process.exit(code || 0); + }); + + child.on('error', () => { + console.error('[X] Failed to start Droid CLI. Is @factory/cli installed?'); + process.exit(1); + }); + } + + /** + * Droid supports all profile types except account-based. + * Account profiles use CLAUDE_CONFIG_DIR which is Claude-specific. + */ + supportsProfileType(profileType: string): boolean { + return profileType !== 'account'; + } +} diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts new file mode 100644 index 00000000..d3352c57 --- /dev/null +++ b/src/targets/droid-config-manager.ts @@ -0,0 +1,242 @@ +/** + * Droid Config Manager + * + * Read/write ~/.factory/settings.json safely. + * Only touches ccs-* prefixed entries in customModels[]. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as lockfile from 'proper-lockfile'; + +const CCS_MODEL_PREFIX = 'ccs-'; + +export interface DroidCustomModel { + model: string; + displayName: string; + baseUrl: string; + apiKey: string; + provider: 'anthropic' | 'openai' | 'generic-chat-completion-api'; + maxOutputTokens?: number; +} + +interface DroidSettings { + customModels?: DroidCustomModelEntry[]; + [key: string]: unknown; +} + +interface DroidCustomModelEntry extends DroidCustomModel { + /** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */ +} + +/** + * Get path to ~/.factory/settings.json. + * Respects CCS_HOME for test isolation (uses CCS_HOME/.factory/ in tests). + */ +function getFactoryDir(): string { + const base = process.env.CCS_HOME || os.homedir(); + return path.join(base, '.factory'); +} + +function getSettingsPath(): string { + return path.join(getFactoryDir(), 'settings.json'); +} + +/** + * Ensure ~/.factory/ directory exists. + */ +function ensureFactoryDir(): void { + const dir = getFactoryDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } +} + +/** + * Read ~/.factory/settings.json, creating empty structure if missing. + */ +function readDroidSettings(): DroidSettings { + const settingsPath = getSettingsPath(); + if (!fs.existsSync(settingsPath)) { + return { customModels: [] }; + } + + const raw = fs.readFileSync(settingsPath, 'utf8'); + try { + return JSON.parse(raw) as DroidSettings; + } catch { + // Corrupted file — preserve as backup, start fresh + const backup = settingsPath + '.bak'; + fs.copyFileSync(settingsPath, backup); + console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`); + return { customModels: [] }; + } +} + +/** + * Write ~/.factory/settings.json atomically with safe permissions. + * Uses temp file + rename for atomicity on same filesystem. + */ +function writeDroidSettings(settings: DroidSettings): void { + ensureFactoryDir(); + const settingsPath = getSettingsPath(); + const tmpPath = settingsPath + '.tmp'; + + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + fs.renameSync(tmpPath, settingsPath); + + // Fix permissions on existing file if world-readable + try { + const stat = fs.statSync(settingsPath); + + if (stat.mode & 0o077) { + fs.chmodSync(settingsPath, 0o600); + console.warn('[!] Fixed permissions on ~/.factory/settings.json (was world-readable)'); + } + } catch { + // Best-effort permission check + } +} + +/** + * Build the custom model alias from a CCS profile name. + * e.g., "gemini" → "ccs-gemini" + */ +function ccsAlias(profile: string): string { + return `${CCS_MODEL_PREFIX}${profile}`; +} + +/** + * Upsert a CCS-managed custom model entry. + * Acquires file lock to prevent concurrent write races. + */ +export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise { + ensureFactoryDir(); + const settingsPath = getSettingsPath(); + + // Create file if it doesn't exist (lockfile needs an existing file) + if (!fs.existsSync(settingsPath)) { + writeDroidSettings({ customModels: [] }); + } + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(settingsPath, { + stale: 10000, + retries: { retries: 5, minTimeout: 200, maxTimeout: 1000 }, + }); + + const settings = readDroidSettings(); + if (!settings.customModels) { + settings.customModels = []; + } + + const alias = ccsAlias(profile); + const entry: DroidCustomModelEntry = { + ...model, + displayName: `CCS ${profile}`, + }; + + // Find existing entry by checking displayName for CCS prefix match + const idx = settings.customModels.findIndex( + (m) => m.displayName === `CCS ${profile}` || m.displayName === alias + ); + + if (idx >= 0) { + settings.customModels[idx] = entry; + } else { + settings.customModels.push(entry); + } + + writeDroidSettings(settings); + } finally { + if (release) await release(); + } +} + +/** + * Remove a CCS-managed custom model entry. + */ +export async function removeCcsModel(profile: string): Promise { + const settingsPath = getSettingsPath(); + if (!fs.existsSync(settingsPath)) return; + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(settingsPath, { + stale: 10000, + retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 }, + }); + + const settings = readDroidSettings(); + if (!settings.customModels) return; + + settings.customModels = settings.customModels.filter( + (m) => m.displayName !== `CCS ${profile}` && m.displayName !== ccsAlias(profile) + ); + + writeDroidSettings(settings); + } finally { + if (release) await release(); + } +} + +/** + * List all CCS-managed custom model entries. + */ +export async function listCcsModels(): Promise> { + const result = new Map(); + const settings = readDroidSettings(); + if (!settings.customModels) return result; + + for (const entry of settings.customModels) { + if (entry.displayName?.startsWith('CCS ')) { + const profile = entry.displayName.slice(4); // Remove "CCS " prefix + result.set(profile, entry); + } + } + + return result; +} + +/** + * Prune orphaned CCS entries from settings.json. + * Removes ccs-* entries whose profile no longer exists in active profiles. + */ +export async function pruneOrphanedModels(activeProfiles: string[]): Promise { + const settingsPath = getSettingsPath(); + if (!fs.existsSync(settingsPath)) return 0; + + let release: (() => Promise) | undefined; + let removed = 0; + + try { + release = await lockfile.lock(settingsPath, { + stale: 10000, + retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 }, + }); + + const settings = readDroidSettings(); + if (!settings.customModels) return 0; + + const before = settings.customModels.length; + settings.customModels = settings.customModels.filter((m) => { + if (!m.displayName?.startsWith('CCS ')) return true; // Keep non-CCS entries + const profile = m.displayName.slice(4); + return activeProfiles.includes(profile); + }); + + removed = before - settings.customModels.length; + if (removed > 0) { + writeDroidSettings(settings); + } + } finally { + if (release) await release(); + } + + return removed; +} diff --git a/src/targets/droid-detector.ts b/src/targets/droid-detector.ts new file mode 100644 index 00000000..fa8a3d2b --- /dev/null +++ b/src/targets/droid-detector.ts @@ -0,0 +1,104 @@ +/** + * Droid CLI Detector + * + * Detects Factory Droid CLI binary in PATH. + * Mirrors claude-detector.ts pattern. + */ + +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import { expandPath } from '../utils/helpers'; +import { TargetBinaryInfo } from './target-adapter'; + +/** + * Detect Droid CLI executable. + * + * Priority: + * 1. CCS_DROID_PATH env var (user override) + * 2. PATH lookup via which/where.exe + */ +export function detectDroidCli(): string | null { + // Priority 1: CCS_DROID_PATH environment variable + if (process.env.CCS_DROID_PATH) { + const customPath = expandPath(process.env.CCS_DROID_PATH); + if (fs.existsSync(customPath)) { + return customPath; + } + console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); + console.warn(' Falling back to system PATH lookup...'); + } + + // Priority 2: Resolve 'droid' from PATH + const isWindows = process.platform === 'win32'; + + try { + const cmd = isWindows ? 'where.exe droid' : 'which droid'; + const result = execSync(cmd, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }).trim(); + + const matches = result + .split('\n') + .map((p) => p.trim()) + .filter((p) => p); + + if (isWindows) { + const withExtension = matches.find((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)); + const droidPath = withExtension || matches[0]; + if (droidPath && fs.existsSync(droidPath)) { + return droidPath; + } + } else { + const droidPath = matches[0]; + if (droidPath && fs.existsSync(droidPath)) { + return droidPath; + } + } + } catch { + // droid not in PATH + } + + return null; +} + +/** + * Get Droid CLI binary info for target adapter. + */ +export function getDroidBinaryInfo(): TargetBinaryInfo | null { + const droidPath = detectDroidCli(); + if (!droidPath) return null; + + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath); + + return { path: droidPath, needsShell }; +} + +/** + * Check Droid CLI version for compatibility warnings. + * Non-blocking — logs warning and continues. + */ +export function checkDroidVersion(droidPath: string): void { + try { + const version = execSync(`"${droidPath}" --version`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }).trim(); + + // Parse semver major version + const match = version.match(/(\d+)\.\d+\.\d+/); + if (match) { + const major = parseInt(match[1]); + if (major >= 2) { + console.warn( + `[!] Droid version ${version} not verified with CCS. Config format may differ.` + ); + } + } + } catch { + // Version check is best-effort — don't block execution + } +} diff --git a/src/targets/index.ts b/src/targets/index.ts new file mode 100644 index 00000000..a0c42429 --- /dev/null +++ b/src/targets/index.ts @@ -0,0 +1,30 @@ +/** + * Target Adapter Module + * + * Re-exports for convenient access to target adapter types and registry. + */ + +export type { + TargetAdapter, + TargetBinaryInfo, + TargetCredentials, + TargetType, +} from './target-adapter'; +export { + registerTarget, + getTarget, + getDefaultTarget, + hasTarget, + getRegisteredTargets, +} from './target-registry'; +export { ClaudeAdapter } from './claude-adapter'; +export { DroidAdapter } from './droid-adapter'; +export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; +export { + upsertCcsModel, + removeCcsModel, + listCcsModels, + pruneOrphanedModels, +} from './droid-config-manager'; +export type { DroidCustomModel } from './droid-config-manager'; +export { resolveTargetType, stripTargetFlag } from './target-resolver'; diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts new file mode 100644 index 00000000..747c0ca3 --- /dev/null +++ b/src/targets/target-adapter.ts @@ -0,0 +1,66 @@ +/** + * Target Adapter Interface + * + * Abstraction layer for different CLI targets (Claude, Droid, etc.). + * Profile resolution is target-agnostic — only the "last mile" execution differs. + */ + +/** + * Supported CLI target types. + * 'claude' is the default; additional targets register via target-registry. + */ +export type TargetType = 'claude' | 'droid'; + +/** + * Credentials resolved by CCS profile system, ready for delivery to target CLI. + */ +export interface TargetCredentials { + /** CCS profile name (e.g., 'gemini', 'codex', 'glm') */ + profile: string; + baseUrl: string; + apiKey: string; + model?: string; + provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api'; + /** Additional env vars from profile resolution (websearch, hooks, etc.) */ + envVars?: NodeJS.ProcessEnv; +} + +/** + * Result of detecting a target CLI binary on the system. + */ +export interface TargetBinaryInfo { + path: string; + needsShell: boolean; // Windows .cmd/.bat/.ps1 +} + +/** + * Target adapter contract. + * + * Each target CLI implements this interface to handle: + * - Binary detection (is the CLI installed?) + * - Credential delivery (env vars vs config file writes) + * - Argument building (target-specific flags) + * - Process spawning (cross-platform execution) + */ +export interface TargetAdapter { + readonly type: TargetType; + readonly displayName: string; + + /** Detect if the target CLI binary exists on system */ + detectBinary(): TargetBinaryInfo | null; + + /** Prepare credentials for delivery to target CLI */ + prepareCredentials(creds: TargetCredentials): Promise; + + /** Build spawn arguments for the target CLI */ + buildArgs(profile: string, userArgs: string[]): string[]; + + /** Build environment variables for the target CLI */ + buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; + + /** Spawn the target CLI process (replaces current process flow) */ + exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void; + + /** Check if a profile type is supported by this target */ + supportsProfileType(profileType: string): boolean; +} diff --git a/src/targets/target-registry.ts b/src/targets/target-registry.ts new file mode 100644 index 00000000..c9ec1de3 --- /dev/null +++ b/src/targets/target-registry.ts @@ -0,0 +1,52 @@ +/** + * Target Registry + * + * Map-based registry for target adapters. + * Adapters self-register at startup; lookup is O(1). + */ + +import { TargetAdapter, TargetType } from './target-adapter'; + +const adapters = new Map(); + +/** + * Register a target adapter. Overwrites if already registered. + */ +export function registerTarget(adapter: TargetAdapter): void { + adapters.set(adapter.type, adapter); +} + +/** + * Get a registered target adapter by type. + * @throws Error if target type is not registered + */ +export function getTarget(type: TargetType): TargetAdapter { + const adapter = adapters.get(type); + if (!adapter) { + const available = Array.from(adapters.keys()).join(', '); + throw new Error(`Unknown target "${type}". Available: ${available}`); + } + return adapter; +} + +/** + * Get the default target adapter ('claude'). + * @throws Error if claude adapter is not registered + */ +export function getDefaultTarget(): TargetAdapter { + return getTarget('claude'); +} + +/** + * Check if a target type is registered. + */ +export function hasTarget(type: TargetType): boolean { + return adapters.has(type); +} + +/** + * Get all registered target types. + */ +export function getRegisteredTargets(): TargetType[] { + return Array.from(adapters.keys()); +} diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts new file mode 100644 index 00000000..d96955dd --- /dev/null +++ b/src/targets/target-resolver.ts @@ -0,0 +1,79 @@ +/** + * Target Resolver + * + * Resolves which CLI target to use based on: + * 1. --target flag (highest priority) + * 2. Per-profile config + * 3. argv[0] detection (busybox/symlink pattern) + * 4. Default: 'claude' + */ + +import * as path from 'path'; +import { TargetType } from './target-adapter'; + +/** + * Map of binary names to target types (busybox pattern). + * When CCS is invoked as `ccsd`, it auto-selects the droid target. + */ +const ARGV0_TARGET_MAP: Record = { + ccsd: 'droid', +}; + +/** + * Valid target types for --target flag validation. + */ +const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); + +/** + * Resolve target type from multiple sources with priority ordering. + * + * @param args - CLI arguments (may contain --target flag) + * @param profileConfig - Per-profile config with optional target field + * @returns Resolved target type + */ +export function resolveTargetType( + args: string[], + profileConfig?: { target?: TargetType } +): TargetType { + // 1. Check --target flag (highest priority) + const targetIdx = args.indexOf('--target'); + if (targetIdx !== -1 && args[targetIdx + 1]) { + const flagValue = args[targetIdx + 1]; + if (VALID_TARGETS.has(flagValue)) { + return flagValue as TargetType; + } + const available = Array.from(VALID_TARGETS).join(', '); + throw new Error(`Unknown target "${flagValue}". Available: ${available}`); + } + + // 2. Check per-profile config + if (profileConfig?.target) { + return profileConfig.target; + } + + // 3. Check argv[0] (busybox pattern) + // Strip .cmd/.bat extension for Windows npm shims + const rawBin = path.basename(process.argv[1] || ''); + const binName = rawBin.replace(/\.(cmd|bat)$/i, ''); + const argv0Target = ARGV0_TARGET_MAP[binName]; + if (argv0Target) { + return argv0Target; + } + + // 4. Default + return 'claude'; +} + +/** + * Strip --target flag and its value from args array. + * Returns new array without the flag (so it's not passed to target CLI). + */ +export function stripTargetFlag(args: string[]): string[] { + const targetIdx = args.indexOf('--target'); + if (targetIdx === -1) return args; + + const result = [...args]; + // Remove --target and its value + result.splice(targetIdx, 2); + return result; +} diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts index 820bfc53..1866e783 100644 --- a/src/web-server/usage/types.ts +++ b/src/web-server/usage/types.ts @@ -79,6 +79,8 @@ export interface SessionUsage { modelsUsed: string[]; modelBreakdowns: ModelBreakdown[]; source: string; + /** Target CLI used for this session (default: 'claude') */ + target?: string; } // ============================================================================ diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts new file mode 100644 index 00000000..734fdf3b --- /dev/null +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -0,0 +1,274 @@ +/** + * Unit tests for Droid config manager + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + upsertCcsModel, + removeCcsModel, + listCcsModels, + pruneOrphanedModels, +} from '../../../src/targets/droid-config-manager'; + +describe('droid-config-manager', () => { + let tmpDir: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('upsertCcsModel', () => { + it('should create settings.json with customModels', async () => { + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + provider: 'anthropic', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].displayName).toBe('CCS gemini'); + expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317'); + }); + + it('should update existing entry on second upsert', async () => { + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'key-1', + provider: 'anthropic', + }); + + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8318', + apiKey: 'key-2', + provider: 'anthropic', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].apiKey).toBe('key-2'); + expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318'); + }); + + it('should preserve user entries', async () => { + // Create existing settings with user's own custom model + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'gpt-4o', + displayName: 'My GPT', + baseUrl: 'https://api.openai.com', + apiKey: 'sk-xxx', + provider: 'openai', + }, + ], + }) + ); + + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }); + + const settings = JSON.parse( + fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') + ); + expect(settings.customModels).toHaveLength(2); + expect(settings.customModels[0].displayName).toBe('My GPT'); + expect(settings.customModels[1].displayName).toBe('CCS gemini'); + }); + + it('should write with restricted permissions', async () => { + await upsertCcsModel('test', { + model: 'test-model', + displayName: 'CCS test', + baseUrl: 'http://localhost:8317', + apiKey: 'secret', + provider: 'anthropic', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const stat = fs.statSync(settingsPath); + // eslint-disable-next-line no-bitwise + const otherPerms = stat.mode & 0o077; + expect(otherPerms).toBe(0); + }); + }); + + describe('removeCcsModel', () => { + it('should remove a CCS entry', async () => { + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }); + + await removeCcsModel('gemini'); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels).toHaveLength(0); + }); + + it('should not remove user entries', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, + { model: 'opus', displayName: 'CCS gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + ], + }) + ); + + await removeCcsModel('gemini'); + + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].displayName).toBe('My GPT'); + }); + }); + + describe('listCcsModels', () => { + it('should list only CCS entries', async () => { + await upsertCcsModel('gemini', { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }); + + await upsertCcsModel('codex', { + model: 'sonnet', + displayName: 'CCS codex', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }); + + const models = await listCcsModels(); + expect(models.size).toBe(2); + expect(models.has('gemini')).toBe(true); + expect(models.has('codex')).toBe(true); + }); + + it('should return empty map when no settings file', async () => { + const models = await listCcsModels(); + expect(models.size).toBe(0); + }); + }); + + describe('pruneOrphanedModels', () => { + it('should remove orphaned CCS entries', async () => { + await upsertCcsModel('gemini', { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }); + await upsertCcsModel('codex', { + model: 'sonnet', + displayName: 'CCS codex', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }); + + // Only gemini is active — codex should be pruned + const removed = await pruneOrphanedModels(['gemini']); + expect(removed).toBe(1); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + }); + + it('should preserve user entries during prune', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, + { model: 'opus', displayName: 'CCS old-profile', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + ], + }) + ); + + const removed = await pruneOrphanedModels([]); + expect(removed).toBe(1); + + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].displayName).toBe('My GPT'); + }); + }); + + describe('concurrent writes', () => { + it('should handle concurrent upserts without data loss', async () => { + // Write in batches of 3 to simulate realistic concurrency + // (10 simultaneous locks exceeds retry budget) + const profiles = Array.from({ length: 9 }, (_, i) => `profile-${i}`); + + for (let i = 0; i < profiles.length; i += 3) { + const batch = profiles.slice(i, i + 3); + await Promise.all( + batch.map((p) => + upsertCcsModel(p, { + model: 'test-model', + displayName: `CCS ${p}`, + baseUrl: 'http://localhost:8317', + apiKey: 'key', + provider: 'anthropic', + }) + ) + ); + } + + const models = await listCcsModels(); + expect(models.size).toBe(9); + + for (const p of profiles) { + expect(models.has(p)).toBe(true); + } + }); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts new file mode 100644 index 00000000..ab437a2d --- /dev/null +++ b/tests/unit/targets/target-registry.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for target registry and adapters + */ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + registerTarget, + getTarget, + getDefaultTarget, + hasTarget, + getRegisteredTargets, + ClaudeAdapter, + DroidAdapter, +} from '../../../src/targets'; + +describe('target-registry', () => { + beforeEach(() => { + // Re-register adapters (registry is module-scoped singleton) + registerTarget(new ClaudeAdapter()); + registerTarget(new DroidAdapter()); + }); + + it('should register and retrieve claude adapter', () => { + const adapter = getTarget('claude'); + expect(adapter.type).toBe('claude'); + expect(adapter.displayName).toBe('Claude Code'); + }); + + it('should register and retrieve droid adapter', () => { + const adapter = getTarget('droid'); + expect(adapter.type).toBe('droid'); + expect(adapter.displayName).toBe('Factory Droid'); + }); + + it('should return claude as default target', () => { + const adapter = getDefaultTarget(); + expect(adapter.type).toBe('claude'); + }); + + it('should throw for unknown target', () => { + expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/); + }); + + it('should check target existence', () => { + expect(hasTarget('claude')).toBe(true); + expect(hasTarget('droid')).toBe(true); + expect(hasTarget('unknown' as never)).toBe(false); + }); + + it('should list registered targets', () => { + const targets = getRegisteredTargets(); + expect(targets).toContain('claude'); + expect(targets).toContain('droid'); + }); +}); + +describe('ClaudeAdapter', () => { + const adapter = new ClaudeAdapter(); + + it('should have correct type and displayName', () => { + expect(adapter.type).toBe('claude'); + expect(adapter.displayName).toBe('Claude Code'); + }); + + it('should support all profile types', () => { + expect(adapter.supportsProfileType('account')).toBe(true); + expect(adapter.supportsProfileType('settings')).toBe(true); + expect(adapter.supportsProfileType('cliproxy')).toBe(true); + expect(adapter.supportsProfileType('default')).toBe(true); + expect(adapter.supportsProfileType('copilot')).toBe(true); + }); + + it('should build env with credentials', () => { + const env = adapter.buildEnv( + { + profile: 'gemini', + baseUrl: 'https://api.example.com', + apiKey: 'test-key', + model: 'claude-opus-4-6', + }, + 'settings' + ); + + expect(env['ANTHROPIC_BASE_URL']).toBe('https://api.example.com'); + expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('test-key'); + expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6'); + }); + + it('should pass through args unchanged', () => { + const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']); + expect(args).toEqual(['-p', 'hello', '--verbose']); + }); + + it('prepareCredentials should be no-op', async () => { + // Should not throw + await adapter.prepareCredentials({ + profile: 'test', + baseUrl: 'x', + apiKey: 'y', + }); + }); +}); + +describe('DroidAdapter', () => { + const adapter = new DroidAdapter(); + + it('should have correct type and displayName', () => { + expect(adapter.type).toBe('droid'); + expect(adapter.displayName).toBe('Factory Droid'); + }); + + it('should NOT support account profile type', () => { + expect(adapter.supportsProfileType('account')).toBe(false); + }); + + it('should support non-account profile types', () => { + expect(adapter.supportsProfileType('settings')).toBe(true); + expect(adapter.supportsProfileType('cliproxy')).toBe(true); + expect(adapter.supportsProfileType('default')).toBe(true); + expect(adapter.supportsProfileType('copilot')).toBe(true); + }); + + it('should build args with -m custom:ccs- prefix', () => { + const args = adapter.buildArgs('gemini', ['--verbose']); + expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']); + }); + + it('should build minimal env (no ANTHROPIC_ vars)', () => { + const env = adapter.buildEnv( + { + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + }, + 'cliproxy' + ); + + // Droid uses config file, not env vars + expect(env['ANTHROPIC_BASE_URL']).toBeUndefined(); + expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined(); + }); +}); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts new file mode 100644 index 00000000..0d609add --- /dev/null +++ b/tests/unit/targets/target-resolver.test.ts @@ -0,0 +1,96 @@ +/** + * Unit tests for target resolver + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target-resolver'; + +describe('resolveTargetType', () => { + const originalArgv = process.argv; + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should return claude as default', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('claude'); + }); + + it('should detect --target flag', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'droid'])).toBe('droid'); + }); + + it('should detect --target claude', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); + }); + + it('should use per-profile config when no flag', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'droid' })).toBe('droid'); + }); + + it('should prioritize --target flag over profile config', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude'); + }); + + it('should detect ccsd argv[0] (busybox pattern)', () => { + process.argv = ['node', 'ccsd']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should strip .cmd extension on Windows argv[0]', () => { + process.argv = ['node', 'ccsd.cmd']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should strip .bat extension on Windows argv[0]', () => { + process.argv = ['node', 'ccsd.bat']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should not match ccsd with .exe extension', () => { + // .exe is not stripped — ccsd.exe won't match 'ccsd' in the map + // This is intentional — npm creates .cmd shims, not .exe + process.argv = ['node', 'ccsd.exe']; + expect(resolveTargetType([])).toBe('claude'); + }); + + it('should prioritize --target over argv[0]', () => { + process.argv = ['node', 'ccsd']; + expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); + }); + + it('should prioritize profile config over argv[0]', () => { + process.argv = ['node', 'ccsd']; + expect(resolveTargetType([], { target: 'claude' })).toBe('claude'); + }); + + it('should throw for invalid --target value', () => { + process.argv = ['node', 'ccs']; + expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/); + }); +}); + +describe('stripTargetFlag', () => { + it('should remove --target and its value', () => { + expect(stripTargetFlag(['gemini', '--target', 'droid'])).toEqual(['gemini']); + }); + + it('should handle --target at start', () => { + expect(stripTargetFlag(['--target', 'droid', 'gemini'])).toEqual(['gemini']); + }); + + it('should return args unchanged if no --target', () => { + const args = ['gemini', '-p', 'hello']; + expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']); + }); + + it('should not modify the original array', () => { + const args = ['--target', 'droid', 'gemini']; + stripTargetFlag(args); + expect(args).toEqual(['--target', 'droid', 'gemini']); + }); +}); diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx index ad8e0d78..6d5c73a2 100644 --- a/ui/src/components/analytics/session-stats-card.tsx +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -133,9 +133,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar className="flex items-center justify-between text-xs p-1.5 rounded bg-muted/30 hover:bg-muted/50 transition-colors" >
- - {getProjectDisplayName(session.projectPath)} - +
+ + {getProjectDisplayName(session.projectPath)} + + {(session.target ?? 'claude') !== 'claude' && ( + + {session.target} + + )} +
{formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })} diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 39e8e393..709da6b6 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -100,6 +100,8 @@ export interface Session { cost: number; lastActivity: string; modelsUsed: string[]; + /** Target CLI used (default: 'claude') */ + target?: string; } export interface PaginatedSessions { From 3191a4ab3887b79c598c31de278a45361c587a48 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Feb 2026 15:32:34 +0700 Subject: [PATCH 04/31] fix(targets): harden edge cases from parallel code review - droid-config-manager.ts: Profile name validation, backup file perms (0o600), symlink detection before write - droid-detector.ts: Directory check for CCS_DROID_PATH via isFile() - ccs.ts: Replace hardcoded guards with supportsProfileType() calls, add prepareCredentials to default branch - droid-adapter.ts: errno-aware error messages (EACCES vs ENOENT) - maintainability-baseline.json: Updated edge case metrics Hardening fixes address permission handling, symlink detection, and error classification for improved robustness in edge cases. --- docs/metrics/maintainability-baseline.json | 6 ++-- src/ccs.ts | 39 ++++++++++++++-------- src/targets/droid-adapter.ts | 10 ++++-- src/targets/droid-config-manager.ts | 27 +++++++++++++++ src/targets/droid-detector.ts | 11 ++++-- 5 files changed, 71 insertions(+), 22 deletions(-) diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index ce360ce9..36e2bb6a 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -2,8 +2,8 @@ "sourceDirectory": "src", "largeFileThresholdLoc": 350, "typeScriptFileCount": 347, - "locInSrc": 69998, - "processExitReferenceCount": 168, - "synchronousFsApiReferenceCount": 850, + "locInSrc": 70100, + "processExitReferenceCount": 175, + "synchronousFsApiReferenceCount": 869, "largeFileCountOver350Loc": 55 } diff --git a/src/ccs.ts b/src/ccs.ts index 05f54109..79bc8027 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -660,9 +660,11 @@ async function main(): Promise { // Guard: non-claude targets don't support CLIProxy flow yet if (resolvedTarget !== 'claude') { const adapter = getTarget(resolvedTarget); - console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`)); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } } // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants @@ -686,8 +688,10 @@ async function main(): Promise { // Guard: non-claude targets don't support Copilot flow if (resolvedTarget !== 'claude') { const adapter = getTarget(resolvedTarget); - console.error(fail(`${adapter.displayName} does not support Copilot profiles`)); - process.exit(1); + if (!adapter.supportsProfileType('copilot')) { + console.error(fail(`${adapter.displayName} does not support Copilot profiles`)); + process.exit(1); + } } // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy @@ -773,8 +777,10 @@ async function main(): Promise { // Guard: non-claude targets don't support GLMT proxy flow if (resolvedTarget !== 'claude') { const adapter = getTarget(resolvedTarget); - console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`)); - process.exit(1); + if (!adapter.supportsProfileType('settings')) { + console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`)); + process.exit(1); + } } // GLMT FLOW: Settings-based with embedded proxy for thinking support await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); @@ -831,9 +837,11 @@ async function main(): Promise { // Guard: non-claude targets don't support account profiles if (resolvedTarget !== 'claude') { const adapter = getTarget(resolvedTarget); - console.error(fail(`${adapter.displayName} does not support account-based profiles`)); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); + if (!adapter.supportsProfileType('account')) { + console.error(fail(`${adapter.displayName} does not support account-based profiles`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } } // NEW FLOW: Account-based profile (work, personal) @@ -874,11 +882,14 @@ async function main(): Promise { // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { const adapter = getTarget(resolvedTarget); + if (!adapter.supportsProfileType('default')) { + console.error(fail(`${adapter.displayName} does not support default profile mode`)); + process.exit(1); + } + const creds: TargetCredentials = { profile: 'default', baseUrl: '', apiKey: '' }; + await adapter.prepareCredentials(creds); const targetArgs = adapter.buildArgs('default', remainingArgs); - const targetEnv = adapter.buildEnv( - { profile: 'default', baseUrl: '', apiKey: '' }, - 'default' - ); + const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(targetArgs, targetEnv); return; } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 067234f8..88133485 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -82,8 +82,14 @@ export class DroidAdapter implements TargetAdapter { else process.exit(code || 0); }); - child.on('error', () => { - console.error('[X] Failed to start Droid CLI. Is @factory/cli installed?'); + child.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EACCES') { + console.error('[X] Droid CLI not executable. Check file permissions.'); + } else if (err.code === 'ENOENT') { + console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); + } else { + console.error('[X] Failed to start Droid CLI:', err.message); + } process.exit(1); }); } diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index d3352c57..6f6e206d 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -12,6 +12,18 @@ import * as lockfile from 'proper-lockfile'; const CCS_MODEL_PREFIX = 'ccs-'; +/** + * Validate profile name to prevent filesystem/security issues. + * Only alphanumeric, underscore, hyphen allowed. + */ +function validateProfileName(profile: string): void { + if (!profile || !/^[a-zA-Z0-9_-]+$/.test(profile)) { + throw new Error( + `Invalid profile name "${profile}": must contain only alphanumeric characters, underscores, or hyphens` + ); + } +} + export interface DroidCustomModel { model: string; displayName: string; @@ -69,6 +81,7 @@ function readDroidSettings(): DroidSettings { // Corrupted file — preserve as backup, start fresh const backup = settingsPath + '.bak'; fs.copyFileSync(settingsPath, backup); + fs.chmodSync(backup, 0o600); // Secure backup permissions console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`); return { customModels: [] }; } @@ -81,6 +94,15 @@ function readDroidSettings(): DroidSettings { function writeDroidSettings(settings: DroidSettings): void { ensureFactoryDir(); const settingsPath = getSettingsPath(); + + // Refuse to write if target is a symlink (prevents symlink attacks) + if (fs.existsSync(settingsPath)) { + const stat = fs.lstatSync(settingsPath); + if (stat.isSymbolicLink()) { + throw new Error('Refusing to write: settings.json is a symlink'); + } + } + const tmpPath = settingsPath + '.tmp'; fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', { @@ -115,6 +137,7 @@ function ccsAlias(profile: string): string { * Acquires file lock to prevent concurrent write races. */ export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise { + validateProfileName(profile); ensureFactoryDir(); const settingsPath = getSettingsPath(); @@ -162,6 +185,7 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel): * Remove a CCS-managed custom model entry. */ export async function removeCcsModel(profile: string): Promise { + validateProfileName(profile); const settingsPath = getSettingsPath(); if (!fs.existsSync(settingsPath)) return; @@ -208,6 +232,9 @@ export async function listCcsModels(): Promise> { * Removes ccs-* entries whose profile no longer exists in active profiles. */ export async function pruneOrphanedModels(activeProfiles: string[]): Promise { + // Validate all profile names before pruning + activeProfiles.forEach((profile) => validateProfileName(profile)); + const settingsPath = getSettingsPath(); if (!fs.existsSync(settingsPath)) return 0; diff --git a/src/targets/droid-detector.ts b/src/targets/droid-detector.ts index fa8a3d2b..a833b54f 100644 --- a/src/targets/droid-detector.ts +++ b/src/targets/droid-detector.ts @@ -22,10 +22,15 @@ export function detectDroidCli(): string | null { if (process.env.CCS_DROID_PATH) { const customPath = expandPath(process.env.CCS_DROID_PATH); if (fs.existsSync(customPath)) { - return customPath; + if (fs.statSync(customPath).isFile()) { + return customPath; + } + console.warn('[!] CCS_DROID_PATH points to a directory, not a file:', customPath); + console.warn(' Falling back to system PATH lookup...'); + } else { + console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); + console.warn(' Falling back to system PATH lookup...'); } - console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); - console.warn(' Falling back to system PATH lookup...'); } // Priority 2: Resolve 'droid' from PATH From f81c56204cd981e265b1ed072b887ac80114f5c3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 01:55:02 +0700 Subject: [PATCH 05/31] ci(ai-review): switch from CLIProxy to GLM API for code reviews Route AI code reviews through api.z.ai instead of localhost CLIProxy. Uses GLM_API_KEY repo secret for authentication. --- .github/workflows/ai-review.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 5630e449..4f9403cc 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -67,15 +67,15 @@ jobs: contains(github.event.comment.body, '/review') && github.event.comment.user.type != 'Bot') - # CLIProxy environment for model routing + # GLM API environment for model routing env: - ANTHROPIC_BASE_URL: http://localhost:8317 - REVIEW_MODEL: claude-opus-4-6-thinking - ANTHROPIC_AUTH_TOKEN: ccs-internal-managed - ANTHROPIC_MODEL: claude-opus-4-6-thinking - ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4-6-thinking - ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-5-thinking - ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-sonnet-4-5 + ANTHROPIC_BASE_URL: https://api.z.ai/api/anthropic + REVIEW_MODEL: glm-5 + ANTHROPIC_AUTH_TOKEN: ${{ secrets.GLM_API_KEY }} + ANTHROPIC_MODEL: glm-5 + ANTHROPIC_DEFAULT_OPUS_MODEL: glm-5 + ANTHROPIC_DEFAULT_SONNET_MODEL: glm-5 + ANTHROPIC_DEFAULT_HAIKU_MODEL: GLM-4.7-FlashX DISABLE_BUG_COMMAND: "1" DISABLE_ERROR_REPORTING: "1" DISABLE_TELEMETRY: "1" @@ -119,7 +119,7 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - anthropic_api_key: ${{ secrets.CLIPROXY_API_KEY }} + anthropic_api_key: ${{ secrets.GLM_API_KEY }} github_token: ${{ steps.app-token.outputs.token }} path_to_claude_code_executable: /home/github-runner/.local/bin/claude track_progress: false # Disabled - no progress comments, just final review From 88ddae7c563f775221f3364e5f18ce2a65f1cbf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Feb 2026 18:56:31 +0000 Subject: [PATCH 06/31] chore(release): 7.45.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9c32852e..dbf89516 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.45.0-dev.1", + "version": "7.45.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 0431adf3061388b5b984cbcbbf336a09bab85be3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 03:22:45 +0700 Subject: [PATCH 07/31] fix(targets): close all remaining multi-target droid edge cases --- docs/system-architecture/target-adapters.md | 72 ++-- src/auth/profile-detector.ts | 5 + src/ccs.ts | 325 +++++++++++++----- src/cliproxy/session-tracker.ts | 152 +++++--- src/targets/claude-adapter.ts | 61 +++- src/targets/droid-adapter.ts | 91 ++++- src/targets/droid-config-manager.ts | 210 ++++++++--- src/targets/droid-detector.ts | 48 ++- src/targets/target-adapter.ts | 6 +- src/targets/target-resolver.ts | 84 ++++- src/utils/shell-executor.ts | 55 ++- src/web-server/jsonl-parser.ts | 6 + src/web-server/usage/data-aggregator.ts | 12 +- src/web-server/usage/handlers.ts | 1 + .../cliproxy/session-tracker-target.test.ts | 56 +++ tests/unit/data-aggregator.test.ts | 36 ++ tests/unit/jsonl-parser.test.ts | 20 ++ .../unit/targets/droid-config-manager.test.ts | 103 ++++++ tests/unit/targets/droid-detector.test.ts | 53 +++ tests/unit/targets/target-registry.test.ts | 52 ++- tests/unit/targets/target-resolver.test.ts | 74 +++- 21 files changed, 1257 insertions(+), 265 deletions(-) create mode 100644 tests/unit/cliproxy/session-tracker-target.test.ts create mode 100644 tests/unit/targets/droid-detector.test.ts diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 1edb7704..51488b3c 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -95,16 +95,11 @@ export function resolveTargetType( args: string[], profileConfig?: { target?: TargetType } ): TargetType { - // 1. Check --target flag - const targetIdx = args.indexOf('--target'); - if (targetIdx !== -1 && args[targetIdx + 1]) { - const flagValue = args[targetIdx + 1]; - if (VALID_TARGETS.has(flagValue)) { - return flagValue as TargetType; - } - // Invalid target → error - console.error(`[X] Unknown target "${flagValue}". Available: claude, droid`); - process.exit(1); + // 1. Parse --target flags (supports --target value and --target=value) + // Repeated flags: last one wins. + const parsed = parseTargetFlags(args); + if (parsed.targetOverride) { + return parsed.targetOverride; } // 2. Check profile config @@ -113,7 +108,7 @@ export function resolveTargetType( } // 3. Check argv[0] (binary name) - const binName = path.basename(process.argv[1] || '').replace(/\.(cmd|bat)$/i, ''); + const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, ''); if (ARGV0_TARGET_MAP[binName]) { return ARGV0_TARGET_MAP[binName]; } @@ -194,8 +189,14 @@ export class ClaudeAdapter implements TargetAdapter { } // Handle process termination - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); + const onSigInt = () => child.kill('SIGINT'); + const onSigTerm = () => child.kill('SIGTERM'); + process.once('SIGINT', onSigInt); + process.once('SIGTERM', onSigTerm); + child.on('exit', () => { + process.removeListener('SIGINT', onSigInt); + process.removeListener('SIGTERM', onSigTerm); + }); } supportsProfileType(profileType: string): boolean { @@ -253,12 +254,10 @@ export class DroidAdapter implements TargetAdapter { } async prepareCredentials(creds: TargetCredentials): Promise { - const profile = creds.envVars?.['CCS_PROFILE_NAME'] || 'default'; - // Write custom model entry to ~/.factory/settings.json - await upsertCcsModel(profile, { + await upsertCcsModel(creds.profile, { model: creds.model || 'claude-opus-4-6', - displayName: `CCS ${profile}`, + displayName: `CCS ${creds.profile}`, baseUrl: creds.baseUrl, apiKey: creds.apiKey, provider: creds.provider || 'anthropic', @@ -296,13 +295,19 @@ export class DroidAdapter implements TargetAdapter { } // Handle process termination - process.on('SIGINT', () => child.kill('SIGINT')); - process.on('SIGTERM', () => child.kill('SIGTERM')); + const onSigInt = () => child.kill('SIGINT'); + const onSigTerm = () => child.kill('SIGTERM'); + process.once('SIGINT', onSigInt); + process.once('SIGTERM', onSigTerm); + child.on('exit', () => { + process.removeListener('SIGINT', onSigInt); + process.removeListener('SIGTERM', onSigTerm); + }); } supportsProfileType(profileType: string): boolean { - // Droid supports all profile types (like Claude) - return true; + // Droid currently supports direct settings/default paths only + return profileType === 'settings' || profileType === 'default'; } } ``` @@ -313,22 +318,22 @@ export class DroidAdapter implements TargetAdapter { ```json { - "customModels": { - "ccs-gemini": { + "customModels": [ + { "model": "claude-opus-4-6", "displayName": "CCS gemini", "baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/", "apiKey": "AIza...", "provider": "openai" }, - "ccs-glm": { + { "model": "glm-4", "displayName": "CCS glm", "baseUrl": "https://open.bigmodel.cn/api/paas/v4/", "apiKey": "your-glm-key", "provider": "openai" } - } + ] } ``` @@ -349,7 +354,7 @@ ccs --target droid glm ### Binary Alias Pattern ```bash -# Create symlink to auto-select droid target +# Create alias/symlink to auto-select droid target ln -s /path/to/ccs /path/to/ccsd # Usage @@ -358,6 +363,8 @@ ccsd glm → droid -m custom:ccs-glm "args..." ``` +On Windows, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. + --- ## Registry and Lookup @@ -552,8 +559,15 @@ export function escapeShellArg(arg: string): string { Both adapters propagate signals from parent to child: ```typescript -process.on('SIGINT', () => child.kill('SIGINT')); -process.on('SIGTERM', () => child.kill('SIGTERM')); +const onSigInt = () => child.kill('SIGINT'); +const onSigTerm = () => child.kill('SIGTERM'); +process.once('SIGINT', onSigInt); +process.once('SIGTERM', onSigTerm); + +child.on('exit', () => { + process.removeListener('SIGINT', onSigInt); + process.removeListener('SIGTERM', onSigTerm); +}); ``` This ensures CTRL+C and graceful shutdowns work correctly. @@ -578,7 +592,7 @@ describe('ClaudeAdapter', () => { baseUrl: 'https://api.anthropic.com', apiKey: 'sk-ant-...', model: 'claude-opus-4-6', - }, 'clipproxy'); + }, 'cliproxy'); expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...'); }); diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 0d10bad3..064f9360 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -24,6 +24,7 @@ import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loade import { getCcsDir } from '../utils/config-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import type { TargetType } from '../targets/target-adapter'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; @@ -34,6 +35,7 @@ export type CLIProxyProfileName = CLIProxyProvider; export interface ProfileDetectionResult { type: ProfileType; name: string; + target?: TargetType; settingsPath?: string; profile?: Settings | ProfileMetadata; message?: string; @@ -143,6 +145,7 @@ class ProfileDetector { return { type: 'cliproxy', name: profileName, + target: composite.target, provider: defaultTierConfig.provider as CLIProxyProfileName, settingsPath: composite.settings, port: composite.port, @@ -156,6 +159,7 @@ class ProfileDetector { return { type: 'cliproxy', name: profileName, + target: singleVariant.target, provider: singleVariant.provider as CLIProxyProfileName, settingsPath: singleVariant.settings, port: singleVariant.port, @@ -170,6 +174,7 @@ class ProfileDetector { return { type: 'settings', name: profileName, + target: profile.target, env: settingsEnv, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 79bc8027..e7e7dd74 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -44,6 +44,7 @@ import { getTarget, ClaudeAdapter, DroidAdapter, + pruneOrphanedModels, type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; @@ -180,7 +181,8 @@ async function execClaudeWithProxy( }; const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); const imageAnalysisEnv = getImageAnalysisHookEnv(profileName); const env = { @@ -192,7 +194,17 @@ async function execClaudeWithProxy( }; let claude: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + claude = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); claude = spawn(cmdString, { stdio: 'inherit', @@ -209,28 +221,57 @@ async function execClaudeWithProxy( } // 5. Cleanup: kill proxy when Claude exits + const forwardSigTerm = () => { + proxy.kill('SIGTERM'); + claude.kill('SIGTERM'); + }; + const forwardSigInt = () => { + proxy.kill('SIGTERM'); + claude.kill('SIGINT'); + }; + const forwardSighup = () => { + proxy.kill('SIGTERM'); + claude.kill('SIGHUP'); + }; + process.on('SIGTERM', forwardSigTerm); + process.on('SIGINT', forwardSigInt); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGHUP', forwardSighup); + }; + claude.on('exit', (code, signal) => { + cleanupSignalHandlers(); proxy.kill('SIGTERM'); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); claude.on('error', (error) => { - console.error(fail(`Claude CLI error: ${error}`)); + cleanupSignalHandlers(); + const err = error as NodeJS.ErrnoException; + if (err.code === 'EACCES') { + console.error(fail(`Claude CLI is not executable: ${claudeCli}`)); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error(fail('PowerShell executable not found (required for .ps1 wrapper launch).')); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error(fail('Windows command shell not found for Claude wrapper launch.')); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + console.error(fail(`Claude CLI not found: ${claudeCli}`)); + } + } else { + console.error(fail(`Claude CLI error: ${err.message}`)); + } proxy.kill('SIGTERM'); process.exit(1); }); - - // Also handle parent process termination - process.once('SIGTERM', () => { - proxy.kill('SIGTERM'); - claude.kill('SIGTERM'); - }); - - process.once('SIGINT', () => { - proxy.kill('SIGTERM'); - claude.kill('SIGTERM'); - }); } // ========== Main Execution ========== @@ -399,7 +440,8 @@ async function main(): Promise { // Special case: version command (check BEFORE profile detection) if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { - handleVersionCommand(); + await handleVersionCommand(); + return; } // Special case: help command @@ -575,33 +617,6 @@ async function main(): Promise { process.exit(exitCode); } - // Special case: headless delegation (-p flag) - if (args.includes('-p') || args.includes('--prompt')) { - // CLIProxy profiles (codex/gemini/agy/etc, including user variants) must stay on - // the normal CLIProxy path so provider-specific flags (e.g. --effort/--thinking) - // and proxy chains are applied consistently. - let shouldUseDelegation = true; - const { profile } = detectProfile(args); - try { - const ProfileDetectorModule = await import('./auth/profile-detector'); - const ProfileDetector = ProfileDetectorModule.default; - const detector = new ProfileDetector(); - const profileInfo = detector.detectProfileType(profile); - if (profileInfo.type === 'cliproxy') { - shouldUseDelegation = false; - } - } catch { - // Best effort detection only; keep delegation fallback behavior. - } - - if (shouldUseDelegation) { - const { DelegationHandler } = await import('./delegation/delegation-handler'); - const handler = new DelegationHandler(); - await handler.route(args); - return; - } - } - // 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 @@ -613,36 +628,6 @@ async function main(): Promise { console.log(''); } - // Detect profile (strip --target from args before profile detection) - const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - - // Resolve target CLI (--target flag > per-profile config > argv[0] > 'claude') - const resolvedTarget = resolveTargetType(args); - - // Detect Claude CLI (needed for claude target and CLIProxy flows) - const claudeCliRaw = detectClaudeCli(); - if (resolvedTarget === 'claude' && !claudeCliRaw) { - await ErrorManager.showClaudeNotFound(); - process.exit(1); - } - // For claude target, claudeCli is guaranteed non-null after the check above. - // For non-claude targets, CLIProxy flows still need Claude CLI — warn if missing. - const claudeCli = claudeCliRaw || ''; - - // For non-claude targets, verify target binary exists - if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - const binaryInfo = adapter.detectBinary(); - if (!binaryInfo) { - console.error(fail(`${adapter.displayName} CLI not found.`)); - if (resolvedTarget === 'droid') { - console.error(info('Install: npm i -g @factory/cli')); - } - process.exit(1); - } - } - // Use ProfileDetector to determine profile type const ProfileDetectorModule = await import('./auth/profile-detector'); const ProfileDetector = ProfileDetectorModule.default; @@ -654,14 +639,132 @@ async function main(): Promise { const detector = new ProfileDetector(); try { + // Detect profile (strip --target flags before profile detection) + const cleanArgs = stripTargetFlag(args); + const { profile, remainingArgs } = detectProfile(cleanArgs); const profileInfo = detector.detectProfileType(profile); + let resolvedTarget: ReturnType; + try { + resolvedTarget = resolveTargetType( + args, + profileInfo.target ? { target: profileInfo.target } : undefined + ); + } catch (error) { + console.error(fail((error as Error).message)); + process.exit(1); + return; + } + + // Detect Claude CLI (needed for claude target and all CLIProxy-derived flows) + const claudeCliRaw = detectClaudeCli(); + if (resolvedTarget === 'claude' && !claudeCliRaw) { + await ErrorManager.showClaudeNotFound(); + process.exit(1); + } + const claudeCli = claudeCliRaw || ''; + + // Resolve non-claude target adapter once. + const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null; + + // Preflight unsupported profile/target combinations BEFORE binary detection, + // so users get the most actionable error even when the target CLI is not installed. + if (resolvedTarget !== 'claude') { + if (!targetAdapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } + + if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) { + console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } + + if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) { + console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`)); + process.exit(1); + } + + if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) { + console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`)); + console.error(info('Use a settings-based profile with --target instead')); + process.exit(1); + } + + // GLMT always requires Claude target because it depends on embedded proxy flow. + if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') { + console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`)); + console.error( + info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)') + ); + process.exit(1); + } + + if (profileInfo.type === 'default') { + if (!targetAdapter.supportsProfileType('default')) { + console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); + process.exit(1); + } + + // For default mode, Droid requires explicit credentials from environment. + if (resolvedTarget === 'droid') { + const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; + const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; + if (!baseUrl.trim() || !apiKey.trim()) { + console.error( + fail( + `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } + } + } + } + + // For non-claude targets, verify target binary exists once and pass it through. + const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; + if (resolvedTarget !== 'claude' && (!targetAdapter || !targetBinaryInfo)) { + const displayName = targetAdapter?.displayName || resolvedTarget; + console.error(fail(`${displayName} CLI not found.`)); + if (resolvedTarget === 'droid') { + console.error(info('Install: npm i -g @factory/cli')); + } + process.exit(1); + } + + // Best-effort: prune stale Droid model entries at runtime so settings.json stays clean. + if (resolvedTarget === 'droid') { + try { + const allProfiles = detector.getAllProfiles(); + const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name)); + await pruneOrphanedModels(activeProfiles); + } catch (error) { + console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); + } + } + + // Special case: headless delegation (-p/--prompt) + // Keep existing behavior for Claude targets only; non-claude targets must continue + // through normal adapter dispatch logic. + if (args.includes('-p') || args.includes('--prompt')) { + const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy'; + if (shouldUseDelegation) { + const { DelegationHandler } = await import('./delegation/delegation-handler'); + const handler = new DelegationHandler(); + await handler.route(cleanArgs); + return; + } + } if (profileInfo.type === 'cliproxy') { // Guard: non-claude targets don't support CLIProxy flow yet if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('cliproxy')) { - console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`)); + if (!targetAdapter?.supportsProfileType('cliproxy')) { + console.error( + fail(`${targetAdapter?.displayName || 'Target'} does not support CLIProxy profiles`) + ); console.error(info('Use a settings-based profile with --target instead')); process.exit(1); } @@ -687,9 +790,10 @@ async function main(): Promise { } else if (profileInfo.type === 'copilot') { // Guard: non-claude targets don't support Copilot flow if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('copilot')) { - console.error(fail(`${adapter.displayName} does not support Copilot profiles`)); + if (!targetAdapter?.supportsProfileType('copilot')) { + console.error( + fail(`${targetAdapter?.displayName || 'Target'} does not support Copilot profiles`) + ); process.exit(1); } } @@ -774,13 +878,14 @@ async function main(): Promise { // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { - // Guard: non-claude targets don't support GLMT proxy flow if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('settings')) { - console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`)); - process.exit(1); - } + console.error( + fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`) + ); + console.error( + info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)') + ); + process.exit(1); } // GLMT FLOW: Settings-based with embedded proxy for thinking support await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); @@ -817,7 +922,11 @@ async function main(): Promise { // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } const creds: TargetCredentials = { profile: profileInfo.name, baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', @@ -827,7 +936,7 @@ async function main(): Promise { await adapter.prepareCredentials(creds); const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); - adapter.exec(targetArgs, targetEnv); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; } @@ -836,9 +945,12 @@ async function main(): Promise { } else if (profileInfo.type === 'account') { // Guard: non-claude targets don't support account profiles if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); - if (!adapter.supportsProfileType('account')) { - console.error(fail(`${adapter.displayName} does not support account-based profiles`)); + if (!targetAdapter?.supportsProfileType('account')) { + console.error( + fail( + `${targetAdapter?.displayName || 'Target'} does not support account-based profiles` + ) + ); console.error(info('Use a settings-based profile with --target instead')); process.exit(1); } @@ -881,16 +993,34 @@ async function main(): Promise { // Dispatch through target adapter for non-claude targets if (resolvedTarget !== 'claude') { - const adapter = getTarget(resolvedTarget); + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exit(1); + } if (!adapter.supportsProfileType('default')) { console.error(fail(`${adapter.displayName} does not support default profile mode`)); process.exit(1); } - const creds: TargetCredentials = { profile: 'default', baseUrl: '', apiKey: '' }; + const creds: TargetCredentials = { + profile: 'default', + baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', + apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', + model: process.env['ANTHROPIC_MODEL'], + }; + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` + ) + ); + console.error(info('Use a settings-based profile instead: ccs glm --target droid')); + process.exit(1); + } await adapter.prepareCredentials(creds); const targetArgs = adapter.buildArgs('default', remainingArgs); const targetEnv = adapter.buildEnv(creds, 'default'); - adapter.exec(targetArgs, targetEnv); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; } @@ -924,12 +1054,19 @@ process.on('unhandledRejection', (reason: unknown) => { // Handle process termination signals for cleanup process.on('SIGTERM', () => { runCleanup(); - process.exit(0); + // If a target exec path registered additional signal listeners, let those + // listeners forward/coordinate child shutdown and final exit codes. + if (process.listenerCount('SIGTERM') <= 1) { + process.exit(143); // 128 + SIGTERM(15) + } }); process.on('SIGINT', () => { runCleanup(); - process.exit(130); // 128 + SIGINT(2) + // Same coordination rule as SIGTERM. + if (process.listenerCount('SIGINT') <= 1) { + process.exit(130); // 128 + SIGINT(2) + } }); // Run main diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts index 90ec06f9..5ec19009 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -17,6 +17,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; +import * as lockfile from 'proper-lockfile'; import { getCliproxyDir } from './config-generator'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import { CLIPROXY_DEFAULT_PORT } from './config-generator'; @@ -31,8 +32,10 @@ interface SessionLock { version?: string; /** Backend type running (original vs plus) */ backend?: 'original' | 'plus'; - /** Target CLI used for this session (default: 'claude') */ + /** Target summary for active sessions ('mixed' when multiple targets share the proxy) */ target?: string; + /** Per-session target metadata */ + sessionTargets?: Record; } /** Generate unique session ID */ @@ -48,6 +51,34 @@ function getSessionLockPathForPort(port: number): string { return path.join(getCliproxyDir(), `sessions-${port}.json`); } +function ensureCliproxyDir(): string { + const dir = getCliproxyDir(); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + return dir; +} + +function withSessionTrackerLock(fn: () => T): T { + const dir = ensureCliproxyDir(); + let release: (() => void) | undefined; + + try { + release = lockfile.lockSync(dir, { + stale: 10000, + }) as () => void; + return fn(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release + } + } + } +} + /** Get path to session lock file (default port) - kept for future use */ function _getSessionLockPath(): string { return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT); @@ -87,13 +118,24 @@ function readSessionLock(): SessionLock | null { return readSessionLockForPort(CLIPROXY_DEFAULT_PORT); } +function getTargetSummary(lock: SessionLock): string | undefined { + const targets = lock.sessionTargets ? Object.values(lock.sessionTargets).filter(Boolean) : []; + if (targets.length === 0) { + return lock.target; + } + + const unique = new Set(targets); + if (unique.size === 1) { + return targets[0]; + } + return 'mixed'; +} + /** 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 }); - } + ensureCliproxyDir(); + lock.target = getTargetSummary(lock); fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 }); } @@ -186,16 +228,24 @@ export function registerSession( port: number, proxyPid: number, version?: string, - backend?: 'original' | 'plus' + backend?: 'original' | 'plus', + target?: string ): string { const sessionId = generateSessionId(); - const existingLock = readSessionLockForPort(port); + withSessionTrackerLock(() => { + const existingLock = readSessionLockForPort(port); + + if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) { + // Add to existing sessions + existingLock.sessions.push(sessionId); + if (target) { + existingLock.sessionTargets = existingLock.sessionTargets || {}; + existingLock.sessionTargets[sessionId] = target; + } + writeSessionLockForPort(existingLock); + return; + } - if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) { - // Add to existing sessions - existingLock.sessions.push(sessionId); - writeSessionLockForPort(existingLock); - } else { // Create new lock (first session for this proxy) const newLock: SessionLock = { port, @@ -204,9 +254,11 @@ export function registerSession( startedAt: new Date().toISOString(), version, backend, + target, + sessionTargets: target ? { [sessionId]: target } : undefined, }; writeSessionLockForPort(newLock); - } + }); return sessionId; } @@ -220,48 +272,66 @@ export function registerSession( export function unregisterSession(sessionId: string, port?: number): boolean { // If port provided, use port-specific lookup if (port !== undefined) { - const lock = readSessionLockForPort(port); + return withSessionTrackerLock(() => { + const lock = readSessionLockForPort(port); + if (!lock) { + return true; + } + + const index = lock.sessions.indexOf(sessionId); + if (index !== -1) { + lock.sessions.splice(index, 1); + } + + if (lock.sessionTargets) { + delete lock.sessionTargets[sessionId]; + if (Object.keys(lock.sessionTargets).length === 0) { + delete lock.sessionTargets; + } + } + + if (lock.sessions.length === 0) { + deleteSessionLockForPort(port); + return true; + } + + writeSessionLockForPort(lock); + return false; + }); + } + + // Fallback: search default port (backward compat) + return withSessionTrackerLock(() => { + const lock = readSessionLock(); if (!lock) { + // No lock file - assume we're the only session return true; } + // Remove this session from the list const index = lock.sessions.indexOf(sessionId); if (index !== -1) { lock.sessions.splice(index, 1); } + if (lock.sessionTargets) { + delete lock.sessionTargets[sessionId]; + if (Object.keys(lock.sessionTargets).length === 0) { + delete lock.sessionTargets; + } + } + + // Check if any sessions remain if (lock.sessions.length === 0) { - deleteSessionLockForPort(port); + // Last session - clean up lock file + deleteSessionLock(); return true; } + // Other sessions still active - keep proxy running writeSessionLockForPort(lock); return false; - } - - // Fallback: search default port (backward compat) - const lock = readSessionLock(); - if (!lock) { - // No lock file - assume we're the only session - return true; - } - - // Remove this session from the list - const index = lock.sessions.indexOf(sessionId); - if (index !== -1) { - lock.sessions.splice(index, 1); - } - - // Check if any sessions remain - if (lock.sessions.length === 0) { - // Last session - clean up lock file - deleteSessionLock(); - return true; - } - - // Other sessions still active - keep proxy running - writeSessionLockForPort(lock); - return false; + }); } /** @@ -422,6 +492,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { sessionCount?: number; startedAt?: string; version?: string; + target?: string; } { const lock = readSessionLockForPort(port); @@ -442,6 +513,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): { sessionCount: lock.sessions.length, startedAt: lock.startedAt, version: lock.version, + target: lock.target || 'claude', }; } diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 3b267de8..2f61e4b6 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -56,7 +56,11 @@ export class ClaudeAdapter implements TargetAdapter { return env; } - exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { + exec( + args: string[], + env: NodeJS.ProcessEnv, + _options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void { const claudeCli = detectClaudeCli(); if (!claudeCli) { void ErrorManager.showClaudeNotFound(); @@ -65,10 +69,21 @@ export class ClaudeAdapter implements TargetAdapter { } const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); let child: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); child = spawn(cmdString, { stdio: 'inherit', @@ -84,13 +99,49 @@ export class ClaudeAdapter implements TargetAdapter { }); } + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; + child.on('exit', (code, signal) => { + cleanupSignalHandlers(); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); - child.on('error', async () => { - await ErrorManager.showClaudeNotFound(); + child.on('error', async (err: NodeJS.ErrnoException) => { + cleanupSignalHandlers(); + if (err.code === 'EACCES') { + console.error(`[X] Claude CLI is not executable: ${claudeCli}`); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Claude wrapper launch.'); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + await ErrorManager.showClaudeNotFound(); + } + } else { + console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`); + } process.exit(1); }); } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 88133485..0cbb6946 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -6,6 +6,7 @@ */ import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; import { upsertCcsModel } from './droid-config-manager'; @@ -15,6 +16,15 @@ export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; readonly displayName = 'Factory Droid'; + private validateCredentials(creds: TargetCredentials): void { + if (!creds.baseUrl?.trim()) { + throw new Error('Droid target requires ANTHROPIC_BASE_URL'); + } + if (!creds.apiKey?.trim()) { + throw new Error('Droid target requires ANTHROPIC_AUTH_TOKEN'); + } + } + detectBinary(): TargetBinaryInfo | null { const info = getDroidBinaryInfo(); if (!info) return null; @@ -29,6 +39,7 @@ export class DroidAdapter implements TargetAdapter { * This is the key difference from Claude — Droid reads config files, not env vars. */ async prepareCredentials(creds: TargetCredentials): Promise { + this.validateCredentials(creds); await upsertCcsModel(creds.profile, { model: creds.model || 'claude-opus-4-6', displayName: `CCS ${creds.profile}`, @@ -49,19 +60,49 @@ export class DroidAdapter implements TargetAdapter { return { ...process.env }; } - exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void { - const droidPath = detectDroidCli(); + exec( + args: string[], + env: NodeJS.ProcessEnv, + options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void { + const droidPath = options?.binaryInfo?.path || detectDroidCli(); if (!droidPath) { console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); process.exit(1); return; } + try { + const stat = fs.statSync(droidPath); + if (!stat.isFile()) { + console.error(`[X] Droid CLI path is not a file: ${droidPath}`); + process.exit(1); + return; + } + } catch (err) { + const error = err as NodeJS.ErrnoException; + console.error( + `[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}` + ); + process.exit(1); + return; + } const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath); + const isPowerShellScript = isWindows && /\.ps1$/i.test(droidPath); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(droidPath); let child: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', droidPath, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { const cmdString = [droidPath, ...args].map(escapeShellArg).join(' '); child = spawn(cmdString, { stdio: 'inherit', @@ -77,28 +118,58 @@ export class DroidAdapter implements TargetAdapter { }); } + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; + child.on('exit', (code, signal) => { + cleanupSignalHandlers(); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); child.on('error', (err: NodeJS.ErrnoException) => { + cleanupSignalHandlers(); if (err.code === 'EACCES') { - console.error('[X] Droid CLI not executable. Check file permissions.'); + console.error(`[X] Droid CLI is not executable: ${droidPath}`); + console.error(' Check file permissions and executable bit.'); } else if (err.code === 'ENOENT') { - console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Droid wrapper launch.'); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + console.error(`[X] Droid CLI not found: ${droidPath}`); + console.error(' Install: npm i -g @factory/cli'); + } } else { - console.error('[X] Failed to start Droid CLI:', err.message); + console.error(`[X] Failed to start Droid CLI (${droidPath}):`, err.message); } process.exit(1); }); } /** - * Droid supports all profile types except account-based. - * Account profiles use CLAUDE_CONFIG_DIR which is Claude-specific. + * Droid currently supports direct settings-based and default flows only. */ supportsProfileType(profileType: string): boolean { - return profileType !== 'account'; + return profileType === 'settings' || profileType === 'default'; } } diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 6f6e206d..f1255f66 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -38,10 +38,54 @@ interface DroidSettings { [key: string]: unknown; } -interface DroidCustomModelEntry extends DroidCustomModel { +interface DroidCustomModelEntry { + model: string; + displayName: string; + baseUrl: string; + apiKey: string; + provider: string; + maxOutputTokens?: number; /** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */ } +function isSupportedProvider(value: string): value is DroidCustomModel['provider'] { + return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api'; +} + +function asModelEntry(value: unknown): DroidCustomModelEntry | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + if ( + typeof record.displayName !== 'string' || + record.displayName.trim() === '' || + typeof record.model !== 'string' || + typeof record.baseUrl !== 'string' || + typeof record.apiKey !== 'string' || + typeof record.provider !== 'string' || + record.provider.trim() === '' + ) { + return null; + } + return value as DroidCustomModelEntry; +} + +function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] { + if (Array.isArray(value)) { + return value + .map((entry) => asModelEntry(entry)) + .filter((entry): entry is DroidCustomModelEntry => !!entry); + } + + // Accept legacy object-map shapes and normalize to array. + if (value && typeof value === 'object') { + return Object.values(value) + .map((entry) => asModelEntry(entry)) + .filter((entry): entry is DroidCustomModelEntry => !!entry); + } + + return []; +} + /** * Get path to ~/.factory/settings.json. * Respects CCS_HOME for test isolation (uses CCS_HOME/.factory/ in tests). @@ -65,6 +109,35 @@ function ensureFactoryDir(): void { } } +function getNoFollowFlag(): number { + const candidate = (fs.constants as Record)['O_NOFOLLOW']; + if (process.platform !== 'win32' && typeof candidate === 'number') { + return candidate; + } + return 0; +} + +function openFileNoFollow(filePath: string, flags: number, mode?: number): number { + const safeFlags = flags | getNoFollowFlag(); + if (mode === undefined) { + return fs.openSync(filePath, safeFlags); + } + return fs.openSync(filePath, safeFlags, mode); +} + +function readFileUtf8NoFollow(filePath: string): string { + const fd = openFileNoFollow(filePath, fs.constants.O_RDONLY); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + throw new Error('Refusing to read: settings.json is not a regular file'); + } + return fs.readFileSync(fd, 'utf8'); + } finally { + fs.closeSync(fd); + } +} + /** * Read ~/.factory/settings.json, creating empty structure if missing. */ @@ -74,19 +147,63 @@ function readDroidSettings(): DroidSettings { return { customModels: [] }; } - const raw = fs.readFileSync(settingsPath, 'utf8'); + const fileStat = fs.lstatSync(settingsPath); + if (fileStat.isSymbolicLink()) { + throw new Error('Refusing to read: settings.json is a symlink'); + } + if (!fileStat.isFile()) { + throw new Error('Refusing to read: settings.json is not a regular file'); + } + + const raw = readFileUtf8NoFollow(settingsPath); try { - return JSON.parse(raw) as DroidSettings; + const parsed = JSON.parse(raw) as DroidSettings; + return { + ...parsed, + customModels: normalizeCustomModels((parsed as { customModels?: unknown }).customModels), + }; } catch { // Corrupted file — preserve as backup, start fresh const backup = settingsPath + '.bak'; - fs.copyFileSync(settingsPath, backup); - fs.chmodSync(backup, 0o600); // Secure backup permissions - console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`); + try { + fs.copyFileSync(settingsPath, backup); + fs.chmodSync(backup, 0o600); // Secure backup permissions + console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`); + } catch (error) { + console.warn(`[!] Corrupted ${settingsPath}; backup failed: ${(error as Error).message}`); + } return { customModels: [] }; } } +async function acquireFactoryLock(retries: number): Promise<() => Promise> { + ensureFactoryDir(); + const factoryDir = getFactoryDir(); + try { + return await lockfile.lock(factoryDir, { + stale: 10000, + retries: { retries, minTimeout: 200, maxTimeout: 1000 }, + }); + } catch (error) { + throw new Error( + `Failed to lock Droid settings directory (${factoryDir}): ${(error as Error).message}` + ); + } +} + +function fsyncDir(dirPath: string): void { + try { + const dirFd = fs.openSync(dirPath, fs.constants.O_RDONLY); + try { + fs.fsyncSync(dirFd); + } finally { + fs.closeSync(dirFd); + } + } catch { + // Best-effort directory fsync (platform dependent). + } +} + /** * Write ~/.factory/settings.json atomically with safe permissions. * Uses temp file + rename for atomicity on same filesystem. @@ -104,12 +221,38 @@ function writeDroidSettings(settings: DroidSettings): void { } const tmpPath = settingsPath + '.tmp'; + if (fs.existsSync(tmpPath)) { + const tmpStat = fs.lstatSync(tmpPath); + if (tmpStat.isSymbolicLink()) { + throw new Error('Refusing to write: settings.json.tmp is a symlink'); + } + } - fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', { - encoding: 'utf8', - mode: 0o600, - }); + const payload = JSON.stringify( + { + ...settings, + customModels: normalizeCustomModels((settings as { customModels?: unknown }).customModels), + }, + null, + 2 + ); + const fd = openFileNoFollow( + tmpPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC, + 0o600 + ); + try { + const tmpFdStat = fs.fstatSync(fd); + if (!tmpFdStat.isFile()) { + throw new Error('Refusing to write: settings.json.tmp is not a regular file'); + } + fs.writeFileSync(fd, payload + '\n', { encoding: 'utf8' }); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } fs.renameSync(tmpPath, settingsPath); + fsyncDir(path.dirname(settingsPath)); // Fix permissions on existing file if world-readable try { @@ -139,24 +282,13 @@ function ccsAlias(profile: string): string { export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise { validateProfileName(profile); ensureFactoryDir(); - const settingsPath = getSettingsPath(); - - // Create file if it doesn't exist (lockfile needs an existing file) - if (!fs.existsSync(settingsPath)) { - writeDroidSettings({ customModels: [] }); - } let release: (() => Promise) | undefined; try { - release = await lockfile.lock(settingsPath, { - stale: 10000, - retries: { retries: 5, minTimeout: 200, maxTimeout: 1000 }, - }); + release = await acquireFactoryLock(5); const settings = readDroidSettings(); - if (!settings.customModels) { - settings.customModels = []; - } + settings.customModels = normalizeCustomModels(settings.customModels); const alias = ccsAlias(profile); const entry: DroidCustomModelEntry = { @@ -186,18 +318,16 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel): */ export async function removeCcsModel(profile: string): Promise { validateProfileName(profile); + ensureFactoryDir(); const settingsPath = getSettingsPath(); - if (!fs.existsSync(settingsPath)) return; let release: (() => Promise) | undefined; try { - release = await lockfile.lock(settingsPath, { - stale: 10000, - retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 }, - }); + release = await acquireFactoryLock(3); + if (!fs.existsSync(settingsPath)) return; const settings = readDroidSettings(); - if (!settings.customModels) return; + settings.customModels = normalizeCustomModels(settings.customModels); settings.customModels = settings.customModels.filter( (m) => m.displayName !== `CCS ${profile}` && m.displayName !== ccsAlias(profile) @@ -215,12 +345,16 @@ export async function removeCcsModel(profile: string): Promise { export async function listCcsModels(): Promise> { const result = new Map(); const settings = readDroidSettings(); - if (!settings.customModels) return result; - - for (const entry of settings.customModels) { + for (const entry of normalizeCustomModels(settings.customModels)) { if (entry.displayName?.startsWith('CCS ')) { + if (!isSupportedProvider(entry.provider)) { + continue; + } const profile = entry.displayName.slice(4); // Remove "CCS " prefix - result.set(profile, entry); + result.set(profile, { + ...entry, + provider: entry.provider, + }); } } @@ -235,20 +369,18 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise validateProfileName(profile)); + ensureFactoryDir(); const settingsPath = getSettingsPath(); - if (!fs.existsSync(settingsPath)) return 0; let release: (() => Promise) | undefined; let removed = 0; try { - release = await lockfile.lock(settingsPath, { - stale: 10000, - retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 }, - }); + release = await acquireFactoryLock(3); + if (!fs.existsSync(settingsPath)) return 0; const settings = readDroidSettings(); - if (!settings.customModels) return 0; + settings.customModels = normalizeCustomModels(settings.customModels); const before = settings.customModels.length; settings.customModels = settings.customModels.filter((m) => { diff --git a/src/targets/droid-detector.ts b/src/targets/droid-detector.ts index a833b54f..bfa2415f 100644 --- a/src/targets/droid-detector.ts +++ b/src/targets/droid-detector.ts @@ -6,7 +6,7 @@ */ import * as fs from 'fs'; -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import { expandPath } from '../utils/helpers'; import { TargetBinaryInfo } from './target-adapter'; @@ -21,15 +21,25 @@ export function detectDroidCli(): string | null { // Priority 1: CCS_DROID_PATH environment variable if (process.env.CCS_DROID_PATH) { const customPath = expandPath(process.env.CCS_DROID_PATH); - if (fs.existsSync(customPath)) { + try { if (fs.statSync(customPath).isFile()) { return customPath; } console.warn('[!] CCS_DROID_PATH points to a directory, not a file:', customPath); - console.warn(' Falling back to system PATH lookup...'); - } else { - console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); - console.warn(' Falling back to system PATH lookup...'); + console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.'); + return null; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath); + } else { + console.warn( + `[!] Warning: CCS_DROID_PATH is not accessible (${error.code || 'unknown error'}):`, + customPath + ); + } + console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.'); + return null; } } @@ -49,16 +59,20 @@ export function detectDroidCli(): string | null { .map((p) => p.trim()) .filter((p) => p); - if (isWindows) { - const withExtension = matches.find((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)); - const droidPath = withExtension || matches[0]; - if (droidPath && fs.existsSync(droidPath)) { - return droidPath; - } - } else { - const droidPath = matches[0]; - if (droidPath && fs.existsSync(droidPath)) { - return droidPath; + const candidates = isWindows + ? [ + ...matches.filter((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)), + ...matches.filter((p) => !/\.(exe|cmd|bat|ps1)$/i.test(p)), + ] + : matches; + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Ignore unreadable or disappearing path candidates and try next one } } } catch { @@ -87,7 +101,7 @@ export function getDroidBinaryInfo(): TargetBinaryInfo | null { */ export function checkDroidVersion(droidPath: string): void { try { - const version = execSync(`"${droidPath}" --version`, { + const version = execFileSync(droidPath, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index 747c0ca3..edb6ff7d 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -59,7 +59,11 @@ export interface TargetAdapter { buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; /** Spawn the target CLI process (replaces current process flow) */ - exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void; + exec( + args: string[], + env: NodeJS.ProcessEnv, + options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } + ): void; /** Check if a profile type is supported by this target */ supportsProfileType(profileType: string): boolean; diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index d96955dd..3aa06441 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -24,6 +24,64 @@ const ARGV0_TARGET_MAP: Record = { */ const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); +interface ParsedTargetFlags { + targetOverride?: TargetType; + cleanedArgs: string[]; +} + +function normalizeTargetValue(value: string): TargetType { + const normalized = value.toLowerCase(); + if (VALID_TARGETS.has(normalized)) { + return normalized as TargetType; + } + + const available = Array.from(VALID_TARGETS).join(', '); + throw new Error(`Unknown target "${value}". Available: ${available}`); +} + +/** + * Parse and strip all --target flags from args. + * Supports both "--target value" and "--target=value" forms. + * For repeated flags, last one wins (common CLI precedence behavior). + */ +function parseTargetFlags(args: string[]): ParsedTargetFlags { + const cleanedArgs: string[] = []; + let targetOverride: TargetType | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + // POSIX option terminator: everything after `--` is positional. + if (arg === '--') { + cleanedArgs.push(...args.slice(i)); + break; + } + + if (arg === '--target') { + const value = args[i + 1]; + if (!value || value.startsWith('-')) { + throw new Error('--target requires a value (claude or droid)'); + } + targetOverride = normalizeTargetValue(value); + i += 1; // Skip value + continue; + } + + if (arg.startsWith('--target=')) { + const value = arg.slice('--target='.length).trim(); + if (!value) { + throw new Error('--target requires a value (claude or droid)'); + } + targetOverride = normalizeTargetValue(value); + continue; + } + + cleanedArgs.push(arg); + } + + return { targetOverride, cleanedArgs }; +} + /** * Resolve target type from multiple sources with priority ordering. * @@ -35,15 +93,11 @@ export function resolveTargetType( args: string[], profileConfig?: { target?: TargetType } ): TargetType { + const parsed = parseTargetFlags(args); + // 1. Check --target flag (highest priority) - const targetIdx = args.indexOf('--target'); - if (targetIdx !== -1 && args[targetIdx + 1]) { - const flagValue = args[targetIdx + 1]; - if (VALID_TARGETS.has(flagValue)) { - return flagValue as TargetType; - } - const available = Array.from(VALID_TARGETS).join(', '); - throw new Error(`Unknown target "${flagValue}". Available: ${available}`); + if (parsed.targetOverride) { + return parsed.targetOverride; } // 2. Check per-profile config @@ -52,9 +106,9 @@ export function resolveTargetType( } // 3. Check argv[0] (busybox pattern) - // Strip .cmd/.bat extension for Windows npm shims - const rawBin = path.basename(process.argv[1] || ''); - const binName = rawBin.replace(/\.(cmd|bat)$/i, ''); + // Strip common wrapper extensions for Windows shims/wrappers + const rawBin = path.basename(process.argv[1] || process.argv0 || ''); + const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, ''); const argv0Target = ARGV0_TARGET_MAP[binName]; if (argv0Target) { return argv0Target; @@ -69,11 +123,5 @@ export function resolveTargetType( * Returns new array without the flag (so it's not passed to target CLI). */ export function stripTargetFlag(args: string[]): string[] { - const targetIdx = args.indexOf('--target'); - if (targetIdx === -1) return args; - - const result = [...args]; - // Remove --target and its value - result.splice(targetIdx, 2); - return result; + return parseTargetFlags(args).cleanedArgs; } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 6afa2620..a672f97b 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -62,7 +62,8 @@ export function execClaude( envVars: NodeJS.ProcessEnv | null = null ): void { const isWindows = process.platform === 'win32'; - const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); + const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli); + const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli); // Get WebSearch hook config env vars const webSearchEnv = getWebSearchHookEnv(); @@ -98,7 +99,17 @@ export function execClaude( } let child: ChildProcess; - if (needsShell) { + if (isPowerShellScript) { + child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); + } else if (needsShell) { // When shell needed: concatenate into string to avoid DEP0190 warning const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); child = spawn(cmdString, { @@ -116,13 +127,49 @@ export function execClaude( }); } + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + const cleanupSignalHandlers = () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; + child.on('exit', (code, signal) => { + cleanupSignalHandlers(); if (signal) process.kill(process.pid, signal as NodeJS.Signals); else process.exit(code || 0); }); - child.on('error', async () => { - await ErrorManager.showClaudeNotFound(); + child.on('error', async (err: NodeJS.ErrnoException) => { + cleanupSignalHandlers(); + if (err.code === 'EACCES') { + console.error(`[X] Claude CLI is not executable: ${claudeCli}`); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error('[X] Windows command shell not found for Claude wrapper launch.'); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + await ErrorManager.showClaudeNotFound(); + } + } else { + console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`); + } process.exit(1); }); } diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index 85d0caf6..41432e7b 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -31,6 +31,7 @@ export interface RawUsageEntry { timestamp: string; projectPath: string; version?: string; + target?: string; } /** Internal structure matching JSONL assistant entries */ @@ -40,6 +41,7 @@ interface JsonlAssistantEntry { timestamp: string; version?: string; cwd?: string; + target?: string; message: { model: string; usage: { @@ -95,6 +97,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr timestamp: assistant.timestamp || new Date().toISOString(), projectPath, version: assistant.version, + target: + typeof (entry as { target?: unknown }).target === 'string' + ? ((entry as { target?: string }).target as string) + : undefined, }; } catch { // Malformed JSON - skip silently diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index 85a13c22..b0f4fb5f 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -371,6 +371,10 @@ export function aggregateSessionUsage( const sessionUsage: SessionUsage[] = []; for (const [sessionId, sessionEntries] of bySession) { + const orderedEntries = [...sessionEntries].sort((a, b) => + a.timestamp.localeCompare(b.timestamp) + ); + // Aggregate by model const modelMap = new Map(); const versions = new Set(); @@ -380,8 +384,9 @@ export function aggregateSessionUsage( let totalCacheRead = 0; let lastActivity = ''; let projectPath = ''; + let target: string | undefined; - for (const entry of sessionEntries) { + for (const entry of orderedEntries) { const model = entry.model; const acc = modelMap.get(model) || { inputTokens: 0, @@ -415,6 +420,10 @@ export function aggregateSessionUsage( if (entry.projectPath) { projectPath = entry.projectPath; } + + if (entry.target) { + target = entry.target; + } } // Build model breakdowns @@ -450,6 +459,7 @@ export function aggregateSessionUsage( modelsUsed: Array.from(modelMap.keys()), modelBreakdowns, source, + target, }); } diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index e2b69f06..fb42867c 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -549,6 +549,7 @@ export async function handleSessions( cost: Math.round(s.totalCost * 100) / 100, lastActivity: s.lastActivity, modelsUsed: s.modelsUsed, + target: s.target || 'claude', })); res.json({ diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts new file mode 100644 index 00000000..2a8d23ad --- /dev/null +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + registerSession, + unregisterSession, + getProxyStatus, +} from '../../../src/cliproxy/session-tracker'; + +describe('session-tracker target metadata', () => { + let tmpDir: string; + let originalCcsHome: string | undefined; + const port = 28317; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns single target when all sessions share same target', () => { + const s1 = registerSession(port, process.pid, undefined, undefined, 'droid'); + const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); + + const status = getProxyStatus(port); + expect(status.running).toBe(true); + expect(status.target).toBe('droid'); + expect(status.sessionCount).toBe(2); + + unregisterSession(s1, port); + unregisterSession(s2, port); + }); + + it('returns mixed when active sessions use different targets', () => { + const s1 = registerSession(port, process.pid, undefined, undefined, 'claude'); + const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); + + const status = getProxyStatus(port); + expect(status.running).toBe(true); + expect(status.target).toBe('mixed'); + expect(status.sessionCount).toBe(2); + + unregisterSession(s1, port); + unregisterSession(s2, port); + }); +}); diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index 206bddae..50268007 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -237,6 +237,42 @@ describe('aggregateSessionUsage', () => { expect(result[1].sessionId).toBe('old-session'); }); + test('propagates target metadata into session usage', () => { + const entries: RawUsageEntry[] = [ + createEntry({ sessionId: 'session-A', target: 'droid' }), + createEntry({ sessionId: 'session-B' }), + ]; + + const result = aggregateSessionUsage(entries); + const sessionA = result.find((s) => s.sessionId === 'session-A'); + const sessionB = result.find((s) => s.sessionId === 'session-B'); + + expect(sessionA?.target).toBe('droid'); + expect(sessionB?.target).toBeUndefined(); + }); + + test('uses latest timestamp target when a session has mixed targets', () => { + const entries: RawUsageEntry[] = [ + createEntry({ + sessionId: 'session-A', + timestamp: '2025-12-09T10:00:00.000Z', + target: 'claude', + }), + createEntry({ + sessionId: 'session-A', + timestamp: '2025-12-09T12:00:00.000Z', + target: 'droid', + }), + createEntry({ + sessionId: 'session-A', + timestamp: '2025-12-09T11:00:00.000Z', + }), + ]; + + const result = aggregateSessionUsage(entries); + expect(result[0].target).toBe('droid'); + }); + test('returns empty array for no entries', () => { const result = aggregateSessionUsage([]); expect(result.length).toBe(0); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index a4b6a9e2..a6a19b1d 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -147,6 +147,26 @@ describe('parseUsageEntry', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); }); + + test('parses string target when present', () => { + const withTarget = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + target: 'droid', + }); + const result = parseUsageEntry(withTarget, '/test'); + expect(result).not.toBeNull(); + expect(result!.target).toBe('droid'); + }); + + test('ignores non-string target values', () => { + const withNumericTarget = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + target: 123, + }); + const result = parseUsageEntry(withNumericTarget, '/test'); + expect(result).not.toBeNull(); + expect(result!.target).toBeUndefined(); + }); }); // ============================================================================ diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 734fdf3b..e2d63790 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -109,6 +109,40 @@ describe('droid-config-manager', () => { expect(settings.customModels[1].displayName).toBe('CCS gemini'); }); + it('should preserve user entries with unknown provider strings', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'user-model', + displayName: 'My Custom Provider', + baseUrl: 'https://example.invalid', + apiKey: 'user-key', + provider: 'custom-provider', + }, + ], + }) + ); + + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }); + + const settings = JSON.parse( + fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') + ); + expect(settings.customModels).toHaveLength(2); + expect(settings.customModels[0].provider).toBe('custom-provider'); + expect(settings.customModels[1].displayName).toBe('CCS gemini'); + }); + it('should write with restricted permissions', async () => { await upsertCcsModel('test', { model: 'test-model', @@ -124,6 +158,23 @@ describe('droid-config-manager', () => { const otherPerms = stat.mode & 0o077; expect(otherPerms).toBe(0); }); + + it('should reject symlinked temp file path', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync(path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [] })); + fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp')); + + await expect( + upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + provider: 'anthropic', + }) + ).rejects.toThrow(/settings\.json\.tmp is a symlink/); + }); }); describe('removeCcsModel', () => { @@ -192,6 +243,58 @@ describe('droid-config-manager', () => { const models = await listCcsModels(); expect(models.size).toBe(0); }); + + it('should normalize legacy object-map customModels', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: { + gemini: { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }, + invalid: { + model: 'x', + baseUrl: 'x', + }, + }, + }) + ); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + }); + + it('should ignore malformed customModels entries', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [null, 123, 'bad', { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }], + }) + ); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('ok')).toBe(true); + }); + + it('should reject symlinked settings file on read', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + const target = path.join(factoryDir, 'real-settings.json'); + fs.writeFileSync(target, JSON.stringify({ customModels: [] })); + fs.symlinkSync(target, path.join(factoryDir, 'settings.json')); + + await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/); + }); }); describe('pruneOrphanedModels', () => { diff --git a/tests/unit/targets/droid-detector.test.ts b/tests/unit/targets/droid-detector.test.ts new file mode 100644 index 00000000..17eaabe5 --- /dev/null +++ b/tests/unit/targets/droid-detector.test.ts @@ -0,0 +1,53 @@ +/** + * Unit tests for Droid detector edge cases + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { detectDroidCli, checkDroidVersion } from '../../../src/targets/droid-detector'; + +describe('droid-detector', () => { + let tmpDir: string; + let originalPath: string | undefined; + let originalDroidPath: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-detector-test-')); + originalPath = process.env.PATH; + originalDroidPath = process.env.CCS_DROID_PATH; + process.env.PATH = ''; + }); + + afterEach(() => { + if (originalPath !== undefined) process.env.PATH = originalPath; + else delete process.env.PATH; + + if (originalDroidPath !== undefined) process.env.CCS_DROID_PATH = originalDroidPath; + else delete process.env.CCS_DROID_PATH; + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should prefer CCS_DROID_PATH when it points to a file', () => { + const fakeDroid = path.join(tmpDir, 'droid'); + fs.writeFileSync(fakeDroid, '#!/bin/sh\necho droid\n'); + process.env.CCS_DROID_PATH = fakeDroid; + + expect(detectDroidCli()).toBe(fakeDroid); + }); + + it('should fall back (null) when CCS_DROID_PATH points to directory', () => { + process.env.CCS_DROID_PATH = tmpDir; + expect(detectDroidCli()).toBeNull(); + }); + + it('should fall back (null) when CCS_DROID_PATH does not exist', () => { + process.env.CCS_DROID_PATH = path.join(tmpDir, 'missing-droid'); + expect(detectDroidCli()).toBeNull(); + }); + + it('checkDroidVersion should be non-throwing for invalid binaries', () => { + expect(() => checkDroidVersion(path.join(tmpDir, 'missing-droid'))).not.toThrow(); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index ab437a2d..d6485d49 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -2,6 +2,9 @@ * Unit tests for target registry and adapters */ import { describe, it, expect, beforeEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { registerTarget, getTarget, @@ -112,11 +115,14 @@ describe('DroidAdapter', () => { expect(adapter.supportsProfileType('account')).toBe(false); }); - it('should support non-account profile types', () => { + it('should support settings and default profile types', () => { expect(adapter.supportsProfileType('settings')).toBe(true); - expect(adapter.supportsProfileType('cliproxy')).toBe(true); expect(adapter.supportsProfileType('default')).toBe(true); - expect(adapter.supportsProfileType('copilot')).toBe(true); + }); + + it('should NOT support cliproxy and copilot profile types', () => { + expect(adapter.supportsProfileType('cliproxy')).toBe(false); + expect(adapter.supportsProfileType('copilot')).toBe(false); }); it('should build args with -m custom:ccs- prefix', () => { @@ -137,4 +143,44 @@ describe('DroidAdapter', () => { expect(env['ANTHROPIC_BASE_URL']).toBeUndefined(); expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined(); }); + + it('prepareCredentials should reject missing required credentials', async () => { + await expect( + adapter.prepareCredentials({ + profile: 'gemini', + baseUrl: '', + apiKey: 'dummy', + }) + ).rejects.toThrow(/ANTHROPIC_BASE_URL/); + + await expect( + adapter.prepareCredentials({ + profile: 'gemini', + baseUrl: 'http://localhost:8317', + apiKey: '', + }) + ).rejects.toThrow(/ANTHROPIC_AUTH_TOKEN/); + }); + + it('prepareCredentials should persist valid credentials', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + + try { + await adapter.prepareCredentials({ + profile: 'gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + model: 'claude-opus-4-6', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + } finally { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 0d609add..03ca380a 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -51,11 +51,19 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); - it('should not match ccsd with .exe extension', () => { - // .exe is not stripped — ccsd.exe won't match 'ccsd' in the map - // This is intentional — npm creates .cmd shims, not .exe + it('should strip .ps1 extension on Windows argv[0]', () => { + process.argv = ['node', 'ccsd.ps1']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should strip .exe extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.exe']; - expect(resolveTargetType([])).toBe('claude'); + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should handle full path argv[0]', () => { + process.argv = ['node', '/usr/local/bin/ccsd']; + expect(resolveTargetType([])).toBe('droid'); }); it('should prioritize --target over argv[0]', () => { @@ -72,6 +80,31 @@ describe('resolveTargetType', () => { process.argv = ['node', 'ccs']; expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/); }); + + it('should support --target= form', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target=droid'])).toBe('droid'); + }); + + it('should throw when --target is missing value', () => { + process.argv = ['node', 'ccs']; + expect(() => resolveTargetType(['--target'])).toThrow(/--target requires a value/); + }); + + it('should throw when --target value is another flag', () => { + process.argv = ['node', 'ccs']; + expect(() => resolveTargetType(['--target', '--help'])).toThrow(/--target requires a value/); + }); + + it('should use last --target flag when repeated', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'droid', '--target=claude'])).toBe('claude'); + }); + + it('should ignore --target after option terminator', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--', '--target', 'droid'])).toBe('claude'); + }); }); describe('stripTargetFlag', () => { @@ -88,9 +121,42 @@ describe('stripTargetFlag', () => { expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']); }); + it('should remove --target= form', () => { + expect(stripTargetFlag(['gemini', '--target=droid', '--verbose'])).toEqual([ + 'gemini', + '--verbose', + ]); + }); + + it('should remove repeated --target flags', () => { + expect(stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose'])).toEqual( + ['gemini', '--verbose'] + ); + }); + + it('should throw when --target has no value', () => { + expect(() => stripTargetFlag(['gemini', '--target'])).toThrow(/--target requires a value/); + }); + + it('should throw when --target value is another flag', () => { + expect(() => stripTargetFlag(['gemini', '--target', '--help'])).toThrow( + /--target requires a value/ + ); + }); + it('should not modify the original array', () => { const args = ['--target', 'droid', 'gemini']; stripTargetFlag(args); expect(args).toEqual(['--target', 'droid', 'gemini']); }); + + it('should preserve args after option terminator', () => { + expect(stripTargetFlag(['glm', '--', '--target', 'droid', '-p'])).toEqual([ + 'glm', + '--', + '--target', + 'droid', + '-p', + ]); + }); }); From f1a61f6eb516617c66cb0f6bd8c012230cae1b99 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 03:28:01 +0700 Subject: [PATCH 08/31] chore(metrics): update maintainability baseline for CI Sync baseline with current metrics after edge case hardening commit. --- docs/metrics/maintainability-baseline.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index 36e2bb6a..967cd8dd 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -1,9 +1,9 @@ { "sourceDirectory": "src", "largeFileThresholdLoc": 350, - "typeScriptFileCount": 347, - "locInSrc": 70100, - "processExitReferenceCount": 175, - "synchronousFsApiReferenceCount": 869, - "largeFileCountOver350Loc": 55 + "typeScriptFileCount": 355, + "locInSrc": 71420, + "processExitReferenceCount": 188, + "synchronousFsApiReferenceCount": 880, + "largeFileCountOver350Loc": 56 } From 02af8d5737d9c3db4172b094b1bfae13ccdccbb1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 03:38:27 +0700 Subject: [PATCH 09/31] fix(targets): DRY signal handling, remove redundant guards, harden config manager - Extract signal forwarding to reusable src/utils/signal-forwarder.ts - Update shell-executor, claude-adapter, droid-adapter to use shared utility - Remove 35 lines of redundant duplicate guards in src/ccs.ts - Harden droid-config-manager with lock constants and race-safe ensureFactoryDir - Update help-command with --target usage examples - Update maintainability metrics baseline --- docs/metrics/maintainability-baseline.json | 6 ++-- src/ccs.ts | 36 +--------------------- src/commands/help-command.ts | 7 +++++ src/targets/claude-adapter.ts | 20 ++---------- src/targets/droid-adapter.ts | 20 ++---------- src/targets/droid-config-manager.ts | 14 +++++++-- src/utils/shell-executor.ts | 20 ++---------- src/utils/signal-forwarder.ts | 33 ++++++++++++++++++++ 8 files changed, 61 insertions(+), 95 deletions(-) create mode 100644 src/utils/signal-forwarder.ts diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index 967cd8dd..734ab29f 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -2,8 +2,8 @@ "sourceDirectory": "src", "largeFileThresholdLoc": 350, "typeScriptFileCount": 355, - "locInSrc": 71420, - "processExitReferenceCount": 188, - "synchronousFsApiReferenceCount": 880, + "locInSrc": 71353, + "processExitReferenceCount": 185, + "synchronousFsApiReferenceCount": 879, "largeFileCountOver350Loc": 56 } diff --git a/src/ccs.ts b/src/ccs.ts index e7e7dd74..b1df1571 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -725,7 +725,7 @@ async function main(): Promise { // For non-claude targets, verify target binary exists once and pass it through. const targetBinaryInfo = targetAdapter?.detectBinary() ?? null; - if (resolvedTarget !== 'claude' && (!targetAdapter || !targetBinaryInfo)) { + if (resolvedTarget !== 'claude' && !targetBinaryInfo) { const displayName = targetAdapter?.displayName || resolvedTarget; console.error(fail(`${displayName} CLI not found.`)); if (resolvedTarget === 'droid') { @@ -759,17 +759,6 @@ async function main(): Promise { } if (profileInfo.type === 'cliproxy') { - // Guard: non-claude targets don't support CLIProxy flow yet - if (resolvedTarget !== 'claude') { - if (!targetAdapter?.supportsProfileType('cliproxy')) { - console.error( - fail(`${targetAdapter?.displayName || 'Target'} does not support CLIProxy profiles`) - ); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); - } - } - // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); @@ -788,16 +777,6 @@ async function main(): Promise { profileName: profileInfo.name, }); } else if (profileInfo.type === 'copilot') { - // Guard: non-claude targets don't support Copilot flow - if (resolvedTarget !== 'claude') { - if (!targetAdapter?.supportsProfileType('copilot')) { - console.error( - fail(`${targetAdapter?.displayName || 'Target'} does not support Copilot profiles`) - ); - process.exit(1); - } - } - // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); @@ -943,19 +922,6 @@ async function main(): Promise { execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); } } else if (profileInfo.type === 'account') { - // Guard: non-claude targets don't support account profiles - if (resolvedTarget !== 'claude') { - if (!targetAdapter?.supportsProfileType('account')) { - console.error( - fail( - `${targetAdapter?.displayName || 'Target'} does not support account-based profiles` - ) - ); - console.error(info('Use a settings-based profile with --target instead')); - process.exit(1); - } - } - // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR const registry = new ProfileRegistry(); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index bc84c08f..5971dfb5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -312,6 +312,13 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccsd [args]', 'Shorthand for: ccs --target droid'], ]); + // Multi-target examples + printSubSection('Multi-Target', [ + ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], + ['ccsd glm', 'Same as above (alias)'], + ['ccs glm', 'Run GLM profile on Claude Code (default)'], + ]); + // Configuration printConfigSection('Configuration', [ ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 2f61e4b6..2c2b7235 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -11,6 +11,7 @@ import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector'; import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { forwardSignals } from '../utils/signal-forwarder'; export class ClaudeAdapter implements TargetAdapter { readonly type: TargetType = 'claude'; @@ -99,24 +100,7 @@ export class ClaudeAdapter implements TargetAdapter { }); } - const forwardSigInt = () => { - if (!child.killed) child.kill('SIGINT'); - }; - const forwardSigTerm = () => { - if (!child.killed) child.kill('SIGTERM'); - }; - const forwardSighup = () => { - if (!child.killed) child.kill('SIGHUP'); - }; - process.on('SIGINT', forwardSigInt); - process.on('SIGTERM', forwardSigTerm); - process.on('SIGHUP', forwardSighup); - - const cleanupSignalHandlers = () => { - process.removeListener('SIGINT', forwardSigInt); - process.removeListener('SIGTERM', forwardSigTerm); - process.removeListener('SIGHUP', forwardSighup); - }; + const cleanupSignalHandlers = forwardSignals(child); child.on('exit', (code, signal) => { cleanupSignalHandlers(); diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 0cbb6946..8368b0c7 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from ' import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; import { upsertCcsModel } from './droid-config-manager'; import { escapeShellArg } from '../utils/shell-executor'; +import { forwardSignals } from '../utils/signal-forwarder'; export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; @@ -118,24 +119,7 @@ export class DroidAdapter implements TargetAdapter { }); } - const forwardSigInt = () => { - if (!child.killed) child.kill('SIGINT'); - }; - const forwardSigTerm = () => { - if (!child.killed) child.kill('SIGTERM'); - }; - const forwardSighup = () => { - if (!child.killed) child.kill('SIGHUP'); - }; - process.on('SIGINT', forwardSigInt); - process.on('SIGTERM', forwardSigTerm); - process.on('SIGHUP', forwardSighup); - - const cleanupSignalHandlers = () => { - process.removeListener('SIGINT', forwardSigInt); - process.removeListener('SIGTERM', forwardSigTerm); - process.removeListener('SIGHUP', forwardSighup); - }; + const cleanupSignalHandlers = forwardSignals(child); child.on('exit', (code, signal) => { cleanupSignalHandlers(); diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index f1255f66..47a73cc8 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -12,6 +12,11 @@ import * as lockfile from 'proper-lockfile'; const CCS_MODEL_PREFIX = 'ccs-'; +/** Lock configuration for concurrent write safety */ +const LOCK_STALE_MS = 10000; +const LOCK_RETRY_MIN_MS = 200; +const LOCK_RETRY_MAX_MS = 1000; + /** * Validate profile name to prevent filesystem/security issues. * Only alphanumeric, underscore, hyphen allowed. @@ -104,8 +109,11 @@ function getSettingsPath(): string { */ function ensureFactoryDir(): void { const dir = getFactoryDir(); - if (!fs.existsSync(dir)) { + try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EEXIST') throw error; } } @@ -181,8 +189,8 @@ async function acquireFactoryLock(retries: number): Promise<() => Promise> const factoryDir = getFactoryDir(); try { return await lockfile.lock(factoryDir, { - stale: 10000, - retries: { retries, minTimeout: 200, maxTimeout: 1000 }, + stale: LOCK_STALE_MS, + retries: { retries, minTimeout: LOCK_RETRY_MIN_MS, maxTimeout: LOCK_RETRY_MAX_MS }, }); } catch (error) { throw new Error( diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index a672f97b..88b56fc3 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,6 +7,7 @@ import { spawn, spawnSync, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; +import { forwardSignals } from './signal-forwarder'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -127,24 +128,7 @@ export function execClaude( }); } - const forwardSigInt = () => { - if (!child.killed) child.kill('SIGINT'); - }; - const forwardSigTerm = () => { - if (!child.killed) child.kill('SIGTERM'); - }; - const forwardSighup = () => { - if (!child.killed) child.kill('SIGHUP'); - }; - process.on('SIGINT', forwardSigInt); - process.on('SIGTERM', forwardSigTerm); - process.on('SIGHUP', forwardSighup); - - const cleanupSignalHandlers = () => { - process.removeListener('SIGINT', forwardSigInt); - process.removeListener('SIGTERM', forwardSigTerm); - process.removeListener('SIGHUP', forwardSighup); - }; + const cleanupSignalHandlers = forwardSignals(child); child.on('exit', (code, signal) => { cleanupSignalHandlers(); diff --git a/src/utils/signal-forwarder.ts b/src/utils/signal-forwarder.ts new file mode 100644 index 00000000..e2af8787 --- /dev/null +++ b/src/utils/signal-forwarder.ts @@ -0,0 +1,33 @@ +/** + * Signal Forwarder + * + * Shared utility for forwarding process signals to child processes + * and cleaning up handlers on exit. + */ +import { ChildProcess } from 'child_process'; + +/** + * Forward SIGINT, SIGTERM, SIGHUP to a child process. + * Returns a cleanup function to remove the handlers. + */ +export function forwardSignals(child: ChildProcess): () => void { + const forwardSigInt = () => { + if (!child.killed) child.kill('SIGINT'); + }; + const forwardSigTerm = () => { + if (!child.killed) child.kill('SIGTERM'); + }; + const forwardSighup = () => { + if (!child.killed) child.kill('SIGHUP'); + }; + + process.on('SIGINT', forwardSigInt); + process.on('SIGTERM', forwardSigTerm); + process.on('SIGHUP', forwardSighup); + + return () => { + process.removeListener('SIGINT', forwardSigInt); + process.removeListener('SIGTERM', forwardSigTerm); + process.removeListener('SIGHUP', forwardSighup); + }; +} From c639cefa7bd33807e96d96fd566c64a89166f22f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 04:09:06 +0700 Subject: [PATCH 10/31] feat(cursor): harden daemon integration and model discovery --- docs/cursor-integration.md | 1 + src/cursor/cursor-client-policy.ts | 124 ++++++++ src/cursor/cursor-daemon-entry.ts | 16 +- src/cursor/cursor-default-models.ts | 173 +++++++++++ src/cursor/cursor-executor.ts | 129 ++++----- src/cursor/cursor-models.ts | 303 +++++++++----------- src/cursor/index.ts | 3 + tests/unit/cursor/cursor-daemon.test.ts | 7 + tests/unit/cursor/cursor-models.test.ts | 130 ++++++++- tests/unit/cursor/cursor-protobuf.test.ts | 61 ++++ tests/unit/web-server/cursor-routes.test.ts | 258 ++++++++++++++++- 11 files changed, 953 insertions(+), 252 deletions(-) create mode 100644 src/cursor/cursor-client-policy.ts create mode 100644 src/cursor/cursor-default-models.ts diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 6997dfd3..fad1eabd 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -59,6 +59,7 @@ ccs cursor stop - Default port: `20129` - `ghost_mode`: enabled - `auto_start`: disabled +- Model list resolution: authenticated live fetch when available, with cached/default fallback. These values are managed in unified config and can be updated from CLI or dashboard. diff --git a/src/cursor/cursor-client-policy.ts b/src/cursor/cursor-client-policy.ts new file mode 100644 index 00000000..157b46d2 --- /dev/null +++ b/src/cursor/cursor-client-policy.ts @@ -0,0 +1,124 @@ +/** + * Cursor Client Policy + * + * Single source of truth for Cursor request identity headers and checksum generation. + */ + +import * as crypto from 'crypto'; +import type { CursorCredentials } from './cursor-protobuf-schema'; + +export const CURSOR_CLIENT_VERSION = '2.3.41'; +export const CURSOR_USER_AGENT = 'connect-es/1.6.1'; + +function getClientOs(): string { + if (process.platform === 'win32') return 'windows'; + if (process.platform === 'darwin') return 'macos'; + return 'linux'; +} + +function getClientArch(): string { + return process.arch === 'arm64' ? 'aarch64' : 'x64'; +} + +export function normalizeCursorAccessToken(accessToken: string): string { + const delimIdx = accessToken.indexOf('::'); + return delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken; +} + +/** + * Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165) + */ +export function generateCursorChecksum(machineId: string, nowMs: number = Date.now()): string { + if (!machineId) { + throw new Error('Machine ID is required for Cursor API'); + } + + const timestamp = Math.floor(nowMs / 1000000); + // JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results. + // Use Math.trunc division for upper bytes that exceed 32-bit range. + const byteArray = new Uint8Array([ + Math.trunc(timestamp / 2 ** 40) & 0xff, + Math.trunc(timestamp / 2 ** 32) & 0xff, + (timestamp >>> 24) & 0xff, + (timestamp >>> 16) & 0xff, + (timestamp >>> 8) & 0xff, + timestamp & 0xff, + ]); + + let t = 165; + for (let i = 0; i < byteArray.length; i++) { + byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff; + t = byteArray[i]; + } + + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let encoded = ''; + + for (let i = 0; i < byteArray.length; i += 3) { + const a = byteArray[i]; + const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; + const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; + + encoded += alphabet[a >> 2]; + encoded += alphabet[((a & 3) << 4) | (b >> 4)]; + + if (i + 1 < byteArray.length) { + encoded += alphabet[((b & 15) << 2) | (c >> 6)]; + } + if (i + 2 < byteArray.length) { + encoded += alphabet[c & 63]; + } + } + + return `${encoded}${machineId}`; +} + +function buildCursorBaseHeaders(credentials: CursorCredentials): Record { + const cleanToken = normalizeCursorAccessToken(credentials.accessToken); + + if (!cleanToken) { + throw new Error('Access token is empty after parsing'); + } + + if (!credentials.machineId) { + throw new Error('Machine ID is required for Cursor API'); + } + + const ghostMode = credentials.ghostMode !== false; + const tokenHash = crypto.createHash('sha256').update(cleanToken).digest('hex'); + + return { + authorization: `Bearer ${cleanToken}`, + 'x-amzn-trace-id': `Root=${crypto.randomUUID()}`, + 'x-client-key': tokenHash, + 'x-cursor-checksum': generateCursorChecksum(credentials.machineId), + 'x-cursor-client-version': CURSOR_CLIENT_VERSION, + 'x-cursor-client-type': 'ide', + 'x-cursor-client-os': getClientOs(), + 'x-cursor-client-arch': getClientArch(), + 'x-cursor-client-device-type': 'desktop', + 'x-cursor-config-version': crypto.randomUUID(), + 'x-cursor-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + 'x-ghost-mode': ghostMode ? 'true' : 'false', + 'x-request-id': crypto.randomUUID(), + 'x-session-id': tokenHash.substring(0, 36), + }; +} + +export function buildCursorConnectHeaders(credentials: CursorCredentials): Record { + return { + ...buildCursorBaseHeaders(credentials), + 'connect-accept-encoding': 'gzip', + 'connect-protocol-version': '1', + 'content-type': 'application/connect+proto', + 'user-agent': CURSOR_USER_AGENT, + }; +} + +export function buildCursorModelsHeaders(credentials: CursorCredentials): Record { + return { + ...buildCursorBaseHeaders(credentials), + accept: 'application/json', + 'user-agent': CURSOR_USER_AGENT, + }; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 2fcf0afd..841de56d 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -8,7 +8,7 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; import { checkAuthStatus } from './cursor-auth'; -import { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS } from './cursor-models'; +import { DEFAULT_CURSOR_MODEL, getModelsForDaemon } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; interface DaemonRuntimeOptions { @@ -184,7 +184,19 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } if (method === 'GET' && requestUrl === '/v1/models') { - const data = DEFAULT_CURSOR_MODELS.map((model) => ({ + const authStatus = checkAuthStatus(); + const models = await getModelsForDaemon({ + credentials: + authStatus.authenticated && !authStatus.expired && authStatus.credentials + ? { + accessToken: authStatus.credentials.accessToken, + machineId: authStatus.credentials.machineId, + ghostMode: options.ghostMode, + } + : null, + }); + + const data = models.map((model) => ({ id: model.id, object: 'model', created: 0, diff --git a/src/cursor/cursor-default-models.ts b/src/cursor/cursor-default-models.ts new file mode 100644 index 00000000..1457de05 --- /dev/null +++ b/src/cursor/cursor-default-models.ts @@ -0,0 +1,173 @@ +/** + * Cursor Default Model Catalog + */ + +import type { CursorModel } from './types'; + +/** Default model ID */ +export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex'; + +/** + * Default models available through Cursor IDE. + * Used as fallback when daemon is not reachable. + * Source: Cursor docs model catalog (Feb 2026) + */ +export const DEFAULT_CURSOR_MODELS: CursorModel[] = [ + // Anthropic Models + { + id: 'claude-4.6-opus', + name: 'Claude 4.6 Opus', + provider: 'anthropic', + }, + { + id: 'claude-4.6-opus-fast-mode', + name: 'Claude 4.6 Opus (Fast mode)', + provider: 'anthropic', + }, + { + id: 'claude-4.5-sonnet', + name: 'Claude 4.5 Sonnet', + provider: 'anthropic', + }, + { + id: 'claude-4.5-opus', + name: 'Claude 4.5 Opus', + provider: 'anthropic', + }, + { + id: 'claude-4.5-haiku', + name: 'Claude 4.5 Haiku', + provider: 'anthropic', + }, + { + id: 'claude-4-sonnet', + name: 'Claude 4 Sonnet', + provider: 'anthropic', + }, + { + id: 'claude-4-sonnet-1m', + name: 'Claude 4 Sonnet 1M', + provider: 'anthropic', + }, + + // Cursor Models + { + id: 'composer-1.5', + name: 'Composer 1.5', + provider: 'cursor', + }, + { + id: 'composer-1', + name: 'Composer 1', + provider: 'cursor', + }, + + // OpenAI Models + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + provider: 'openai', + isDefault: true, + }, + { + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + provider: 'openai', + }, + { + id: 'gpt-5.2', + name: 'GPT-5.2', + provider: 'openai', + }, + { + id: 'gpt-5.1-codex', + name: 'GPT-5.1 Codex', + provider: 'openai', + }, + { + id: 'gpt-5.1-codex-max', + name: 'GPT-5.1 Codex Max', + provider: 'openai', + }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + provider: 'openai', + }, + { + id: 'gpt-5-codex', + name: 'GPT-5-Codex', + provider: 'openai', + }, + { + id: 'gpt-5', + name: 'GPT-5', + provider: 'openai', + }, + { + id: 'gpt-5-fast', + name: 'GPT-5 Fast', + provider: 'openai', + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + provider: 'openai', + }, + + // Google Models + { + id: 'gemini-3-pro', + name: 'Gemini 3 Pro', + provider: 'google', + }, + { + id: 'gemini-3-pro-image-preview', + name: 'Gemini 3 Pro Image Preview', + provider: 'google', + }, + { + id: 'gemini-3-flash', + name: 'Gemini 3 Flash', + provider: 'google', + }, + { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + provider: 'google', + }, + + // xAI Models + { + id: 'grok-code', + name: 'Grok Code', + provider: 'xai', + }, +]; + +/** + * Detect provider from model ID. + */ +export function detectProvider(modelId: string): string { + if (modelId.includes('claude')) return 'anthropic'; + if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai'; + if (modelId.includes('gemini')) return 'google'; + if (modelId.includes('cursor') || modelId.includes('composer')) return 'cursor'; + if (modelId.includes('grok')) return 'xai'; + return 'unknown'; +} + +/** + * Format model ID to human-readable name. + */ +export function formatModelName(modelId: string): string { + const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId); + if (model) { + return model.name; + } + + return modelId + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 43ec4d5e..1aa7e48a 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -3,11 +3,11 @@ * Handles HTTP/2 requests to Cursor API with protobuf encoding/decoding */ -import * as crypto from 'crypto'; import type { IncomingHttpHeaders } from 'http'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { buildCursorRequest } from './cursor-translator.js'; import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js'; +import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js'; import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js'; @@ -92,8 +92,6 @@ function createErrorResponse(jsonError: { export class CursorExecutor { private readonly baseUrl = 'https://api2.cursor.sh'; private readonly chatPath = '/aiserver.v1.AiService/StreamChat'; - private readonly CURSOR_CLIENT_VERSION = '2.3.41'; - private readonly CURSOR_USER_AGENT = 'connect-es/1.6.1'; buildUrl(): string { return `${this.baseUrl}${this.chatPath}`; @@ -103,87 +101,11 @@ export class CursorExecutor { * Generate checksum using Jyh cipher (time-based XOR with rolling key seed=165) */ generateChecksum(machineId: string): string { - const timestamp = Math.floor(Date.now() / 1000000); - // JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results. - // Use Math.trunc division for upper bytes that exceed 32-bit range. - const byteArray = new Uint8Array([ - Math.trunc(timestamp / 2 ** 40) & 0xff, - Math.trunc(timestamp / 2 ** 32) & 0xff, - (timestamp >>> 24) & 0xff, - (timestamp >>> 16) & 0xff, - (timestamp >>> 8) & 0xff, - timestamp & 0xff, - ]); - - let t = 165; - for (let i = 0; i < byteArray.length; i++) { - byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xff; - t = byteArray[i]; - } - - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; - let encoded = ''; - - for (let i = 0; i < byteArray.length; i += 3) { - const a = byteArray[i]; - const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; - const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; - - encoded += alphabet[a >> 2]; - encoded += alphabet[((a & 3) << 4) | (b >> 4)]; - - if (i + 1 < byteArray.length) { - encoded += alphabet[((b & 15) << 2) | (c >> 6)]; - } - if (i + 2 < byteArray.length) { - encoded += alphabet[c & 63]; - } - } - - return `${encoded}${machineId}`; + return generateCursorChecksum(machineId); } buildHeaders(credentials: CursorCredentials): Record { - const accessToken = credentials.accessToken; - const machineId = credentials.machineId; - const ghostMode = credentials.ghostMode !== false; - - if (!machineId) { - throw new Error('Machine ID is required for Cursor API'); - } - - const delimIdx = accessToken.indexOf('::'); - const cleanToken = delimIdx !== -1 ? accessToken.slice(delimIdx + 2) : accessToken; - - if (!cleanToken) { - throw new Error('Access token is empty after parsing'); - } - - return { - authorization: `Bearer ${cleanToken}`, - 'connect-accept-encoding': 'gzip', - 'connect-protocol-version': '1', - 'content-type': 'application/connect+proto', - 'user-agent': this.CURSOR_USER_AGENT, - 'x-amzn-trace-id': `Root=${crypto.randomUUID()}`, - 'x-client-key': crypto.createHash('sha256').update(cleanToken).digest('hex'), - 'x-cursor-checksum': this.generateChecksum(machineId), - 'x-cursor-client-version': this.CURSOR_CLIENT_VERSION, - 'x-cursor-client-type': 'ide', - 'x-cursor-client-os': - process.platform === 'win32' - ? 'windows' - : process.platform === 'darwin' - ? 'macos' - : 'linux', - 'x-cursor-client-arch': process.arch === 'arm64' ? 'aarch64' : 'x64', - 'x-cursor-client-device-type': 'desktop', - 'x-cursor-config-version': crypto.randomUUID(), - 'x-cursor-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', - 'x-ghost-mode': ghostMode ? 'true' : 'false', - 'x-request-id': crypto.randomUUID(), - 'x-session-id': crypto.createHash('sha256').update(cleanToken).digest('hex').substring(0, 36), - }; + return buildCursorConnectHeaders(credentials); } transformRequest( @@ -589,6 +511,15 @@ export class CursorExecutor { emitSSE(buildChunk(delta, null)); chunkCount++; } + + if (frame.type === 'thinking') { + const delta = + chunkCount === 0 && toolCallCount === 0 + ? { role: 'assistant', reasoning_content: frame.text } + : { reasoning_content: frame.text }; + emitSSE(buildChunk(delta, null)); + chunkCount++; + } } }); @@ -659,6 +590,7 @@ export class CursorExecutor { private *parseProtobufFrames(buffer: Buffer): Generator< | { type: 'error'; response: Response } | { type: 'text'; text: string } + | { type: 'thinking'; text: string } | { type: 'toolCall'; toolCall: { @@ -732,6 +664,10 @@ export class CursorExecutor { if (result.text) { yield { type: 'text', text: result.text }; } + + if (result.thinking) { + yield { type: 'thinking', text: result.thinking }; + } } } @@ -740,6 +676,7 @@ export class CursorExecutor { const created = Math.floor(Date.now() / 1000); let totalContent = ''; + let totalReasoning = ''; const toolCalls: Array<{ id: string; type: string; @@ -793,6 +730,10 @@ export class CursorExecutor { if (frame.type === 'text') { totalContent += frame.text; } + + if (frame.type === 'thinking') { + totalReasoning += frame.text; + } } // Finalize remaining tool calls @@ -814,6 +755,7 @@ export class CursorExecutor { const message: { role: string; content: string | null; + reasoning_content?: string | null; tool_calls?: Array<{ id: string; type: string; @@ -828,6 +770,10 @@ export class CursorExecutor { message.tool_calls = toolCalls; } + if (totalReasoning) { + message.reasoning_content = totalReasoning; + } + const completion = { id: responseId, object: 'chat.completion', @@ -992,6 +938,27 @@ export class CursorExecutor { })}\n\n` ); } + + if (frame.type === 'thinking') { + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: 'chat.completion.chunk', + created, + model, + choices: [ + { + index: 0, + delta: + chunks.length === 0 && toolCalls.length === 0 + ? { role: 'assistant', reasoning_content: frame.text } + : { reasoning_content: frame.text }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } } if (chunks.length === 0 && toolCalls.length === 0) { diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index c1355fa0..460c0a83 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -2,156 +2,158 @@ * Cursor Model Catalog * * Manages available models from Cursor IDE. - * Based on Cursor's supported models catalog. */ import * as http from 'http'; import type { CursorModel } from './types'; +import type { CursorCredentials } from './cursor-protobuf-schema'; import { isDaemonRunning } from './cursor-daemon'; +import { buildCursorModelsHeaders } from './cursor-client-policy'; +import { + DEFAULT_CURSOR_MODEL, + DEFAULT_CURSOR_MODELS, + detectProvider, + formatModelName, +} from './cursor-default-models'; /** Default daemon port */ export const DEFAULT_CURSOR_PORT = 20129; -/** Default model ID */ -export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex'; +export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatModelName }; -/** - * Default models available through Cursor IDE. - * Used as fallback when daemon is not reachable. - * Source: Cursor docs model catalog (Feb 2026) - */ -export const DEFAULT_CURSOR_MODELS: CursorModel[] = [ - // Anthropic Models - { - id: 'claude-4.6-opus', - name: 'Claude 4.6 Opus', - provider: 'anthropic', - }, - { - id: 'claude-4.6-opus-fast-mode', - name: 'Claude 4.6 Opus (Fast mode)', - provider: 'anthropic', - }, - { - id: 'claude-4.5-sonnet', - name: 'Claude 4.5 Sonnet', - provider: 'anthropic', - }, - { - id: 'claude-4.5-opus', - name: 'Claude 4.5 Opus', - provider: 'anthropic', - }, - { - id: 'claude-4.5-haiku', - name: 'Claude 4.5 Haiku', - provider: 'anthropic', - }, - { - id: 'claude-4-sonnet', - name: 'Claude 4 Sonnet', - provider: 'anthropic', - }, - { - id: 'claude-4-sonnet-1m', - name: 'Claude 4 Sonnet 1M', - provider: 'anthropic', - }, +const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models'; +const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; - // Cursor Models - { - id: 'composer-1.5', - name: 'Composer 1.5', - provider: 'cursor', - }, - { - id: 'composer-1', - name: 'Composer 1', - provider: 'cursor', - }, +let liveModelsCache: { + models: CursorModel[]; + expiresAtMs: number; +} | null = null; - // OpenAI Models - { - id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex', - provider: 'openai', - isDefault: true, - }, - { - id: 'gpt-5.2-codex', - name: 'GPT-5.2 Codex', - provider: 'openai', - }, - { - id: 'gpt-5.2', - name: 'GPT-5.2', - provider: 'openai', - }, - { - id: 'gpt-5.1-codex', - name: 'GPT-5.1 Codex', - provider: 'openai', - }, - { - id: 'gpt-5.1-codex-max', - name: 'GPT-5.1 Codex Max', - provider: 'openai', - }, - { - id: 'gpt-5.1-codex-mini', - name: 'GPT-5.1 Codex Mini', - provider: 'openai', - }, - { - id: 'gpt-5-codex', - name: 'GPT-5-Codex', - provider: 'openai', - }, - { - id: 'gpt-5', - name: 'GPT-5', - provider: 'openai', - }, - { - id: 'gpt-5-fast', - name: 'GPT-5 Fast', - provider: 'openai', - }, - { - id: 'gpt-5-mini', - name: 'GPT-5 Mini', - provider: 'openai', - }, +interface CursorModelsApiResponse { + data?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>; + models?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>; +} - // Google Models - { - id: 'gemini-3-pro', - name: 'Gemini 3 Pro', - provider: 'google', - }, - { - id: 'gemini-3-pro-image-preview', - name: 'Gemini 3 Pro Image Preview', - provider: 'google', - }, - { - id: 'gemini-3-flash', - name: 'Gemini 3 Flash', - provider: 'google', - }, - { - id: 'gemini-2.5-flash', - name: 'Gemini 2.5 Flash', - provider: 'google', - }, +function normalizeModelRecords( + records: Array<{ id?: unknown; name?: unknown; provider?: unknown }> +): CursorModel[] { + const models: CursorModel[] = []; + for (const record of records) { + if (!record || typeof record !== 'object') continue; + if (typeof record.id !== 'string' || !record.id) continue; + const modelId = record.id; + const modelName = typeof record.name === 'string' && record.name ? record.name : modelId; + const provider = + typeof record.provider === 'string' && record.provider + ? record.provider + : detectProvider(modelId); + models.push({ + id: modelId, + name: modelName, + provider, + isDefault: modelId === DEFAULT_CURSOR_MODEL, + }); + } + return models; +} - // xAI Models - { - id: 'grok-code', - name: 'Grok Code', - provider: 'xai', - }, -]; +function parseApiModelsResponse(payload: unknown): CursorModel[] | null { + if (!payload || typeof payload !== 'object') return null; + const response = payload as CursorModelsApiResponse; + const records = Array.isArray(response.data) + ? response.data + : Array.isArray(response.models) + ? response.models + : null; + + if (!records) return null; + + const models = normalizeModelRecords(records); + return models.length > 0 ? models : null; +} + +function getCachedLiveModels(nowMs: number = Date.now()): CursorModel[] | null { + if (!liveModelsCache) return null; + if (liveModelsCache.expiresAtMs <= nowMs) { + liveModelsCache = null; + return null; + } + return liveModelsCache.models; +} + +function setCachedLiveModels(models: CursorModel[], nowMs: number = Date.now()): void { + liveModelsCache = { + models, + expiresAtMs: nowMs + CURSOR_MODELS_CACHE_TTL_MS, + }; +} + +export function clearCursorModelsCache(): void { + liveModelsCache = null; +} + +export async function fetchModelsFromCursorApi( + credentials: CursorCredentials, + options: { + endpoint?: string; + timeoutMs?: number; + } = {} +): Promise { + if (!credentials.accessToken || !credentials.machineId) { + return null; + } + + const endpoint = options.endpoint || CURSOR_MODELS_API_ENDPOINT; + const timeoutMs = options.timeoutMs ?? 5000; + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), timeoutMs); + + try { + const response = await fetch(endpoint, { + method: 'GET', + headers: buildCursorModelsHeaders(credentials), + signal: abortController.signal, + }); + + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as unknown; + return parseApiModelsResponse(payload); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +export async function getModelsForDaemon( + options: { + credentials?: CursorCredentials | null; + endpoint?: string; + timeoutMs?: number; + } = {} +): Promise { + const cached = getCachedLiveModels(); + if (cached) { + return cached; + } + + const credentials = options.credentials; + if (credentials?.accessToken && credentials.machineId) { + const liveModels = await fetchModelsFromCursorApi(credentials, { + endpoint: options.endpoint, + timeoutMs: options.timeoutMs, + }); + if (liveModels && liveModels.length > 0) { + setCachedLiveModels(liveModels); + return liveModels; + } + } + + return DEFAULT_CURSOR_MODELS; +} /** * Fetch available models from running cursor daemon. @@ -241,32 +243,3 @@ export async function getAvailableModels(port: number): Promise { export function getDefaultModel(): string { return DEFAULT_CURSOR_MODEL; } - -/** - * Detect provider from model ID. - */ -export function detectProvider(modelId: string): string { - if (modelId.includes('claude')) return 'anthropic'; - if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai'; - if (modelId.includes('gemini')) return 'google'; - if (modelId.includes('cursor') || modelId.includes('composer')) return 'cursor'; - if (modelId.includes('grok')) return 'xai'; - return 'unknown'; -} - -/** - * Format model ID to human-readable name. - */ -export function formatModelName(modelId: string): string { - // Find model in catalog for metadata - const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId); - if (model) { - return model.name; - } - - // Fallback: convert kebab-case to title case - return modelId - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 59fd6d74..0322083c 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -34,8 +34,11 @@ export { DEFAULT_CURSOR_PORT, DEFAULT_CURSOR_MODEL, fetchModelsFromDaemon, + fetchModelsFromCursorApi, + getModelsForDaemon, getAvailableModels, getDefaultModel, + clearCursorModelsCache, detectProvider, formatModelName, } from './cursor-models'; diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index b46fa317..bccd17c3 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -153,6 +153,13 @@ describe('startDaemon', () => { const running = await isDaemonRunning(port); expect(running).toBe(true); + // Verify models endpoint exists and is OpenAI-compatible list shape + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); + expect(modelsResponse.status).toBe(200); + const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; + expect(modelsJson.object).toBe('list'); + expect(Array.isArray(modelsJson.data)).toBe(true); + // Verify chat endpoint exists (requires auth, should not be 404) const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index e5127f02..3221e7f8 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -2,7 +2,8 @@ * Unit tests for Cursor models module */ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, beforeEach } from 'bun:test'; +import * as http from 'http'; import { DEFAULT_CURSOR_MODELS, DEFAULT_CURSOR_PORT, @@ -11,6 +12,9 @@ import { detectProvider, formatModelName, fetchModelsFromDaemon, + fetchModelsFromCursorApi, + getModelsForDaemon, + clearCursorModelsCache, } from '../../../src/cursor/cursor-models'; describe('DEFAULT_CURSOR_MODELS', () => { @@ -102,3 +106,127 @@ describe('fetchModelsFromDaemon', () => { expect(models).toEqual(DEFAULT_CURSOR_MODELS); }); }); + +describe('fetchModelsFromCursorApi', () => { + it('parses model list from API response', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + data: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }, + { id: 'claude-4.6-opus', name: 'Claude 4.6 Opus', provider: 'anthropic' }, + ], + }) + ); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromCursorApi( + { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: `http://127.0.0.1:${address.port}/v1/models`, + timeoutMs: 2000, + } + ); + + expect(models).not.toBeNull(); + expect(models?.[0].id).toBe('gpt-5.3-codex'); + expect(models?.[1].id).toBe('claude-4.6-opus'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('returns null for non-200 responses', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'forbidden' })); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromCursorApi( + { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: `http://127.0.0.1:${address.port}/v1/models`, + timeoutMs: 2000, + } + ); + + expect(models).toBeNull(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + +describe('getModelsForDaemon', () => { + beforeEach(() => { + clearCursorModelsCache(); + }); + + it('falls back to defaults without credentials', async () => { + const models = await getModelsForDaemon(); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + }); + + it('uses cached live models when endpoint becomes unavailable', async () => { + const liveModelId = 'test-live-model'; + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + data: [{ id: liveModelId, name: 'Live Model', provider: 'openai' }], + }) + ); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + const endpoint = `http://127.0.0.1:${address.port}/v1/models`; + + try { + const first = await getModelsForDaemon({ + credentials: { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + endpoint, + timeoutMs: 2000, + }); + + expect(first[0]?.id).toBe(liveModelId); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + + const second = await getModelsForDaemon({ + endpoint: 'http://127.0.0.1:9/v1/models', + timeoutMs: 250, + }); + + expect(second[0]?.id).toBe(liveModelId); + }); +}); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 0ea16a7c..a4a699e1 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -531,6 +531,28 @@ describe('CursorExecutor', () => { const body = JSON.parse(bodyText); expect(body.error.type).toBe('rate_limit_error'); }); + + it('should surface reasoning_content when thinking payload is present', async () => { + const textContent = 'Final answer'; + const thinkingContent = 'Internal reasoning trail'; + const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinkingContent); + const chatResponse = concatArrays( + encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, textContent), + encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField) + ); + const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, chatResponse); + const frame = wrapConnectRPCFrame(responseMsg, false); + + const result = executor.transformProtobufToJSON(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const bodyText = await result.text(); + const body = JSON.parse(bodyText); + expect(body.choices[0].message.content).toBe(textContent); + expect(body.choices[0].message.reasoning_content).toBe(thinkingContent); + }); }); describe('transformProtobufToSSE', () => { @@ -572,6 +594,23 @@ describe('CursorExecutor', () => { const body = JSON.parse(bodyText); expect(body.error.type).toBe('rate_limit_error'); }); + + it('should emit reasoning_content deltas for thinking payloads', async () => { + const thinkingContent = 'Deliberate reasoning'; + const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinkingContent); + const chatResponse = encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField); + const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, chatResponse); + const frame = wrapConnectRPCFrame(responseMsg, false); + + const result = executor.transformProtobufToSSE(Buffer.from(frame), 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const bodyText = await result.text(); + expect(bodyText).toContain('reasoning_content'); + expect(bodyText).toContain(thinkingContent); + }); }); describe('decompressPayload error handling', () => { @@ -676,6 +715,16 @@ function buildTextFrame(text: string): Buffer { return buildFrame(responseMsg); } +/** + * Helper: build a protobuf thinking response frame + */ +function buildThinkingFrame(thinking: string): Buffer { + const thinkingField = encodeField(FIELD.Thinking.TEXT, WIRE_TYPE.LEN, thinking); + const responseField = encodeField(FIELD.ChatResponse.THINKING, WIRE_TYPE.LEN, thinkingField); + const responseMsg = encodeField(FIELD.Response.RESPONSE, WIRE_TYPE.LEN, responseField); + return buildFrame(responseMsg); +} + describe('StreamingFrameParser', () => { it('should parse a complete single frame', () => { const parser = new StreamingFrameParser(); @@ -784,6 +833,18 @@ describe('StreamingFrameParser', () => { } }); + it('should parse thinking frames', () => { + const parser = new StreamingFrameParser(); + const frame = buildThinkingFrame('Think step by step'); + const results = parser.push(frame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('thinking'); + if (results[0].type === 'thinking') { + expect(results[0].text).toBe('Think step by step'); + } + }); + it('should report hasPartial() correctly', () => { const parser = new StreamingFrameParser(); expect(parser.hasPartial()).toBe(false); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index a54bd2c6..1bb76640 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -1,10 +1,148 @@ /** * Cursor Routes Tests - * Tests for daemon start precondition validation logic. + * Endpoint contract tests without module-level mocks. */ -import { describe, it, expect } from 'bun:test'; -import { getDaemonStartPreconditionError } from '../../../src/web-server/routes/cursor-routes'; +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; + +let server: Server; +let baseUrl = ''; +let tempDir = ''; +let originalCcsHome: string | undefined; + +let setGlobalConfigDir: (dir: string | undefined) => void; +let getCcsDir: () => string; +let loadOrCreateUnifiedConfig: () => { + cursor?: { + enabled?: boolean; + port?: number; + auto_start?: boolean; + ghost_mode?: boolean; + model?: string; + }; +}; +let saveUnifiedConfig: (config: { + cursor?: { + enabled?: boolean; + port?: number; + auto_start?: boolean; + ghost_mode?: boolean; + model?: string; + }; +}) => void; +let saveCredentials: (credentials: { + accessToken: string; + machineId: string; + authMethod: 'manual' | 'auto-detect'; + importedAt: string; +}) => void; +let deleteCredentials: () => boolean; +let checkAuthStatus: () => { authenticated: boolean; expired?: boolean }; +let getDaemonStartPreconditionError: ( + input: { enabled: boolean; authenticated: boolean; tokenExpired?: boolean } +) => { status: number; error: string } | null; + +function seedCursorConfig(overrides: { + enabled?: boolean; + port?: number; + auto_start?: boolean; + ghost_mode?: boolean; + model?: string; +} = {}): void { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { + enabled: overrides.enabled ?? true, + port: overrides.port ?? 20129, + auto_start: overrides.auto_start ?? false, + ghost_mode: overrides.ghost_mode ?? true, + model: overrides.model ?? 'gpt-5.3-codex', + }; + saveUnifiedConfig(config); +} + +function seedCredentials(expired: boolean): void { + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: expired + ? new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString() + : new Date().toISOString(), + }); +} + +beforeAll(async () => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-routes-test-')); + process.env.CCS_HOME = tempDir; + + const configManager = await import('../../../src/utils/config-manager'); + setGlobalConfigDir = configManager.setGlobalConfigDir; + getCcsDir = configManager.getCcsDir; + setGlobalConfigDir(undefined); + + const unifiedConfig = await import('../../../src/config/unified-config-loader'); + loadOrCreateUnifiedConfig = unifiedConfig.loadOrCreateUnifiedConfig; + saveUnifiedConfig = unifiedConfig.saveUnifiedConfig; + + const cursorAuth = await import('../../../src/cursor/cursor-auth'); + saveCredentials = cursorAuth.saveCredentials; + deleteCredentials = cursorAuth.deleteCredentials; + checkAuthStatus = cursorAuth.checkAuthStatus; + + const cursorRoutesModule = await import('../../../src/web-server/routes/cursor-routes'); + getDaemonStartPreconditionError = cursorRoutesModule.getDaemonStartPreconditionError; + + const app = express(); + app.use(express.json()); + app.use('/api/cursor', cursorRoutesModule.default); + + server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.on('listening', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +beforeEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.mkdirSync(tempDir, { recursive: true }); + + setGlobalConfigDir(undefined); + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + seedCursorConfig(); + + // Ensure clean auth state for each test. + deleteCredentials(); +}); + +afterAll(async () => { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + setGlobalConfigDir(undefined); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); describe('Cursor Routes Logic', () => { describe('POST /daemon/start preconditions', () => { @@ -57,4 +195,118 @@ describe('Cursor Routes Logic', () => { expect(result).toBeNull(); }); }); + + describe('HTTP contracts', () => { + it('GET /api/cursor/status returns current state', async () => { + const res = await fetch(`${baseUrl}/api/cursor/status`); + expect(res.status).toBe(200); + + const json = (await res.json()) as { + enabled: boolean; + authenticated: boolean; + token_expired: boolean; + daemon_running: boolean; + port: number; + }; + + expect(json.enabled).toBe(true); + expect(json.authenticated).toBe(false); + expect(json.token_expired).toBe(false); + expect(json.daemon_running).toBe(false); + expect(json.port).toBe(20129); + }); + + it('POST /api/cursor/auth/import validates required fields', async () => { + const res = await fetch(`${baseUrl}/api/cursor/auth/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken: 'only-token' }), + }); + + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: string }; + expect(json.error).toContain('Missing accessToken or machineId'); + }); + + it('POST /api/cursor/auth/import rejects invalid token format', async () => { + const res = await fetch(`${baseUrl}/api/cursor/auth/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: 'short', + machineId: 'bad', + }), + }); + + expect(res.status).toBe(400); + const json = (await res.json()) as { error?: string }; + expect(json.error).toContain('Invalid token or machine ID format'); + expect(checkAuthStatus().authenticated).toBe(false); + }); + + it('POST /api/cursor/auth/import persists valid credentials', async () => { + const res = await fetch(`${baseUrl}/api/cursor/auth/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + }), + }); + + expect(res.status).toBe(200); + expect(checkAuthStatus().authenticated).toBe(true); + }); + + it('POST /api/cursor/auth/auto-detect returns 404 when no token source found', async () => { + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(404); + const json = (await res.json()) as { error?: string }; + expect(typeof json.error).toBe('string'); + expect(json.error?.length).toBeGreaterThan(0); + }); + + it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => { + seedCursorConfig({ enabled: false }); + seedCredentials(false); + + const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' }); + expect(res.status).toBe(400); + const json = (await res.json()) as { success?: boolean; error?: string }; + expect(json.success).toBe(false); + expect(json.error).toContain('disabled'); + }); + + it('POST /api/cursor/daemon/start returns 401 when unauthenticated', async () => { + seedCursorConfig({ enabled: true }); + + const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' }); + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string }; + expect(json.error).toContain('authentication required'); + }); + + it('POST /api/cursor/daemon/start returns 401 when token is expired', async () => { + seedCursorConfig({ enabled: true }); + seedCredentials(true); + + const res = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' }); + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string }; + expect(json.error).toContain('expired'); + }); + + it('GET /api/cursor/models returns current model and list payload', async () => { + const res = await fetch(`${baseUrl}/api/cursor/models`); + expect(res.status).toBe(200); + + const json = (await res.json()) as { models: Array<{ id: string }>; current: string }; + expect(Array.isArray(json.models)).toBe(true); + expect(json.models.length).toBeGreaterThan(0); + expect(json.current).toBe('gpt-5.3-codex'); + }); + }); }); From 3da3407f9a3470f34aaf03c19d3410e5b33caa36 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 04:09:48 +0700 Subject: [PATCH 11/31] fix(targets): harden adapter lifecycle and droid model edge cases --- src/auth/profile-detector.ts | 4 +- src/targets/claude-adapter.ts | 18 +- src/targets/droid-adapter.ts | 18 +- src/targets/droid-config-manager.ts | 96 +++++---- src/targets/target-adapter.ts | 39 +++- src/types/index.ts | 3 + src/types/profile.ts | 4 + src/utils/shell-executor.ts | 13 +- src/utils/signal-forwarder.ts | 44 ++++ .../targets/ccsd-alias-integration.test.ts | 72 +++++++ .../unit/targets/droid-config-manager.test.ts | 198 ++++++++++++++++-- tests/unit/utils/signal-forwarder.test.ts | 148 +++++++++++++ 12 files changed, 556 insertions(+), 101 deletions(-) create mode 100644 src/types/profile.ts create mode 100644 tests/unit/targets/ccsd-alias-integration.test.ts create mode 100644 tests/unit/utils/signal-forwarder.test.ts diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 064f9360..23570249 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -25,8 +25,8 @@ import { getCcsDir } from '../utils/config-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; import type { TargetType } from '../targets/target-adapter'; - -export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; +import type { ProfileType } from '../types/profile'; +export type { ProfileType } from '../types/profile'; /** CLIProxy profile names (OAuth-based, zero config) */ export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS; diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 2c2b7235..d7b60035 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -8,10 +8,11 @@ import { spawn, ChildProcess } from 'child_process'; import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector'; +import type { ProfileType } from '../types/profile'; import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; -import { forwardSignals } from '../utils/signal-forwarder'; +import { wireChildProcessSignals } from '../utils/signal-forwarder'; export class ClaudeAdapter implements TargetAdapter { readonly type: TargetType = 'claude'; @@ -34,7 +35,7 @@ export class ClaudeAdapter implements TargetAdapter { return userArgs; } - buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv { + buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv { const webSearchEnv = getWebSearchHookEnv(); // For account/default profiles, strip ANTHROPIC_* from parent env to prevent @@ -100,16 +101,7 @@ export class ClaudeAdapter implements TargetAdapter { }); } - const cleanupSignalHandlers = forwardSignals(child); - - child.on('exit', (code, signal) => { - cleanupSignalHandlers(); - if (signal) process.kill(process.pid, signal as NodeJS.Signals); - else process.exit(code || 0); - }); - - child.on('error', async (err: NodeJS.ErrnoException) => { - cleanupSignalHandlers(); + wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => { if (err.code === 'EACCES') { console.error(`[X] Claude CLI is not executable: ${claudeCli}`); console.error(' Check file permissions and executable bit.'); @@ -133,7 +125,7 @@ export class ClaudeAdapter implements TargetAdapter { /** * Claude supports all CCS profile types. */ - supportsProfileType(_profileType: string): boolean { + supportsProfileType(_profileType: ProfileType): boolean { return true; } } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 8368b0c7..79004bd3 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -9,9 +9,10 @@ import { spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter'; import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; +import type { ProfileType } from '../types/profile'; import { upsertCcsModel } from './droid-config-manager'; import { escapeShellArg } from '../utils/shell-executor'; -import { forwardSignals } from '../utils/signal-forwarder'; +import { wireChildProcessSignals } from '../utils/signal-forwarder'; export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; @@ -57,7 +58,7 @@ export class DroidAdapter implements TargetAdapter { /** * Droid uses config file for credentials — minimal env needed. */ - buildEnv(_creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv { + buildEnv(_creds: TargetCredentials, _profileType: ProfileType): NodeJS.ProcessEnv { return { ...process.env }; } @@ -119,16 +120,7 @@ export class DroidAdapter implements TargetAdapter { }); } - const cleanupSignalHandlers = forwardSignals(child); - - child.on('exit', (code, signal) => { - cleanupSignalHandlers(); - if (signal) process.kill(process.pid, signal as NodeJS.Signals); - else process.exit(code || 0); - }); - - child.on('error', (err: NodeJS.ErrnoException) => { - cleanupSignalHandlers(); + wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => { if (err.code === 'EACCES') { console.error(`[X] Droid CLI is not executable: ${droidPath}`); console.error(' Check file permissions and executable bit.'); @@ -153,7 +145,7 @@ export class DroidAdapter implements TargetAdapter { /** * Droid currently supports direct settings-based and default flows only. */ - supportsProfileType(profileType: string): boolean { + supportsProfileType(profileType: ProfileType): boolean { return profileType === 'settings' || profileType === 'default'; } } diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 47a73cc8..348e345d 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -11,6 +11,7 @@ import * as os from 'os'; import * as lockfile from 'proper-lockfile'; const CCS_MODEL_PREFIX = 'ccs-'; +const CCS_DISPLAY_PREFIX = 'CCS '; /** Lock configuration for concurrent write safety */ const LOCK_STALE_MS = 10000; @@ -21,8 +22,12 @@ const LOCK_RETRY_MAX_MS = 1000; * Validate profile name to prevent filesystem/security issues. * Only alphanumeric, underscore, hyphen allowed. */ +function isValidProfileName(profile: string): boolean { + return !!profile && /^[a-zA-Z0-9_-]+$/.test(profile); +} + function validateProfileName(profile: string): void { - if (!profile || !/^[a-zA-Z0-9_-]+$/.test(profile)) { + if (!isValidProfileName(profile)) { throw new Error( `Invalid profile name "${profile}": must contain only alphanumeric characters, underscores, or hyphens` ); @@ -57,21 +62,39 @@ function isSupportedProvider(value: string): value is DroidCustomModel['provider return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api'; } -function asModelEntry(value: unknown): DroidCustomModelEntry | null { - if (!value || typeof value !== 'object') return null; +function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry { + if (!value || typeof value !== 'object') return false; const record = value as Record; - if ( - typeof record.displayName !== 'string' || - record.displayName.trim() === '' || - typeof record.model !== 'string' || - typeof record.baseUrl !== 'string' || - typeof record.apiKey !== 'string' || - typeof record.provider !== 'string' || - record.provider.trim() === '' - ) { - return null; + return ( + typeof record.displayName === 'string' && + record.displayName.trim() !== '' && + typeof record.model === 'string' && + typeof record.baseUrl === 'string' && + typeof record.apiKey === 'string' && + typeof record.provider === 'string' && + record.provider.trim() !== '' + ); +} + +function isManagedDisplayName(displayName: string): boolean { + return displayName.startsWith(CCS_DISPLAY_PREFIX) || displayName.startsWith(CCS_MODEL_PREFIX); +} + +function parseManagedProfile(displayName: string): string | null { + let profile: string | null = null; + + if (displayName.startsWith(CCS_DISPLAY_PREFIX)) { + profile = displayName.slice(CCS_DISPLAY_PREFIX.length).trim(); + } else if (displayName.startsWith(CCS_MODEL_PREFIX)) { + profile = displayName.slice(CCS_MODEL_PREFIX.length).trim(); } - return value as DroidCustomModelEntry; + + if (!profile || !isValidProfileName(profile)) return null; + return profile; +} + +function asModelEntry(value: unknown): DroidCustomModelEntry | null { + return isDroidCustomModelEntry(value) ? value : null; } function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] { @@ -275,14 +298,6 @@ function writeDroidSettings(settings: DroidSettings): void { } } -/** - * Build the custom model alias from a CCS profile name. - * e.g., "gemini" → "ccs-gemini" - */ -function ccsAlias(profile: string): string { - return `${CCS_MODEL_PREFIX}${profile}`; -} - /** * Upsert a CCS-managed custom model entry. * Acquires file lock to prevent concurrent write races. @@ -293,20 +308,19 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel): let release: (() => Promise) | undefined; try { - release = await acquireFactoryLock(5); + release = await acquireFactoryLock(10); const settings = readDroidSettings(); settings.customModels = normalizeCustomModels(settings.customModels); - const alias = ccsAlias(profile); const entry: DroidCustomModelEntry = { ...model, displayName: `CCS ${profile}`, }; - // Find existing entry by checking displayName for CCS prefix match + // Find existing current or legacy entry for this profile. const idx = settings.customModels.findIndex( - (m) => m.displayName === `CCS ${profile}` || m.displayName === alias + (m) => parseManagedProfile(m.displayName) === profile ); if (idx >= 0) { @@ -338,7 +352,7 @@ export async function removeCcsModel(profile: string): Promise { settings.customModels = normalizeCustomModels(settings.customModels); settings.customModels = settings.customModels.filter( - (m) => m.displayName !== `CCS ${profile}` && m.displayName !== ccsAlias(profile) + (m) => parseManagedProfile(m.displayName) !== profile ); writeDroidSettings(settings); @@ -354,16 +368,14 @@ export async function listCcsModels(): Promise> { const result = new Map(); const settings = readDroidSettings(); for (const entry of normalizeCustomModels(settings.customModels)) { - if (entry.displayName?.startsWith('CCS ')) { - if (!isSupportedProvider(entry.provider)) { - continue; - } - const profile = entry.displayName.slice(4); // Remove "CCS " prefix - result.set(profile, { - ...entry, - provider: entry.provider, - }); - } + const profile = parseManagedProfile(entry.displayName); + if (!profile) continue; + if (!isSupportedProvider(entry.provider)) continue; + + result.set(profile, { + ...entry, + provider: entry.provider, + }); } return result; @@ -392,9 +404,13 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise { - if (!m.displayName?.startsWith('CCS ')) return true; // Keep non-CCS entries - const profile = m.displayName.slice(4); - return activeProfiles.includes(profile); + const profile = parseManagedProfile(m.displayName); + if (profile) { + return activeProfiles.includes(profile); + } + + // Drop malformed managed entries; keep user-managed entries. + return !isManagedDisplayName(m.displayName); }); removed = before - settings.customModels.length; diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index edb6ff7d..522ee905 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -9,6 +9,8 @@ * Supported CLI target types. * 'claude' is the default; additional targets register via target-registry. */ +import type { ProfileType } from '../types/profile'; + export type TargetType = 'claude' | 'droid'; /** @@ -46,25 +48,46 @@ export interface TargetAdapter { readonly type: TargetType; readonly displayName: string; - /** Detect if the target CLI binary exists on system */ + /** + * Resolve the target CLI executable on the current machine. + * Return `null` when the binary is unavailable. + */ detectBinary(): TargetBinaryInfo | null; - /** Prepare credentials for delivery to target CLI */ + /** + * Prepare credential delivery for the target. + * Targets may write config files, mutate process state, or no-op. + * + * @throws Error when required credentials are missing or invalid. + */ prepareCredentials(creds: TargetCredentials): Promise; - /** Build spawn arguments for the target CLI */ + /** + * Build target-specific argument vector. + * `userArgs` are the arguments after CCS profile/flag parsing. + */ buildArgs(profile: string, userArgs: string[]): string[]; - /** Build environment variables for the target CLI */ - buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv; + /** + * Build environment variables for process spawn. + * `profileType` allows targets to vary env behavior by CCS profile mode. + */ + buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv; - /** Spawn the target CLI process (replaces current process flow) */ + /** + * Spawn and hand over execution to the target CLI process. + * Implementations are responsible for signal forwarding and exit propagation. + * + * @throws Error for unrecoverable launch failures (if not exiting directly). + */ exec( args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } ): void; - /** Check if a profile type is supported by this target */ - supportsProfileType(profileType: string): boolean; + /** + * Report whether this target can run a given CCS profile type. + */ + supportsProfileType(profileType: ProfileType): boolean; } diff --git a/src/types/index.ts b/src/types/index.ts index 4adba8b4..494883be 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,3 +50,6 @@ export type { // Utility types export { LogLevel } from './utils'; export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils'; + +// Profile routing types +export type { ProfileType } from './profile'; diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 00000000..75e3ff44 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,4 @@ +/** + * Profile mode types used across routing and target adapters. + */ +export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 88b56fc3..017cd709 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,7 +7,7 @@ import { spawn, spawnSync, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; -import { forwardSignals } from './signal-forwarder'; +import { wireChildProcessSignals } from './signal-forwarder'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -128,16 +128,7 @@ export function execClaude( }); } - const cleanupSignalHandlers = forwardSignals(child); - - child.on('exit', (code, signal) => { - cleanupSignalHandlers(); - if (signal) process.kill(process.pid, signal as NodeJS.Signals); - else process.exit(code || 0); - }); - - child.on('error', async (err: NodeJS.ErrnoException) => { - cleanupSignalHandlers(); + wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => { if (err.code === 'EACCES') { console.error(`[X] Claude CLI is not executable: ${claudeCli}`); console.error(' Check file permissions and executable bit.'); diff --git a/src/utils/signal-forwarder.ts b/src/utils/signal-forwarder.ts index e2af8787..174d56fd 100644 --- a/src/utils/signal-forwarder.ts +++ b/src/utils/signal-forwarder.ts @@ -31,3 +31,47 @@ export function forwardSignals(child: ChildProcess): () => void { process.removeListener('SIGHUP', forwardSighup); }; } + +export type ChildProcessErrorHandler = (err: NodeJS.ErrnoException) => void | Promise; +export type ChildProcessExitHandler = (code: number | null, signal: NodeJS.Signals | null) => void; + +function defaultExitHandler(code: number | null, signal: NodeJS.Signals | null): void { + if (signal) process.kill(process.pid, signal); + else process.exit(code || 0); +} + +/** + * Attach shared signal-forwarding lifecycle handlers to a child process. + * Ensures signal listeners are always cleaned up on child exit/error. + */ +export function wireChildProcessSignals( + child: ChildProcess, + onError: ChildProcessErrorHandler, + onExit: ChildProcessExitHandler = defaultExitHandler +): void { + const cleanupSignalHandlers = forwardSignals(child); + let settled = false; + + const settle = (): boolean => { + if (settled) return false; + settled = true; + cleanupSignalHandlers(); + return true; + }; + + child.on('exit', (code, signal) => { + if (!settle()) return; + onExit(code, signal); + }); + + child.on('error', async (err: NodeJS.ErrnoException) => { + if (!settle()) return; + try { + await onError(err); + } catch (handlerErr) { + const message = handlerErr instanceof Error ? handlerErr.message : String(handlerErr); + console.error(`[X] Failed to handle child process error: ${message}`); + process.exit(1); + } + }); +} diff --git a/tests/unit/targets/ccsd-alias-integration.test.ts b/tests/unit/targets/ccsd-alias-integration.test.ts new file mode 100644 index 00000000..ebc4fd69 --- /dev/null +++ b/tests/unit/targets/ccsd-alias-integration.test.ts @@ -0,0 +1,72 @@ +/** + * Integration-style test for Node argv alias behavior. + * + * This validates the runtime assumption used by target-resolver: + * when invoked via a `ccsd` symlink, Node preserves the invoked + * symlink path in process.argv[1]. + */ +import { describe, it, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +function probeArgvPath(aliasBasename: string): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsd-alias-')); + const scriptPath = path.join(tmpDir, 'probe.js'); + const aliasPath = path.join(tmpDir, aliasBasename); + + try { + fs.writeFileSync(scriptPath, 'console.log(process.argv[1]);\n', { encoding: 'utf8' }); + fs.symlinkSync(scriptPath, aliasPath); + + const result = spawnSync('node', [aliasPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + expect(result.status).toBe(0); + return result.stdout.trim(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function probeArgvPathDirect(filename: string): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsd-direct-')); + const scriptPath = path.join(tmpDir, filename); + + try { + fs.writeFileSync(scriptPath, 'console.log(process.argv[1]);\n', { encoding: 'utf8' }); + + const result = spawnSync('node', [scriptPath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + expect(result.status).toBe(0); + return result.stdout.trim(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe('ccsd alias integration', () => { + it('should preserve ccsd symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + // Windows symlink creation requires elevated privileges/Developer Mode. + return; + } + + const argvPath = probeArgvPath('ccsd'); + expect(path.basename(argvPath)).toBe('ccsd'); + }); + + it('should preserve extension-style alias basenames for wrapper compatibility', () => { + const cmdArgvPath = probeArgvPathDirect('ccsd.cmd'); + const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1'); + + expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd'); + expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1'); + }); +}); diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index e2d63790..c1743d67 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -175,6 +175,60 @@ describe('droid-config-manager', () => { }) ).rejects.toThrow(/settings\.json\.tmp is a symlink/); }); + + it('should update legacy ccs- alias entry on upsert', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'claude-opus-4-6', + displayName: 'ccs-gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'old-key', + provider: 'anthropic', + }, + ], + }) + ); + + await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8318', + apiKey: 'new-key', + provider: 'anthropic', + }); + + const settings = JSON.parse( + fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') + ); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].displayName).toBe('CCS gemini'); + expect(settings.customModels[0].apiKey).toBe('new-key'); + expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318'); + }); + + it('should reject symlinked settings file on write', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + + const realSettings = path.join(factoryDir, 'real-settings.json'); + fs.writeFileSync(realSettings, JSON.stringify({ customModels: [] })); + fs.symlinkSync(realSettings, path.join(factoryDir, 'settings.json')); + + await expect( + upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + provider: 'anthropic', + }) + ).rejects.toThrow(/settings\.json is a symlink/); + }); }); describe('removeCcsModel', () => { @@ -213,6 +267,26 @@ describe('droid-config-manager', () => { expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('My GPT'); }); + + it('should remove legacy ccs- alias entries', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, + ], + }) + ); + + await removeCcsModel('gemini'); + + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].displayName).toBe('My GPT'); + }); }); describe('listCcsModels', () => { @@ -295,6 +369,60 @@ describe('droid-config-manager', () => { await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/); }); + + it('should include legacy ccs- alias entries', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'opus', + displayName: 'ccs-gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy', + provider: 'anthropic', + }, + ], + }) + ); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + }); + + it('should ignore malformed managed display names', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { model: 'x', displayName: 'CCS ok', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + ], + }) + ); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('ok')).toBe(true); + }); + + it('should recover from corrupted JSON by backing up and returning empty models', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + const settingsPath = path.join(factoryDir, 'settings.json'); + + fs.writeFileSync(settingsPath, '{"customModels":[', 'utf8'); + + const models = await listCcsModels(); + expect(models.size).toBe(0); + expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true); + }); }); describe('pruneOrphanedModels', () => { @@ -343,18 +471,59 @@ describe('droid-config-manager', () => { expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('My GPT'); }); + + it('should prune orphaned legacy ccs- alias entries', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { model: 'sonnet', displayName: 'ccs-codex', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + ], + }) + ); + + const removed = await pruneOrphanedModels(['gemini']); + expect(removed).toBe(1); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + }); + + it('should prune malformed managed entries while preserving user models', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, + ], + }) + ); + + const removed = await pruneOrphanedModels([]); + expect(removed).toBe(2); + + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); + expect(settings.customModels).toHaveLength(1); + expect(settings.customModels[0].displayName).toBe('My GPT'); + }); }); describe('concurrent writes', () => { - it('should handle concurrent upserts without data loss', async () => { - // Write in batches of 3 to simulate realistic concurrency - // (10 simultaneous locks exceeds retry budget) - const profiles = Array.from({ length: 9 }, (_, i) => `profile-${i}`); + it( + 'should handle concurrent upserts without data loss', + async () => { + const profiles = Array.from({ length: 10 }, (_, i) => `profile-${i}`); - for (let i = 0; i < profiles.length; i += 3) { - const batch = profiles.slice(i, i + 3); await Promise.all( - batch.map((p) => + profiles.map((p) => upsertCcsModel(p, { model: 'test-model', displayName: `CCS ${p}`, @@ -364,14 +533,15 @@ describe('droid-config-manager', () => { }) ) ); - } - const models = await listCcsModels(); - expect(models.size).toBe(9); + const models = await listCcsModels(); + expect(models.size).toBe(10); - for (const p of profiles) { - expect(models.has(p)).toBe(true); - } - }); + for (const p of profiles) { + expect(models.has(p)).toBe(true); + } + }, + 15000 + ); }); }); diff --git a/tests/unit/utils/signal-forwarder.test.ts b/tests/unit/utils/signal-forwarder.test.ts new file mode 100644 index 00000000..463453f8 --- /dev/null +++ b/tests/unit/utils/signal-forwarder.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, jest } from 'bun:test'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; +import { forwardSignals, wireChildProcessSignals } from '../../../src/utils/signal-forwarder'; + +type MockChildProcess = EventEmitter & { + killed: boolean; + kill: ChildProcess['kill']; +}; + +function createMockChildProcess(): ChildProcess { + const child = new EventEmitter() as MockChildProcess; + child.killed = false; + child.kill = jest.fn(() => true) as ChildProcess['kill']; + return child as ChildProcess; +} + +function getSignalListenerCounts(): Record<'SIGINT' | 'SIGTERM' | 'SIGHUP', number> { + return { + SIGINT: process.listenerCount('SIGINT'), + SIGTERM: process.listenerCount('SIGTERM'), + SIGHUP: process.listenerCount('SIGHUP'), + }; +} + +describe('signal-forwarder', () => { + it('forwardSignals should register and cleanup listeners', () => { + const child = createMockChildProcess(); + const before = getSignalListenerCounts(); + + const cleanup = forwardSignals(child); + + expect(process.listenerCount('SIGINT')).toBe(before.SIGINT + 1); + expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM + 1); + expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP + 1); + + cleanup(); + + expect(process.listenerCount('SIGINT')).toBe(before.SIGINT); + expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM); + expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP); + }); + + it('wireChildProcessSignals should use default exit behavior for exit code', () => { + const child = createMockChildProcess(); + const before = getSignalListenerCounts(); + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation((() => true) as typeof process.kill); + + try { + wireChildProcessSignals(child, () => {}); + child.emit('exit', 7, null); + + expect(exitSpy).toHaveBeenCalledWith(7); + expect(killSpy).not.toHaveBeenCalled(); + expect(process.listenerCount('SIGINT')).toBe(before.SIGINT); + expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM); + expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP); + } finally { + exitSpy.mockRestore(); + killSpy.mockRestore(); + } + }); + + it('wireChildProcessSignals should use default exit behavior for signal', () => { + const child = createMockChildProcess(); + const before = getSignalListenerCounts(); + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const killSpy = jest + .spyOn(process, 'kill') + .mockImplementation((() => true) as typeof process.kill); + + try { + wireChildProcessSignals(child, () => {}); + child.emit('exit', null, 'SIGTERM'); + + expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.listenerCount('SIGINT')).toBe(before.SIGINT); + expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM); + expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP); + } finally { + exitSpy.mockRestore(); + killSpy.mockRestore(); + } + }); + + it('wireChildProcessSignals should invoke onError and cleanup listeners', async () => { + const child = createMockChildProcess(); + const before = getSignalListenerCounts(); + const onError = jest.fn(async () => {}); + const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + + wireChildProcessSignals(child, onError); + child.emit('error', err); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith(err); + expect(process.listenerCount('SIGINT')).toBe(before.SIGINT); + expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM); + expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP); + }); + + it('wireChildProcessSignals should run only one terminal callback when error is followed by exit', async () => { + const child = createMockChildProcess(); + const onError = jest.fn(async () => {}); + const onExit = jest.fn(); + const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + + wireChildProcessSignals(child, onError, onExit); + child.emit('error', err); + child.emit('exit', 1, null); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('wireChildProcessSignals should exit with code 1 when onError throws', async () => { + const child = createMockChildProcess(); + const onError = jest.fn(async () => { + throw new Error('handler exploded'); + }); + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + + try { + const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + wireChildProcessSignals(child, onError); + child.emit('error', err); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + } + }); +}); From 905b35eea5a65f46902d1a7c3344b21bd35614e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Feb 2026 21:24:38 +0000 Subject: [PATCH 12/31] chore(release): 7.45.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 153ceeb1..2daeb014 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.45.0-dev.2", + "version": "7.45.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 4798741a99eb60190c6ba09215f50d3bc67db6e4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 16:33:21 +0700 Subject: [PATCH 13/31] fix(cursor): resolve review feedback and harden edge cases --- src/cursor/cursor-auth.ts | 14 +- src/cursor/cursor-client-policy.ts | 13 +- src/cursor/cursor-daemon-entry.ts | 41 +++-- src/cursor/cursor-executor.ts | 36 ++-- src/cursor/cursor-models.ts | 58 ++++-- src/cursor/cursor-protobuf-decoder.ts | 6 +- src/cursor/cursor-protobuf-schema.ts | 2 +- tests/unit/cursor/cursor-auth.test.ts | 20 ++- tests/unit/cursor/cursor-daemon.test.ts | 89 ++++++++- tests/unit/cursor/cursor-models.test.ts | 190 +++++++++++++++++++- tests/unit/cursor/cursor-protobuf.test.ts | 142 +++++++++++++-- tests/unit/utils/process-utils.test.ts | 129 ++++++++----- tests/unit/web-server/cursor-routes.test.ts | 120 ++++++++++++- 13 files changed, 746 insertions(+), 114 deletions(-) diff --git a/src/cursor/cursor-auth.ts b/src/cursor/cursor-auth.ts index 7440e983..924077bd 100644 --- a/src/cursor/cursor-auth.ts +++ b/src/cursor/cursor-auth.ts @@ -21,12 +21,24 @@ import * as os from 'os'; import type { CursorCredentials, CursorAuthStatus, AutoDetectResult } from './types'; import { getCcsDir } from '../utils/config-manager'; +/** + * Resolve home directory from environment first for deterministic testability, + * then fall back to os.homedir() when env vars are unavailable. + */ +function resolveHomeDir(): string { + if (process.platform === 'win32') { + return process.env.USERPROFILE || process.env.HOME || os.homedir(); + } + + return process.env.HOME || os.homedir(); +} + /** * Get platform-specific path to Cursor's state.vscdb */ export function getTokenStoragePath(): string { const platform = process.platform; - const home = os.homedir(); + const home = resolveHomeDir(); if (platform === 'win32') { const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); diff --git a/src/cursor/cursor-client-policy.ts b/src/cursor/cursor-client-policy.ts index 157b46d2..e5a5fe75 100644 --- a/src/cursor/cursor-client-policy.ts +++ b/src/cursor/cursor-client-policy.ts @@ -5,7 +5,7 @@ */ import * as crypto from 'crypto'; -import type { CursorCredentials } from './cursor-protobuf-schema'; +import type { CursorApiCredentials } from './cursor-protobuf-schema'; export const CURSOR_CLIENT_VERSION = '2.3.41'; export const CURSOR_USER_AGENT = 'connect-es/1.6.1'; @@ -33,6 +33,7 @@ export function generateCursorChecksum(machineId: string, nowMs: number = Date.n throw new Error('Machine ID is required for Cursor API'); } + // Convert milliseconds to coarse ~1000-second units required by Cursor's checksum routine. const timestamp = Math.floor(nowMs / 1000000); // JS bitwise shifts wrap modulo 32, so >>40 and >>32 give wrong results. // Use Math.trunc division for upper bytes that exceed 32-bit range. @@ -73,7 +74,7 @@ export function generateCursorChecksum(machineId: string, nowMs: number = Date.n return `${encoded}${machineId}`; } -function buildCursorBaseHeaders(credentials: CursorCredentials): Record { +function buildCursorBaseHeaders(credentials: CursorApiCredentials): Record { const cleanToken = normalizeCursorAccessToken(credentials.accessToken); if (!cleanToken) { @@ -105,7 +106,9 @@ function buildCursorBaseHeaders(credentials: CursorCredentials): Record { +export function buildCursorConnectHeaders( + credentials: CursorApiCredentials +): Record { return { ...buildCursorBaseHeaders(credentials), 'connect-accept-encoding': 'gzip', @@ -115,7 +118,9 @@ export function buildCursorConnectHeaders(credentials: CursorCredentials): Recor }; } -export function buildCursorModelsHeaders(credentials: CursorCredentials): Record { +export function buildCursorModelsHeaders( + credentials: CursorApiCredentials +): Record { return { ...buildCursorBaseHeaders(credentials), accept: 'application/json', diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 841de56d..355cedfc 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -59,12 +59,26 @@ function readJsonBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let total = 0; + let settled = false; + + const resolveOnce = (payload: unknown) => { + if (settled) return; + settled = true; + resolve(payload); + }; + + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; req.on('data', (chunk: Buffer) => { total += chunk.length; if (total > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error('Request body too large (max 10MB)')); + // Stop processing body, but avoid force-closing socket so caller can return 413 cleanly. + req.pause(); + rejectOnce(new Error('Request body too large (max 10MB)')); return; } chunks.push(chunk); @@ -73,17 +87,19 @@ function readJsonBody(req: http.IncomingMessage): Promise { req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8').trim(); if (!raw) { - resolve({}); + resolveOnce({}); return; } try { - resolve(JSON.parse(raw)); + resolveOnce(JSON.parse(raw)); } catch { - reject(new Error('Invalid JSON in request body')); + rejectOnce(new Error('Invalid JSON in request body')); } }); - req.on('error', reject); + req.on('error', (error) => { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + }); }); } @@ -241,11 +257,15 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } const abortController = new AbortController(); - req.on('close', () => { - if (!res.writableEnded) { + const abortOnDisconnect = () => { + if (!abortController.signal.aborted && !res.writableEnded) { abortController.abort(); } - }); + }; + + req.on('aborted', abortOnDisconnect); + req.on('close', abortOnDisconnect); + res.on('close', abortOnDisconnect); const result = await executor.execute({ model, @@ -269,7 +289,8 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser await pipeWebResponseToNode(result.response, res); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - writeJson(res, 400, { + const isPayloadTooLarge = message.includes('Request body too large'); + writeJson(res, isPayloadTooLarge ? 413 : 400, { error: { type: 'invalid_request_error', message, diff --git a/src/cursor/cursor-executor.ts b/src/cursor/cursor-executor.ts index 1aa7e48a..7f46ec42 100644 --- a/src/cursor/cursor-executor.ts +++ b/src/cursor/cursor-executor.ts @@ -6,7 +6,7 @@ import type { IncomingHttpHeaders } from 'http'; import { generateCursorBody, extractTextFromResponse } from './cursor-protobuf.js'; import { buildCursorRequest } from './cursor-translator.js'; -import type { CursorTool, CursorCredentials } from './cursor-protobuf-schema.js'; +import type { CursorTool, CursorApiCredentials } from './cursor-protobuf-schema.js'; import { buildCursorConnectHeaders, generateCursorChecksum } from './cursor-client-policy.js'; import { StreamingFrameParser, decompressPayload } from './cursor-stream-parser.js'; @@ -30,7 +30,7 @@ interface ExecutorParams { reasoning_effort?: string; }; stream: boolean; - credentials: CursorCredentials; + credentials: CursorApiCredentials; signal?: AbortSignal; } @@ -104,7 +104,7 @@ export class CursorExecutor { return generateCursorChecksum(machineId); } - buildHeaders(credentials: CursorCredentials): Record { + buildHeaders(credentials: CursorApiCredentials): Record { return buildCursorConnectHeaders(credentials); } @@ -112,7 +112,7 @@ export class CursorExecutor { model: string, body: ExecutorParams['body'], stream: boolean, - credentials: CursorCredentials + credentials: CursorApiCredentials ): Uint8Array { const translatedBody = buildCursorRequest(model, body, stream, credentials); const messages = translatedBody.messages || []; @@ -320,12 +320,24 @@ export class CursorExecutor { const created = Math.floor(Date.now() / 1000); return new Promise((resolve, reject) => { + let settled = false; + const resolveOnce = (response: Response) => { + if (settled) return; + settled = true; + resolve(response); + }; + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + const urlObj = new URL(url); const client = http2.connect(`https://${urlObj.host}`); client.on('error', (err) => { client.close(); - reject(err); + rejectOnce(err instanceof Error ? err : new Error(String(err))); }); const req = client.request({ @@ -342,7 +354,8 @@ export class CursorExecutor { if (signal) { const onAbort = () => { streamClosed = true; - // Close the ReadableStream controller so consumers don't hang on reader.read() + + // If stream already started, close readable to unblock consumers. if (streamController) { try { streamController.close(); @@ -350,9 +363,12 @@ export class CursorExecutor { /* already closed */ } } + req.close(); client.close(); + rejectOnce(new Error('Request aborted')); }; + signal.addEventListener('abort', onAbort, { once: true }); const cleanup = () => signal.removeEventListener('abort', onAbort); req.on('end', cleanup); @@ -368,12 +384,12 @@ export class CursorExecutor { req.on('end', () => { client.close(); const errorText = Buffer.concat(errorChunks).toString(); - resolve( + resolveOnce( new Response( JSON.stringify({ error: { message: `[${status}]: ${errorText}`, - type: 'invalid_request_error', + type: status === 429 ? 'rate_limit_error' : 'invalid_request_error', code: '', }, }), @@ -561,7 +577,7 @@ export class CursorExecutor { }, }); - resolve( + resolveOnce( new Response(readable, { status: 200, headers: { @@ -575,7 +591,7 @@ export class CursorExecutor { req.on('error', (err) => { client.close(); - reject(err); + rejectOnce(err instanceof Error ? err : new Error(String(err))); }); req.write(body); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 460c0a83..12e7ad58 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -6,7 +6,7 @@ import * as http from 'http'; import type { CursorModel } from './types'; -import type { CursorCredentials } from './cursor-protobuf-schema'; +import type { CursorApiCredentials } from './cursor-protobuf-schema'; import { isDaemonRunning } from './cursor-daemon'; import { buildCursorModelsHeaders } from './cursor-client-policy'; import { @@ -34,6 +34,15 @@ interface CursorModelsApiResponse { models?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>; } +function debugLog(message: string, error?: unknown): void { + if (!process.env.CCS_DEBUG) return; + if (error) { + console.error(`[cursor] ${message}`, error); + return; + } + console.error(`[cursor] ${message}`); +} + function normalizeModelRecords( records: Array<{ id?: unknown; name?: unknown; provider?: unknown }> ): CursorModel[] { @@ -93,7 +102,7 @@ export function clearCursorModelsCache(): void { } export async function fetchModelsFromCursorApi( - credentials: CursorCredentials, + credentials: CursorApiCredentials, options: { endpoint?: string; timeoutMs?: number; @@ -116,12 +125,21 @@ export async function fetchModelsFromCursorApi( }); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + clearCursorModelsCache(); + } + debugLog(`Cursor models API returned ${response.status} (${endpoint})`); return null; } const payload = (await response.json()) as unknown; - return parseApiModelsResponse(payload); - } catch { + const parsed = parseApiModelsResponse(payload); + if (!parsed) { + debugLog(`Cursor models API payload shape invalid (${endpoint})`); + } + return parsed; + } catch (error) { + debugLog(`Cursor models API fetch failed (${endpoint})`, error); return null; } finally { clearTimeout(timeout); @@ -130,7 +148,7 @@ export async function fetchModelsFromCursorApi( export async function getModelsForDaemon( options: { - credentials?: CursorCredentials | null; + credentials?: CursorApiCredentials | null; endpoint?: string; timeoutMs?: number; } = {} @@ -186,6 +204,7 @@ export async function fetchModelsFromDaemon(port: number): Promise { data += chunk; if (data.length > MAX_BODY_SIZE) { + debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults'); req.destroy(); safeResolve(DEFAULT_CURSOR_MODELS); } @@ -193,30 +212,39 @@ export async function fetchModelsFromDaemon(port: number): Promise { try { - const response = JSON.parse(data) as { data?: Array<{ id: string }> }; - if (response.data && Array.isArray(response.data)) { - const models: CursorModel[] = response.data.map((m) => ({ - id: m.id, - name: formatModelName(m.id), - provider: detectProvider(m.id), - isDefault: m.id === DEFAULT_CURSOR_MODEL, - })); + const response = JSON.parse(data) as { data?: Array<{ id?: unknown }> }; + if (Array.isArray(response.data)) { + const models: CursorModel[] = response.data + .filter((m) => m && typeof m.id === 'string' && m.id.length > 0) + .map((m) => ({ + id: m.id as string, + name: formatModelName(m.id as string), + provider: detectProvider(m.id as string), + isDefault: m.id === DEFAULT_CURSOR_MODEL, + })); safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS); } else { + debugLog('Cursor daemon /v1/models payload missing data[]; falling back to defaults'); safeResolve(DEFAULT_CURSOR_MODELS); } - } catch { + } catch (error) { + debugLog( + 'Cursor daemon /v1/models returned invalid JSON; falling back to defaults', + error + ); safeResolve(DEFAULT_CURSOR_MODELS); } }); } ); - req.on('error', () => { + req.on('error', (error) => { + debugLog('Cursor daemon /v1/models request failed; falling back to defaults', error); safeResolve(DEFAULT_CURSOR_MODELS); }); req.on('timeout', () => { + debugLog('Cursor daemon /v1/models request timed out; falling back to defaults'); req.destroy(); safeResolve(DEFAULT_CURSOR_MODELS); }); diff --git a/src/cursor/cursor-protobuf-decoder.ts b/src/cursor/cursor-protobuf-decoder.ts index 1dde8dd6..a5b5fe8d 100644 --- a/src/cursor/cursor-protobuf-decoder.ts +++ b/src/cursor/cursor-protobuf-decoder.ts @@ -326,11 +326,15 @@ export function extractTextFromResponse(payload: Uint8Array): { } } + if (payload.length > 0) { + return { text: null, error: 'Malformed protobuf response', toolCall: null, thinking: null }; + } + return { text: null, error: null, toolCall: null, thinking: null }; } catch (err) { if (process.env.CCS_DEBUG) { console.error('[cursor] extractTextFromResponse parsing failed:', err); } - return { text: null, error: null, toolCall: null, thinking: null }; + return { text: null, error: 'Malformed protobuf response', toolCall: null, thinking: null }; } } diff --git a/src/cursor/cursor-protobuf-schema.ts b/src/cursor/cursor-protobuf-schema.ts index 01a78fdf..296cfd49 100644 --- a/src/cursor/cursor-protobuf-schema.ts +++ b/src/cursor/cursor-protobuf-schema.ts @@ -135,7 +135,7 @@ export type UnifiedModeType = (typeof UNIFIED_MODE)[keyof typeof UNIFIED_MODE]; export type ThinkingLevelType = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL]; /** Cursor credentials structure */ -export interface CursorCredentials { +export interface CursorApiCredentials { accessToken: string; machineId: string; ghostMode?: boolean; diff --git a/tests/unit/cursor/cursor-auth.test.ts b/tests/unit/cursor/cursor-auth.test.ts index 8743fbe6..b2aaa6bf 100644 --- a/tests/unit/cursor/cursor-auth.test.ts +++ b/tests/unit/cursor/cursor-auth.test.ts @@ -431,11 +431,23 @@ describe('autoDetectTokens', () => { return; } - const result = autoDetectTokens(); + const originalHome = process.env.HOME; + const isolatedHome = path.join(tempDir, 'no-cursor-home'); + process.env.HOME = isolatedHome; - // Should fail because Cursor database doesn't exist in test environment - expect(result.found).toBe(false); - expect(result.error).toBeDefined(); + try { + const result = autoDetectTokens(); + + // Should fail because isolated test home has no Cursor database + expect(result.found).toBe(false); + expect(result.error).toBeDefined(); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } }); it('should have found property in return type', () => { diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index bccd17c3..c2f64ce7 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -19,7 +19,7 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; -import { loadCredentials } from '../../../src/cursor/cursor-auth'; +import { loadCredentials, saveCredentials } from '../../../src/cursor/cursor-auth'; // Test isolation let originalCcsHome: string | undefined; @@ -181,6 +181,93 @@ describe('startDaemon', () => { }, 35000 ); + + it('returns 404 for unknown routes', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + try { + const response = await fetch(`http://127.0.0.1:${port}/unknown`); + expect(response.status).toBe(404); + } finally { + await stopDaemon(); + } + }); + + it('returns 401 when credentials are expired', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: expiredAt, + }); + + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { error?: { message?: string } }; + expect(body.error?.message).toContain('expired'); + } finally { + await stopDaemon(); + } + }); + + it('validates invalid JSON, invalid message schema, and oversized body', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + try { + const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid-json', + }); + expect(invalidJson.status).toBe(400); + + const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: { role: 'user', content: 'hello' }, + }), + }); + expect(invalidSchema.status).toBe(400); + + const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [ + { + role: 'user', + content: 'x'.repeat(10 * 1024 * 1024 + 1024), + }, + ], + }), + }); + expect(oversized.status).toBe(413); + } finally { + await stopDaemon(); + } + }); }); describe('isDaemonRunning', () => { diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 3221e7f8..7d2dc8d0 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -99,12 +99,52 @@ describe('formatModelName', () => { describe('fetchModelsFromDaemon', () => { it('falls back to DEFAULT_CURSOR_MODELS when daemon is unreachable', async () => { - // Use a port that nothing is listening on const unreachablePort = 9999; const models = await fetchModelsFromDaemon(unreachablePort); expect(models).toEqual(DEFAULT_CURSOR_MODELS); }); + + it('falls back to defaults when daemon returns invalid JSON', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{not-valid-json'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('falls back to defaults when daemon response exceeds max body size', async () => { + const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(oversizedPayload); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); describe('fetchModelsFromCursorApi', () => { @@ -176,6 +216,90 @@ describe('fetchModelsFromCursorApi', () => { await new Promise((resolve) => server.close(() => resolve())); } }); + + it('parses response.models and filters invalid records', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + models: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' }, + { id: '', name: 'invalid-empty-id' }, + { id: 123, name: 'invalid-type-id' }, + ], + }) + ); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromCursorApi( + { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: `http://127.0.0.1:${address.port}/v1/models`, + timeoutMs: 2000, + } + ); + + expect(models).not.toBeNull(); + expect(models).toHaveLength(1); + expect(models?.[0].id).toBe('gpt-5.3-codex'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('returns null when required credentials are missing', async () => { + const models = await fetchModelsFromCursorApi( + { + accessToken: '', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: 'http://127.0.0.1:9/v1/models', + timeoutMs: 50, + } + ); + + expect(models).toBeNull(); + }); + + it('returns null on timeout/abort', async () => { + const server = http.createServer((_req, _res) => { + // Intentionally no response within timeout. + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromCursorApi( + { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: `http://127.0.0.1:${address.port}/v1/models`, + timeoutMs: 25, + } + ); + + expect(models).toBeNull(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); describe('getModelsForDaemon', () => { @@ -229,4 +353,68 @@ describe('getModelsForDaemon', () => { expect(second[0]?.id).toBe(liveModelId); }); + + it('clears cache after auth failures and falls back to defaults', async () => { + const liveModelId = 'test-live-model-auth-cache'; + const okServer = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + data: [{ id: liveModelId, name: 'Live Model', provider: 'openai' }], + }) + ); + }); + + await new Promise((resolve) => okServer.listen(0, '127.0.0.1', resolve)); + const okAddress = okServer.address(); + if (!okAddress || typeof okAddress === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const first = await getModelsForDaemon({ + credentials: { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + endpoint: `http://127.0.0.1:${okAddress.port}/v1/models`, + timeoutMs: 2000, + }); + + expect(first[0]?.id).toBe(liveModelId); + } finally { + await new Promise((resolve) => okServer.close(() => resolve())); + } + + const forbiddenServer = http.createServer((_req, res) => { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'forbidden' })); + }); + + await new Promise((resolve) => forbiddenServer.listen(0, '127.0.0.1', resolve)); + const forbiddenAddress = forbiddenServer.address(); + if (!forbiddenAddress || typeof forbiddenAddress === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const forbidden = await fetchModelsFromCursorApi( + { + accessToken: 'test-token-123', + machineId: '1234567890abcdef1234567890abcdef', + }, + { + endpoint: `http://127.0.0.1:${forbiddenAddress.port}/v1/models`, + timeoutMs: 2000, + } + ); + + expect(forbidden).toBeNull(); + } finally { + await new Promise((resolve) => forbiddenServer.close(() => resolve())); + } + + const afterAuthFailure = await getModelsForDaemon(); + expect(afterAuthFailure).toEqual(DEFAULT_CURSOR_MODELS); + }); }); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index a4a699e1..5831fda2 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -371,18 +371,12 @@ describe('Request Encoding', () => { it('should handle multi-frame buffer', () => { const executor = new CursorExecutor(); - // Create two simple frames - const frame1 = wrapConnectRPCFrame( - encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, 'Frame 1'), - false - ); - const frame2 = wrapConnectRPCFrame( - encodeField(FIELD.ChatResponse.TEXT, WIRE_TYPE.LEN, ' Frame 2'), - false - ); + // Build two valid response frames (top-level Response.RESPONSE wrapper). + const frame1 = buildTextFrame('Frame 1'); + const frame2 = buildTextFrame(' Frame 2'); // Concatenate them - const multiFrame = Buffer.concat([Buffer.from(frame1), Buffer.from(frame2)]); + const multiFrame = Buffer.concat([frame1, frame2]); const result = executor.transformProtobufToJSON(multiFrame, 'gpt-4', { messages: [], @@ -457,6 +451,29 @@ describe('CursorExecutor', () => { expect(headers.authorization).toBe('Bearer actual-token'); }); + it('should throw when token becomes empty after delimiter parsing', () => { + const credentials = { + accessToken: 'prefix::', + machineId: 'test-machine-id', + }; + + expect(() => executor.buildHeaders(credentials)).toThrow('Access token is empty'); + }); + + it('should include normalized platform and timezone headers', () => { + const credentials = { + accessToken: 'test-token', + machineId: 'test-machine-id', + }; + + const headers = executor.buildHeaders(credentials); + + expect(['windows', 'macos', 'linux']).toContain(headers['x-cursor-client-os']); + expect(['aarch64', 'x64']).toContain(headers['x-cursor-client-arch']); + expect(typeof headers['x-cursor-timezone']).toBe('string'); + expect(headers['x-cursor-timezone'].length).toBeGreaterThan(0); + }); + it('should respect ghostMode flag', () => { const credentialsGhost = { accessToken: 'test-token', @@ -553,6 +570,33 @@ describe('CursorExecutor', () => { expect(body.choices[0].message.content).toBe(textContent); expect(body.choices[0].message.reasoning_content).toBe(thinkingContent); }); + + it('should merge fragmented tool call arguments and set tool_calls finish reason', async () => { + const frame1 = buildToolCallFrame({ + id: 'call_123', + name: 'search_docs', + args: '{"q":"hel', + isLast: false, + }); + const frame2 = buildToolCallFrame({ + id: 'call_123', + name: 'search_docs', + args: 'lo"}', + isLast: true, + }); + const combined = Buffer.concat([frame1, frame2]); + + const result = executor.transformProtobufToJSON(combined, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const body = JSON.parse(await result.text()); + expect(body.choices[0].finish_reason).toBe('tool_calls'); + expect(body.choices[0].message.tool_calls[0].id).toBe('call_123'); + expect(body.choices[0].message.tool_calls[0].function.name).toBe('search_docs'); + expect(body.choices[0].message.tool_calls[0].function.arguments).toBe('{"q":"hello"}'); + }); }); describe('transformProtobufToSSE', () => { @@ -611,6 +655,32 @@ describe('CursorExecutor', () => { expect(bodyText).toContain('reasoning_content'); expect(bodyText).toContain(thinkingContent); }); + + it('should emit tool call deltas and end with finish_reason tool_calls', async () => { + const frame1 = buildToolCallFrame({ + id: 'call_abc', + name: 'search_docs', + args: '{"q":"foo', + isLast: false, + }); + const frame2 = buildToolCallFrame({ + id: 'call_abc', + name: 'search_docs', + args: '"}', + isLast: true, + }); + const combined = Buffer.concat([frame1, frame2]); + + const result = executor.transformProtobufToSSE(combined, 'gpt-4', { + messages: [], + }); + + expect(result.status).toBe(200); + const bodyText = await result.text(); + expect(bodyText).toContain('tool_calls'); + expect(bodyText).toContain('search_docs'); + expect(bodyText).toContain('"finish_reason":"tool_calls"'); + }); }); describe('decompressPayload error handling', () => { @@ -725,6 +795,25 @@ function buildThinkingFrame(thinking: string): Buffer { return buildFrame(responseMsg); } +/** + * Helper: build a protobuf tool call response frame + */ +function buildToolCallFrame(options: { + id: string; + name: string; + args: string; + isLast: boolean; +}): Buffer { + const toolCallPayload = concatArrays( + encodeField(FIELD.ToolCall.ID, WIRE_TYPE.LEN, options.id), + encodeField(FIELD.ToolCall.NAME, WIRE_TYPE.LEN, options.name), + encodeField(FIELD.ToolCall.RAW_ARGS, WIRE_TYPE.LEN, options.args), + encodeField(FIELD.ToolCall.IS_LAST, WIRE_TYPE.VARINT, options.isLast ? 1 : 0) + ); + const responseMsg = encodeField(FIELD.Response.TOOL_CALL, WIRE_TYPE.LEN, toolCallPayload); + return buildFrame(responseMsg); +} + describe('StreamingFrameParser', () => { it('should parse a complete single frame', () => { const parser = new StreamingFrameParser(); @@ -845,6 +934,39 @@ describe('StreamingFrameParser', () => { } }); + it('should parse tool call frames', () => { + const parser = new StreamingFrameParser(); + const frame = buildToolCallFrame({ + id: 'call_parser', + name: 'search_docs', + args: '{"q":"docs"}', + isLast: true, + }); + const results = parser.push(frame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('toolCall'); + if (results[0].type === 'toolCall') { + expect(results[0].toolCall.id).toBe('call_parser'); + expect(results[0].toolCall.function.name).toBe('search_docs'); + expect(results[0].toolCall.function.arguments).toBe('{"q":"docs"}'); + expect(results[0].toolCall.isLast).toBe(true); + } + }); + + it('should classify malformed protobuf payload as server error', () => { + const parser = new StreamingFrameParser(); + const malformedFrame = buildFrame(new Uint8Array([0xff, 0xff, 0xff])); + const results = parser.push(malformedFrame); + + expect(results.length).toBe(1); + expect(results[0].type).toBe('error'); + if (results[0].type === 'error') { + expect(results[0].errorType).toBe('server_error'); + expect(results[0].message).toContain('Malformed protobuf response'); + } + }); + it('should report hasPartial() correctly', () => { const parser = new StreamingFrameParser(); expect(parser.hasPartial()).toBe(false); diff --git a/tests/unit/utils/process-utils.test.ts b/tests/unit/utils/process-utils.test.ts index cfae232a..29d62ac9 100644 --- a/tests/unit/utils/process-utils.test.ts +++ b/tests/unit/utils/process-utils.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for process-utils.ts */ -import { describe, it, expect, beforeEach, afterEach, jest } from 'bun:test'; +import { describe, it, expect, jest } from 'bun:test'; import { EventEmitter } from 'events'; import { killWithEscalation } from '../../../src/utils/process-utils'; import type { ChildProcess } from 'child_process'; @@ -20,15 +20,11 @@ function createMockProcess(exitCode: number | null = null): ChildProcess { return proc as ChildProcess; } +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + describe('killWithEscalation', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - it('should send SIGTERM immediately', () => { const proc = createMockProcess(); killWithEscalation(proc); @@ -37,36 +33,34 @@ describe('killWithEscalation', () => { expect(proc.kill).toHaveBeenCalledTimes(1); }); - it('should send SIGKILL after grace period if process still running', () => { + it('should send SIGKILL after grace period if process still running', async () => { const proc = createMockProcess(null); // exitCode null = still running - killWithEscalation(proc, 3000); + killWithEscalation(proc, 10); // SIGTERM sent immediately expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); - // Advance time by grace period - jest.advanceTimersByTime(3000); + await wait(40); // SIGKILL sent after grace period expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); expect(proc.kill).toHaveBeenCalledTimes(2); }); - it('should NOT send SIGKILL if process exits before grace period', () => { + it('should NOT send SIGKILL if process exits before grace period', async () => { const proc = createMockProcess(null); - killWithEscalation(proc, 3000); + killWithEscalation(proc, 40); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); - // Simulate process exit after 1 second - jest.advanceTimersByTime(1000); - proc.exitCode = 0; // Process exited + // Simulate process exit before grace timeout + await wait(10); + proc.exitCode = 0; proc.emit('exit', 0); - // Advance remaining time - jest.advanceTimersByTime(2000); + await wait(60); // SIGKILL should NOT have been sent expect(proc.kill).toHaveBeenCalledTimes(1); @@ -75,56 +69,95 @@ describe('killWithEscalation', () => { it('should use default grace period of 3000ms', () => { const proc = createMockProcess(null); - killWithEscalation(proc); // No grace period argument + const originalSetTimeout = globalThis.setTimeout; + const fakeTimer = { + unref: () => fakeTimer, + ref: () => fakeTimer, + hasRef: () => false, + refresh: () => fakeTimer, + } as unknown as ReturnType; - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + let observedDelay: number | undefined; - // Advance by default 3000ms - jest.advanceTimersByTime(3000); + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { + observedDelay = timeout; + void handler; // avoid executing callback in this assertion-only test + return fakeTimer; + }) as typeof globalThis.setTimeout; - expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + try { + killWithEscalation(proc); + expect(observedDelay).toBe(3000); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + } finally { + globalThis.setTimeout = originalSetTimeout; + } }); it('should respect custom grace period', () => { const proc = createMockProcess(null); - killWithEscalation(proc, 5000); // Custom 5 second grace period + const originalSetTimeout = globalThis.setTimeout; + const fakeTimer = { + unref: () => fakeTimer, + ref: () => fakeTimer, + hasRef: () => false, + refresh: () => fakeTimer, + } as unknown as ReturnType; - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + let observedDelay: number | undefined; - // Advance by less than grace period - jest.advanceTimersByTime(4999); - expect(proc.kill).toHaveBeenCalledTimes(1); // Still only SIGTERM + globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { + observedDelay = timeout; + void handler; + return fakeTimer; + }) as typeof globalThis.setTimeout; - // Advance to grace period - jest.advanceTimersByTime(1); - expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + try { + killWithEscalation(proc, 5000); + expect(observedDelay).toBe(5000); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(proc.kill).toHaveBeenCalledTimes(1); + } finally { + globalThis.setTimeout = originalSetTimeout; + } }); - it('should clear timer when process exits', () => { + it('should clear timer when process exits', async () => { const proc = createMockProcess(null); - killWithEscalation(proc, 3000); + const originalClearTimeout = globalThis.clearTimeout; + let clearCalled = false; - // Simulate immediate exit - proc.exitCode = 0; - proc.emit('exit', 0); + globalThis.clearTimeout = ((id: ReturnType) => { + clearCalled = true; + return originalClearTimeout(id); + }) as typeof globalThis.clearTimeout; - // Advance way past grace period - jest.advanceTimersByTime(10000); + try { + killWithEscalation(proc, 50); - // Should only have SIGTERM, timer was cleared - expect(proc.kill).toHaveBeenCalledTimes(1); - expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + // Simulate immediate exit + proc.exitCode = 0; + proc.emit('exit', 0); + + await wait(70); + + // Should only have SIGTERM, timer was cleared + expect(clearCalled).toBe(true); + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + } finally { + globalThis.clearTimeout = originalClearTimeout; + } }); - it('should handle process that already exited', () => { + it('should handle process that already exited', async () => { const proc = createMockProcess(0); // Already exited - killWithEscalation(proc, 3000); + killWithEscalation(proc, 10); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); - // Even though exitCode is not null, timer still fires - // (because we check exitCode at timer callback time) - jest.advanceTimersByTime(3000); + await wait(30); // SIGKILL should NOT be sent because exitCode is not null expect(proc.kill).toHaveBeenCalledTimes(1); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index 1bb76640..fda059b0 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; +import { execFileSync, spawnSync } from 'child_process'; let server: Server; let baseUrl = ''; @@ -42,7 +43,15 @@ let saveCredentials: (credentials: { importedAt: string; }) => void; let deleteCredentials: () => boolean; -let checkAuthStatus: () => { authenticated: boolean; expired?: boolean }; +let checkAuthStatus: () => { + authenticated: boolean; + expired?: boolean; + credentials?: { + authMethod?: 'manual' | 'auto-detect'; + machineId?: string; + }; +}; +let getTokenStoragePath: () => string; let getDaemonStartPreconditionError: ( input: { enabled: boolean; authenticated: boolean; tokenExpired?: boolean } ) => { status: number; error: string } | null; @@ -94,6 +103,7 @@ beforeAll(async () => { saveCredentials = cursorAuth.saveCredentials; deleteCredentials = cursorAuth.deleteCredentials; checkAuthStatus = cursorAuth.checkAuthStatus; + getTokenStoragePath = cursorAuth.getTokenStoragePath; const cursorRoutesModule = await import('../../../src/web-server/routes/cursor-routes'); getDaemonStartPreconditionError = cursorRoutesModule.getDaemonStartPreconditionError; @@ -259,14 +269,79 @@ describe('Cursor Routes Logic', () => { }); it('POST /api/cursor/auth/auto-detect returns 404 when no token source found', async () => { - const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { - method: 'POST', - }); + const originalHome = process.env.HOME; + const isolatedHome = path.join(tempDir, 'auto-detect-empty-home'); + process.env.HOME = isolatedHome; + fs.mkdirSync(isolatedHome, { recursive: true }); - expect(res.status).toBe(404); - const json = (await res.json()) as { error?: string }; - expect(typeof json.error).toBe('string'); - expect(json.error?.length).toBeGreaterThan(0); + try { + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(404); + const json = (await res.json()) as { error?: string }; + expect(typeof json.error).toBe('string'); + expect(json.error?.length).toBeGreaterThan(0); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } + }); + + it('POST /api/cursor/auth/auto-detect persists credentials on success', async () => { + if (process.platform === 'win32') { + return; + } + + const sqliteCheck = spawnSync('sqlite3', ['--version'], { stdio: 'ignore' }); + if (sqliteCheck.status !== 0) { + return; + } + + const originalHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'fake-home'); + process.env.HOME = fakeHome; + fs.mkdirSync(fakeHome, { recursive: true }); + + const token = 'a'.repeat(60); + const machineId = '1234567890abcdef1234567890abcdef'; + + try { + const dbPath = getTokenStoragePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + execFileSync('sqlite3', [dbPath, 'CREATE TABLE IF NOT EXISTS itemTable (key TEXT PRIMARY KEY, value TEXT);']); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('cursorAuth/accessToken', '${token}');`, + ]); + execFileSync('sqlite3', [ + dbPath, + `INSERT OR REPLACE INTO itemTable (key, value) VALUES ('storage.serviceMachineId', '${machineId}');`, + ]); + + const res = await fetch(`${baseUrl}/api/cursor/auth/auto-detect`, { + method: 'POST', + }); + + expect(res.status).toBe(200); + const json = (await res.json()) as { success?: boolean }; + expect(json.success).toBe(true); + + const auth = checkAuthStatus(); + expect(auth.authenticated).toBe(true); + expect(auth.credentials?.authMethod).toBe('auto-detect'); + expect(auth.credentials?.machineId).toBe(machineId); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + } }); it('POST /api/cursor/daemon/start returns 400 when integration is disabled', async () => { @@ -299,6 +374,35 @@ describe('Cursor Routes Logic', () => { expect(json.error).toContain('expired'); }); + it( + 'POST /api/cursor/daemon/start starts daemon and /daemon/stop stops it', + async () => { + const port = 15000 + Math.floor(Math.random() * 20000); + seedCursorConfig({ enabled: true, port }); + seedCredentials(false); + + const startRes = await fetch(`${baseUrl}/api/cursor/daemon/start`, { method: 'POST' }); + expect(startRes.status).toBe(200); + + const startJson = (await startRes.json()) as { success?: boolean; pid?: number }; + expect(startJson.success).toBe(true); + expect(typeof startJson.pid).toBe('number'); + + const stopRes = await fetch(`${baseUrl}/api/cursor/daemon/stop`, { method: 'POST' }); + expect(stopRes.status).toBe(200); + const stopJson = (await stopRes.json()) as { success?: boolean }; + expect(stopJson.success).toBe(true); + }, + 35000 + ); + + it('POST /api/cursor/daemon/stop returns success when daemon is not running', async () => { + const res = await fetch(`${baseUrl}/api/cursor/daemon/stop`, { method: 'POST' }); + expect(res.status).toBe(200); + const json = (await res.json()) as { success?: boolean }; + expect(json.success).toBe(true); + }); + it('GET /api/cursor/models returns current model and list payload', async () => { const res = await fetch(`${baseUrl}/api/cursor/models`); expect(res.status).toBe(200); From c5b1345fa2d8920836f114965cd45b7ca8b43dc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Feb 2026 09:50:55 +0000 Subject: [PATCH 14/31] chore(release): 7.45.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2daeb014..851b7d7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.45.0-dev.3", + "version": "7.45.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 94b03c7f75fd398282a0737c497f882aeb708724 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 17:02:45 +0700 Subject: [PATCH 15/31] refactor(cliproxy): DRY provider lists into single source of truth - unify CLIPROXY_SUPPORTED_PROVIDERS to import from CLIPROXY_PROVIDER_IDS - replace 4 inline union types with CLIProxyProvider import - replace hardcoded provider arrays in migration-manager and proxy-routes - remove duplicate PROVIDER_DISPLAY_NAMES, use getProviderDisplayName() - sync test now imports from ui/src/lib/provider-config instead of hardcoded array Adding a new CLIProxy provider no longer requires updating 14+ hardcoded lists. --- src/cliproxy/config/generator.ts | 20 ++---------------- src/config/migration-manager.ts | 3 ++- src/config/unified-config-types.ts | 21 +++++++------------ src/types/config.ts | 4 +++- src/web-server/routes/proxy-routes.ts | 3 ++- .../backend-ui-provider-arrays-sync.test.ts | 19 ++++------------- 6 files changed, 20 insertions(+), 50 deletions(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index dd1799bc..ca832297 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CLIProxyProvider, ProviderConfig } from '../types'; +import { getProviderDisplayName } from '../provider-capabilities'; import { getModelMappingFromConfig } from '../base-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager'; @@ -46,34 +47,17 @@ const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: b { name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true }, ]; -/** Provider display names (static metadata) */ -const PROVIDER_DISPLAY_NAMES: Record = { - gemini: 'Gemini', - codex: 'Codex', - agy: 'Antigravity', - qwen: 'Qwen Code', - iflow: 'iFlow', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', - claude: 'Claude (Anthropic)', -}; - /** * Get provider configuration * Model mappings are loaded from config/base-{provider}.settings.json */ export function getProviderConfig(provider: CLIProxyProvider): ProviderConfig { - const displayName = PROVIDER_DISPLAY_NAMES[provider]; - if (!displayName) { - throw new Error(`Unknown provider: ${provider}`); - } - // Load models from base config file const models = getModelMappingFromConfig(provider); return { name: provider, - displayName, + displayName: getProviderDisplayName(provider), models, requiresOAuth: true, // All CLIProxy providers require OAuth }; diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 9fa347d4..e14342a2 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -18,6 +18,7 @@ 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 { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; import { infoBox, warn } from '../utils/ui'; @@ -203,7 +204,7 @@ export async function migrate(dryRun = false): Promise { // 6b. Migrate built-in CLIProxy OAuth profile settings (gemini, codex, agy, qwen, iflow) // Keep settings in *.settings.json files - only record reference in config.yaml // This matches Claude's ~/.claude/settings.json pattern for user familiarity - const builtInProviders = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + const builtInProviders = [...CLIPROXY_PROVIDER_IDS]; for (const provider of builtInProviders) { const settingsFile = `${provider}.settings.json`; const settingsPath = path.join(ccsDir, settingsFile); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 6e6368c0..3420d406 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -10,6 +10,8 @@ */ import type { TargetType } from '../targets/target-adapter'; +import type { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; /** * Unified config version. @@ -25,18 +27,9 @@ export const UNIFIED_CONFIG_VERSION = 8; /** * Supported CLIProxy providers. - * Includes all OAuth-based providers supported by CLIProxyAPI. + * Derived from CLIPROXY_PROVIDER_IDS — single source of truth in provider-capabilities.ts. */ -export const CLIPROXY_SUPPORTED_PROVIDERS = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', - 'claude', -] as const; +export const CLIPROXY_SUPPORTED_PROVIDERS = CLIPROXY_PROVIDER_IDS; /** * Account configuration (formerly in profiles.json). @@ -80,7 +73,7 @@ export type OAuthAccounts = Record; */ export interface CLIProxyVariantConfig { /** Base provider to use */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; + provider: CLIProxyProvider; /** Account nickname (references oauth_accounts) */ account?: string; /** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */ @@ -98,14 +91,14 @@ export interface CLIProxyVariantConfig { */ export interface CompositeTierConfig { /** Provider for this tier */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; + provider: CLIProxyProvider; /** Model ID to use for this tier */ model: string; /** Account nickname (optional, references oauth_accounts) */ account?: string; /** Fallback provider+model if primary fails */ fallback?: { - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; + provider: CLIProxyProvider; model: string; account?: string; }; diff --git a/src/types/config.ts b/src/types/config.ts index db910441..952355a9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,8 @@ * Source: ~/.ccs/config.json */ +import type { CLIProxyProvider } from '../cliproxy/types'; + /** * Profile configuration mapping * Maps profile names to settings.json paths @@ -18,7 +20,7 @@ export interface ProfilesConfig { */ export interface CLIProxyVariantConfig { /** CLIProxy provider to use */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; + provider: CLIProxyProvider; /** Path to settings.json with custom model configuration (optional) */ settings?: string; /** Account identifier for multi-account support (optional, defaults to 'default') */ diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index b4cc3f68..a8c92f84 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -16,6 +16,7 @@ import { DEFAULT_CLIPROXY_SERVER_CONFIG, CliproxyServerConfig, } from '../../config/unified-config-types'; +import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; const router = Router(); @@ -113,7 +114,7 @@ router.put('/backend', async (req: Request, res: Response) => { config.cliproxy = { backend, oauth_accounts: {}, - providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'], + providers: [...CLIPROXY_PROVIDER_IDS], variants: {}, }; } else { diff --git a/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts b/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts index 50f48dea..15b6d489 100644 --- a/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts @@ -7,34 +7,23 @@ import { describe, expect, test } from 'bun:test'; import { CLIPROXY_PROFILES } from '../../../src/auth/profile-detector'; - -// UI providers (must manually sync - this test validates the sync) -const UI_CLIPROXY_PROVIDERS = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', - 'claude', -] as const; +import { CLIPROXY_PROVIDERS } from '../../../ui/src/lib/provider-config'; describe('Provider Sync', () => { test('backend CLIPROXY_PROFILES matches UI CLIPROXY_PROVIDERS', () => { const backend = [...CLIPROXY_PROFILES].sort(); - const ui = [...UI_CLIPROXY_PROVIDERS].sort(); + const ui = [...CLIPROXY_PROVIDERS].sort(); expect(backend).toEqual(ui); }); test('both arrays have same length', () => { - expect(CLIPROXY_PROFILES.length).toBe(UI_CLIPROXY_PROVIDERS.length); + expect(CLIPROXY_PROFILES.length).toBe(CLIPROXY_PROVIDERS.length); }); test('UI array contains all backend providers', () => { for (const provider of CLIPROXY_PROFILES) { - expect(UI_CLIPROXY_PROVIDERS).toContain(provider); + expect(CLIPROXY_PROVIDERS).toContain(provider); } }); }); From 08b2a6791398912ff5ea6b3cce18b37552c1ef10 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 17:02:55 +0700 Subject: [PATCH 16/31] feat(cliproxy): add Kimi as OAuth CLIProxy provider - add 'kimi' to CLIProxyProvider union type - register Kimi capabilities (device_code flow, moonshot alias) - add OAuth config, auth prefixes, type values, channel maps - add CLIProxy-delegated token refresh for Kimi - add Kimi model catalog (k2.5, k2-thinking, k2) - switch base-kimi.settings.json to CLIProxy mode (127.0.0.1:8317) - update comments removing kimi from settings-based profile mentions Closes #574 --- config/base-kimi.settings.json | 17 +++++---- src/auth/profile-detector.ts | 2 +- src/ccs.ts | 4 +-- src/cliproxy/auth/auth-types.ts | 13 +++++++ .../auth/provider-refreshers/index.ts | 4 ++- src/cliproxy/model-catalog.ts | 36 +++++++++++++++++++ src/cliproxy/provider-capabilities.ts | 6 ++++ src/cliproxy/types.ts | 4 ++- 8 files changed, 72 insertions(+), 14 deletions(-) diff --git a/config/base-kimi.settings.json b/config/base-kimi.settings.json index ad4b2dee..5b72a69f 100644 --- a/config/base-kimi.settings.json +++ b/config/base-kimi.settings.json @@ -1,11 +1,10 @@ { "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 -} \ No newline at end of file + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kimi", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "kimi-k2.5", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2.5", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2-thinking", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k2" + } +} diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 23570249..f700b030 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -308,7 +308,7 @@ class ProfileDetector { }; } - // Priority 3: Check settings-based profiles (glm, kimi) - LEGACY FALLBACK + // Priority 3: Check settings-based profiles (glm) - LEGACY FALLBACK if (config.profiles && config.profiles[profileName]) { return { type: 'settings', diff --git a/src/ccs.ts b/src/ccs.ts index b1df1571..1bf77d6d 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -792,7 +792,7 @@ async function main(): Promise { const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs); process.exit(exitCode); } else if (profileInfo.type === 'settings') { - // Settings-based profiles (glm, glmt, kimi) are third-party providers + // Settings-based profiles (glm, glmt) are third-party providers // WebSearch is server-side tool - third-party providers have no access // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); @@ -869,7 +869,7 @@ async function main(): Promise { // GLMT FLOW: Settings-based with embedded proxy for thinking support await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs); } else { - // EXISTING FLOW: Settings-based profile (glm, kimi) + // EXISTING FLOW: Settings-based profile (glm) // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 91feaa70..acb10a43 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -88,6 +88,7 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * - Kiro: Device Code Flow (polling-based, NO callback port needed) * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) + * - Kimi: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = { gemini: 8085, @@ -98,6 +99,7 @@ export const OAUTH_CALLBACK_PORTS: Partial> = { // kiro: Device Code Flow - no callback port // qwen: Device Code Flow - no callback port // ghcp: Device Code Flow - no callback port + // kimi: Device Code Flow - no callback port }; /** @@ -199,6 +201,13 @@ export const OAUTH_CONFIGS: Record = { scopes: ['user:inference', 'user:profile'], authFlag: '--claude-login', }, + kimi: { + provider: 'kimi', + displayName: 'Kimi (Moonshot)', + authUrl: 'https://auth.kimi.com/api/oauth/device_authorization', + scopes: ['api'], + authFlag: '--kimi-login', + }, }; /** @@ -215,6 +224,7 @@ export const PROVIDER_AUTH_PREFIXES: Record = { kiro: ['kiro-', 'aws-', 'codewhisperer-'], ghcp: ['github-copilot-', 'copilot-', 'gh-'], claude: ['claude-', 'anthropic-'], + kimi: ['kimi-'], }; /** @@ -230,6 +240,7 @@ export const PROVIDER_TYPE_VALUES: Record = { kiro: ['kiro', 'codewhisperer'], ghcp: ['github-copilot', 'copilot'], claude: ['claude', 'anthropic'], + kimi: ['kimi'], }; /** @@ -245,6 +256,7 @@ export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record = claude: 'anthropic', qwen: 'qwen', iflow: 'iflow', + kimi: 'kimi', }; /** @@ -261,6 +273,7 @@ export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record = claude: 'anthropic', qwen: 'qwen', iflow: 'iflow', + kimi: 'kimi', }; /** diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 3c642340..abcf3ff8 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -5,7 +5,7 @@ * * Refresh responsibility: * - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth) - * - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow + * - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi * (CLIProxyAPIPlus handles refresh automatically in background) * - Not implemented: claude */ @@ -34,6 +34,7 @@ const CLIPROXY_DELEGATED_REFRESH: CLIProxyProvider[] = [ 'ghcp', 'qwen', 'iflow', + 'kimi', ]; /** @@ -63,6 +64,7 @@ export async function refreshToken( case 'iflow': case 'kiro': case 'ghcp': + case 'kimi': // CLIProxyAPIPlus handles refresh for these providers automatically. // No action needed from CCS — report success with delegated flag. return { success: true, delegated: true }; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index e50d0ffc..c73e61bd 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -197,6 +197,42 @@ export const MODEL_CATALOG: Partial> = }, ], }, + kimi: { + provider: 'kimi', + displayName: 'Kimi (Moonshot)', + defaultModel: 'kimi-k2.5', + models: [ + { + id: 'kimi-k2.5', + name: 'Kimi K2.5', + description: 'Latest Moonshot coding model', + thinking: { + type: 'budget', + min: 1024, + max: 32000, + zeroAllowed: true, + dynamicAllowed: true, + }, + }, + { + id: 'kimi-k2-thinking', + name: 'Kimi K2 Thinking', + description: 'Extended reasoning model', + thinking: { + type: 'budget', + min: 1024, + max: 32000, + zeroAllowed: true, + dynamicAllowed: true, + }, + }, + { + id: 'kimi-k2', + name: 'Kimi K2', + description: 'Flagship coding model', + }, + ], + }, claude: { provider: 'claude', displayName: 'Claude (Anthropic)', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 6dec96a8..4ddd4f27 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -62,6 +62,12 @@ export const PROVIDER_CAPABILITIES: Record Date: Tue, 17 Feb 2026 17:03:05 +0700 Subject: [PATCH 17/31] feat(cliproxy): rename kimi API preset to km, add kimi to UI - create config/base-km.settings.json for API-key Kimi users - rename preset id 'kimi' to 'km' in CLI and UI provider presets - add 'ccs km' to help text API section, keep 'ccs kimi' for OAuth - add --km delegation flag for API-key mode, --kimi stays for OAuth - add kimi to UI CLIPROXY_PROVIDERS, assets, colors, names, device code --- config/base-km.settings.json | 10 ++++++++++ src/api/services/provider-presets.ts | 4 ++-- src/commands/help-command.ts | 6 ++++-- ui/src/lib/provider-config.ts | 6 +++++- ui/src/lib/provider-presets.ts | 5 +++-- 5 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 config/base-km.settings.json diff --git a/config/base-km.settings.json b/config/base-km.settings.json new file mode 100644 index 00000000..c60892d5 --- /dev/null +++ b/config/base-km.settings.json @@ -0,0 +1,10 @@ +{ + "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" + } +} diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index c57ca5c5..7daedaa3 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -93,11 +93,11 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ alwaysThinkingEnabled: true, }, { - id: 'kimi', + id: 'km', name: 'Kimi', description: 'Moonshot AI - Fast reasoning model', baseUrl: 'https://api.kimi.com/coding/', - defaultProfileName: 'kimi', + defaultProfileName: 'km', defaultModel: 'kimi-k2-thinking-turbo', apiKeyPlaceholder: 'sk-...', apiKeyHint: 'Get your API key from Moonshot AI', diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 5971dfb5..3a4c0340 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -131,7 +131,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs', 'Use default Claude account'], ['ccs glm', 'GLM 5 (API key required)'], ['ccs glmt', 'GLM with thinking mode'], - ['ccs kimi', 'Kimi for Coding (API key)'], + ['ccs km', 'Kimi for Coding (API key)'], ['ccs ollama', 'Local Ollama (http://localhost:11434)'], ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer @@ -171,6 +171,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs codex', 'OpenAI Codex (supports -medium/-high/-xhigh model suffixes)'], ['ccs agy', 'Antigravity (Claude/Gemini models)'], ['ccs qwen', 'Qwen Code (qwen3-coder)'], + ['ccs kimi', 'Kimi (Moonshot AI K2/K2.5 models)'], ['ccs kiro', 'Kiro (AWS CodeWhisperer Claude models)'], ['ccs ghcp', 'GitHub Copilot (OAuth via CLIProxy Plus)'], ['', ''], // Spacer @@ -255,7 +256,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('Delegation (inside Claude Code CLI)', [ ['/ccs "task"', 'Delegate task (auto-selects profile)'], ['/ccs --glm "task"', 'Force GLM-5 for simple tasks'], - ['/ccs --kimi "task"', 'Force Kimi for long context'], + ['/ccs --kimi "task"', 'Force Kimi OAuth for long context'], + ['/ccs --km "task"', 'Force Kimi API key for long context'], ['/ccs:continue "follow-up"', 'Continue last delegation session'], ]); diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 05e2a958..cfcf4dbb 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -19,6 +19,7 @@ export const CLIPROXY_PROVIDERS = [ 'kiro', 'ghcp', 'claude', + 'kimi', ] as const; /** Union type for CLIProxy provider IDs */ @@ -39,6 +40,7 @@ export const PROVIDER_ASSETS: Record = { kiro: '/assets/providers/kiro.png', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', + kimi: '/assets/providers/kimi.svg', }; // Provider brand colors @@ -52,6 +54,7 @@ export const PROVIDER_COLORS: Record = { kiro: '#4d908e', // Dark Cyan (AWS-inspired) ghcp: '#43aa8b', // Seaweed (GitHub-inspired) claude: '#D97757', // Anthropic brand color (matches SVG) + kimi: '#FF6B35', // Moonshot AI brand orange }; // Provider display names @@ -65,6 +68,7 @@ const PROVIDER_NAMES: Record = { kiro: 'Kiro (AWS)', ghcp: 'GitHub Copilot (OAuth)', claude: 'Claude (Anthropic)', + kimi: 'Kimi (Moonshot)', }; // Map provider to display name @@ -76,7 +80,7 @@ export function getProviderDisplayName(provider: string): string { * Providers that use Device Code OAuth flow instead of Authorization Code flow. * Device Code flow requires displaying a user code for manual entry at provider's website. */ -export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen']; +export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen', 'kimi']; /** Check if provider uses Device Code flow */ export function isDeviceCodeProvider(provider: string): boolean { diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index e998901d..451f0f9d 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -90,11 +90,11 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ category: 'alternative', }, { - id: 'kimi', + id: 'km', name: 'Kimi', description: 'Moonshot AI - Fast reasoning model', baseUrl: 'https://api.kimi.com/coding/', - defaultProfileName: 'kimi', + defaultProfileName: 'km', badge: 'Reasoning', icon: '/icons/kimi.svg', defaultModel: 'kimi-k2-thinking-turbo', @@ -102,6 +102,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ apiKeyPlaceholder: 'sk-...', apiKeyHint: 'Get your API key from Moonshot AI', category: 'alternative', + alwaysThinkingEnabled: true, }, { id: 'foundry', From 539afea7374f2c931ab5cbb1e04e0845c57729b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 17:03:11 +0700 Subject: [PATCH 18/31] style: format source and test files --- src/management/checks/image-analysis-check.ts | 5 +- tests/e2e/image-analyzer-hook.e2e.test.ts | 86 ++++++++-- tests/unit/auth/profile-detector.test.ts | 16 +- .../account-safety-quota-exhaustion.test.ts | 6 +- .../unit/cliproxy/composite-fallback.test.ts | 3 +- .../cliproxy/extended-context-config.test.ts | 4 +- .../cliproxy/management-api-client.test.ts | 8 +- tests/unit/cliproxy/port-validation.test.ts | 5 +- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 4 +- .../cliproxy/quota-monitor-runtime.test.ts | 6 +- .../unit/cliproxy/remote-proxy-client.test.ts | 5 +- tests/unit/cliproxy/schema-sanitizer.test.ts | 5 +- ...ool-sanitization-proxy-integration.test.ts | 11 +- tests/unit/commands/env-command.test.ts | 24 ++- tests/unit/commands/setup-command.test.ts | 14 +- .../commands/shell-completion-command.test.ts | 4 +- tests/unit/cursor/cursor-daemon.test.ts | 54 +++--- tests/unit/cursor/cursor-protobuf.test.ts | 20 ++- tests/unit/data-aggregator.test.ts | 8 +- .../delegation/delegation-handler.test.ts | 24 +-- .../unit/delegation/headless-executor.test.ts | 5 +- tests/unit/glmt/retry-logic.test.ts | 162 +++++++++++++----- tests/unit/jsonl-parser.test.ts | 9 +- tests/unit/mcp-manager.test.ts | 2 +- tests/unit/shared-manager.test.ts | 3 +- .../unit/targets/droid-config-manager.test.ts | 138 ++++++++++----- tests/unit/targets/target-resolver.test.ts | 6 +- tests/unit/unified-config.test.ts | 11 +- tests/unit/utils/expand-path.test.ts | 92 +++++----- tests/unit/utils/signal-forwarder.test.ts | 12 +- tests/unit/utils/websearch/hook-utils.test.ts | 5 +- tests/unit/web-server/auth-middleware.test.ts | 15 +- .../web-server/cliproxy-auth-routes.test.ts | 4 +- .../web-server/cursor-settings-routes.test.ts | 5 +- 34 files changed, 486 insertions(+), 295 deletions(-) diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 39a0f7c5..e539bff4 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -94,9 +94,8 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/unified-config-loader' - ); + const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = + await import('../../config/unified-config-loader'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 8df3adfa..86c5c95e 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -32,7 +32,8 @@ const MOCK_PORT = 59876; // Use a unique port for mock server const CLIPROXY_API_KEY = 'test-api-key-12345'; // Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG) -const DEFAULT_PROVIDER_MODELS = 'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; +const DEFAULT_PROVIDER_MODELS = + 'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; const DEFAULT_PROVIDER = 'agy'; // Default test provider // ============================================================================ @@ -168,13 +169,75 @@ function invokeHook( function createTestPng(filepath: string): void { // 1x1 PNG with a red pixel (RGB: 255, 0, 0) const png = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, - 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk (red pixel) - 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, - 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk - 0x44, 0xae, 0x42, 0x60, 0x82, + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, // PNG signature + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, // IHDR chunk + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x02, + 0x00, + 0x00, + 0x00, + 0x90, + 0x77, + 0x53, + 0xde, + 0x00, + 0x00, + 0x00, + 0x0c, + 0x49, + 0x44, + 0x41, // IDAT chunk (red pixel) + 0x54, + 0x08, + 0xd7, + 0x63, + 0xf8, + 0xcf, + 0xc0, + 0x00, + 0x00, + 0x01, + 0x01, + 0x01, + 0x00, + 0x18, + 0xdd, + 0x8d, + 0xb4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, // IEND chunk + 0x44, + 0xae, + 0x42, + 0x60, + 0x82, ]); fs.writeFileSync(filepath, png); } @@ -487,15 +550,14 @@ describe('Image Analyzer Hook', () => { expect(result.code).toBe(2); const output = JSON.parse(result.stdout); expect(output.decision).toBe('block'); - expect(output.hookSpecificOutput.permissionDecisionReason).toContain( - 'CLIProxy unavailable' - ); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('CLIProxy unavailable'); }); it('should analyze PNG via mock CLIProxy and return analysis', () => { resetMockState(); mockResponse = { - content: 'This image shows a small red square, likely a single pixel or very minimal graphic.', + content: + 'This image shows a small red square, likely a single pixel or very minimal graphic.', statusCode: 200, }; diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 52f2ae68..91cefcd0 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -100,12 +100,14 @@ describe('ProfileDetector', () => { const mockUnifiedConfig = { version: 2, profiles: { - glm: { settings: settingsPath, type: 'api' } - } + glm: { settings: settingsPath, type: 'api' }, + }, }; const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); - const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); try { const result = detector.detectProfileType('glm'); @@ -122,12 +124,14 @@ describe('ProfileDetector', () => { const mockUnifiedConfig = { version: 2, accounts: { - work: { created: '2025-01-01', last_used: '2025-01-02' } - } + 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); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); try { const result = detector.detectProfileType('work'); diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index 855832e7..a24db225 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -13,7 +13,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { handleQuotaExhaustion, writeQuotaWarning, maskEmail } from '../../../src/cliproxy/account-safety'; +import { + handleQuotaExhaustion, + writeQuotaWarning, + maskEmail, +} from '../../../src/cliproxy/account-safety'; // Setup test isolation let tmpDir: string; diff --git a/tests/unit/cliproxy/composite-fallback.test.ts b/tests/unit/cliproxy/composite-fallback.test.ts index 338f6a02..33a5f5d3 100644 --- a/tests/unit/cliproxy/composite-fallback.test.ts +++ b/tests/unit/cliproxy/composite-fallback.test.ts @@ -128,8 +128,7 @@ describe('detectFailedTier', () => { }); it('should match first tier when multiple models mentioned', () => { - const stderr = - 'Tried claude-opus-4-6-thinking, then gemini-3-pro-preview, both failed'; + const stderr = 'Tried claude-opus-4-6-thinking, then gemini-3-pro-preview, both failed'; const result = detectFailedTier(stderr, tiers); expect(result).toBe('opus'); // First match }); diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 5e3a12af..697ba35e 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -57,9 +57,7 @@ describe('shouldApplyExtendedContext', () => { }); it('returns false for Claude models without explicit flag', () => { - expect(shouldApplyExtendedContext('agy', 'claude-opus-4-5-thinking', undefined)).toBe( - false - ); + expect(shouldApplyExtendedContext('agy', 'claude-opus-4-5-thinking', undefined)).toBe(false); }); it('returns false for Claude models without explicit flag', () => { diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts index 1816f8ef..e3b6b7b4 100644 --- a/tests/unit/cliproxy/management-api-client.test.ts +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -427,9 +427,7 @@ describe('management-api-client', () => { describe('CRUD operations', () => { it('should get claude keys', async () => { const client = new ManagementApiClient(config); - const mockKeys: ClaudeKey[] = [ - { 'api-key': 'sk-test-123', prefix: 'glm-' }, - ]; + const mockKeys: ClaudeKey[] = [{ 'api-key': 'sk-test-123', prefix: 'glm-' }]; const originalFetch = global.fetch; global.fetch = mock(() => @@ -449,9 +447,7 @@ describe('management-api-client', () => { it('should put claude keys', async () => { const client = new ManagementApiClient(config); - const mockKeys: ClaudeKey[] = [ - { 'api-key': 'sk-test-456', prefix: 'kimi-' }, - ]; + const mockKeys: ClaudeKey[] = [{ 'api-key': 'sk-test-456', prefix: 'kimi-' }]; const originalFetch = global.fetch; let requestBody: string | undefined; diff --git a/tests/unit/cliproxy/port-validation.test.ts b/tests/unit/cliproxy/port-validation.test.ts index 71d7301a..9fb39153 100644 --- a/tests/unit/cliproxy/port-validation.test.ts +++ b/tests/unit/cliproxy/port-validation.test.ts @@ -12,10 +12,7 @@ */ import { describe, it, expect } from 'bun:test'; -import { - validatePort, - CLIPROXY_DEFAULT_PORT, -} from '../../../src/cliproxy/config-generator'; +import { validatePort, CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config-generator'; import { resolveProxyConfig } from '../../../src/cliproxy/proxy-config-resolver'; describe('Port Validation', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 2a9e6b6f..c879d84b 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -67,9 +67,7 @@ describe('Gemini CLI Quota Fetcher', () => { }); it('should handle camelCase API response', () => { - const rawBuckets = [ - { modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }, - ]; + const rawBuckets = [{ modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }]; const buckets = buildGeminiCliBuckets(rawBuckets); diff --git a/tests/unit/cliproxy/quota-monitor-runtime.test.ts b/tests/unit/cliproxy/quota-monitor-runtime.test.ts index 9bb57284..7afbcc45 100644 --- a/tests/unit/cliproxy/quota-monitor-runtime.test.ts +++ b/tests/unit/cliproxy/quota-monitor-runtime.test.ts @@ -11,7 +11,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { startQuotaMonitor, stopQuotaMonitor, clearQuotaCache } from '../../../src/cliproxy/quota-manager'; +import { + startQuotaMonitor, + stopQuotaMonitor, + clearQuotaCache, +} from '../../../src/cliproxy/quota-manager'; // Setup test isolation let tmpDir: string; diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts index 58defd72..e15b8d3a 100644 --- a/tests/unit/cliproxy/remote-proxy-client.test.ts +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -2,7 +2,10 @@ * Unit tests for remote-proxy-client module */ import { describe, it, expect } from 'bun:test'; -import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client'; +import type { + RemoteProxyClientConfig, + RemoteProxyStatus, +} from '../../../src/cliproxy/remote-proxy-client'; // We test the module's type exports and error handling logic // Actual HTTP calls are not mocked in this unit test - use integration tests for that diff --git a/tests/unit/cliproxy/schema-sanitizer.test.ts b/tests/unit/cliproxy/schema-sanitizer.test.ts index b5e64f38..9d555269 100644 --- a/tests/unit/cliproxy/schema-sanitizer.test.ts +++ b/tests/unit/cliproxy/schema-sanitizer.test.ts @@ -535,10 +535,7 @@ describe('sanitizeToolSchemas', () => { }); test('handles tools without input_schema', () => { - const tools = [ - { name: 'simple_tool', description: 'No schema' }, - { name: 'another_tool' }, - ]; + const tools = [{ name: 'simple_tool', description: 'No schema' }, { name: 'another_tool' }]; const result = sanitizeToolSchemas(tools); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 8bbfc5d0..03a9bc5f 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -111,7 +111,10 @@ describe('ToolSanitizationProxy Integration', () => { body: JSON.stringify({ model: 'test-model', tools: [ - { name: 'gitmcp__plus-pro-components__plus-pro-components', description: 'Test tool' }, + { + name: 'gitmcp__plus-pro-components__plus-pro-components', + description: 'Test tool', + }, { name: 'valid_tool', description: 'Valid tool' }, ], messages: [{ role: 'user', content: 'test' }], @@ -465,11 +468,7 @@ describe('ToolSanitizationProxy Integration', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - tools: [ - { name: 'tool_a__x__x' }, - { name: 'tool_b__y__y' }, - { name: 'tool_c_valid' }, - ], + tools: [{ name: 'tool_a__x__x' }, { name: 'tool_b__y__y' }, { name: 'tool_c_valid' }], }), }); diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index aef8327d..9c2fcfd7 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -77,15 +77,11 @@ describe('env-command', () => { }); it('formats powershell export', () => { - expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe( - "$env:API_KEY = 'sk-123'" - ); + expect(formatExportLine('powershell', 'API_KEY', 'sk-123')).toBe("$env:API_KEY = 'sk-123'"); }); it('escapes single quotes in values', () => { - expect(formatExportLine('bash', 'VAL', "it's here")).toBe( - "export VAL='it'\\''s here'" - ); + expect(formatExportLine('bash', 'VAL', "it's here")).toBe("export VAL='it'\\''s here'"); }); it('handles empty values', () => { @@ -104,9 +100,7 @@ describe('env-command', () => { }); it('prevents backtick injection in values', () => { - expect(formatExportLine('bash', 'TOKEN', 'safe`whoami`')).toBe( - "export TOKEN='safe`whoami`'" - ); + expect(formatExportLine('bash', 'TOKEN', 'safe`whoami`')).toBe("export TOKEN='safe`whoami`'"); }); it('escapes single quotes in fish values', () => { @@ -114,9 +108,7 @@ describe('env-command', () => { }); it('escapes single quotes in powershell values', () => { - expect(formatExportLine('powershell', 'VAL', "it's here")).toBe( - "$env:VAL = 'it''s here'" - ); + expect(formatExportLine('powershell', 'VAL', "it's here")).toBe("$env:VAL = 'it''s here'"); }); }); @@ -205,11 +197,15 @@ describe('env-command', () => { }); it('returns undefined when no positional args', () => { - expect(findProfile(['--format', 'openai', '--shell', 'fish'], ['format', 'shell'])).toBeUndefined(); + expect( + findProfile(['--format', 'openai', '--shell', 'fish'], ['format', 'shell']) + ).toBeUndefined(); }); it('skips multiple flag-value pairs', () => { - expect(findProfile(['--format', 'openai', '--shell', 'fish', 'codex'], ['format', 'shell'])).toBe('codex'); + expect( + findProfile(['--format', 'openai', '--shell', 'fish', 'codex'], ['format', 'shell']) + ).toBe('codex'); }); }); }); diff --git a/tests/unit/commands/setup-command.test.ts b/tests/unit/commands/setup-command.test.ts index 8277aefb..f5e47edb 100644 --- a/tests/unit/commands/setup-command.test.ts +++ b/tests/unit/commands/setup-command.test.ts @@ -39,7 +39,8 @@ describe('isFirstTimeInstall logic', () => { 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; + const hasLegacyProfiles = + legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; expect(hasLegacyProfiles).toBe(true); }); @@ -48,8 +49,11 @@ describe('isFirstTimeInstall logic', () => { 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; + 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); }); @@ -105,7 +109,9 @@ cliproxy_server: 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 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; diff --git a/tests/unit/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts index f9c93a53..4c2d71ab 100644 --- a/tests/unit/commands/shell-completion-command.test.ts +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -135,6 +135,8 @@ describe('shell-completion command', () => { await expect(handleShellCompletionCommand([])).rejects.toThrow('process.exit(1)'); const plainErrorLines = errorLines.map(stripAnsi); expect(plainErrorLines.some((line) => line.includes('Error: boom'))).toBe(true); - expect(plainErrorLines.some((line) => line.includes('ccs --shell-completion --zsh'))).toBe(true); + expect(plainErrorLines.some((line) => line.includes('ccs --shell-completion --zsh'))).toBe( + true + ); }); }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index b46fa317..7990985a 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -141,39 +141,35 @@ describe('startDaemon', () => { expect(result.error).toContain('Invalid port'); }); - it( - 'starts and stops daemon successfully', - async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - expect(result.pid).toBeDefined(); + it('starts and stops daemon successfully', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); - // Verify health - const running = await isDaemonRunning(port); - expect(running).toBe(true); + // Verify health + const running = await isDaemonRunning(port); + expect(running).toBe(true); - // Verify chat endpoint exists (requires auth, should not be 404) - const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - expect(chatResponse.status).toBe(401); + // Verify chat endpoint exists (requires auth, should not be 404) + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); - // Stop - const stopResult = await stopDaemon(); - expect(stopResult.success).toBe(true); + // Stop + const stopResult = await stopDaemon(); + expect(stopResult.success).toBe(true); - // Verify stopped - const stillRunning = await isDaemonRunning(port); - expect(stillRunning).toBe(false); - }, - 35000 - ); + // Verify stopped + const stillRunning = await isDaemonRunning(port); + expect(stillRunning).toBe(false); + }, 35000); }); describe('isDaemonRunning', () => { diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 0ea16a7c..86c2f27d 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -300,7 +300,9 @@ describe('Message Translation', () => { expect(result.messages).toHaveLength(1); expect(result.messages[0].role).toBe('user'); - expect(result.messages[0].content).toBe('[System Instructions]\nSystem instruction part 1 part 2'); + expect(result.messages[0].content).toBe( + '[System Instructions]\nSystem instruction part 1 part 2' + ); }); }); }); @@ -332,7 +334,12 @@ describe('Request Encoding', () => { }, ]; - const result = generateCursorBody([{ role: 'user', content: 'What is the weather?' }], 'gpt-4', tools, null); + const result = generateCursorBody( + [{ role: 'user', content: 'What is the weather?' }], + 'gpt-4', + tools, + null + ); expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); @@ -358,7 +365,9 @@ describe('Request Encoding', () => { const executor = new CursorExecutor(); // Frame header says payload is 100 bytes but only 5 bytes follow - const truncatedFrame = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x02, 0x03, 0x04, 0x05]); + const truncatedFrame = Buffer.from([ + 0x00, 0x00, 0x00, 0x00, 0x64, 0x01, 0x02, 0x03, 0x04, 0x05, + ]); const result = executor.transformProtobufToJSON(truncatedFrame, 'gpt-4', { messages: [], @@ -644,7 +653,10 @@ describe('CursorExecutor', () => { ]; // buildCursorRequest expects (model, body, stream, credentials) - buildCursorRequest('test-model', { messages }, false, { machineId: '12345', accessToken: 'test' }); + buildCursorRequest('test-model', { messages }, false, { + machineId: '12345', + accessToken: 'test', + }); // Should have logged warning const hasWarning = consoleSpy.some((log) => log.includes('Unknown message role')); diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index 50268007..4984cc15 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -14,9 +14,7 @@ import { type RawUsageEntry } from '../../src/web-server/jsonl-parser'; // TEST FIXTURES // ============================================================================ -const createEntry = ( - overrides: Partial = {} -): RawUsageEntry => ({ +const createEntry = (overrides: Partial = {}): RawUsageEntry => ({ inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 100, @@ -66,9 +64,7 @@ describe('aggregateDailyUsage', () => { expect(result[0].modelsUsed).toContain('claude-opus-4-5-20251101'); // Find sonnet breakdown - const sonnet = result[0].modelBreakdowns.find( - (b) => b.modelName === 'claude-sonnet-4-5' - ); + const sonnet = result[0].modelBreakdowns.find((b) => b.modelName === 'claude-sonnet-4-5'); expect(sonnet!.inputTokens).toBe(1500); // 1000 + 500 }); diff --git a/tests/unit/delegation/delegation-handler.test.ts b/tests/unit/delegation/delegation-handler.test.ts index 793e09e0..c5d4e044 100644 --- a/tests/unit/delegation/delegation-handler.test.ts +++ b/tests/unit/delegation/delegation-handler.test.ts @@ -124,13 +124,7 @@ describe('DelegationHandler', () => { describe('_extractOptions - agents JSON validation', () => { it('accepts valid JSON for agents', () => { - const options = handler._extractOptions([ - 'glm', - '-p', - 'test', - '--agents', - '{"name":"test"}', - ]); + const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '{"name":"test"}']); expect(options.agents).toBe('{"name":"test"}'); }); @@ -153,7 +147,13 @@ describe('DelegationHandler', () => { describe('_extractOptions - betas validation', () => { it('accepts valid betas value', () => { - const options = handler._extractOptions(['glm', '-p', 'test', '--betas', 'feature1,feature2']); + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--betas', + 'feature1,feature2', + ]); expect(options.betas).toBe('feature1,feature2'); }); @@ -165,13 +165,7 @@ describe('DelegationHandler', () => { describe('_extractOptions - extraArgs passthrough', () => { it('passes unknown flags through to extraArgs', () => { - const options = handler._extractOptions([ - 'glm', - '-p', - 'test', - '--unknown-flag', - 'value', - ]); + const options = handler._extractOptions(['glm', '-p', 'test', '--unknown-flag', 'value']); expect(options.extraArgs).toContain('--unknown-flag'); expect(options.extraArgs).toContain('value'); }); diff --git a/tests/unit/delegation/headless-executor.test.ts b/tests/unit/delegation/headless-executor.test.ts index cb78d954..14479d27 100644 --- a/tests/unit/delegation/headless-executor.test.ts +++ b/tests/unit/delegation/headless-executor.test.ts @@ -10,10 +10,7 @@ describe('HeadlessExecutor flag construction', () => { // Since HeadlessExecutor.execute() spawns a process, we test the logic directly describe('Duplicate flag filtering', () => { - function filterExtraArgs( - extraArgs: string[], - explicitFlags: Set - ): string[] { + function filterExtraArgs(extraArgs: string[], explicitFlags: Set): string[] { const filteredExtras: string[] = []; for (let i = 0; i < extraArgs.length; i++) { if (explicitFlags.has(extraArgs[i])) { diff --git a/tests/unit/glmt/retry-logic.test.ts b/tests/unit/glmt/retry-logic.test.ts index 6523cbfa..683d2154 100644 --- a/tests/unit/glmt/retry-logic.test.ts +++ b/tests/unit/glmt/retry-logic.test.ts @@ -30,11 +30,13 @@ afterEach(() => { }); // Helper to create proxy instance with specific config -async function createTestableProxy(config: { - maxRetries?: number; - baseDelay?: number; - enabled?: boolean; -} = {}) { +async function createTestableProxy( + config: { + maxRetries?: number; + baseDelay?: number; + enabled?: boolean; + } = {} +) { // Set env vars before import if (config.maxRetries !== undefined) { process.env.GLMT_MAX_RETRIES = String(config.maxRetries); @@ -56,7 +58,11 @@ describe('GLMT Retry Logic', () => { it('should use default values when env vars not set', async () => { const proxy = await createTestableProxy(); // Access private via type assertion for testing - const config = (proxy as unknown as { retryConfig: { maxRetries: number; baseDelay: number; enabled: boolean } }).retryConfig; + const config = ( + proxy as unknown as { + retryConfig: { maxRetries: number; baseDelay: number; enabled: boolean }; + } + ).retryConfig; expect(config.maxRetries).toBe(3); expect(config.baseDelay).toBe(1000); expect(config.enabled).toBe(true); @@ -84,7 +90,11 @@ describe('GLMT Retry Logic', () => { describe('calculateRetryDelay', () => { it('should calculate exponential delay with jitter', async () => { const proxy = await createTestableProxy({ baseDelay: 1000 }); - const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy); + const calcDelay = ( + proxy as unknown as { + calculateRetryDelay: (attempt: number, retryAfter?: string) => number; + } + ).calculateRetryDelay.bind(proxy); // Attempt 0: 2^0 * 1000 = 1000 + jitter (0-500) const delay0 = calcDelay(0); @@ -104,7 +114,11 @@ describe('GLMT Retry Logic', () => { it('should honor Retry-After header in seconds', async () => { const proxy = await createTestableProxy(); - const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy); + const calcDelay = ( + proxy as unknown as { + calculateRetryDelay: (attempt: number, retryAfter?: string) => number; + } + ).calculateRetryDelay.bind(proxy); // Retry-After: 5 seconds → 5000ms const delay = calcDelay(0, '5'); @@ -113,7 +127,11 @@ describe('GLMT Retry Logic', () => { it('should ignore invalid Retry-After header and fallback to exponential', async () => { const proxy = await createTestableProxy({ baseDelay: 1000 }); - const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy); + const calcDelay = ( + proxy as unknown as { + calculateRetryDelay: (attempt: number, retryAfter?: string) => number; + } + ).calculateRetryDelay.bind(proxy); // Invalid header falls back to exponential const delay = calcDelay(0, 'invalid'); @@ -123,7 +141,11 @@ describe('GLMT Retry Logic', () => { it('should ignore zero or negative Retry-After', async () => { const proxy = await createTestableProxy({ baseDelay: 1000 }); - const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy); + const calcDelay = ( + proxy as unknown as { + calculateRetryDelay: (attempt: number, retryAfter?: string) => number; + } + ).calculateRetryDelay.bind(proxy); const delay = calcDelay(0, '0'); expect(delay).toBeGreaterThanOrEqual(1000); @@ -134,7 +156,11 @@ describe('GLMT Retry Logic', () => { describe('isRetryableError', () => { it('should return true for 429 status code', async () => { const proxy = await createTestableProxy(); - const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy); + const isRetryable = ( + proxy as unknown as { + isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string }; + } + ).isRetryableError.bind(proxy); const result = isRetryable(new Error('Upstream error: 429 Too Many Requests')); expect(result.retryable).toBe(true); @@ -142,7 +168,11 @@ describe('GLMT Retry Logic', () => { it('should return true for rate limit message', async () => { const proxy = await createTestableProxy(); - const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy); + const isRetryable = ( + proxy as unknown as { + isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string }; + } + ).isRetryableError.bind(proxy); const result = isRetryable(new Error('Rate limit exceeded')); expect(result.retryable).toBe(true); @@ -150,7 +180,11 @@ describe('GLMT Retry Logic', () => { it('should return false for non-retryable errors', async () => { const proxy = await createTestableProxy(); - const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy); + const isRetryable = ( + proxy as unknown as { + isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string }; + } + ).isRetryableError.bind(proxy); expect(isRetryable(new Error('Connection refused')).retryable).toBe(false); expect(isRetryable(new Error('Timeout')).retryable).toBe(false); @@ -160,7 +194,11 @@ describe('GLMT Retry Logic', () => { it('should extract Retry-After from error message', async () => { const proxy = await createTestableProxy(); - const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy); + const isRetryable = ( + proxy as unknown as { + isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string }; + } + ).isRetryableError.bind(proxy); const result = isRetryable(new Error('429 Too Many Requests, Retry-After: 10')); expect(result.retryable).toBe(true); @@ -174,47 +212,66 @@ describe('GLMT Retry Logic', () => { let attempts = 0; // Mock forwardToUpstream - (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = async () => { - attempts++; - return { choices: [{ message: { content: 'success' } }] }; - }; + (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = + async () => { + attempts++; + return { choices: [{ message: { content: 'success' } }] }; + }; - const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).forwardWithRetry.bind(proxy); + const forwardWithRetry = ( + proxy as unknown as { + forwardWithRetry: (req: unknown, headers: unknown) => Promise; + } + ).forwardWithRetry.bind(proxy); const result = await forwardWithRetry({}, {}); expect(attempts).toBe(1); - expect((result as { choices: Array<{ message: { content: string } }> }).choices[0].message.content).toBe('success'); + expect( + (result as { choices: Array<{ message: { content: string } }> }).choices[0].message.content + ).toBe('success'); }); it('should retry on 429 and succeed eventually', async () => { const proxy = await createTestableProxy({ baseDelay: 10 }); // Fast for tests let attempts = 0; - (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = async () => { - attempts++; - if (attempts < 3) { - throw new Error('Upstream error: 429 Too Many Requests'); - } - return { choices: [{ message: { content: 'success after retry' } }] }; - }; + (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = + async () => { + attempts++; + if (attempts < 3) { + throw new Error('Upstream error: 429 Too Many Requests'); + } + return { choices: [{ message: { content: 'success after retry' } }] }; + }; - const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).forwardWithRetry.bind(proxy); + const forwardWithRetry = ( + proxy as unknown as { + forwardWithRetry: (req: unknown, headers: unknown) => Promise; + } + ).forwardWithRetry.bind(proxy); const result = await forwardWithRetry({}, {}); expect(attempts).toBe(3); - expect((result as { choices: Array<{ message: { content: string } }> }).choices[0].message.content).toBe('success after retry'); + expect( + (result as { choices: Array<{ message: { content: string } }> }).choices[0].message.content + ).toBe('success after retry'); }); it('should fail after max retries exhausted', async () => { const proxy = await createTestableProxy({ maxRetries: 2, baseDelay: 10 }); let attempts = 0; - (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = async () => { - attempts++; - throw new Error('Upstream error: 429 Too Many Requests'); - }; + (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = + async () => { + attempts++; + throw new Error('Upstream error: 429 Too Many Requests'); + }; - const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).forwardWithRetry.bind(proxy); + const forwardWithRetry = ( + proxy as unknown as { + forwardWithRetry: (req: unknown, headers: unknown) => Promise; + } + ).forwardWithRetry.bind(proxy); await expect(forwardWithRetry({}, {})).rejects.toThrow('429'); expect(attempts).toBe(3); // Initial + 2 retries @@ -224,12 +281,17 @@ describe('GLMT Retry Logic', () => { const proxy = await createTestableProxy({ enabled: false }); let attempts = 0; - (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = async () => { - attempts++; - throw new Error('Upstream error: 429 Too Many Requests'); - }; + (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = + async () => { + attempts++; + throw new Error('Upstream error: 429 Too Many Requests'); + }; - const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).forwardWithRetry.bind(proxy); + const forwardWithRetry = ( + proxy as unknown as { + forwardWithRetry: (req: unknown, headers: unknown) => Promise; + } + ).forwardWithRetry.bind(proxy); await expect(forwardWithRetry({}, {})).rejects.toThrow('429'); expect(attempts).toBe(1); @@ -239,12 +301,17 @@ describe('GLMT Retry Logic', () => { const proxy = await createTestableProxy({ baseDelay: 10 }); let attempts = 0; - (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = async () => { - attempts++; - throw new Error('Upstream error: 500 Internal Server Error'); - }; + (proxy as unknown as { forwardToUpstream: () => Promise }).forwardToUpstream = + async () => { + attempts++; + throw new Error('Upstream error: 500 Internal Server Error'); + }; - const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).forwardWithRetry.bind(proxy); + const forwardWithRetry = ( + proxy as unknown as { + forwardWithRetry: (req: unknown, headers: unknown) => Promise; + } + ).forwardWithRetry.bind(proxy); await expect(forwardWithRetry({}, {})).rejects.toThrow('500'); expect(attempts).toBe(1); @@ -254,7 +321,8 @@ describe('GLMT Retry Logic', () => { describe('connection pooling', () => { it('should create https.Agent with keepAlive enabled', async () => { const proxy = await createTestableProxy(); - const agent = (proxy as unknown as { httpsAgent: { options?: { keepAlive?: boolean } } }).httpsAgent; + const agent = (proxy as unknown as { httpsAgent: { options?: { keepAlive?: boolean } } }) + .httpsAgent; expect(agent).toBeDefined(); // Agent should have keepAlive behavior (internal property) @@ -263,7 +331,9 @@ describe('GLMT Retry Logic', () => { it('should destroy agent on stop', async () => { const proxy = await createTestableProxy(); - const agent = (proxy as unknown as { httpsAgent: { destroy: () => void; destroyed?: boolean } }).httpsAgent; + const agent = ( + proxy as unknown as { httpsAgent: { destroy: () => void; destroyed?: boolean } } + ).httpsAgent; let destroyed = false; const originalDestroy = agent.destroy.bind(agent); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index a6a19b1d..b3c00e71 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -219,14 +219,7 @@ describe('parseJsonlFile', () => { test('handles file with blank lines', async () => { const filePath = path.join(tempDir, 'blanks.jsonl'); - const content = [ - '', - VALID_ASSISTANT_ENTRY, - '', - ' ', - ASSISTANT_ENTRY_NO_CACHE, - '', - ].join('\n'); + const content = ['', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, ''].join('\n'); fs.writeFileSync(filePath, content); diff --git a/tests/unit/mcp-manager.test.ts b/tests/unit/mcp-manager.test.ts index e82362e8..bc77b9f5 100644 --- a/tests/unit/mcp-manager.test.ts +++ b/tests/unit/mcp-manager.test.ts @@ -92,7 +92,7 @@ describe('mcp-manager logic', () => { it('should not detect unrelated MCPs', () => { expect(detectWebSearchMcp({ 'my-custom-mcp': {} })).toBe(false); - expect(detectWebSearchMcp({ 'filesystem': {} })).toBe(false); + expect(detectWebSearchMcp({ filesystem: {} })).toBe(false); expect(detectWebSearchMcp({ 'github-copilot': {} })).toBe(false); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index f9124a58..c06e2b48 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -63,7 +63,8 @@ describe('SharedManager', () => { 'claude-hud@claude-hud': [ { scope: 'user', - installPath: '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', + installPath: + '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', version: '0.0.2', }, ], diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index c1743d67..3df8cb5d 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -101,9 +101,7 @@ describe('droid-config-manager', () => { provider: 'anthropic', }); - const settings = JSON.parse( - fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') - ); + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); expect(settings.customModels).toHaveLength(2); expect(settings.customModels[0].displayName).toBe('My GPT'); expect(settings.customModels[1].displayName).toBe('CCS gemini'); @@ -135,9 +133,7 @@ describe('droid-config-manager', () => { provider: 'anthropic', }); - const settings = JSON.parse( - fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') - ); + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); expect(settings.customModels).toHaveLength(2); expect(settings.customModels[0].provider).toBe('custom-provider'); expect(settings.customModels[1].displayName).toBe('CCS gemini'); @@ -162,7 +158,10 @@ describe('droid-config-manager', () => { it('should reject symlinked temp file path', async () => { const factoryDir = path.join(tmpDir, '.factory'); fs.mkdirSync(factoryDir, { recursive: true }); - fs.writeFileSync(path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [] })); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ customModels: [] }) + ); fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp')); await expect( @@ -202,9 +201,7 @@ describe('droid-config-manager', () => { provider: 'anthropic', }); - const settings = JSON.parse( - fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8') - ); + const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')); expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('CCS gemini'); expect(settings.customModels[0].apiKey).toBe('new-key'); @@ -255,8 +252,20 @@ describe('droid-config-manager', () => { path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [ - { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, - { model: 'opus', displayName: 'CCS gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { + model: 'gpt-4o', + displayName: 'My GPT', + baseUrl: 'x', + apiKey: 'y', + provider: 'openai', + }, + { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, ], }) ); @@ -275,8 +284,20 @@ describe('droid-config-manager', () => { path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [ - { model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, - { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, + { + model: 'opus', + displayName: 'ccs-gemini', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, + { + model: 'gpt-4o', + displayName: 'My GPT', + baseUrl: 'x', + apiKey: 'y', + provider: 'openai', + }, ], }) ); @@ -351,7 +372,12 @@ describe('droid-config-manager', () => { fs.writeFileSync( path.join(factoryDir, 'settings.json'), JSON.stringify({ - customModels: [null, 123, 'bad', { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }], + customModels: [ + null, + 123, + 'bad', + { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + ], }) ); @@ -458,8 +484,20 @@ describe('droid-config-manager', () => { path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [ - { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, - { model: 'opus', displayName: 'CCS old-profile', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { + model: 'gpt-4o', + displayName: 'My GPT', + baseUrl: 'x', + apiKey: 'y', + provider: 'openai', + }, + { + model: 'opus', + displayName: 'CCS old-profile', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, ], }) ); @@ -479,8 +517,20 @@ describe('droid-config-manager', () => { path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [ - { model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, - { model: 'sonnet', displayName: 'ccs-codex', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, + { + model: 'opus', + displayName: 'ccs-gemini', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, + { + model: 'sonnet', + displayName: 'ccs-codex', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, ], }) ); @@ -502,7 +552,13 @@ describe('droid-config-manager', () => { customModels: [ { model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, { model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }, - { model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' }, + { + model: 'gpt-4o', + displayName: 'My GPT', + baseUrl: 'x', + apiKey: 'y', + provider: 'openai', + }, ], }) ); @@ -517,31 +573,27 @@ describe('droid-config-manager', () => { }); describe('concurrent writes', () => { - it( - 'should handle concurrent upserts without data loss', - async () => { - const profiles = Array.from({ length: 10 }, (_, i) => `profile-${i}`); + it('should handle concurrent upserts without data loss', async () => { + const profiles = Array.from({ length: 10 }, (_, i) => `profile-${i}`); - await Promise.all( - profiles.map((p) => - upsertCcsModel(p, { - model: 'test-model', - displayName: `CCS ${p}`, - baseUrl: 'http://localhost:8317', - apiKey: 'key', - provider: 'anthropic', - }) - ) - ); + await Promise.all( + profiles.map((p) => + upsertCcsModel(p, { + model: 'test-model', + displayName: `CCS ${p}`, + baseUrl: 'http://localhost:8317', + apiKey: 'key', + provider: 'anthropic', + }) + ) + ); - const models = await listCcsModels(); - expect(models.size).toBe(10); + const models = await listCcsModels(); + expect(models.size).toBe(10); - for (const p of profiles) { - expect(models.has(p)).toBe(true); - } - }, - 15000 - ); + for (const p of profiles) { + expect(models.has(p)).toBe(true); + } + }, 15000); }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 03ca380a..cfed158d 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -129,9 +129,9 @@ describe('stripTargetFlag', () => { }); it('should remove repeated --target flags', () => { - expect(stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose'])).toEqual( - ['gemini', '--verbose'] - ); + expect( + stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose']) + ).toEqual(['gemini', '--verbose']); }); it('should throw when --target has no value', () => { diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts index eaeb30dc..b4094ea4 100644 --- a/tests/unit/unified-config.test.ts +++ b/tests/unit/unified-config.test.ts @@ -19,7 +19,16 @@ import { isUnifiedConfigEnabled } from '../../src/config/feature-flags'; // Inline helper to test secret key detection (utility kept for potential reuse) function isSecretKey(key: string): boolean { const upper = key.toUpperCase(); - const secretPatterns = ['TOKEN', 'SECRET', 'API_KEY', 'APIKEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'PRIVATE']; + const secretPatterns = [ + 'TOKEN', + 'SECRET', + 'API_KEY', + 'APIKEY', + 'PASSWORD', + 'CREDENTIAL', + 'AUTH', + 'PRIVATE', + ]; return secretPatterns.some((pattern) => upper.includes(pattern)); } diff --git a/tests/unit/utils/expand-path.test.ts b/tests/unit/utils/expand-path.test.ts index 42dab0f5..f8d759ef 100644 --- a/tests/unit/utils/expand-path.test.ts +++ b/tests/unit/utils/expand-path.test.ts @@ -1,89 +1,91 @@ -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"; +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", () => { +describe('expandPath', () => { const originalEnv = { ...process.env }; const HOME = os.homedir(); beforeEach(() => { - process.env.TEST_HOME = "/custom/home"; - process.env.TEST_VAR = "foo"; + 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('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", () => { + 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")); + 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('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('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)", () => { + 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")); + 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")); + 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('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('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('8. Empty/null path handling', () => { + expect(expandPath('')).toBe('.'); }); - test("9. Already absolute paths stay unchanged", () => { - const absPath = "/absolute/path"; + 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('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", () => { + 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"); + const result = expandPath('C:\\Users\\test\\file.txt'); + expect(result).toContain('Users'); + expect(result).toContain('test'); }); - test("12. Windows UNC paths handled", () => { + test('12. Windows UNC paths handled', () => { // UNC paths start with \\ - const uncPath = "\\\\server\\share\\folder"; + const uncPath = '\\\\server\\share\\folder'; const result = expandPath(uncPath); // Should normalize but preserve the structure - expect(result).toContain("server"); - expect(result).toContain("share"); + expect(result).toContain('server'); + expect(result).toContain('share'); }); - test("13. Null-like input throws TypeError", () => { + 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); @@ -91,14 +93,14 @@ describe("expandPath", () => { expect(() => expandPath(null as unknown as string)).toThrow(TypeError); }); - test("14. Path with spaces preserved", () => { - const pathWithSpaces = "~/My Documents/file.txt"; + test('14. Path with spaces preserved', () => { + const pathWithSpaces = '~/My Documents/file.txt'; const result = expandPath(pathWithSpaces); - expect(result).toContain("My Documents"); + 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")); + 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/signal-forwarder.test.ts b/tests/unit/utils/signal-forwarder.test.ts index 463453f8..451987f7 100644 --- a/tests/unit/utils/signal-forwarder.test.ts +++ b/tests/unit/utils/signal-forwarder.test.ts @@ -97,7 +97,9 @@ describe('signal-forwarder', () => { const child = createMockChildProcess(); const before = getSignalListenerCounts(); const onError = jest.fn(async () => {}); - const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + const err = Object.assign(new Error('spawn failed'), { + code: 'ENOENT', + }) as NodeJS.ErrnoException; wireChildProcessSignals(child, onError); child.emit('error', err); @@ -113,7 +115,9 @@ describe('signal-forwarder', () => { const child = createMockChildProcess(); const onError = jest.fn(async () => {}); const onExit = jest.fn(); - const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + const err = Object.assign(new Error('spawn failed'), { + code: 'ENOENT', + }) as NodeJS.ErrnoException; wireChildProcessSignals(child, onError, onExit); child.emit('error', err); @@ -134,7 +138,9 @@ describe('signal-forwarder', () => { .mockImplementation((() => undefined as never) as typeof process.exit); try { - const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException; + const err = Object.assign(new Error('spawn failed'), { + code: 'ENOENT', + }) as NodeJS.ErrnoException; wireChildProcessSignals(child, onError); child.emit('error', err); await Promise.resolve(); diff --git a/tests/unit/utils/websearch/hook-utils.test.ts b/tests/unit/utils/websearch/hook-utils.test.ts index 64373365..56c5aa5f 100644 --- a/tests/unit/utils/websearch/hook-utils.test.ts +++ b/tests/unit/utils/websearch/hook-utils.test.ts @@ -1,5 +1,8 @@ import { expect, test, describe } from 'bun:test'; -import { isCcsWebSearchHook, deduplicateCcsHooks } from '../../../../src/utils/websearch/hook-utils'; +import { + isCcsWebSearchHook, + deduplicateCcsHooks, +} from '../../../../src/utils/websearch/hook-utils'; describe('isCcsWebSearchHook', () => { test('Returns true for CCS hook with forward slashes (Unix path)', () => { diff --git a/tests/unit/web-server/auth-middleware.test.ts b/tests/unit/web-server/auth-middleware.test.ts index 9c0f9191..e441b310 100644 --- a/tests/unit/web-server/auth-middleware.test.ts +++ b/tests/unit/web-server/auth-middleware.test.ts @@ -28,17 +28,13 @@ describe('Dashboard Auth', () => { describe('getDashboardAuthConfig', () => { it('returns disabled by default', async () => { - const { getDashboardAuthConfig } = await import( - '../../src/config/unified-config-loader' - ); + const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); const config = getDashboardAuthConfig(); expect(config.enabled).toBe(false); }); it('returns 24 hour default session timeout', async () => { - const { getDashboardAuthConfig } = await import( - '../../src/config/unified-config-loader' - ); + const { getDashboardAuthConfig } = await import('../../src/config/unified-config-loader'); const config = getDashboardAuthConfig(); expect(config.session_timeout_hours).toBe(24); }); @@ -89,12 +85,7 @@ describe('Dashboard Auth', () => { }); describe('public paths', () => { - const PUBLIC_PATHS = [ - '/api/auth/login', - '/api/auth/check', - '/api/auth/setup', - '/api/health', - ]; + const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health']; it('identifies public paths correctly', () => { const isPublicPath = (path: string) => diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 1ad73d09..9982c133 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -3,7 +3,9 @@ import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cli describe('cliproxy-auth-routes start-url guard', () => { it('rejects device code providers', () => { - expect(getStartUrlUnsupportedReason('kiro')).toContain("Kiro method 'aws' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('kiro')).toContain( + "Kiro method 'aws' uses Device Code flow" + ); expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); }); diff --git a/tests/unit/web-server/cursor-settings-routes.test.ts b/tests/unit/web-server/cursor-settings-routes.test.ts index fb163a7a..6f74edcc 100644 --- a/tests/unit/web-server/cursor-settings-routes.test.ts +++ b/tests/unit/web-server/cursor-settings-routes.test.ts @@ -14,7 +14,10 @@ process.env.CCS_HOME = TEST_CCS_DIR; // Import after setting env var import type { CursorConfig } from '../../../src/config/unified-config-types'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../../../src/config/unified-config-loader'; import { getCcsDir } from '../../../src/utils/config-manager'; describe('Cursor Settings Routes Logic', () => { From ddd5b159d21efe436aa2bf82a190d3b284abe4a8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 17:16:52 +0700 Subject: [PATCH 19/31] fix(cliproxy): add kimi to wizard constants and image analysis config - add kimi entry to PROVIDER_INFO and WIZARD_PROVIDER_ORDER in setup wizard - add kimi to DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models --- src/config/unified-config-types.ts | 1 + ui/src/components/setup/wizard/constants.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 3420d406..5e5e0d2d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -666,6 +666,7 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { // 'vision-model' is a generic placeholder - users can override via config.yaml qwen: 'vision-model', iflow: 'qwen3-vl-plus', + kimi: 'vision-model', }, }; diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 96c9d633..26addd28 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -17,6 +17,7 @@ const PROVIDER_INFO: Record Date: Tue, 17 Feb 2026 17:31:27 +0700 Subject: [PATCH 20/31] fix(test): add kimi to provider tests, remove merge conflict marker - Add kimi to CLIPROXY_PROVIDER_IDS expected array - Add kimi to device_code providers expected array - Remove leftover >>>>>>> origin/dev merge conflict marker in cursor-daemon test - Fix prettier formatting in image-analysis-check.ts --- src/management/checks/image-analysis-check.ts | 5 +++-- tests/unit/cliproxy/provider-capabilities.test.ts | 3 ++- tests/unit/cursor/cursor-daemon.test.ts | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index e539bff4..39a0f7c5 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -94,8 +94,9 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = - await import('../../config/unified-config-loader'); + const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/unified-config-loader' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index c92c702f..13f3e8f7 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -32,6 +32,7 @@ describe('provider-capabilities', () => { 'kiro', 'ghcp', 'claude', + 'kimi', ]); }); @@ -43,7 +44,7 @@ describe('provider-capabilities', () => { }); it('returns providers by OAuth flow capability', () => { - expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp']); + expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp', 'kimi']); expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ 'gemini', 'codex', diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 34b1b1b9..1ba7e553 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -266,7 +266,6 @@ describe('startDaemon', () => { await stopDaemon(); } }); ->>>>>>> origin/dev }); describe('isDaemonRunning', () => { From 6961fb0ec35b326bb77ffea9f8828708d2a40cb7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:13:34 +0700 Subject: [PATCH 21/31] feat(ui): add Kimi provider logo with dark background - Add kimi.svg (Moonshot brand logo, white on dark) - Add kimi to provider-logo.tsx PROVIDER_IMAGES and PROVIDER_CONFIG - Dark background for kimi logo container (bg-gray-900) --- ui/public/assets/providers/kimi.svg | 1 + ui/src/components/cliproxy/provider-logo.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 ui/public/assets/providers/kimi.svg diff --git a/ui/public/assets/providers/kimi.svg b/ui/public/assets/providers/kimi.svg new file mode 100644 index 00000000..286f35e7 --- /dev/null +++ b/ui/public/assets/providers/kimi.svg @@ -0,0 +1 @@ +Kimi diff --git a/ui/src/components/cliproxy/provider-logo.tsx b/ui/src/components/cliproxy/provider-logo.tsx index ee762285..e2b8528b 100644 --- a/ui/src/components/cliproxy/provider-logo.tsx +++ b/ui/src/components/cliproxy/provider-logo.tsx @@ -21,6 +21,7 @@ const PROVIDER_IMAGES: Record = { kiro: '/assets/providers/kiro.png', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', + kimi: '/assets/providers/kimi.svg', }; /** Provider color configuration (for fallback only - no background for image logos) */ @@ -33,8 +34,12 @@ const PROVIDER_CONFIG: Record = { iflow: { text: 'text-indigo-600', letter: 'i' }, kiro: { text: 'text-teal-600', letter: 'K' }, ghcp: { text: 'text-green-600', letter: 'C' }, + kimi: { text: 'text-orange-500', letter: 'K' }, }; +/** Providers whose logos require a dark background */ +const DARK_BG_PROVIDERS = new Set(['kimi']); + /** Size configuration */ const SIZE_CONFIG = { sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' }, @@ -55,7 +60,7 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
Date: Tue, 17 Feb 2026 13:17:32 +0000 Subject: [PATCH 22/31] chore(release): 7.45.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 851b7d7c..97e7817e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.45.0-dev.4", + "version": "7.45.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 812bb5c0a5b60041d1928189019cc75183ae525d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:50:50 +0700 Subject: [PATCH 23/31] fix(targets): run cleanup before adapter launch exits - wrap adapter early-exit paths with a cleanup-aware exit helper - invoke cleanup before exiting on child spawn failures - validate Droid profile names in buildArgs and add unit coverage --- src/targets/claude-adapter.ts | 15 +++++++++++--- src/targets/droid-adapter.ts | 26 +++++++++++++++++------- tests/unit/targets/droid-adapter.test.ts | 17 ++++++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 tests/unit/targets/droid-adapter.test.ts diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index d7b60035..4fee3d80 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -13,6 +13,7 @@ import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; +import { runCleanup } from '../errors'; export class ClaudeAdapter implements TargetAdapter { readonly type: TargetType = 'claude'; @@ -63,11 +64,19 @@ export class ClaudeAdapter implements TargetAdapter { env: NodeJS.ProcessEnv, _options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } ): void { + const exitWithCleanup = (code: number): never => { + try { + runCleanup(); + } catch { + // Cleanup should be best-effort on launch errors. + } + process.exit(code); + }; + const claudeCli = detectClaudeCli(); if (!claudeCli) { void ErrorManager.showClaudeNotFound(); - process.exit(1); - return; + return exitWithCleanup(1); } const isWindows = process.platform === 'win32'; @@ -118,7 +127,7 @@ export class ClaudeAdapter implements TargetAdapter { } else { console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`); } - process.exit(1); + return exitWithCleanup(1); }); } diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 79004bd3..bb3a8008 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -13,6 +13,7 @@ import type { ProfileType } from '../types/profile'; import { upsertCcsModel } from './droid-config-manager'; import { escapeShellArg } from '../utils/shell-executor'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; +import { runCleanup } from '../errors'; export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; @@ -52,6 +53,11 @@ export class DroidAdapter implements TargetAdapter { } buildArgs(profile: string, userArgs: string[]): string[] { + if (!/^[a-zA-Z0-9_-]+$/.test(profile)) { + throw new Error( + `Invalid profile name "${profile}" for Droid target: only alphanumeric, underscore, hyphen allowed` + ); + } return ['-m', `custom:ccs-${profile}`, ...userArgs]; } @@ -67,26 +73,32 @@ export class DroidAdapter implements TargetAdapter { env: NodeJS.ProcessEnv, options?: { cwd?: string; binaryInfo?: TargetBinaryInfo } ): void { + const exitWithCleanup = (code: number): never => { + try { + runCleanup(); + } catch { + // Cleanup should be best-effort on launch errors. + } + process.exit(code); + }; + const droidPath = options?.binaryInfo?.path || detectDroidCli(); if (!droidPath) { console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); - process.exit(1); - return; + return exitWithCleanup(1); } try { const stat = fs.statSync(droidPath); if (!stat.isFile()) { console.error(`[X] Droid CLI path is not a file: ${droidPath}`); - process.exit(1); - return; + return exitWithCleanup(1); } } catch (err) { const error = err as NodeJS.ErrnoException; console.error( `[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}` ); - process.exit(1); - return; + return exitWithCleanup(1); } const isWindows = process.platform === 'win32'; @@ -138,7 +150,7 @@ export class DroidAdapter implements TargetAdapter { } else { console.error(`[X] Failed to start Droid CLI (${droidPath}):`, err.message); } - process.exit(1); + return exitWithCleanup(1); }); } diff --git a/tests/unit/targets/droid-adapter.test.ts b/tests/unit/targets/droid-adapter.test.ts new file mode 100644 index 00000000..3c1501c6 --- /dev/null +++ b/tests/unit/targets/droid-adapter.test.ts @@ -0,0 +1,17 @@ +/** + * Unit tests for Droid adapter argument building. + */ +import { describe, it, expect } from 'bun:test'; +import { DroidAdapter } from '../../../src/targets/droid-adapter'; + +describe('DroidAdapter.buildArgs', () => { + it('builds droid model args for valid profile names', () => { + const adapter = new DroidAdapter(); + expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['-m', 'custom:ccs-gemini_01', '--help']); + }); + + it('rejects unsafe profile names', () => { + const adapter = new DroidAdapter(); + expect(() => adapter.buildArgs('bad profile', [])).toThrow(/Invalid profile name/); + }); +}); From 15d6c06dbc8de72379330c43031a5660b4bc0338 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:05 +0700 Subject: [PATCH 24/31] fix(targets): snapshot active profiles during droid prune - copy activeProfiles at call time before lock acquisition - validate profile names inside locked section - add tests for invalid names and post-call array mutation --- src/targets/droid-config-manager.ts | 12 ++++-- .../unit/targets/droid-config-manager.test.ts | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 348e345d..3483a2d3 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -386,8 +386,8 @@ export async function listCcsModels(): Promise> { * Removes ccs-* entries whose profile no longer exists in active profiles. */ export async function pruneOrphanedModels(activeProfiles: string[]): Promise { - // Validate all profile names before pruning - activeProfiles.forEach((profile) => validateProfileName(profile)); + // Snapshot at call time so caller-side mutation cannot affect filtering while lock is pending. + const activeProfilesSnapshot = [...activeProfiles]; ensureFactoryDir(); const settingsPath = getSettingsPath(); @@ -397,6 +397,12 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise(); + for (const profile of activeProfilesSnapshot) { + validateProfileName(profile); + activeProfileSet.add(profile); + } + if (!fs.existsSync(settingsPath)) return 0; const settings = readDroidSettings(); @@ -406,7 +412,7 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise { const profile = parseManagedProfile(m.displayName); if (profile) { - return activeProfiles.includes(profile); + return activeProfileSet.has(profile); } // Drop malformed managed entries; keep user-managed entries. diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 3df8cb5d..dd1c4675 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -570,6 +570,48 @@ describe('droid-config-manager', () => { expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('My GPT'); }); + + it('should reject invalid active profile names', async () => { + await expect(pruneOrphanedModels(['bad profile'])).rejects.toThrow(/Invalid profile name/); + }); + + it('should use active profile snapshot taken at call time', async () => { + const factoryDir = path.join(tmpDir, '.factory'); + fs.mkdirSync(factoryDir, { recursive: true }); + fs.writeFileSync( + path.join(factoryDir, 'settings.json'), + JSON.stringify({ + customModels: [ + { + model: 'opus', + displayName: 'CCS gemini', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, + { + model: 'sonnet', + displayName: 'CCS codex', + baseUrl: 'x', + apiKey: 'y', + provider: 'anthropic', + }, + ], + }) + ); + + const activeProfiles = ['gemini']; + const prunePromise = pruneOrphanedModels(activeProfiles); + activeProfiles.push('codex'); // Mutation after call should not affect in-flight prune decision. + + const removed = await prunePromise; + expect(removed).toBe(1); + + const models = await listCcsModels(); + expect(models.size).toBe(1); + expect(models.has('gemini')).toBe(true); + expect(models.has('codex')).toBe(false); + }); }); describe('concurrent writes', () => { From 025218a706d30ca75163153293ce0920d9c3917d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:16 +0700 Subject: [PATCH 25/31] feat(targets): support CCS_DROID_ALIASES argv0 mapping - extend argv[0] target resolution with env-configurable aliases - normalize alias matching to lowercase with safe-name filtering - add tests for custom aliases, invalid entries, and case-insensitivity --- src/targets/target-resolver.ts | 24 ++++++++++++++++++++-- tests/unit/targets/target-resolver.test.ts | 24 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index 3aa06441..edc23481 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -18,6 +18,25 @@ import { TargetType } from './target-adapter'; const ARGV0_TARGET_MAP: Record = { ccsd: 'droid', }; +const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/; + +function buildArgv0TargetMap(): Record { + const map: Record = { ...ARGV0_TARGET_MAP }; + const envAliases = process.env['CCS_DROID_ALIASES']; + if (!envAliases) { + return map; + } + + for (const rawAlias of envAliases.split(',')) { + const alias = rawAlias.trim().toLowerCase(); + if (!alias || !ALIAS_NAME_REGEX.test(alias)) { + continue; + } + map[alias] = 'droid'; + } + + return map; +} /** * Valid target types for --target flag validation. @@ -108,8 +127,9 @@ export function resolveTargetType( // 3. Check argv[0] (busybox pattern) // Strip common wrapper extensions for Windows shims/wrappers const rawBin = path.basename(process.argv[1] || process.argv0 || ''); - const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, ''); - const argv0Target = ARGV0_TARGET_MAP[binName]; + const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, '').toLowerCase(); + const argv0TargetMap = buildArgv0TargetMap(); + const argv0Target = argv0TargetMap[binName]; if (argv0Target) { return argv0Target; } diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index cfed158d..a4082cdf 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -6,9 +6,15 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target- describe('resolveTargetType', () => { const originalArgv = process.argv; + const originalDroidAliases = process.env.CCS_DROID_ALIASES; afterEach(() => { process.argv = originalArgv; + if (originalDroidAliases === undefined) { + delete process.env.CCS_DROID_ALIASES; + } else { + process.env.CCS_DROID_ALIASES = originalDroidAliases; + } }); it('should return claude as default', () => { @@ -41,6 +47,24 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect custom argv[0] aliases from CCS_DROID_ALIASES', () => { + process.env.CCS_DROID_ALIASES = 'droidx,my-droid'; + process.argv = ['node', 'my-droid']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should ignore invalid custom alias entries', () => { + process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,'; + process.argv = ['node', '../bad']; + expect(resolveTargetType([])).toBe('claude'); + }); + + it('should normalize argv[0] and custom aliases case-insensitively', () => { + process.env.CCS_DROID_ALIASES = 'DroidCaps'; + process.argv = ['node', 'DROIDCAPS']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should strip .cmd extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.cmd']; expect(resolveTargetType([])).toBe('droid'); From 91edc9565bbd6bf4fe6992b0c7e2457a90a87949 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:27 +0700 Subject: [PATCH 26/31] docs(targets): document built-in and env droid aliases - add README guidance for ccsd argv[0] auto-target behavior - document CCS_DROID_ALIASES runtime extension in architecture docs --- README.md | 11 +++++++++++ docs/system-architecture/target-adapters.md | 3 +++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 6b135b0f..32dde7df 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,17 @@ ccs ollama # Local Ollama (no API key needed) ccs glm # GLM (API key) ``` +### Droid Alias (`argv[0]` pattern) + +By default, invoking CCS as `ccsd` auto-selects the Droid target: + +```bash +ln -s "$(command -v ccs)" /usr/local/bin/ccsd +ccsd glm +``` + +Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`). + ### Kiro Auth Methods `ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts). diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 51488b3c..c1944c6b 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -355,6 +355,7 @@ ccs --target droid glm ```bash # Create alias/symlink to auto-select droid target +# Built-in alias: ccsd ln -s /path/to/ccs /path/to/ccsd # Usage @@ -365,6 +366,8 @@ ccsd glm On Windows, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. +Additional alias names can be configured at runtime via `CCS_DROID_ALIASES` (comma-separated). Example: `CCS_DROID_ALIASES=ccs-droid,mydroid`. + --- ## Registry and Lookup From 53e18d4c8d60ed0d13a1cb034f53c5c2ef91bdad Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:37 +0700 Subject: [PATCH 27/31] fix(core): prevent GLMT proxy leaks in child lifecycle - reuse shared wireChildProcessSignals for GLMT Claude execution - centralize proxy stop logic on child error and exit paths - guard global signal cleanup so cleanup failures do not block exit --- src/ccs.ts | 106 +++++++++++++++++++++++++++-------------------------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 1bf77d6d..32e0302c 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -37,6 +37,7 @@ import { handleUpdateCommand } from './commands/update-command'; // Import extracted utility functions import { execClaude, escapeShellArg } from './utils/shell-executor'; +import { wireChildProcessSignals } from './utils/signal-forwarder'; // Import target adapter system import { @@ -117,6 +118,15 @@ async function execClaudeWithProxy( ANTHROPIC_BASE_URL: envData['ANTHROPIC_BASE_URL'], }, }); + const stopProxy = (): void => { + try { + if (!proxy.killed) { + proxy.kill('SIGTERM'); + } + } catch { + // Best-effort cleanup on process teardown. + } + }; // 3. Wait for proxy ready signal (with timeout) const { ProgressIndicator } = await import('./utils/progress-indicator'); @@ -167,7 +177,8 @@ async function execClaudeWithProxy( console.error(' - Enable verbose logging: ccs glmt --verbose "prompt"'); console.error(` - Check proxy logs in ${getCcsDir()}/logs/ (if debug enabled)`); console.error(''); - proxy.kill(); + stopProxy(); + runCleanup(); process.exit(1); } @@ -220,58 +231,41 @@ async function execClaudeWithProxy( }); } - // 5. Cleanup: kill proxy when Claude exits - const forwardSigTerm = () => { - proxy.kill('SIGTERM'); - claude.kill('SIGTERM'); - }; - const forwardSigInt = () => { - proxy.kill('SIGTERM'); - claude.kill('SIGINT'); - }; - const forwardSighup = () => { - proxy.kill('SIGTERM'); - claude.kill('SIGHUP'); - }; - process.on('SIGTERM', forwardSigTerm); - process.on('SIGINT', forwardSigInt); - process.on('SIGHUP', forwardSighup); - - const cleanupSignalHandlers = () => { - process.removeListener('SIGTERM', forwardSigTerm); - process.removeListener('SIGINT', forwardSigInt); - process.removeListener('SIGHUP', forwardSighup); - }; - - claude.on('exit', (code, signal) => { - cleanupSignalHandlers(); - proxy.kill('SIGTERM'); - if (signal) process.kill(process.pid, signal as NodeJS.Signals); - else process.exit(code || 0); - }); - - claude.on('error', (error) => { - cleanupSignalHandlers(); - const err = error as NodeJS.ErrnoException; - if (err.code === 'EACCES') { - console.error(fail(`Claude CLI is not executable: ${claudeCli}`)); - console.error(' Check file permissions and executable bit.'); - } else if (err.code === 'ENOENT') { - if (isPowerShellScript) { - console.error(fail('PowerShell executable not found (required for .ps1 wrapper launch).')); - console.error(' Ensure powershell.exe is available in PATH.'); - } else if (needsShell) { - console.error(fail('Windows command shell not found for Claude wrapper launch.')); - console.error(' Ensure cmd.exe is available and accessible.'); + // 5. Shared signal forwarding + proxy cleanup lifecycle + wireChildProcessSignals( + claude, + (err: NodeJS.ErrnoException) => { + if (err.code === 'EACCES') { + console.error(fail(`Claude CLI is not executable: ${claudeCli}`)); + console.error(' Check file permissions and executable bit.'); + } else if (err.code === 'ENOENT') { + if (isPowerShellScript) { + console.error( + fail('PowerShell executable not found (required for .ps1 wrapper launch).') + ); + console.error(' Ensure powershell.exe is available in PATH.'); + } else if (needsShell) { + console.error(fail('Windows command shell not found for Claude wrapper launch.')); + console.error(' Ensure cmd.exe is available and accessible.'); + } else { + console.error(fail(`Claude CLI not found: ${claudeCli}`)); + } } else { - console.error(fail(`Claude CLI not found: ${claudeCli}`)); + console.error(fail(`Claude CLI error: ${err.message}`)); + } + stopProxy(); + runCleanup(); + process.exit(1); + }, + (code: number | null, signal: NodeJS.Signals | null) => { + stopProxy(); + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code || 0); } - } else { - console.error(fail(`Claude CLI error: ${err.message}`)); } - proxy.kill('SIGTERM'); - process.exit(1); - }); + ); } // ========== Main Execution ========== @@ -1019,7 +1013,11 @@ process.on('unhandledRejection', (reason: unknown) => { // Handle process termination signals for cleanup process.on('SIGTERM', () => { - runCleanup(); + try { + runCleanup(); + } catch { + // Cleanup failure should not block termination. + } // If a target exec path registered additional signal listeners, let those // listeners forward/coordinate child shutdown and final exit codes. if (process.listenerCount('SIGTERM') <= 1) { @@ -1028,7 +1026,11 @@ process.on('SIGTERM', () => { }); process.on('SIGINT', () => { - runCleanup(); + try { + runCleanup(); + } catch { + // Cleanup failure should not block termination. + } // Same coordination rule as SIGTERM. if (process.listenerCount('SIGINT') <= 1) { process.exit(130); // 128 + SIGINT(2) From 9031e5a085e8c1b4b58e4e871e4d98dbd48bcfc3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:47 +0700 Subject: [PATCH 28/31] fix(cliproxy): log unknown codex quota window labels - detect and de-duplicate unclassified quota window labels - emit diagnostics in verbose/debug modes for API label drift - add unit tests for unknown-label extraction behavior --- src/cliproxy/quota-fetcher-codex.ts | 23 +++++++- .../unit/cliproxy/quota-fetcher-codex.test.ts | 52 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 3c860d5b..bc83f178 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -82,6 +82,20 @@ function getCodexWindowKind(label: string): CodexWindowKind { return 'unknown'; } +function getUnknownCodexWindowLabels(windows: CodexQuotaWindow[]): string[] { + const unknownLabels = windows + .filter((window) => getCodexWindowKind(window.label) === 'unknown') + .map((window) => window.label) + .filter((label): label is string => typeof label === 'string' && label.trim().length > 0); + return Array.from(new Set(unknownLabels)); +} + +function shouldLogCodexWindowWarnings(verbose: boolean): boolean { + if (verbose) return true; + const debugFlag = process.env['CCS_DEBUG']; + return debugFlag === '1' || debugFlag === 'true'; +} + /** * Build explicit 5h + weekly usage summary from raw Codex windows. * Falls back to shortest/longest reset windows if API labels change. @@ -388,6 +402,13 @@ export async function fetchCodexQuota( const data = (await response.json()) as CodexUsageResponse; const windows = buildCodexQuotaWindows(data); + const unknownWindowLabels = getUnknownCodexWindowLabels(windows); + if (unknownWindowLabels.length > 0 && shouldLogCodexWindowWarnings(verbose)) { + console.error( + `[!] Codex quota detected unknown window labels: ${unknownWindowLabels.join(', ')}` + ); + console.error(' Window classification may need an update for upstream API changes.'); + } const coreUsage = buildCodexCoreUsageSummary(windows); // Extract plan type @@ -475,4 +496,4 @@ export async function fetchAllCodexQuotas( } // Export for testing -export { readCodexAuthData, buildCodexQuotaWindows }; +export { readCodexAuthData, buildCodexQuotaWindows, getUnknownCodexWindowLabels }; diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index b771ee4d..69b3f21a 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'bun:test'; import { buildCodexQuotaWindows, buildCodexCoreUsageSummary, + getUnknownCodexWindowLabels, } from '../../../src/cliproxy/quota-fetcher-codex'; describe('Codex Quota Fetcher', () => { @@ -255,4 +256,55 @@ describe('Codex Quota Fetcher', () => { expect(summary.weekly).toBeNull(); }); }); + + describe('getUnknownCodexWindowLabels', () => { + it('returns unknown labels and de-duplicates them', () => { + const labels = getUnknownCodexWindowLabels([ + { + label: 'Window A', + usedPercent: 1, + remainingPercent: 99, + resetAfterSeconds: 10, + resetAt: null, + }, + { + label: 'Window A', + usedPercent: 2, + remainingPercent: 98, + resetAfterSeconds: 20, + resetAt: null, + }, + { + label: 'Primary', + usedPercent: 3, + remainingPercent: 97, + resetAfterSeconds: 30, + resetAt: null, + }, + ]); + + expect(labels).toEqual(['Window A']); + }); + + it('returns empty array when all labels are recognized', () => { + const labels = getUnknownCodexWindowLabels([ + { + label: 'Primary', + usedPercent: 10, + remainingPercent: 90, + resetAfterSeconds: 100, + resetAt: null, + }, + { + label: 'Code Review (Secondary)', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 200, + resetAt: null, + }, + ]); + + expect(labels).toEqual([]); + }); + }); }); From fae87169008b704c319f78fcac958dda435985d0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:57 +0700 Subject: [PATCH 29/31] fix(cursor): fallback when requested model is unavailable - resolve request model against current available catalog - choose safe default when configured default id is not present - add tests and docs for daemon request-model fallback behavior --- docs/cursor-integration.md | 1 + src/cursor/cursor-daemon-entry.ts | 35 +++++++++++++++++------- src/cursor/cursor-models.ts | 36 ++++++++++++++++++++++++- tests/unit/cursor/cursor-models.test.ts | 21 +++++++++++++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index fad1eabd..1aa32cda 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -60,6 +60,7 @@ ccs cursor stop - `ghost_mode`: enabled - `auto_start`: disabled - Model list resolution: authenticated live fetch when available, with cached/default fallback. +- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. These values are managed in unified config and can be updated from CLI or dashboard. diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 355cedfc..f6eb2f7d 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -8,7 +8,7 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; import { checkAuthStatus } from './cursor-auth'; -import { DEFAULT_CURSOR_MODEL, getModelsForDaemon } from './cursor-models'; +import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; interface DaemonRuntimeOptions { @@ -229,10 +229,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; const messages = normalizeMessages(parsedBody.messages); - const model = - typeof parsedBody.model === 'string' && parsedBody.model - ? parsedBody.model - : DEFAULT_CURSOR_MODEL; + const requestedModel = + typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0 + ? parsedBody.model.trim() + : undefined; const stream = parsedBody.stream === true; const authStatus = checkAuthStatus(); @@ -256,6 +256,25 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } + const daemonCredentials = { + accessToken: authStatus.credentials.accessToken, + machineId: authStatus.credentials.machineId, + ghostMode: options.ghostMode, + }; + const availableModels = await getModelsForDaemon({ + credentials: daemonCredentials, + }); + const model = resolveCursorRequestModel(requestedModel, availableModels); + if ( + requestedModel && + requestedModel !== model && + (process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG === 'true') + ) { + console.error( + `[cursor] Requested model "${requestedModel}" is unavailable; falling back to "${model}".` + ); + } + const abortController = new AbortController(); const abortOnDisconnect = () => { if (!abortController.signal.aborted && !res.writableEnded) { @@ -271,11 +290,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser model, stream, signal: abortController.signal, - credentials: { - accessToken: authStatus.credentials.accessToken, - machineId: authStatus.credentials.machineId, - ghostMode: options.ghostMode, - }, + credentials: daemonCredentials, body: { messages, tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined, diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 12e7ad58..208a1a25 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -264,10 +264,44 @@ export async function getAvailableModels(port: number): Promise { return fetchModelsFromDaemon(port); } +function getCatalogDefaultModelId(availableModels: CursorModel[]): string { + if (availableModels.some((model) => model.id === DEFAULT_CURSOR_MODEL)) { + return DEFAULT_CURSOR_MODEL; + } + + const explicitDefault = availableModels.find((model) => model.isDefault)?.id; + if (explicitDefault) { + return explicitDefault; + } + + const firstAvailable = availableModels.find( + (model) => typeof model.id === 'string' && model.id.trim().length > 0 + )?.id; + + return firstAvailable || DEFAULT_CURSOR_MODEL; +} + +export function resolveCursorRequestModel( + requestedModel: string | null | undefined, + availableModels: CursorModel[] +): string { + const fallbackModel = getCatalogDefaultModelId(availableModels); + const normalizedRequested = typeof requestedModel === 'string' ? requestedModel.trim() : ''; + if (!normalizedRequested) { + return fallbackModel; + } + + if (availableModels.some((model) => model.id === normalizedRequested)) { + return normalizedRequested; + } + + return fallbackModel; +} + /** * Get the default model. * Uses GPT-5.3 Codex as default. */ export function getDefaultModel(): string { - return DEFAULT_CURSOR_MODEL; + return getCatalogDefaultModelId(DEFAULT_CURSOR_MODELS); } diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 7d2dc8d0..0390f5e1 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -15,6 +15,7 @@ import { fetchModelsFromCursorApi, getModelsForDaemon, clearCursorModelsCache, + resolveCursorRequestModel, } from '../../../src/cursor/cursor-models'; describe('DEFAULT_CURSOR_MODELS', () => { @@ -50,6 +51,26 @@ describe('getDefaultModel', () => { }); }); +describe('resolveCursorRequestModel', () => { + it('keeps requested model when present in available models', () => { + const resolved = resolveCursorRequestModel('claude-4.6-opus', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe('claude-4.6-opus'); + }); + + it('falls back to default when requested model is unavailable', () => { + const resolved = resolveCursorRequestModel('non-existent-model', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe(DEFAULT_CURSOR_MODEL); + }); + + it('falls back to first available model when default id is absent from available set', () => { + const resolved = resolveCursorRequestModel('non-existent-model', [ + { id: 'fallback-1', name: 'Fallback 1', provider: 'openai' }, + { id: 'fallback-2', name: 'Fallback 2', provider: 'anthropic' }, + ]); + expect(resolved).toBe('fallback-1'); + }); +}); + describe('detectProvider', () => { it('detects anthropic models', () => { expect(detectProvider('claude-4.5-sonnet')).toBe('anthropic'); From e33164f42e96ec6826728c47b615286bb13bfdd6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:52:07 +0700 Subject: [PATCH 30/31] docs(provider): clarify kimi base profile endpoint - document that base-kimi defaults to local CLIProxy route - describe direct Moonshot API override for ANTHROPIC_BASE_URL --- docs/system-architecture/provider-flows.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 92f0ea64..534ccc1f 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -135,6 +135,8 @@ GLMT proxy enables seamless integration with GLM-compatible APIs (Z.AI, Kimi, Op | Kimi | `kimi` | https://api.moonshot.cn/v1/ | API key | | OpenRouter | `openrouter` | https://openrouter.ai/api/v1/ | API key | +Note for `config/base-kimi.settings.json`: the default base URL is `http://127.0.0.1:8317/api/provider/kimi` (local CLIProxy route). For direct Moonshot API access, override `ANTHROPIC_BASE_URL` to `https://api.moonshot.cn/v1/`. + ### GLMT Profile Detection CCS detects GLMT profiles and routes through `execClaudeWithProxy()`: From 37f453c0e3241ad317b04d0b8612490f46cc9a85 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Feb 2026 13:54:40 +0000 Subject: [PATCH 31/31] chore(release): 7.45.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 97e7817e..02465378 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.45.0-dev.5", + "version": "7.45.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",