chore: merge dev into openai provider routing

This commit is contained in:
Tam Nhu Tran committed 2026-04-15 20:36:03 -04:00
commit 1923d87a84
118 files changed
+4162 -3620

No files matched your search

+20 -20
View File
@@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter';
import type { ProfileType } from '../types/profile';
@@ -253,9 +254,8 @@ class ProfileDetector {
* Detect profile type and return routing information
*
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 0.75. Cursor profile (if enabled in config)
* 0. Hardcoded special runtime profiles (copilot, cursor)
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -267,16 +267,7 @@ class ProfileDetector {
return this.resolveDefaultProfile();
}
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
// Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api
if (profileName === 'copilot') {
const unifiedConfig = this.readUnifiedConfig();
const copilotConfig = unifiedConfig?.copilot;
@@ -306,17 +297,17 @@ class ProfileDetector {
};
}
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
if (profileName === 'cursor') {
// Priority 0.25: Check explicit legacy Cursor bridge profile.
if (profileName === LEGACY_CURSOR_PROFILE_NAME) {
const cursorConfig = getCursorConfig();
if (!cursorConfig?.enabled) {
const error = new Error(
'Cursor profile is not enabled.\n\n' +
'Legacy Cursor profile is not enabled.\n\n' +
'To enable Cursor integration:\n' +
' 1. Run: ccs cursor enable\n' +
' 2. Import auth: ccs cursor auth\n' +
' 3. Start daemon: ccs cursor start\n\n' +
' 1. Run: ccs legacy cursor enable\n' +
' 2. Import auth: ccs legacy cursor auth\n' +
' 3. Start daemon: ccs legacy cursor start\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' +
' cursor:\n' +
' enabled: true'
@@ -329,11 +320,20 @@ class ProfileDetector {
return {
type: 'cursor',
name: 'cursor',
name: LEGACY_CURSOR_PROFILE_NAME,
cursorConfig,
};
}
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
+53 -6
View File
@@ -65,7 +65,8 @@ import {
resolveOfficialChannelsLaunchPlan,
} from './channels/official-channels-runtime';
import { getOfficialChannelReadiness } from './channels/official-channels-store';
import { isCursorSubcommandToken } from './cursor/constants';
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants';
import { isCLIProxyProvider } from './cliproxy/provider-capabilities';
// Import centralized error handling
import { handleError, runCleanup } from './errors';
@@ -77,6 +78,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
import type { ProfileDetectionResult } from './auth/profile-detector';
// Import target adapter system
import {
@@ -152,6 +154,26 @@ function detectProfile(args: string[]): DetectedProfile {
}
}
function normalizeLegacyCursorArgs(args: string[]): string[] {
if (args[0] === 'legacy' && args[1] === 'cursor') {
return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)];
}
return args;
}
function printCursorLegacySubcommandDeprecation(subcommand: string): void {
console.error(
info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
);
console.error(
info(
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
)
);
console.error('');
}
function resolveRuntimeReasoningFlags(
args: string[],
envThinkingValue: string | undefined
@@ -346,7 +368,7 @@ async function main(): Promise<void> {
registerTarget(new CodexAdapter());
const cliLogger = createLogger('cli');
const args = process.argv.slice(2);
let args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete';
// Initialize UI colors early to ensure consistent colored output
@@ -422,6 +444,8 @@ async function main(): Promise<void> {
return;
}
args = normalizeLegacyCursorArgs(args);
cliLogger.info('command.start', 'CLI invocation started', {
command: args[0] || 'default',
argCount: args.length,
@@ -489,6 +513,17 @@ async function main(): Promise<void> {
return;
}
if (
typeof firstArg === 'string' &&
isCLIProxyProvider(firstArg) &&
args.length > 1 &&
(args.includes('--help') || args.includes('-h'))
) {
const { showProviderShortcutHelp } = await import('./commands/help-command');
await showProviderShortcutHelp(firstArg);
return;
}
// Special case: copilot command (GitHub Copilot integration)
// Route known subcommands to command handler, keep all other args as profile passthrough.
if (firstArg === 'copilot' && args.length > 1) {
@@ -502,9 +537,8 @@ async function main(): Promise<void> {
}
}
// Special case: cursor command (Cursor local proxy integration)
// Route known admin subcommands to the command handler, keep all other args as profile passthrough.
if (firstArg === 'cursor' && args.length > 1) {
// Special case: explicit legacy Cursor bridge namespace.
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
@@ -514,6 +548,19 @@ async function main(): Promise<void> {
}
}
// Compatibility shim: old `ccs cursor <subcommand>` still forwards to the legacy bridge
// for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy.
if (firstArg === 'cursor' && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') {
printCursorLegacySubcommandDeprecation(cursorToken);
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
}
}
// 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
@@ -543,7 +590,7 @@ async function main(): Promise<void> {
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo = detector.detectProfileType(profile);
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
resolvedTarget = resolveTargetType(
@@ -1,6 +1,9 @@
import type { CLIProxyProvider } from '../types';
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
const FREE_PLAN_PARTS = new Set(['free']);
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
const BUSINESS_PLAN_PARTS = new Set(['team']);
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
// separate because the browser bundle cannot import this server module directly.
@@ -49,6 +52,20 @@ function formatVariantPart(value: string): string {
}
}
function formatWorkspaceLabel(parts: string[]): string | null {
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
if (workspaceId) {
return `Workspace ${workspaceId.toLowerCase()}`;
}
return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null;
}
function formatAudienceDetail(parts: string[]): string | null {
const label = parts.map(formatVariantPart).filter(Boolean).join(' · ');
return label || null;
}
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
}
@@ -136,8 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
}
const suffix = parts[parts.length - 1]?.toLowerCase();
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · ');
}
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
}
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
.filter(Boolean)
.join(' · ');
}
+38
View File
@@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* - 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)
* - Cursor: Device-style browser polling (NO callback port needed)
* - GitLab: Authorization Code Flow with callback server on port 17171
* - CodeBuddy: Device-style browser polling (NO callback port needed)
* - Kilo: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
CLIPROXY_PROVIDER_IDS.reduce(
@@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: ['api'],
authFlag: '--kimi-login',
},
cursor: {
provider: 'cursor',
displayName: 'Cursor',
authUrl: 'https://cursor.com/loginDeepControl',
scopes: [],
authFlag: '--cursor-login',
},
gitlab: {
provider: 'gitlab',
displayName: 'GitLab Duo',
authUrl: 'https://gitlab.com/oauth/authorize',
scopes: ['api', 'read_user'],
authFlag: '--gitlab-login',
},
codebuddy: {
provider: 'codebuddy',
displayName: 'CodeBuddy (Tencent)',
authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state',
scopes: [],
authFlag: '--codebuddy-login',
},
kilo: {
provider: 'kilo',
displayName: 'Kilo AI',
authUrl: 'https://api.kilo.ai/api/device-auth/codes',
scopes: [],
authFlag: '--kilo-login',
},
};
/**
@@ -373,6 +405,12 @@ export interface OAuthOptions {
noIncognito?: boolean;
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
import?: boolean;
/** GitLab auth mode override. */
gitlabAuthMode?: 'oauth' | 'pat';
/** GitLab self-hosted base URL override. */
gitlabBaseUrl?: string;
/** GitLab personal access token for PAT login. */
gitlabPersonalAccessToken?: string;
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
pasteCallback?: boolean;
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
+90
View File
@@ -0,0 +1,90 @@
const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500;
const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]';
function sanitizeGitLabPatErrorDetail(
detail: string | undefined,
submittedToken?: string
): string | undefined {
const trimmed = detail?.trim();
if (!trimmed) {
return undefined;
}
if (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.test(trimmed)) {
return HTML_ERROR_RESPONSE_OMITTED;
}
let sanitized = trimmed.replace(
/"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"'
);
if (submittedToken) {
sanitized = sanitized.split(submittedToken).join('[redacted]');
}
sanitized = sanitized
.replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]')
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
.replace(/\s+/g, ' ');
if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) {
sanitized = `${sanitized.slice(
0,
GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length
)}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
}
return sanitized;
}
export function parseGitLabPatAuthResponse(
responseOk: boolean,
responseStatus: number,
responseBody: string,
submittedToken?: string
):
| { ok: true; payload: Record<string, unknown> }
| { ok: false; payload: Record<string, unknown>; errorMessage: string } {
const trimmedBody = responseBody.trim();
let payload: Record<string, unknown> = {};
if (trimmedBody) {
try {
payload = JSON.parse(trimmedBody) as Record<string, unknown>;
} catch {
payload = {
error:
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
`GitLab PAT login failed with status ${responseStatus}`,
};
}
}
const payloadError =
typeof payload.error === 'string'
? sanitizeGitLabPatErrorDetail(payload.error, submittedToken)
: undefined;
const fallbackError =
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
`GitLab PAT login failed with status ${responseStatus}`;
if (!responseOk) {
return {
ok: false,
payload,
errorMessage: payloadError || fallbackError,
};
}
if (payload.status !== 'ok') {
return {
ok: false,
payload,
errorMessage: payloadError || fallbackError,
};
}
return { ok: true, payload };
}
+183 -4
View File
@@ -56,6 +56,7 @@ import {
} from './token-manager';
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
import {
getProxyTarget,
buildProxyUrl,
@@ -69,6 +70,7 @@ import {
warnPossible403Ban,
} from '../account-safety';
import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility';
import { InteractivePrompt } from '../../utils/prompt';
interface PasteCallbackStartData {
url?: string;
@@ -83,9 +85,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
export async function requestPasteCallbackStart(
provider: CLIProxyProvider,
target: ProxyTarget,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<PasteCallbackStartData> {
const startPath = getPasteCallbackStartPath(provider, {
let startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
@@ -93,6 +98,11 @@ export async function requestPasteCallbackStart(
`Paste-callback start is not available for ${provider} with the selected method`
);
}
const normalizedGitLabBaseUrl =
provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined;
if (normalizedGitLabBaseUrl) {
startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`;
}
const response = await fetch(buildProxyUrl(target, startPath), {
headers: buildManagementHeaders(target),
});
@@ -142,6 +152,49 @@ function parseAuthUrlState(url: string | null | undefined): string | null {
}
}
export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('GitLab URL must be a valid http:// or https:// URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('GitLab URL must use http:// or https://');
}
parsed.hash = '';
parsed.search = '';
parsed.username = '';
parsed.password = '';
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin;
}
export async function promptGitLabPersonalAccessToken(): Promise<string | null> {
try {
const token = (await InteractivePrompt.password('GitLab Personal Access Token')).trim();
return token.length > 0 ? token : null;
} catch (error) {
if ((error as Error).message.includes('TTY')) {
console.log(
fail(
'GitLab Personal Access Token prompt requires an interactive TTY. Set the token explicitly or use Browser OAuth.'
)
);
return null;
}
throw error;
}
}
export function findNewTokenSnapshotForManualAuth(
provider: CLIProxyProvider,
tokenDir: string,
@@ -458,7 +511,10 @@ async function handlePasteCallbackMode(
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -647,6 +703,91 @@ async function handlePasteCallbackMode(
}
}
async function handleGitLabPatLogin(
provider: CLIProxyProvider,
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: {
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken'];
}
): Promise<AccountInfo | null> {
const target = getProxyTarget();
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
const personalAccessToken =
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
let token = personalAccessToken;
if (!token) {
console.log('');
console.log(info(`Starting ${oauthConfig.displayName} PAT login...`));
console.log('Paste a Personal Access Token with api and read_user scopes.');
token = (await promptGitLabPersonalAccessToken()) || undefined;
}
if (!token) {
console.log(info('Cancelled'));
return null;
}
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(baseUrl ? { base_url: baseUrl } : {}),
personal_access_token: token,
}),
});
const responseBody = await response.text();
const parsedResponse = parseGitLabPatAuthResponse(
response.ok,
response.status,
responseBody,
token
);
if (!parsedResponse.ok) {
console.log(fail(parsedResponse.errorMessage));
return null;
}
const tokenSnapshot = findNewTokenSnapshotForAuthAttempt(
provider,
tokenDir,
knownTokenFiles,
expectedAccountId
);
if (!tokenSnapshot) {
console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.'));
return null;
}
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId || tokenSnapshot.file
);
if (!account) {
console.log(fail('Authenticated GitLab token could not be registered as a CCS account.'));
return null;
}
console.log(ok('Authentication successful!'));
return account;
}
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
@@ -666,6 +807,17 @@ export async function triggerOAuth(
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
const resolvedKiroIDCFlow =
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
const resolvedGitLabAuthMode =
provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth';
let resolvedGitLabBaseUrl: string | undefined;
if (provider === 'gitlab') {
try {
resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl);
} catch (error) {
console.log(fail((error as Error).message));
return null;
}
}
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
@@ -744,6 +896,14 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) {
selectedPasteCallback = true;
console.log('');
console.log(
info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.')
);
}
const useSelectedKiroLocalPasteCallback =
selectedPasteCallback &&
provider === 'kiro' &&
@@ -765,6 +925,22 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') {
const tokenDir = getProviderTokenDir(provider);
return handleGitLabPatLogin(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
{
gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
}
);
}
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
@@ -774,7 +950,10 @@ export async function triggerOAuth(
tokenDir,
nickname,
existingNameMatch?.id,
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
}
);
}
+73 -4
View File
@@ -19,7 +19,10 @@ import {
normalizeProtocol,
CLIPROXY_DEFAULT_PORT,
} from './port-manager';
import { getProviderSettingsPath } from './path-resolver';
import {
getLegacyProviderSettingsPath,
migrateLegacyProviderSettingsIfNeeded,
} from './path-resolver';
import {
canonicalizeModelIdForProvider,
MODEL_ENV_VAR_KEYS,
@@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
]);
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
@@ -285,6 +297,63 @@ export function getClaudeEnvVars(
return normalizeModelEnvVarsForProvider(mergedEnv, provider);
}
function buildCursorProviderSettingsFromLegacy(
legacySettings: Record<string, unknown>
): Record<string, unknown> {
const defaultEnv = getClaudeEnvVars('cursor');
const legacyEnvSource = legacySettings.env;
const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {};
const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv };
for (const [key, value] of Object.entries(legacyEnv)) {
if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) {
continue;
}
migratedEnv[key] = value;
}
delete migratedEnv.ANTHROPIC_API_KEY;
return {
...legacySettings,
env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'),
};
}
/**
* Resolve the provider settings path, migrating legacy Cursor provider settings into
* the dedicated cliproxy/providers namespace on first access.
*/
export function resolveProviderSettingsPath(provider: CLIProxyProvider): string {
const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
if (provider !== 'cursor' || fs.existsSync(settingsPath)) {
return settingsPath;
}
const legacySettingsPath = getLegacyProviderSettingsPath(provider);
if (!fs.existsSync(legacySettingsPath)) {
return settingsPath;
}
try {
const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown;
if (!isObjectRecord(parsed)) {
return settingsPath;
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n',
{ mode: 0o600 }
);
} catch {
// Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid.
}
return settingsPath;
}
/**
* Get global env vars to inject into all third-party profiles.
* Returns empty object if disabled.
@@ -465,7 +534,7 @@ export function getEffectiveEnvVars(
}
// Priority 2: Default provider settings file
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
// Check for user override file
if (fs.existsSync(settingsPath)) {
@@ -505,7 +574,7 @@ export function getEffectiveEnvVars(
* Called during installation/first run
*/
export function ensureProviderSettings(provider: CLIProxyProvider): void {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const defaultEnv = getClaudeEnvVars(provider);
const writeSettings = (settings: Record<string, unknown>): void => {
@@ -663,7 +732,7 @@ export function getRemoteEnvVars(
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
if (Object.keys(userEnvVars).length === 0) {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf-8');
+42
View File
@@ -16,6 +16,13 @@ export function getCliproxyDir(): string {
return path.join(getCcsDir(), 'cliproxy');
}
/**
* Get CLIProxy provider settings directory.
*/
export function getCliproxyProvidersDir(): string {
return path.join(getCliproxyDir(), 'providers');
}
/**
* Get CLIProxy writable directory for logs and runtime files.
* This directory is set as WRITABLE_PATH env var when spawning CLIProxy.
@@ -75,5 +82,40 @@ export function getBinDir(): string {
* Example: ~/.ccs/gemini.settings.json
*/
export function getProviderSettingsPath(provider: CLIProxyProvider): string {
if (provider === 'cursor') {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
return getLegacyProviderSettingsPath(provider);
}
/**
* Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace.
* Used only for providers that must not collide with legacy top-level settings files.
*/
export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
}
/**
* Get legacy provider settings file path in ~/.ccs root.
* This is kept for compatibility reads/migration of older provider settings.
*/
export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string {
return path.join(getCcsDir(), `${provider}.settings.json`);
}
/**
* Resolve the effective provider settings path.
*
* Cursor uses a dedicated cliproxy/providers namespace so it does not collide
* with the deprecated Cursor IDE bridge raw settings file.
*/
export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string {
if (provider !== 'cursor') {
return getProviderSettingsPath(provider);
}
const targetPath = getDedicatedProviderSettingsPath(provider);
return targetPath;
}
+32
View File
@@ -152,6 +152,14 @@ export function readOptionValue(
return { present: true, value: next.trim(), missingValue: false };
}
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
return args.includes('--gitlab-token-login') || args.includes('--token-login');
}
function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' {
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
}
/**
* Execute Claude CLI with CLIProxy (main entry point)
*
@@ -353,6 +361,7 @@ export async function execClaudeWithCLIProxy(
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
const forceImport = argsWithoutProxy.includes('--import');
const gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy);
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
const incognitoFlag = argsWithoutProxy.includes('--incognito');
@@ -444,6 +453,16 @@ export async function execClaudeWithCLIProxy(
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
let gitlabBaseUrl: string | undefined;
const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url');
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value'));
process.exitCode = 1;
return;
}
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
@@ -491,6 +510,15 @@ export async function execClaudeWithCLIProxy(
return;
}
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin
? getGitLabTokenLoginFlagName(argsWithoutProxy)
: '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1;
return;
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
if (thinkingParse.error) {
@@ -730,6 +758,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -775,6 +805,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
+5 -5
View File
@@ -9,7 +9,7 @@ import * as fs from 'fs';
import * as os from 'os';
import { InteractivePrompt } from '../utils/prompt';
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator';
import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
@@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string)
* Check if provider has user settings configured
*/
export function hasUserSettings(provider: CLIProxyProvider): boolean {
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
return fs.existsSync(settingsPath);
}
@@ -46,7 +46,7 @@ export function getCurrentModel(
): string | undefined {
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
: resolveProviderSettingsPath(provider);
if (!fs.existsSync(settingsPath)) return undefined;
try {
@@ -116,7 +116,7 @@ export async function configureProviderModel(
// Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = customSettingsPath
? customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
: resolveProviderSettingsPath(provider);
// Skip if already configured with a model (unless --config flag).
// A settings file can exist without model env keys (e.g., hook-only writes).
@@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
await initUI();
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
const settingsPath = resolveProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
+48
View File
@@ -134,6 +134,54 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
},
cursor: {
displayName: 'Cursor',
description: 'Cursor browser-authenticated provider',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'cursor',
authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'],
aliases: [],
},
gitlab: {
displayName: 'GitLab Duo',
description: 'GitLab Duo with OAuth or PAT auth',
oauthFlow: 'authorization_code',
callbackPort: 17171,
callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'],
},
codebuddy: {
displayName: 'CodeBuddy (Tencent)',
description: 'Tencent CodeBuddy AI assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'],
aliases: ['tencent'],
},
kilo: {
displayName: 'Kilo AI',
description: 'Kilo AI coding assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kilo',
authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported',
authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'],
aliases: [],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
@@ -1,7 +1,7 @@
/**
* Claude Quota Response Normalization Helpers
*
* Parses Anthropic policy limits payload into normalized windows and core usage summary.
* Parses Anthropic policy-limits or OAuth-usage payloads into normalized windows and core usage summary.
*/
import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
@@ -74,7 +74,14 @@ function getClaudeWindowLabel(rateLimitType: string): string {
case 'overage':
return 'Extra usage';
default:
return rateLimitType || 'Unknown limit';
if (!rateLimitType) return 'Unknown limit';
return rateLimitType
.split(/[_-]+/g)
.filter((part) => part.length > 0)
.map((part) =>
/^\d+$/.test(part) ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`
)
.join(' ');
}
}
@@ -185,8 +192,17 @@ function normalizeRestriction(
/**
* Parse raw policy limits response into normalized windows.
* Supports both array and object-map `restrictions` shapes.
* Supports both policy-limits `restrictions` payloads and OAuth usage payloads
* keyed by window name (`five_hour`, `seven_day`, `seven_day_sonnet`, ...).
*/
function isClaudeOAuthUsageWindowCandidate(key: string, raw: Record<string, unknown>): boolean {
if (key === 'extra_usage') return false;
if (asNumber(raw['utilization']) === null) return false;
const resetAt = raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at'] ?? null;
return resetAt !== null && resetAt !== undefined;
}
export function buildClaudeQuotaWindows(payload: Record<string, unknown>): ClaudeQuotaWindow[] {
const rawRestrictions = payload['restrictions'];
const windows: ClaudeQuotaWindow[] = [];
@@ -206,9 +222,19 @@ export function buildClaudeQuotaWindows(payload: Record<string, unknown>): Claud
if (window) windows.push(window);
}
} else if (toObject(payload)) {
for (const [key, value] of Object.entries(payload)) {
const raw = toObject(value);
if (!raw) continue;
if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue;
const window = normalizeRestriction(raw, key);
if (window) windows.push(window);
}
// Some responses may contain a single restriction object directly.
const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
if (windows.length === 0) {
const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
}
}
const seen = new Set<string>();
+21 -35
View File
@@ -1,7 +1,7 @@
/**
* Quota Fetcher for Claude (Anthropic) Accounts
*
* Fetches policy limits from Claude API and normalizes 5h + weekly windows.
* Fetches OAuth usage windows from Claude API and normalizes 5h + weekly windows.
*/
import * as path from 'node:path';
@@ -17,11 +17,10 @@ import {
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported';
const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
interface ClaudeAuthData {
accessToken: string;
@@ -193,16 +192,6 @@ function buildEmptyResult(
};
}
function buildPolicyUnavailableResult(accountId: string): ClaudeQuotaResult {
return {
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
lastUpdated: Date.now(),
accountId,
};
}
/**
* Fetch quota for a single Claude account.
*/
@@ -230,46 +219,43 @@ export async function fetchClaudeQuota(
const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS);
try {
const response = await fetch(CLAUDE_POLICY_LIMITS_URL, {
const response = await fetch(CLAUDE_OAUTH_USAGE_URL, {
method: 'GET',
signal: controller.signal,
headers: {
Authorization: `Bearer ${authData.accessToken}`,
Accept: 'application/json',
'User-Agent': CLAUDE_USER_AGENT,
'Content-Type': 'application/json',
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
},
});
clearTimeout(timeoutId);
if (verbose) {
console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`);
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
}
if (response.status === 401) {
const errorMessage = await readResponseErrorMessage(response);
if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) {
if (verbose) {
console.error(
'[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable'
);
}
return buildPolicyUnavailableResult(accountId);
}
return buildEmptyResult('Authentication required for policy limits', accountId, true);
return buildEmptyResult(
errorMessage || 'Authentication required for Claude OAuth usage',
accountId,
true
);
}
if (response.status === 404) {
// Some accounts may not expose policy limits; treat as unavailable but successful.
return buildPolicyUnavailableResult(accountId);
return buildEmptyResult('Claude OAuth usage endpoint not found', accountId);
}
if (response.status === 403) {
return buildEmptyResult('Not authorized for policy limits', accountId);
return buildEmptyResult('Not authorized for Claude OAuth usage', accountId);
}
if (!response.ok) {
lastError = `Policy limits API error: ${response.status}`;
lastError =
(await readResponseErrorMessage(response)) ||
`Claude OAuth usage API error: ${response.status}`;
if (
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500)
@@ -283,11 +269,11 @@ export async function fetchClaudeQuota(
try {
payload = await response.json();
} catch {
return buildEmptyResult('Invalid policy limits format', accountId);
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
}
if (!toObject(payload)) {
return buildEmptyResult('Invalid policy limits format', accountId);
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
}
const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>);
@@ -304,7 +290,7 @@ export async function fetchClaudeQuota(
clearTimeout(timeoutId);
lastError =
error instanceof Error && error.name === 'AbortError'
? 'Policy limits request timeout'
? 'Claude OAuth usage request timeout'
: error instanceof Error
? error.message
: 'Unknown error';
@@ -313,7 +299,7 @@ export async function fetchClaudeQuota(
const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error(
`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
);
}
+2 -1
View File
@@ -624,11 +624,12 @@ export async function fetchCodexQuota(
// Extract plan type
const planTypeRaw = data.plan_type || data.planType;
let planType: 'free' | 'plus' | 'team' | null = null;
let planType: 'free' | 'plus' | 'pro' | 'team' | null = null;
if (planTypeRaw) {
const normalized = planTypeRaw.toLowerCase();
if (normalized === 'free') planType = 'free';
else if (normalized === 'plus') planType = 'plus';
else if (normalized === 'pro') planType = 'pro';
else if (normalized === 'team') planType = 'team';
}
+2 -2
View File
@@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata {
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;
/** Plan type: free, plus, pro, team, or null if unknown */
planType: 'free' | 'plus' | 'pro' | 'team' | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
+19 -4
View File
@@ -122,6 +122,10 @@ export interface DownloadResult {
* - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus)
* - claude: Claude (Anthropic) via OAuth
* - kimi: Kimi (Moonshot AI) via Device Code OAuth
* - cursor: Cursor via PKCE browser polling
* - gitlab: GitLab Duo via OAuth or PAT
* - codebuddy: Tencent CodeBuddy via browser polling
* - kilo: Kilo AI via device flow
*/
export type CLIProxyProvider =
| 'gemini'
@@ -132,12 +136,16 @@ export type CLIProxyProvider =
| 'kiro'
| 'ghcp'
| 'claude'
| 'kimi';
| 'kimi'
| 'cursor'
| 'gitlab'
| 'codebuddy'
| 'kilo';
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (no Kiro/ghcp support)
* - plus: CLIProxyAPIPlus (Kiro/ghcp support, default)
* - original: CLIProxyAPI (legacy provider subset)
* - plus: CLIProxyAPIPlus (expanded provider support, default)
*/
export type CLIProxyBackend = 'original' | 'plus';
@@ -149,7 +157,14 @@ export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
/**
* Providers that require CLIProxyAPIPlus backend
*/
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp'];
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
'kiro',
'ghcp',
'cursor',
'gitlab',
'codebuddy',
'kilo',
];
/**
* CLIProxy config.yaml structure (minimal)
+13 -3
View File
@@ -1,5 +1,4 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
@@ -105,7 +104,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
},
{
name: 'cursor',
summary: 'Run or manage the Cursor bridge',
summary: 'Run Cursor via CLIProxy or manage Cursor provider auth',
group: 'runtime',
visibility: 'public',
},
@@ -189,6 +188,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
ghcp: 'GitHub Copilot via CLIProxy OAuth',
claude: 'Claude via CLIProxy OAuth',
kimi: 'Kimi via CLIProxy OAuth',
cursor: 'Cursor via CLIProxy OAuth',
gitlab: 'GitLab Duo via CLIProxy OAuth',
codebuddy: 'CodeBuddy via CLIProxy OAuth',
kilo: 'Kilo AI via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
@@ -311,7 +314,14 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
update: ['--force', '--beta', '--dev', '--help', '-h'],
};
export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const;
export const CURSOR_COMPLETION_SUBCOMMANDS = [
'--auth',
'--accounts',
'--config',
'--logout',
'--help',
'-h',
] as const;
export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const;
export function getPublicRootCommands(): readonly RootCommandEntry[] {
+46 -30
View File
@@ -4,6 +4,9 @@ import type { CursorConfig } from '../config/unified-config-types';
import { getCcsDirDisplay } from '../utils/config-manager';
import { color } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLines(lines: string[]): void {
for (const line of lines) {
console.log(line);
@@ -12,37 +15,46 @@ function printLines(lines: string[]): void {
export function renderCursorHelp(): number {
printLines([
'Cursor IDE Integration',
'Legacy Cursor Compatibility',
'',
'Usage: ccs cursor <subcommand>',
'Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.',
`Supported auth path: ${CLIPROXY_CURSOR_COMMAND} --auth`,
'Supported dashboard path: ccs config -> CLIProxy -> Cursor',
'',
'Subcommands:',
' auth Import Cursor IDE authentication token',
' status Show integration, authentication, and daemon status',
`Usage: ${LEGACY_CURSOR_COMMAND} <subcommand>`,
'',
'Subcommands (deprecated compatibility for the local reverse-engineered bridge):',
' auth Import Cursor IDE authentication token (deprecated)',
' status Show legacy integration, authentication, and daemon status',
' probe Run a live authenticated runtime probe',
' models List available models',
' start Start cursor daemon',
' stop Stop cursor daemon',
' enable Enable cursor integration in unified config',
' disable Disable cursor integration in unified config',
' start Start local cursor daemon',
' stop Stop local cursor daemon',
' enable Enable legacy cursor integration in unified config',
' disable Disable legacy cursor integration in unified config',
' help Show this help message',
'',
'Runtime entry:',
' ccs cursor [claude args] # Run Claude via the local Cursor proxy',
'Supported CLIProxy path:',
` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`,
` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`,
` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`,
'',
'Auth options:',
' ccs cursor auth # Auto-detect from Cursor SQLite',
' ccs cursor auth --manual --token <t> --machine-id <id>',
'Legacy runtime entry (deprecated compatibility):',
` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`,
'',
'Quick start:',
' 1. ccs cursor enable # Enable integration',
' 2. ccs cursor auth # Import Cursor IDE token',
' 3. ccs cursor start # Start daemon',
' 4. ccs cursor probe # Verify live runtime health',
' 5. ccs cursor "task" # Run Claude through Cursor',
' 6. ccs cursor status # Inspect auth/daemon wiring',
'Legacy auth options:',
` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`,
` ${LEGACY_CURSOR_COMMAND} auth --manual --token <t> --machine-id <id>`,
'',
'Or use the web UI: ccs config -> Cursor page',
'Legacy bridge quick start:',
` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`,
` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`,
` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`,
` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`,
` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`,
` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`,
'',
'Web UI: ccs config -> Deprecated -> Cursor IDE',
'',
]);
@@ -100,10 +112,13 @@ export function renderCursorStatus(
console.log('');
console.log('Client setup:');
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
console.log(' Runtime entry: ccs cursor [claude args]');
console.log(' Live probe: ccs cursor probe');
console.log(' Status command: ccs cursor status');
console.log(' Help command: ccs cursor help');
console.log(
` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)`
);
console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`);
console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`);
console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`);
if (isReady) {
return;
@@ -113,15 +128,16 @@ export function renderCursorStatus(
console.log('Next steps:');
if (!cursorConfig.enabled) {
console.log(' - Enable: ccs cursor enable');
console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`);
}
if (!authStatus.authenticated || authStatus.expired) {
console.log(' - Auth: ccs cursor auth');
console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`);
}
if (!daemonStatus.running) {
console.log(' - Start: ccs cursor start');
console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`);
}
console.log(' - Help: ccs cursor help');
console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`);
}
export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
+36 -13
View File
@@ -26,6 +26,18 @@ import {
} from './cursor-command-display';
import { ok, fail, info } from '../utils/ui';
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
function printLegacyCursorDeprecationNotice(): void {
console.log(
info(
`Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.`
)
);
console.log('');
}
/**
* Handle cursor subcommand.
*/
@@ -114,6 +126,7 @@ function printAutoDetectFailure(result: {
* Handle auth subcommand.
*/
async function handleAuth(args: string[]): Promise<number> {
printLegacyCursorDeprecationNotice();
const manual = args.includes('--manual');
if (manual) {
@@ -125,7 +138,7 @@ async function handleAuth(args: string[]): Promise<number> {
if (!accessToken || !machineId) {
console.error(
fail(
'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token <token> --machine-id <machine-id>'
`Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`
)
);
return 1;
@@ -146,8 +159,9 @@ async function handleAuth(args: string[]): Promise<number> {
console.log(ok('Cursor credentials imported (manual mode)'));
console.log('');
console.log('Next steps:');
console.log(' 1. Enable integration: ccs cursor enable');
console.log(' 2. Start daemon: ccs cursor start');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
return 0;
}
@@ -168,9 +182,10 @@ async function handleAuth(args: string[]): Promise<number> {
console.log(ok('Auto-detected Cursor credentials'));
console.log('');
console.log('Next steps:');
console.log(' 1. Enable integration: ccs cursor enable');
console.log(' 2. Start daemon: ccs cursor start');
console.log(' 3. Check status: ccs cursor status');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0;
}
@@ -178,13 +193,14 @@ async function handleAuth(args: string[]): Promise<number> {
printAutoDetectFailure(autoResult);
console.log('');
console.log('Manual fallback:');
console.log(' ccs cursor auth --manual --token <token> --machine-id <machine-id>');
console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`);
console.log('');
return 1;
}
async function handleStatus(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const authStatus = checkAuthStatus();
const daemonStatus = await getDaemonStatus(cursorConfig.port);
@@ -193,6 +209,7 @@ async function handleStatus(): Promise<number> {
}
async function handleProbe(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const result = await probeCursorRuntime(cursorConfig);
renderCursorProbe(result);
@@ -200,6 +217,7 @@ async function handleProbe(): Promise<number> {
}
async function handleModels(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
const models = await getAvailableModels(cursorConfig.port);
const defaultModel = getDefaultModel();
@@ -211,20 +229,21 @@ async function handleModels(): Promise<number> {
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
printLegacyCursorDeprecationNotice();
const cursorConfig = getCursorConfig();
if (!cursorConfig.enabled) {
console.error(fail('Cursor integration is disabled. Run: ccs cursor enable'));
console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`));
return 1;
}
const authStatus = checkAuthStatus();
if (!authStatus.authenticated) {
console.error(fail('Not authenticated. Run: ccs cursor auth'));
console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1;
}
if (authStatus.expired) {
console.error(fail('Credentials expired. Run: ccs cursor auth'));
console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`));
return 1;
}
@@ -248,6 +267,7 @@ async function handleStart(): Promise<number> {
* Handle stop subcommand.
*/
async function handleStop(): Promise<number> {
printLegacyCursorDeprecationNotice();
console.log(info('Stopping cursor daemon...'));
const result = await stopDaemon();
@@ -265,6 +285,7 @@ async function handleStop(): Promise<number> {
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
if (!config.cursor) {
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
@@ -276,9 +297,10 @@ async function handleEnable(): Promise<number> {
console.log(ok('Cursor integration enabled'));
console.log('');
console.log('Next steps:');
console.log(' 1. Authenticate: ccs cursor auth');
console.log(' 2. Start daemon: ccs cursor start');
console.log(' 3. Check status: ccs cursor status');
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`);
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
return 0;
}
@@ -287,6 +309,7 @@ async function handleEnable(): Promise<number> {
* Handle disable subcommand.
*/
async function handleDisable(): Promise<number> {
printLegacyCursorDeprecationNotice();
mutateUnifiedConfig((config) => {
if (config.cursor) {
config.cursor.enabled = false;
+72 -2
View File
@@ -1,4 +1,5 @@
import packageJson from '../../package.json';
import type { CLIProxyProvider } from '../cliproxy';
import { color, dim, header, initUI, subheader } from '../utils/ui';
import {
BUILTIN_PROVIDER_SHORTCUTS,
@@ -82,10 +83,80 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
],
writeLine
);
writeCommandTable(
'GitLab Duo Flags',
[
{
name: 'ccs gitlab --auth --gitlab-token-login',
summary: 'Authenticate with a GitLab Personal Access Token',
},
{
name: 'ccs gitlab --auth --token-login',
summary: 'Legacy alias for GitLab PAT login (still supported)',
},
{
name: 'ccs gitlab --auth --gitlab-url <url>',
summary: 'Use a self-hosted GitLab base URL during OAuth or PAT auth',
},
],
writeLine
);
writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`);
writeLine('');
}
export async function showProviderShortcutHelp(
provider: CLIProxyProvider,
writeLine: HelpWriter = console.log
): Promise<void> {
if (provider === 'kiro') {
await showKiroHelp(writeLine);
return;
}
await initUI();
const providerEntry = BUILTIN_PROVIDER_SHORTCUTS.find((entry) => entry.name === provider);
writeLine(header(`CCS ${provider} Shortcut Help`));
writeLine('');
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
writeLine('');
writeCommandTable(
'Common Commands',
[
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
],
writeLine
);
if (provider === 'gitlab') {
writeCommandTable(
'GitLab Duo Flags',
[
{
name: '--gitlab-token-login',
summary: 'Use a GitLab Personal Access Token instead of browser OAuth',
},
{
name: '--token-login',
summary: 'Legacy alias for `--gitlab-token-login`',
},
{
name: '--gitlab-url <url>',
summary: 'Target a self-hosted GitLab base URL',
},
],
writeLine
);
}
writeLine(` ${dim('See also: ccs help providers | ccs cliproxy --help')}`);
writeLine('');
}
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Kiro Help'));
@@ -274,8 +345,7 @@ export async function handleHelpRoute(
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
copilot: async () =>
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
cursor: async () =>
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
cursor: async () => await showProviderShortcutHelp('cursor', writeLine),
proxy: async () =>
process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])),
docker: async () => (await import('./docker/help-subcommand')).showHelp(),
+9
View File
@@ -9,10 +9,19 @@ export const RESERVED_PROFILE_NAMES = [
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
'claude',
'kimi',
'gitlab',
'codebuddy',
'kilo',
// Copilot API (GitHub Copilot proxy)
'copilot',
// Cursor IDE (Cursor proxy daemon)
'cursor',
'legacy-cursor',
'legacy',
// CLI commands and special names
'default',
'config',
+29
View File
@@ -1,3 +1,5 @@
export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor';
export const CURSOR_SUBCOMMANDS = [
'auth',
'status',
@@ -12,8 +14,35 @@ export const CURSOR_SUBCOMMANDS = [
'-h',
] as const;
export const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set([
'--auth',
'--logout',
'--config',
'--accounts',
]);
export function isCursorSubcommandToken(token?: string): boolean {
return (
Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number])
);
}
export function shouldUseCursorCliproxyShortcut(args: string[]): boolean {
if (args[0] !== 'cursor') {
return false;
}
for (const token of args.slice(1)) {
if (token === '--') {
break;
}
if (CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(token)) {
return true;
}
if (!token.startsWith('-')) {
return false;
}
}
return false;
}
+2 -2
View File
@@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const authStatus = checkAuthStatus();
if (!authStatus.authenticated || !authStatus.credentials) {
const message = 'Cursor credentials not found. Run `ccs cursor auth` first.';
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
@@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
}
if (authStatus.expired) {
const message = 'Cursor credentials expired. Run `ccs cursor auth` again.';
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
+4 -4
View File
@@ -97,7 +97,7 @@ export async function executeCursorProfile(
if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.'));
console.error('');
console.error('Enable it first: ccs cursor enable');
console.error('Enable it first: ccs legacy cursor enable');
return 1;
}
@@ -105,13 +105,13 @@ export async function executeCursorProfile(
if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.'));
console.error('');
console.error('Authenticate first: ccs cursor auth');
console.error('Authenticate first: ccs legacy cursor auth');
return 1;
}
if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.'));
console.error('');
console.error('Refresh them with: ccs cursor auth');
console.error('Refresh them with: ccs legacy cursor auth');
return 1;
}
@@ -133,7 +133,7 @@ export async function executeCursorProfile(
console.error(fail('Cursor daemon is not running.'));
console.error('');
console.error('Start the daemon:');
console.error(' ccs cursor start');
console.error(' ccs legacy cursor start');
console.error('Or enable auto_start in the Cursor config section.');
return 1;
}
+3 -3
View File
@@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth',
status: 401,
duration_ms: Date.now() - startedAt,
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
message: 'Cursor credentials not found. Run `ccs legacy cursor auth` first.',
error_type: 'authentication_error',
};
}
@@ -131,7 +131,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
stage: 'auth',
status: 401,
duration_ms: Date.now() - startedAt,
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
message: 'Cursor credentials expired. Run `ccs legacy cursor auth` again.',
error_type: 'authentication_error',
};
}
@@ -168,7 +168,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
status: 503,
duration_ms: Date.now() - startedAt,
message:
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.',
'Cursor daemon is not running. Start it with `ccs legacy cursor start` or enable auto_start.',
error_type: 'daemon_not_running',
};
}
+28 -13
View File
@@ -936,6 +936,10 @@ class SharedManager {
}
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!this.isMarketplaceRegistryEntry(value)) {
continue;
}
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
}
} catch (err) {
@@ -947,22 +951,20 @@ class SharedManager {
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
for (const [name, value] of Object.entries(discoveredEntries)) {
const existing = merged[name];
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
merged[name] = {
...(existing as Record<string, unknown>),
installLocation: value.installLocation,
};
continue;
}
merged[name] = value;
}
// Keep only registry entries that have a physical directory, and update their
// installLocation. Entries only on disk (no registry record) are excluded —
// they lack required schema fields that Claude Code enforces.
for (const name of Object.keys(merged)) {
const entry = merged[name];
if (!(name in discoveredEntries)) {
delete merged[name];
} else if (this.isMarketplaceRegistryEntry(entry)) {
merged[name] = {
...entry,
installLocation: discoveredEntries[name].installLocation,
};
} else {
delete merged[name];
}
}
@@ -984,6 +986,11 @@ class SharedManager {
continue;
}
// Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak).
if (this.isTransientMarketplaceDirectory(entry.name)) {
continue;
}
discovered[entry.name] = {
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
};
@@ -992,6 +999,14 @@ class SharedManager {
return discovered;
}
private isTransientMarketplaceDirectory(name: string): boolean {
return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak');
}
private isMarketplaceRegistryEntry(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
private writePluginMetadataFile(
registryPath: string,
content: string,
+1 -1
View File
@@ -121,7 +121,7 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
'default',
...all.accounts,
...all.settings,
...all.cliproxy,
...all.cliproxy.filter((profileName) => profileName !== 'cursor'),
...all.cliproxyVariants,
];
const deduped = [...new Set(orderedNames)];
+18 -9
View File
@@ -10,17 +10,26 @@ const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version'];
function buildWindowsCodexCandidates(matches: string[]): string[] {
const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry));
const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry));
const prioritized: string[] = [];
for (const entry of shellCandidates) {
if (/\.(cmd|bat)$/i.test(entry)) {
prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1'));
const prioritized = shellCandidates.map((entry) => {
if (!/\.ps1$/i.test(entry)) {
return entry;
}
prioritized.push(entry);
}
prioritized.push(...bareCandidates);
return [...new Set(prioritized)];
for (const preferredExtension of ['.cmd', '.bat', '.exe']) {
const siblingCandidate = entry.replace(/\.ps1$/i, preferredExtension);
try {
if (fs.statSync(siblingCandidate).isFile()) {
return siblingCandidate;
}
} catch {
// Ignore missing sibling wrappers and keep the original PowerShell path.
}
}
return entry;
});
return [...new Set([...prioritized, ...bareCandidates])];
}
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
@@ -396,7 +396,7 @@ function resolveBackend(
};
}
if (profileType === 'cursor' || profileName === 'cursor') {
if (profileType === 'cursor') {
return {
backendId: null,
backendDisplayName: null,
+161 -1
View File
@@ -33,6 +33,7 @@ import {
} from '../../cliproxy/proxy-target-resolver';
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
import { invalidateQuotaCache } from '../../cliproxy/quota-response-cache';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import {
@@ -42,6 +43,7 @@ import {
listProviderTokenSnapshots,
registerAccountFromToken,
} from '../../cliproxy/auth/token-manager';
import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -190,6 +192,13 @@ function shouldKeepWaitingForLocalToken(
);
}
function invalidateQuotaForRegisteredAccount(account: {
provider: CLIProxyProvider;
id: string;
}): void {
invalidateQuotaCache(account.provider, account.id);
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -221,6 +230,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean }
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
}
function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } {
if (raw === undefined || raw === null || raw === '') {
return { mode: 'oauth', invalid: false };
}
if (typeof raw !== 'string') {
return { mode: 'oauth', invalid: true };
}
const normalized = raw.trim().toLowerCase();
if (normalized === 'oauth' || normalized === 'pat') {
return { mode: normalized, invalid: false };
}
return { mode: 'oauth', invalid: true };
}
export function getKiroStartIDCValidationError(options: {
kiroMethod: KiroAuthMethod;
kiroIDCStartUrl?: string;
@@ -595,6 +618,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const kiroIDCRegion =
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const gitlabPersonalAccessToken =
typeof requestBody.gitlabPersonalAccessToken === 'string'
? requestBody.gitlabPersonalAccessToken.trim()
: undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const target = getProxyTarget();
if (target.isRemote) {
@@ -605,6 +635,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -620,6 +652,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'kiro') {
const kiroIDCValidationError = getKiroStartIDCValidationError({
kiroMethod,
@@ -643,6 +683,92 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
if (!gitlabPersonalAccessToken) {
res.status(400).json({
error: 'gitlabPersonalAccessToken is required when gitlabAuthMode=pat',
code: 'MISSING_GITLAB_PAT',
});
return;
}
try {
const localProvider = provider as CLIProxyProvider;
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(gitlabBaseUrl ? { base_url: gitlabBaseUrl } : {}),
personal_access_token: gitlabPersonalAccessToken,
}),
});
const responseBody = await response.text();
const parsedResponse = parseGitLabPatAuthResponse(
response.ok,
response.status,
responseBody,
gitlabPersonalAccessToken
);
if (!parsedResponse.ok) {
res.status(response.ok ? 400 : response.status).json({
error: parsedResponse.errorMessage || 'GitLab PAT authentication failed',
});
return;
}
const tokenSnapshot = findNewTokenSnapshot(
listProviderTokenSnapshots(localProvider),
knownTokenFiles
);
if (!tokenSnapshot) {
res.status(409).json({
error: 'GitLab PAT authentication completed, but CCS could not find the saved token.',
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
nickname,
false,
tokenSnapshot.file
);
if (!account) {
res.status(409).json({
error: 'GitLab PAT authentication succeeded, but account registration failed.',
});
return;
}
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep auth success path non-fatal when prefix repair cannot run.
}
res.json({
success: true,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
return;
} catch (error) {
respondInternalError(res, error, 'Failed to start GitLab PAT flow.');
return;
}
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
@@ -673,6 +799,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
gitlabAuthMode: provider === 'gitlab' ? gitlabAuthMode : undefined,
gitlabBaseUrl: provider === 'gitlab' ? gitlabBaseUrl : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -829,9 +957,14 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Check remote mode
const target = getProxyTarget();
@@ -854,6 +987,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
res.status(400).json({
error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start',
code: 'GITLAB_PAT_REQUIRES_START',
});
return;
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
@@ -892,11 +1041,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
provider === 'kiro' && kiroManagementMethod
? `&method=${encodeURIComponent(kiroManagementMethod)}`
: '';
const gitlabQuery =
provider === 'gitlab' && gitlabBaseUrl
? `&base_url=${encodeURIComponent(gitlabBaseUrl)}`
: '';
// Call CLIProxyAPI to start OAuth and get auth URL
// CLIProxyAPI management routes are under /v0/management prefix
const response = await fetch(
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`),
buildProxyUrl(
target,
`/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`
),
{ headers: buildManagementHeaders(target) }
);
@@ -1022,6 +1178,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
} catch {
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
invalidateQuotaForRegisteredAccount(account);
res.json({
status: 'ok',
account: {
@@ -1173,6 +1330,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
// Keep manual callback success path non-fatal when prefix repair cannot run.
}
}
invalidateQuotaForRegisteredAccount(account);
res.json({
success: true,
@@ -1205,6 +1363,8 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
return;
}
invalidateQuotaForRegisteredAccount(account);
res.json({
success: true,
account: {
+1
View File
@@ -104,6 +104,7 @@ apiRoutes.use('/copilot', copilotRoutes);
// ==================== Cursor ====================
apiRoutes.use('/cursor', cursorRoutes);
apiRoutes.use('/legacy/cursor', cursorRoutes);
// ==================== Droid ====================
apiRoutes.use('/droid', droidRoutes);
+12
View File
@@ -30,6 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
import type { Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { resolveProviderSettingsPath } from '../../cliproxy/config/env-builder';
import { expandPath } from '../../utils/helpers';
import {
canonicalizeModelIdForProvider,
@@ -101,6 +102,17 @@ function resolveSettingsPath(profileOrVariant: string): string {
const ccsDir = getCcsDir();
const resolvedCcsDir = path.resolve(ccsDir);
const directProvider = mapExternalProviderName(profileOrVariant);
if (directProvider) {
if (profileOrVariant !== directProvider) {
return resolvePathWithin(
resolvedCcsDir,
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
return path.resolve(resolveProviderSettingsPath(directProvider));
}
// Check if this is a variant
const variants = listVariants();
const variant = variants[profileOrVariant];