mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-16 02:15:23 +00:00
chore: merge dev into openai provider routing
This commit is contained in:
commit
1923d87a84
118 files changed
+4162
-3620
No files matched your search
@@ -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
@@ -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(' · ');
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -104,6 +104,7 @@ apiRoutes.use('/copilot', copilotRoutes);
|
||||
|
||||
// ==================== Cursor ====================
|
||||
apiRoutes.use('/cursor', cursorRoutes);
|
||||
apiRoutes.use('/legacy/cursor', cursorRoutes);
|
||||
|
||||
// ==================== Droid ====================
|
||||
apiRoutes.use('/droid', droidRoutes);
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user