refactor: make cli-auth part of the providers class

This commit is contained in:
Haileyesus
2026-04-08 18:45:39 +03:00
parent 89b0067478
commit a9d778e3fb
22 changed files with 572 additions and 608 deletions

View File

@@ -4,6 +4,7 @@ import { asyncHandler } from '@/shared/http/async-handler.js';
import { AppError } from '@/shared/utils/app-error.js';
import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js';
import { llmService } from '@/modules/ai-runtime/services/ai-runtime.service.js';
import { llmAuthService } from '@/modules/ai-runtime/services/auth.service.js';
import { llmSessionsService } from '@/modules/ai-runtime/services/sessions.service.js';
import { llmMcpService } from '@/modules/ai-runtime/services/mcp.service.js';
import { llmSkillsService } from '@/modules/ai-runtime/services/skills.service.js';
@@ -233,6 +234,15 @@ router.get(
}),
);
router.get(
'/providers/:provider/auth/status',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const auth = await llmAuthService.getProviderAuthStatus(provider);
res.json(createApiSuccessResponse({ provider, auth }));
}),
);
router.get(
'/providers/:provider/sessions',
asyncHandler(async (req: Request, res: Response) => {

View File

@@ -1,5 +1,6 @@
import type {
IProvider,
IProviderAuthRuntime,
IProviderMcpRuntime,
IProviderSessionSynchronizerRuntime,
IProviderSkillsRuntime,
@@ -25,6 +26,7 @@ export abstract class AbstractProvider implements IProvider {
abstract readonly mcp: IProviderMcpRuntime;
abstract readonly skills: IProviderSkillsRuntime;
abstract readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime;
abstract readonly auth: IProviderAuthRuntime;
protected readonly sessions = new Map<string, MutableProviderSession>();

View File

@@ -0,0 +1,120 @@
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
type ClaudeCredentialsFile = {
email?: string;
user?: string;
claudeAiOauth?: {
accessToken?: string;
expiresAt?: number | string;
};
};
/**
* Reads auth status for Claude from env/settings and OAuth credentials.
*/
export class ClaudeAuthRuntime implements IProviderAuthRuntime {
async getStatus(): Promise<ProviderAuthStatus> {
try {
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return {
provider: 'claude',
authenticated: true,
email: 'API Key Auth',
method: 'api_key',
};
}
const settingsEnv = await this.loadClaudeSettingsEnv();
if (settingsEnv.ANTHROPIC_API_KEY?.trim()) {
return {
provider: 'claude',
authenticated: true,
email: 'API Key Auth',
method: 'api_key',
};
}
if (settingsEnv.ANTHROPIC_AUTH_TOKEN?.trim()) {
return {
provider: 'claude',
authenticated: true,
email: 'Configured via settings.json',
method: 'api_key',
};
}
const credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await readFile(credentialsPath, 'utf8');
const credentials = JSON.parse(content) as ClaudeCredentialsFile;
const oauth = credentials.claudeAiOauth;
const accessToken = oauth?.accessToken;
if (accessToken && !this.isExpired(oauth?.expiresAt)) {
return {
provider: 'claude',
authenticated: true,
email: credentials.email ?? credentials.user ?? null,
method: 'credentials_file',
};
}
return {
provider: 'claude',
authenticated: false,
email: null,
method: null,
error: 'Not authenticated',
};
} catch {
return {
provider: 'claude',
authenticated: false,
email: null,
method: null,
error: 'Not authenticated',
};
}
}
/**
* Reads optional env values from ~/.claude/settings.json.
*/
private async loadClaudeSettingsEnv(): Promise<Record<string, string>> {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await readFile(settingsPath, 'utf8');
const settings = JSON.parse(content) as { env?: unknown };
if (!settings.env || typeof settings.env !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(settings.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
);
} catch {
return {};
}
}
/**
* Returns true when an OAuth expiration timestamp is in the past.
*/
private isExpired(expiresAt: number | string | undefined): boolean {
if (expiresAt === undefined) {
return false;
}
if (typeof expiresAt === 'number') {
return Date.now() >= expiresAt;
}
const numeric = Number.parseInt(expiresAt, 10);
return Number.isFinite(numeric) ? Date.now() >= numeric : false;
}
}

View File

@@ -9,6 +9,7 @@ import { readFile } from 'node:fs/promises';
import { BaseSdkProvider } from '@/modules/ai-runtime/providers/base/base-sdk.provider.js';
import type {
IProviderAuthRuntime,
IProviderMcpRuntime,
IProviderSessionSynchronizerRuntime,
IProviderSkillsRuntime,
@@ -18,6 +19,7 @@ import type {
StartSessionInput,
} from '@/modules/ai-runtime/types/index.js';
import { ClaudeMcpRuntime } from '@/modules/ai-runtime/providers/claude/claude-mcp.runtime.js';
import { ClaudeAuthRuntime } from '@/modules/ai-runtime/providers/claude/claude-auth.runtime.js';
import { ClaudeSkillsRuntime } from '@/modules/ai-runtime/providers/claude/claude-skills.runtime.js';
import { ClaudeSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/claude/claude-session-synchronizer.runtime.js';
@@ -75,6 +77,7 @@ const readString = (value: unknown): string | undefined => {
* Claude SDK provider implementation.
*/
export class ClaudeProvider extends BaseSdkProvider {
readonly auth: IProviderAuthRuntime = new ClaudeAuthRuntime();
readonly mcp: IProviderMcpRuntime = new ClaudeMcpRuntime();
readonly skills: IProviderSkillsRuntime = new ClaudeSkillsRuntime();
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new ClaudeSessionSynchronizerRuntime();

View File

@@ -0,0 +1,88 @@
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
type CodexAuthFile = {
OPENAI_API_KEY?: string;
tokens?: {
id_token?: string;
access_token?: string;
};
};
/**
* Reads auth status from ~/.codex/auth.json.
*/
export class CodexAuthRuntime implements IProviderAuthRuntime {
async getStatus(): Promise<ProviderAuthStatus> {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = JSON.parse(content) as CodexAuthFile;
const tokens = auth.tokens ?? {};
if (tokens.id_token || tokens.access_token) {
return {
provider: 'codex',
authenticated: true,
email: this.extractEmail(tokens.id_token),
method: 'token_file',
};
}
if (auth.OPENAI_API_KEY?.trim()) {
return {
provider: 'codex',
authenticated: true,
email: 'API Key Auth',
method: 'api_key',
};
}
return {
provider: 'codex',
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found',
};
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
return {
provider: 'codex',
authenticated: false,
email: null,
method: null,
error: code === 'ENOENT'
? 'Codex not configured'
: (error instanceof Error ? error.message : 'Failed to read Codex auth state'),
};
}
}
/**
* Best-effort id_token email extraction from JWT payload.
*/
private extractEmail(idToken: string | undefined): string {
if (!idToken) {
return 'Authenticated';
}
try {
const parts = idToken.split('.');
if (parts.length < 2) {
return 'Authenticated';
}
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
email?: string;
user?: string;
};
return payload.email ?? payload.user ?? 'Authenticated';
} catch {
return 'Authenticated';
}
}
}

View File

@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import { BaseSdkProvider } from '@/modules/ai-runtime/providers/base/base-sdk.provider.js';
import type {
IProviderAuthRuntime,
IProviderMcpRuntime,
IProviderSessionSynchronizerRuntime,
IProviderSkillsRuntime,
@@ -12,6 +13,7 @@ import type {
StartSessionInput,
} from '@/modules/ai-runtime/types/index.js';
import { CodexMcpRuntime } from '@/modules/ai-runtime/providers/codex/codex-mcp.runtime.js';
import { CodexAuthRuntime } from '@/modules/ai-runtime/providers/codex/codex-auth.runtime.js';
import { CodexSkillsRuntime } from '@/modules/ai-runtime/providers/codex/codex-skills.runtime.js';
import { CodexSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/codex/codex-session-synchronizer.runtime.js';
import { AppError } from '@/shared/utils/app-error.js';
@@ -67,6 +69,7 @@ type CodexSdkModule = {
* Codex SDK provider implementation.
*/
export class CodexProvider extends BaseSdkProvider {
readonly auth: IProviderAuthRuntime = new CodexAuthRuntime();
readonly mcp: IProviderMcpRuntime = new CodexMcpRuntime();
readonly skills: IProviderSkillsRuntime = new CodexSkillsRuntime();
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new CodexSessionSynchronizerRuntime();

View File

@@ -0,0 +1,126 @@
import spawn from 'cross-spawn';
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
const CURSOR_STATUS_TIMEOUT_MS = 5_000;
/**
* Reads auth status from `cursor-agent status`.
*/
export class CursorAuthRuntime implements IProviderAuthRuntime {
async getStatus(): Promise<ProviderAuthStatus> {
return new Promise((resolve) => {
let completed = false;
let childProcess: ReturnType<typeof spawn> | null = null;
const timeout = setTimeout(() => {
if (completed) {
return;
}
completed = true;
if (childProcess) {
childProcess.kill();
}
resolve({
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Command timeout',
});
}, CURSOR_STATUS_TIMEOUT_MS);
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
completed = true;
resolve({
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
childProcess.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
childProcess.on('close', (code) => {
if (completed) {
return;
}
completed = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({
provider: 'cursor',
authenticated: true,
email: emailMatch[1],
method: null,
});
return;
}
if (stdout.includes('Logged in')) {
resolve({
provider: 'cursor',
authenticated: true,
email: 'Logged in',
method: null,
});
return;
}
resolve({
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Not logged in',
});
return;
}
resolve({
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: stderr.trim() || 'Not logged in',
});
});
childProcess.on('error', () => {
if (completed) {
return;
}
completed = true;
clearTimeout(timeout);
resolve({
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
});
});
}
}

View File

@@ -1,5 +1,6 @@
import { BaseCliProvider } from '@/modules/ai-runtime/providers/base/base-cli.provider.js';
import type {
IProviderAuthRuntime,
IProviderMcpRuntime,
IProviderSessionSynchronizerRuntime,
IProviderSkillsRuntime,
@@ -7,6 +8,7 @@ import type {
StartSessionInput,
} from '@/modules/ai-runtime/types/index.js';
import { CursorMcpRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-mcp.runtime.js';
import { CursorAuthRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-auth.runtime.js';
import { CursorSkillsRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-skills.runtime.js';
import { CursorSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-session-synchronizer.runtime.js';
@@ -23,6 +25,7 @@ const ANSI_REGEX =
* Cursor CLI provider implementation.
*/
export class CursorProvider extends BaseCliProvider {
readonly auth: IProviderAuthRuntime = new CursorAuthRuntime();
readonly mcp: IProviderMcpRuntime = new CursorMcpRuntime();
readonly skills: IProviderSkillsRuntime = new CursorSkillsRuntime();
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new CursorSessionSynchronizerRuntime();

View File

@@ -0,0 +1,115 @@
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
type GeminiOauthCreds = {
access_token?: string;
refresh_token?: string;
};
/**
* Reads auth status from env and Gemini OAuth files.
*/
export class GeminiAuthRuntime implements IProviderAuthRuntime {
async getStatus(): Promise<ProviderAuthStatus> {
if (process.env.GEMINI_API_KEY?.trim()) {
return {
provider: 'gemini',
authenticated: true,
email: 'API Key Auth',
method: 'api_key',
};
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await readFile(credsPath, 'utf8');
const creds = JSON.parse(content) as GeminiOauthCreds;
if (!creds.access_token) {
return {
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found in oauth_creds',
};
}
const validated = await this.resolveEmailFromAccessToken(creds.access_token);
if (validated.email) {
return {
provider: 'gemini',
authenticated: true,
email: validated.email,
method: 'oauth',
};
}
if (!validated.tokenValid && !creds.refresh_token) {
return {
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Access token invalid and no refresh token found',
};
}
const fallbackEmail = await this.readActiveGoogleAccountEmail();
return {
provider: 'gemini',
authenticated: true,
email: fallbackEmail ?? 'OAuth Session',
method: 'oauth',
};
} catch {
return {
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI not configured',
};
}
}
/**
* Validates token and extracts email via Google's tokeninfo endpoint.
*/
private async resolveEmailFromAccessToken(
accessToken: string,
): Promise<{ tokenValid: boolean; email: string | null }> {
try {
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
if (!response.ok) {
return { tokenValid: false, email: null };
}
const tokenInfo = await response.json() as { email?: string };
return {
tokenValid: true,
email: tokenInfo.email ?? null,
};
} catch {
return { tokenValid: false, email: null };
}
}
/**
* Reads active Google account email from ~/.gemini/google_accounts.json.
*/
private async readActiveGoogleAccountEmail(): Promise<string | null> {
try {
const accountsPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const content = await readFile(accountsPath, 'utf8');
const accounts = JSON.parse(content) as { active?: string };
return typeof accounts.active === 'string' && accounts.active.trim()
? accounts.active
: null;
} catch {
return null;
}
}
}

View File

@@ -1,5 +1,6 @@
import { BaseCliProvider } from '@/modules/ai-runtime/providers/base/base-cli.provider.js';
import type {
IProviderAuthRuntime,
IProviderMcpRuntime,
IProviderSessionSynchronizerRuntime,
IProviderSkillsRuntime,
@@ -7,6 +8,7 @@ import type {
StartSessionInput,
} from '@/modules/ai-runtime/types/index.js';
import { GeminiMcpRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-mcp.runtime.js';
import { GeminiAuthRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-auth.runtime.js';
import { GeminiSkillsRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-skills.runtime.js';
import { GeminiSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-session-synchronizer.runtime.js';
@@ -31,6 +33,7 @@ const GEMINI_MODELS: ProviderModel[] = [
* Gemini CLI provider implementation.
*/
export class GeminiProvider extends BaseCliProvider {
readonly auth: IProviderAuthRuntime = new GeminiAuthRuntime();
readonly mcp: IProviderMcpRuntime = new GeminiMcpRuntime();
readonly skills: IProviderSkillsRuntime = new GeminiSkillsRuntime();
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new GeminiSessionSynchronizerRuntime();

View File

@@ -0,0 +1,12 @@
import { llmProviderRegistry } from '@/modules/ai-runtime/ai-runtime.registry.js';
import type { ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js';
export const llmAuthService = {
/**
* Returns auth status for one provider.
*/
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
const provider = llmProviderRegistry.resolveProvider(providerName);
return provider.auth.getStatus();
},
};

View File

@@ -0,0 +1,19 @@
import type { LLMProvider } from '@/shared/types/app.js';
/**
* Provider authentication status normalized for frontend consumption.
*/
export type ProviderAuthStatus = {
provider: LLMProvider;
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
/**
* Auth runtime contract for one provider.
*/
export interface IProviderAuthRuntime {
getStatus(): Promise<ProviderAuthStatus>;
}

View File

@@ -2,3 +2,4 @@ export * from '@/modules/ai-runtime/types/provider.types.js';
export * from '@/modules/ai-runtime/types/mcp.types.js';
export * from '@/modules/ai-runtime/types/skills.types.js';
export * from '@/modules/ai-runtime/types/session-synchronizer.types.js';
export * from '@/modules/ai-runtime/types/auth.types.js';

View File

@@ -2,6 +2,7 @@ import type { LLMProvider } from '@/shared/types/app.js';
import type { IProviderMcpRuntime } from '@/modules/ai-runtime/types/mcp.types.js';
import type { IProviderSkillsRuntime } from '@/modules/ai-runtime/types/skills.types.js';
import type { IProviderSessionSynchronizerRuntime } from '@/modules/ai-runtime/types/session-synchronizer.types.js';
import type { IProviderAuthRuntime } from '@/modules/ai-runtime/types/auth.types.js';
export type ProviderExecutionFamily = 'sdk' | 'cli';
@@ -80,6 +81,7 @@ export interface IProvider {
readonly mcp: IProviderMcpRuntime;
readonly skills: IProviderSkillsRuntime;
readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime;
readonly auth: IProviderAuthRuntime;
listModels(): Promise<ProviderModel[]>;

View File

@@ -1 +0,0 @@

View File

@@ -1,434 +0,0 @@
import express from 'express';
import spawn from 'cross-spawn';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const router = express.Router();
router.get('/claude/status', async (req, res) => {
try {
const credentialsResult = await checkClaudeCredentials();
if (credentialsResult.authenticated) {
return res.json({
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method // 'api_key' or 'credentials_file'
});
}
return res.json({
authenticated: false,
email: null,
method: null,
error: credentialsResult.error || 'Not authenticated'
});
} catch (error) {
console.error('Error checking Claude auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
method: null,
error: error.message
});
}
});
router.get('/cursor/status', async (req, res) => {
try {
const result = await checkCursorStatus();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Cursor auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/codex/status', async (req, res) => {
try {
const result = await checkCodexCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Codex auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/gemini/status', async (req, res) => {
try {
const result = await checkGeminiCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Gemini auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
async function loadClaudeSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch (error) {
// Ignore missing or malformed settings and fall back to other auth sources.
}
return {};
}
/**
* Checks Claude authentication credentials using two methods with priority order:
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
* This matching behavior ensures consistency with how the SDK authenticates.
*
* References:
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
* - https://platform.claude.com/docs/en/agent-sdk/overview
* SDK authentication documentation
*
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
* - authenticated: boolean indicating if valid credentials exist
* - email: user email or auth method identifier
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/
async function checkClaudeCredentials() {
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
// Priority 1b: Check ~/.claude/settings.json env values.
// Claude Code can read proxy/auth values from settings.json even when the
// CloudCLI server process itself was not started with those env vars exported.
const settingsEnv = await loadClaudeSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return {
authenticated: true,
email: 'Configured via settings.json',
method: 'api_key'
};
}
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
// This is the standard authentication method used by Claude CLI after running
// 'claude /login' or 'claude setup-token' commands.
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
}
return {
authenticated: false,
email: null,
method: null
};
} catch (error) {
return {
authenticated: false,
email: null,
method: null
};
}
}
function checkCursorStatus() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch (err) {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({
authenticated: true,
email: emailMatch[1],
output: stdout
});
} else if (stdout.includes('Logged in')) {
resolve({
authenticated: true,
email: 'Logged in',
output: stdout
});
} else {
resolve({
authenticated: false,
email: null,
error: 'Not logged in'
});
}
} else {
resolve({
authenticated: false,
email: null,
error: stderr || 'Not logged in'
});
}
});
childProcess.on('error', (err) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}
async function checkCodexCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
// Tokens are nested under 'tokens' key
const tokens = auth.tokens || {};
// Check for valid tokens (id_token or access_token)
if (tokens.id_token || tokens.access_token) {
// Try to extract email from id_token JWT payload
let email = 'Authenticated';
if (tokens.id_token) {
try {
// JWT is base64url encoded: header.payload.signature
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
// Decode the payload (second part)
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
// If JWT decoding fails, use fallback
email = 'Authenticated';
}
}
return {
authenticated: true,
email
};
}
// Also check for OPENAI_API_KEY as fallback auth method
if (auth.OPENAI_API_KEY) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found'
};
} catch (error) {
if (error.code === 'ENOENT') {
return {
authenticated: false,
email: null,
error: 'Codex not configured'
};
}
return {
authenticated: false,
email: null,
error: error.message
};
}
}
async function checkGeminiCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
// Validate token against Google API
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
// Token invalid and no refresh token available
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (e) { }
}
} catch (e) {
// Network error, fallback to checking local accounts file
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (err) { }
}
return {
authenticated: true,
email: email
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found in oauth_creds'
};
} catch (error) {
return {
authenticated: false,
email: null,
error: 'Gemini CLI not configured'
};
}
}
export default router;

View File

@@ -56,7 +56,6 @@ const [
pushSubRoutes,
agentRoutes,
projectsRoutes,
cliAuthRoutes,
codexRoutes,
geminiRoutes,
pluginsRoutes,
@@ -83,7 +82,6 @@ const [
importRoute('./modules/push-sub/push-sub.routes.js'),
importRoute('./modules/agent/agent.routes.js'),
importRoute('./modules/projects/projects.routes.js'),
importRoute('./modules/cli-auth/cli-auth.routes.js'),
importRoute('./modules/codex/codex.routes.js'),
importRoute('./modules/gemini/gemini.routes.js'),
importRoute('./modules/plugins/plugins.routes.js'),
@@ -162,9 +160,6 @@ app.use('/api/credentials', authenticateToken, credentialsRoutes);
app.use('/api/notification-preferences', authenticateToken, notificationPreferencesRoutes);
app.use('/api/push-sub', authenticateToken, pushSubRoutes);
// CLI Authentication API Routes (protected)
app.use('/api/cli', authenticateToken, cliAuthRoutes);
// User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes);

View File

@@ -1,11 +1,8 @@
import { I18nextProvider } from 'react-i18next';
import {
Navigate,
Outlet,
RouterProvider,
createBrowserRouter,
useLocation,
useParams,
} from 'react-router-dom';
import { AuthProvider, ProtectedRoute } from './components/auth';
import { ThemeProvider } from './contexts/ThemeContext';
@@ -21,95 +18,8 @@ import FileTreeRouterAdapter from '@/components/file-tree/view/FileTreeRouterAda
import GitPanelRouterAdapter from '@/components/git-panel/view/GitPanelRouterAdapter.js';
import { TaskMasterPanel } from '@/components/task-master/index.js';
import PluginContentRouterAdapter from '@/components/plugins/view/PluginContentRouterAdapter.js';
import ChatInterface from '@/components/refactored/chat/view/ChatInterface.js';
import ChooseWorkspaceView from '@/components/refactored/shared/view/ChooseWorkspaceView.js';
const isValidRouteTab = (value: string | undefined): boolean => {
if (!value) {
return false;
}
const normalizedValue = decodeURIComponent(value);
return (
normalizedValue === 'chat' ||
normalizedValue === 'shell' ||
normalizedValue === 'files' ||
normalizedValue === 'git' ||
normalizedValue === 'tasks' ||
normalizedValue === 'plugins' ||
normalizedValue === 'preview' ||
normalizedValue.startsWith('plugin:')
);
};
function NoWorkspaceRoute() {
return (
<div className="flex h-full items-center justify-center p-8">
<div className="max-w-md space-y-2 text-center">
<h1 className="text-2xl font-semibold">Choose Your Project</h1>
<p className="text-sm text-muted-foreground">
This is the root route (`/`) empty state. Select a workspace from the sidebar to continue.
</p>
</div>
</div>
);
}
function WorkspaceLayout() {
const { workspaceId } = useParams<{ workspaceId: string }>();
if (!workspaceId) {
return <Navigate to="/" replace />;
}
return (
<div className="min-h-0 flex-1 overflow-auto">
<Outlet />
</div>
);
}
function WorkspaceTabRoute() {
const location = useLocation();
const { workspaceId, sessionId, tab } = useParams<{
workspaceId: string;
sessionId?: string;
tab: string;
}>();
if (!workspaceId && !sessionId) {
return <Navigate to="/" replace />;
}
if (!isValidRouteTab(tab)) {
return <Navigate to="../chat" replace />;
}
const decodedWorkspaceId = workspaceId ? decodeURIComponent(workspaceId) : null;
const decodedSessionId = sessionId ? decodeURIComponent(sessionId) : null;
const decodedTab = tab ? decodeURIComponent(tab) : 'chat';
const pluginName = decodeURIComponent(new URLSearchParams(location.search).get('name') || '');
const tabLabel = decodedTab === 'plugins' && pluginName ? `plugin:${pluginName}` : decodedTab;
return (
<div className="h-full p-6">
<div className="rounded-xl border border-border/70 bg-card/30 p-5">
<h2 className="text-lg font-semibold">{tabLabel} view</h2>
<p className="mt-2 text-sm text-muted-foreground">
Workspace:{' '}
<span className="font-medium text-foreground">
{decodedWorkspaceId || 'none (session-level route)'}
</span>
</p>
<p className="mt-1 text-sm text-muted-foreground">
Session:{' '}
<span className="font-medium text-foreground">
{decodedSessionId || 'none (workspace-level tab)'}
</span>
</p>
</div>
</div>
);
}
const router = createBrowserRouter(
[
@@ -120,16 +30,14 @@ const router = createBrowserRouter(
{ index: true, element: <ChooseWorkspaceView /> }, // TODO: Show empty state component loader here.
{
path: 'workspaces/:workspaceId',
element: <WorkspaceLayout />,
children: [
{ index: true, element: <Navigate to="chat" replace /> },
{ path: 'chat', element: <ChatInterface /> },
{ path: 'shell', element: <StandaloneShellRouterAdapter /> },
{ path: 'files', element: <FileTreeRouterAdapter /> },
{ path: 'git', element: <GitPanelRouterAdapter /> },
{ path: 'tasks', element: <TaskMasterPanel isVisible={true} /> },
{ path: 'plugins', element: <PluginContentRouterAdapter /> },
{ path: ':tab', element: <WorkspaceTabRoute /> },
{ path: 'chat', element: <h1>Sample component</h1>}
],
},
{
@@ -137,7 +45,11 @@ const router = createBrowserRouter(
children: [
{ index: true, element: <Navigate to="chat" replace /> },
{ path: 'shell', element: <StandaloneShellRouterAdapter /> },
{ path: ':tab', element: <WorkspaceTabRoute /> },
{ path: 'files', element: <FileTreeRouterAdapter /> },
{ path: 'git', element: <GitPanelRouterAdapter /> },
{ path: 'tasks', element: <TaskMasterPanel isVisible={true} /> },
{ path: 'plugins', element: <PluginContentRouterAdapter /> },
{ path: 'chat', element: <h1>Sample component</h1>}
],
},
],
@@ -168,42 +80,4 @@ export default function App() {
</ThemeProvider>
</I18nextProvider>
);
}
// import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
// import { I18nextProvider } from 'react-i18next';
// import { ThemeProvider } from './contexts/ThemeContext';
// import { AuthProvider, ProtectedRoute } from './components/auth';
// import { TaskMasterProvider } from './contexts/TaskMasterContext';
// import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
// import { WebSocketProvider } from './contexts/WebSocketContext';
// import { PluginsProvider } from './contexts/PluginsContext';
// import AppContent from './components/app/AppContent';
// import i18n from './i18n/config.js';
// export default function App() {
// return (
// <I18nextProvider i18n={i18n}>
// <ThemeProvider>
// <AuthProvider>
// <WebSocketProvider>
// 1<PluginsProvider>
// <TasksSettingsProvider>
// <TaskMasterProvider>
// <ProtectedRoute>
// <Router basename={window.__ROUTER_BASENAME__ || ''}>
// <Routes>
// <Route path="/" element={<AppContent />} />
// <Route path="/session/:sessionId" element={<AppContent />} />
// </Routes>
// </Router>
// </ProtectedRoute>
// </TaskMasterProvider>
// </TasksSettingsProvider>
// </PluginsProvider>
// </WebSocketProvider>
// </AuthProvider>
// </ThemeProvider>
// </I18nextProvider>
// );
// }
}

View File

@@ -31,7 +31,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => {
try {
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
const response = await authenticatedFetch(`/api/llm/providers/${provider}/auth/status`);
if (!response.ok) {
setProviderStatuses((previous) => ({
...previous,
@@ -46,18 +46,39 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
}
const payload = (await response.json()) as {
authenticated?: boolean;
email?: string | null;
error?: string | null;
success?: boolean;
data?: {
auth?: {
authenticated?: boolean;
email?: string | null;
error?: string | null;
};
};
error?: {
message?: string;
};
};
const auth = payload.data?.auth;
if (!payload.success || !auth) {
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: false,
email: null,
loading: false,
error: payload.error?.message ?? 'Failed to parse authentication status',
},
}));
return;
}
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
authenticated: Boolean(auth.authenticated),
email: auth.email ?? null,
loading: false,
error: payload.error ?? null,
error: auth.error ?? null,
},
}));
} catch (caughtError) {

View File

@@ -1,20 +0,0 @@
// Create a sample component
// TODO: Place this in a shared folder
import ErrorBoundary from "@/components/main-content/view/ErrorBoundary.js";
import { QuickSettingsPanel } from "@/components/quick-settings-panel/index.js";
export default function ChatInterface() {
return (
<div className={`h-full`}>
<ErrorBoundary showDetails>
<div className="flex h-full items-center justify-center">
<p className="text-gray-500">Chat interface goes here</p>
</div>
</ErrorBoundary>
<QuickSettingsPanel />
</div>
);
}

View File

@@ -80,8 +80,8 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
};
export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
claude: '/api/llm/providers/claude/auth/status',
cursor: '/api/llm/providers/cursor/auth/status',
codex: '/api/llm/providers/codex/auth/status',
gemini: '/api/llm/providers/gemini/auth/status',
};

View File

@@ -33,11 +33,22 @@ type UseSettingsControllerArgs = {
onClose: () => void;
};
type StatusApiResponse = {
type AuthStatusPayload = {
authenticated?: boolean;
email?: string | null;
error?: string | null;
method?: string;
method?: string | null;
};
type StatusApiResponse = {
success?: boolean;
data?: {
provider?: AgentProvider;
auth?: AuthStatusPayload;
};
error?: {
message?: string;
};
};
type JsonResult = {
@@ -249,13 +260,24 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
return;
}
const data = await toResponseJson<StatusApiResponse>(response);
const payload = await toResponseJson<StatusApiResponse>(response);
const data = payload.data?.auth;
if (!payload.success || !data) {
setAuthStatusByProvider(provider, {
authenticated: false,
email: null,
loading: false,
error: payload.error?.message || 'Failed to parse authentication status',
});
return;
}
setAuthStatusByProvider(provider, {
authenticated: Boolean(data.authenticated),
email: data.email || null,
loading: false,
error: data.error || null,
method: data.method,
method: data.method ?? undefined,
});
} catch (error) {
console.error(`Error checking ${provider} auth status:`, error);