mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 01:12:46 +00:00
refactor: make cli-auth part of the providers class
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
12
server/src/modules/ai-runtime/services/auth.service.ts
Normal file
12
server/src/modules/ai-runtime/services/auth.service.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
19
server/src/modules/ai-runtime/types/auth.types.ts
Normal file
19
server/src/modules/ai-runtime/types/auth.types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
140
src/App.tsx
140
src/App.tsx
@@ -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>
|
||||
// );
|
||||
// }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user