Merge remote-tracking branch 'upstream/v0.15.0rc' into refactor/auto-mode-service-gsxdsm

This commit is contained in:
gsxdsm
2026-02-15 10:20:53 -08:00
42 changed files with 1363 additions and 153 deletions

View File

@@ -30,15 +30,15 @@ const model2 = resolveModelString('haiku');
// Returns: 'claude-haiku-4-5'
const model3 = resolveModelString('opus');
// Returns: 'claude-opus-4-5-20251101'
// Returns: 'claude-opus-4-6'
// Use with custom default
const model4 = resolveModelString(undefined, 'claude-sonnet-4-20250514');
// Returns: 'claude-sonnet-4-20250514' (default)
// Direct model ID passthrough
const model5 = resolveModelString('claude-opus-4-5-20251101');
// Returns: 'claude-opus-4-5-20251101' (unchanged)
const model5 = resolveModelString('claude-opus-4-6');
// Returns: 'claude-opus-4-6' (unchanged)
```
### Get Effective Model
@@ -72,7 +72,7 @@ console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514'
// Model alias mappings
console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5'
console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514'
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101'
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-6'
```
## Usage Example
@@ -103,7 +103,7 @@ const feature: Feature = {
};
prepareFeatureExecution(feature);
// Output: Executing feature with model: claude-opus-4-5-20251101
// Output: Executing feature with model: claude-opus-4-6
```
## Supported Models
@@ -112,7 +112,7 @@ prepareFeatureExecution(feature);
- `haiku``claude-haiku-4-5`
- `sonnet``claude-sonnet-4-20250514`
- `opus``claude-opus-4-5-20251101`
- `opus``claude-opus-4-6`
### Model Selection Guide

View File

@@ -484,12 +484,12 @@ describe('model-resolver', () => {
it('should handle full Claude model string in entry', () => {
const entry: PhaseModelEntry = {
model: 'claude-opus-4-5-20251101',
model: 'claude-opus-4-6',
thinkingLevel: 'high',
};
const result = resolvePhaseModel(entry);
expect(result.model).toBe('claude-opus-4-5-20251101');
expect(result.model).toBe('claude-opus-4-6');
expect(result.thinkingLevel).toBe('high');
});
});

View File

@@ -134,6 +134,8 @@ export {
findClaudeCliPath,
getClaudeAuthIndicators,
type ClaudeAuthIndicators,
type FileCheckResult,
type DirectoryCheckResult,
findCodexCliPath,
getCodexAuthIndicators,
type CodexAuthIndicators,

View File

@@ -25,6 +25,16 @@ import fs from 'fs/promises';
// System Tool Path Definitions
// =============================================================================
/**
* Get NVM for Windows (nvm4w) symlink paths for a given CLI tool.
* Reused across getClaudeCliPaths, getCodexCliPaths, and getOpenCodeCliPaths.
*/
function getNvmWindowsCliPaths(cliName: string): string[] {
const nvmSymlink = process.env.NVM_SYMLINK;
if (!nvmSymlink) return [];
return [path.join(nvmSymlink, `${cliName}.cmd`), path.join(nvmSymlink, cliName)];
}
/**
* Get common paths where GitHub CLI might be installed
*/
@@ -60,6 +70,7 @@ export function getClaudeCliPaths(): string[] {
path.join(appData, 'npm', 'claude'),
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, '.npm-global', 'bin', 'claude'),
...getNvmWindowsCliPaths('claude'),
];
}
@@ -141,6 +152,7 @@ export function getCodexCliPaths(): string[] {
// pnpm on Windows
path.join(localAppData, 'pnpm', 'codex.cmd'),
path.join(localAppData, 'pnpm', 'codex'),
...getNvmWindowsCliPaths('codex'),
];
}
@@ -976,6 +988,27 @@ export async function findGitBashPath(): Promise<string | null> {
return findFirstExistingPath(getGitBashPaths());
}
/**
* Details about a file check performed during auth detection
*/
export interface FileCheckResult {
path: string;
exists: boolean;
readable: boolean;
error?: string;
}
/**
* Details about a directory check performed during auth detection
*/
export interface DirectoryCheckResult {
path: string;
exists: boolean;
readable: boolean;
entryCount: number;
error?: string;
}
/**
* Get Claude authentication status by checking various indicators
*/
@@ -988,67 +1021,165 @@ export interface ClaudeAuthIndicators {
hasOAuthToken: boolean;
hasApiKey: boolean;
} | null;
/** Detailed information about what was checked */
checks: {
settingsFile: FileCheckResult;
statsCache: FileCheckResult & { hasDailyActivity?: boolean };
projectsDir: DirectoryCheckResult;
credentialFiles: FileCheckResult[];
};
}
export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const settingsPath = getClaudeSettingsPath();
const statsCachePath = getClaudeStatsCachePath();
const projectsDir = getClaudeProjectsDir();
const credentialPaths = getClaudeCredentialPaths();
// Initialize checks with paths
const settingsFileCheck: FileCheckResult = {
path: settingsPath,
exists: false,
readable: false,
};
const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = {
path: statsCachePath,
exists: false,
readable: false,
};
const projectsDirCheck: DirectoryCheckResult = {
path: projectsDir,
exists: false,
readable: false,
entryCount: 0,
};
const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({
path: p,
exists: false,
readable: false,
}));
const result: ClaudeAuthIndicators = {
hasCredentialsFile: false,
hasSettingsFile: false,
hasStatsCacheWithActivity: false,
hasProjectsSessions: false,
credentials: null,
checks: {
settingsFile: settingsFileCheck,
statsCache: statsCacheCheck,
projectsDir: projectsDirCheck,
credentialFiles: credentialFileChecks,
},
};
// Check settings file
// First check existence, then try to read to confirm it's actually readable
try {
if (await systemPathAccess(getClaudeSettingsPath())) {
result.hasSettingsFile = true;
if (await systemPathAccess(settingsPath)) {
settingsFileCheck.exists = true;
// Try to actually read the file to confirm read permissions
try {
await systemPathReadFile(settingsPath);
settingsFileCheck.readable = true;
result.hasSettingsFile = true;
} catch (readErr) {
// File exists but cannot be read (permission denied, etc.)
settingsFileCheck.readable = false;
settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`;
}
}
} catch {
// Ignore errors
} catch (err) {
settingsFileCheck.error = err instanceof Error ? err.message : String(err);
}
// Check stats cache for recent activity
try {
const statsContent = await systemPathReadFile(getClaudeStatsCachePath());
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
result.hasStatsCacheWithActivity = true;
const statsContent = await systemPathReadFile(statsCachePath);
statsCacheCheck.exists = true;
statsCacheCheck.readable = true;
try {
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
statsCacheCheck.hasDailyActivity = true;
result.hasStatsCacheWithActivity = true;
} else {
statsCacheCheck.hasDailyActivity = false;
}
} catch (parseErr) {
statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
statsCacheCheck.exists = false;
} else {
statsCacheCheck.error = err instanceof Error ? err.message : String(err);
}
} catch {
// Ignore errors
}
// Check for sessions in projects directory
try {
const sessions = await systemPathReaddir(getClaudeProjectsDir());
const sessions = await systemPathReaddir(projectsDir);
projectsDirCheck.exists = true;
projectsDirCheck.readable = true;
projectsDirCheck.entryCount = sessions.length;
if (sessions.length > 0) {
result.hasProjectsSessions = true;
}
} catch {
// Ignore errors
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
projectsDirCheck.exists = false;
} else {
projectsDirCheck.error = err instanceof Error ? err.message : String(err);
}
}
// Check credentials files
const credentialPaths = getClaudeCredentialPaths();
for (const credPath of credentialPaths) {
// We iterate through all credential paths and only stop when we find a file
// that contains actual credentials (OAuth tokens or API keys). An empty or
// token-less file should not prevent checking subsequent credential paths.
for (let i = 0; i < credentialPaths.length; i++) {
const credPath = credentialPaths[i];
const credCheck = credentialFileChecks[i];
try {
const content = await systemPathReadFile(credPath);
const credentials = JSON.parse(content);
result.hasCredentialsFile = true;
// Support multiple credential formats:
// 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } }
// 2. Legacy format: { oauth_token } or { access_token }
// 3. API key format: { api_key }
const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken;
const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token);
result.credentials = {
hasOAuthToken: hasClaudeOauth || hasLegacyOauth,
hasApiKey: !!credentials.api_key,
};
break;
} catch {
// Continue to next path
credCheck.exists = true;
credCheck.readable = true;
try {
const credentials = JSON.parse(content);
// Support multiple credential formats:
// 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } }
// 2. Legacy format: { oauth_token } or { access_token }
// 3. API key format: { api_key }
const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken;
const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token);
const hasOAuthToken = hasClaudeOauth || hasLegacyOauth;
const hasApiKey = !!credentials.api_key;
// Only consider this a valid credentials file if it actually contains tokens
// An empty JSON file ({}) or file without tokens should not stop us from
// checking subsequent credential paths
if (hasOAuthToken || hasApiKey) {
result.hasCredentialsFile = true;
result.credentials = {
hasOAuthToken,
hasApiKey,
};
break; // Found valid credentials, stop searching
}
// File exists and is valid JSON but contains no tokens - continue checking other paths
} catch (parseErr) {
credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
credCheck.exists = false;
} else {
credCheck.error = err instanceof Error ? err.message : String(err);
}
}
}
@@ -1142,6 +1273,7 @@ export function getOpenCodeCliPaths(): string[] {
// Go installation (if OpenCode is a Go binary)
path.join(homeDir, 'go', 'bin', 'opencode.exe'),
path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode.exe'),
...getNvmWindowsCliPaths('opencode'),
];
}

View File

@@ -0,0 +1,761 @@
/**
* Unit tests for OAuth credential detection scenarios
*
* Tests the various Claude credential detection formats including:
* - Claude Code CLI OAuth format (claudeAiOauth)
* - Legacy OAuth token format (oauth_token, access_token)
* - API key format (api_key)
* - Invalid/malformed credential files
*
* These tests use real temp directories to avoid complex fs mocking issues.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
describe('OAuth Credential Detection', () => {
let tempDir: string;
let originalHomedir: () => string;
let mockClaudeDir: string;
let mockCodexDir: string;
let mockOpenCodeDir: string;
beforeEach(async () => {
// Reset modules to get fresh state
vi.resetModules();
// Create a temporary directory
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-'));
// Create mock home directory structure
mockClaudeDir = path.join(tempDir, '.claude');
mockCodexDir = path.join(tempDir, '.codex');
mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode');
await fs.mkdir(mockClaudeDir, { recursive: true });
await fs.mkdir(mockCodexDir, { recursive: true });
await fs.mkdir(mockOpenCodeDir, { recursive: true });
// Mock os.homedir to return our temp directory
originalHomedir = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
});
afterEach(async () => {
vi.restoreAllMocks();
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getClaudeAuthIndicators', () => {
it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => {
const credentialsContent = JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-access-token-12345',
refreshToken: 'oauth-refresh-token-67890',
expiresAt: Date.now() + 3600000,
},
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials).not.toBeNull();
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect legacy OAuth token format (oauth_token)', async () => {
const credentialsContent = JSON.stringify({
oauth_token: 'legacy-oauth-token-abcdef',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect legacy access_token format', async () => {
const credentialsContent = JSON.stringify({
access_token: 'legacy-access-token-xyz',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect API key format', async () => {
const credentialsContent = JSON.stringify({
api_key: 'sk-ant-api03-xxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(false);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should detect both OAuth and API key when present', async () => {
const credentialsContent = JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
},
api_key: 'sk-ant-api03-xxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should handle missing credentials file gracefully', async () => {
// No credentials file created
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
expect(indicators.checks.credentialFiles).toBeDefined();
expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0);
expect(indicators.checks.credentialFiles[0].exists).toBe(false);
});
it('should handle malformed JSON in credentials file', async () => {
const malformedContent = '{ invalid json }';
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File exists but parsing fails
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
expect(indicators.checks.credentialFiles[0].exists).toBe(true);
expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error');
});
it('should handle empty credentials file', async () => {
const emptyContent = JSON.stringify({});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Empty credentials file ({}) should NOT be treated as having credentials
// because it contains no actual tokens. This allows the system to continue
// checking subsequent credential paths that might have valid tokens.
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
// But the file should still show as existing and readable in the checks
expect(indicators.checks.credentialFiles[0].exists).toBe(true);
expect(indicators.checks.credentialFiles[0].readable).toBe(true);
});
it('should handle credentials file with null values', async () => {
const nullContent = JSON.stringify({
claudeAiOauth: null,
api_key: null,
oauth_token: null,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File with all null values should NOT be treated as having credentials
// because null values are not valid tokens
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle credentials with empty string values', async () => {
const emptyStrings = JSON.stringify({
claudeAiOauth: {
accessToken: '',
refreshToken: '',
},
api_key: '',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Empty strings should NOT be treated as having credentials
// This allows checking subsequent credential paths for valid tokens
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should detect settings file presence', async () => {
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.checks.settingsFile.exists).toBe(true);
expect(indicators.checks.settingsFile.readable).toBe(true);
});
it('should detect stats cache with activity', async () => {
const statsContent = JSON.stringify({
dailyActivity: [
{ date: '2025-01-15', messagesCount: 10 },
{ date: '2025-01-16', messagesCount: 5 },
],
});
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(true);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.hasDailyActivity).toBe(true);
});
it('should detect stats cache without activity', async () => {
const statsContent = JSON.stringify({
dailyActivity: [],
});
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(false);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.hasDailyActivity).toBe(false);
});
it('should detect project sessions', async () => {
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
await fs.mkdir(path.join(projectsDir, 'session-2'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasProjectsSessions).toBe(true);
expect(indicators.checks.projectsDir.exists).toBe(true);
expect(indicators.checks.projectsDir.entryCount).toBe(2);
});
it('should return comprehensive check details', async () => {
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Verify all check detail objects are present
expect(indicators.checks).toBeDefined();
expect(indicators.checks.settingsFile).toBeDefined();
expect(indicators.checks.settingsFile.path).toContain('settings.json');
expect(indicators.checks.statsCache).toBeDefined();
expect(indicators.checks.statsCache.path).toContain('stats-cache.json');
expect(indicators.checks.projectsDir).toBeDefined();
expect(indicators.checks.projectsDir.path).toContain('projects');
expect(indicators.checks.credentialFiles).toBeDefined();
expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true);
});
it('should try both .credentials.json and credentials.json paths', async () => {
// Write to credentials.json (without leading dot)
const credentialsContent = JSON.stringify({
api_key: 'sk-test-key',
});
await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should find credentials in the second path
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should prefer first credentials file if both exist', async () => {
// Write OAuth to .credentials.json (first path checked)
await fs.writeFile(
path.join(mockClaudeDir, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
},
})
);
// Write API key to credentials.json (second path)
await fs.writeFile(
path.join(mockClaudeDir, 'credentials.json'),
JSON.stringify({
api_key: 'sk-test-key',
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should use first file (.credentials.json) which has OAuth
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should check second credentials file if first file has no tokens', async () => {
// Write empty/token-less content to .credentials.json (first path checked)
// This tests the bug fix: previously, an empty JSON file would stop the search
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({}));
// Write actual credentials to credentials.json (second path)
await fs.writeFile(
path.join(mockClaudeDir, 'credentials.json'),
JSON.stringify({
api_key: 'sk-test-key-from-second-file',
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should find credentials in second file since first file has no tokens
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
});
describe('getCodexAuthIndicators', () => {
it('should detect OAuth token in Codex auth file', async () => {
const authContent = JSON.stringify({
access_token: 'codex-oauth-token-12345',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect API key in Codex auth file', async () => {
const authContent = JSON.stringify({
OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(true);
});
it('should detect nested tokens in Codex auth file', async () => {
const authContent = JSON.stringify({
tokens: {
oauth_token: 'nested-oauth-token',
},
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should handle missing Codex auth file', async () => {
// No auth file created
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(false);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect api_key field in Codex auth', async () => {
const authContent = JSON.stringify({
api_key: 'sk-api-key-value',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasApiKey).toBe(true);
});
});
describe('getOpenCodeAuthIndicators', () => {
it('should detect provider-specific OAuth credentials', async () => {
const authContent = JSON.stringify({
anthropic: {
type: 'oauth',
access: 'oauth-access-token',
refresh: 'oauth-refresh-token',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect GitHub Copilot refresh token as OAuth', async () => {
const authContent = JSON.stringify({
'github-copilot': {
type: 'oauth',
access: '', // Empty access token
refresh: 'gh-refresh-token', // But has refresh token
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should detect provider-specific API key credentials', async () => {
const authContent = JSON.stringify({
openai: {
type: 'api_key',
key: 'sk-xxxxxxxxxxxx',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(true);
});
it('should detect multiple providers', async () => {
const authContent = JSON.stringify({
anthropic: {
type: 'oauth',
access: 'anthropic-token',
refresh: 'refresh-token',
},
openai: {
type: 'api_key',
key: 'sk-xxxxxxxxxxxx',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(true);
});
it('should handle missing OpenCode auth file', async () => {
// No auth file created
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(false);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(false);
});
it('should handle legacy top-level OAuth keys', async () => {
const authContent = JSON.stringify({
access_token: 'legacy-access-token',
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should detect copilot provider OAuth', async () => {
const authContent = JSON.stringify({
copilot: {
type: 'oauth',
access: 'copilot-access-token',
refresh: 'copilot-refresh-token',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
});
describe('Credential path helpers', () => {
it('should return correct Claude credential paths', async () => {
const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths');
const configDir = getClaudeConfigDir();
expect(configDir).toContain('.claude');
const credPaths = getClaudeCredentialPaths();
expect(credPaths.length).toBeGreaterThan(0);
expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true);
expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true);
});
it('should return correct Codex auth path', async () => {
const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths');
const configDir = getCodexConfigDir();
expect(configDir).toContain('.codex');
const authPath = getCodexAuthPath();
expect(authPath).toContain('.codex');
expect(authPath).toContain('auth.json');
});
it('should return correct OpenCode auth path', async () => {
const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths');
const configDir = getOpenCodeConfigDir();
expect(configDir).toContain('opencode');
const authPath = getOpenCodeAuthPath();
expect(authPath).toContain('opencode');
expect(authPath).toContain('auth.json');
});
});
describe('Edge cases for credential detection', () => {
it('should handle credentials file with unexpected structure', async () => {
const unexpectedContent = JSON.stringify({
someUnexpectedKey: 'value',
nested: {
deeply: {
unexpected: true,
},
},
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File with unexpected structure but no valid tokens should NOT be treated as having credentials
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle array instead of object in credentials', async () => {
const arrayContent = JSON.stringify(['token1', 'token2']);
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle numeric values in credential fields', async () => {
const numericContent = JSON.stringify({
api_key: 12345,
oauth_token: 67890,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Note: Current implementation uses JavaScript truthiness which accepts numbers
// This documents the actual behavior - ideally would validate string type
expect(indicators.hasCredentialsFile).toBe(true);
// The implementation checks truthiness, not strict string type
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should handle boolean values in credential fields', async () => {
const booleanContent = JSON.stringify({
api_key: true,
oauth_token: false,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Note: Current implementation uses JavaScript truthiness
// api_key: true is truthy, oauth_token: false is falsy
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy
expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy
});
it('should handle malformed stats-cache.json gracefully', async () => {
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }');
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(false);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.error).toBeDefined();
});
it('should handle empty projects directory', async () => {
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasProjectsSessions).toBe(false);
expect(indicators.checks.projectsDir.exists).toBe(true);
expect(indicators.checks.projectsDir.entryCount).toBe(0);
});
});
describe('Combined authentication scenarios', () => {
it('should detect CLI authenticated state with settings + sessions', async () => {
// Create settings file
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
// Create projects directory with sessions
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.hasProjectsSessions).toBe(true);
});
it('should detect recent activity indicating working auth', async () => {
// Create stats cache with recent activity
await fs.writeFile(
path.join(mockClaudeDir, 'stats-cache.json'),
JSON.stringify({
dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }],
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(true);
});
it('should handle complete auth setup', async () => {
// Create all auth indicators
await fs.writeFile(
path.join(mockClaudeDir, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'token',
refreshToken: 'refresh',
},
})
);
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
await fs.writeFile(
path.join(mockClaudeDir, 'stats-cache.json'),
JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] })
);
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.hasStatsCacheWithActivity).toBe(true);
expect(indicators.hasProjectsSessions).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
});
});
});

View File

@@ -6,6 +6,7 @@
* IMPORTANT: All Codex models use 'codex-' prefix to distinguish from Cursor CLI models
*/
export type CodexModelId =
| 'codex-gpt-5.3-codex'
| 'codex-gpt-5.2-codex'
| 'codex-gpt-5.1-codex-max'
| 'codex-gpt-5.1-codex-mini'
@@ -29,31 +30,38 @@ export interface CodexModelConfig {
* All keys use 'codex-' prefix to distinguish from Cursor CLI models
*/
export const CODEX_MODEL_CONFIG_MAP: Record<CodexModelId, CodexModelConfig> = {
'codex-gpt-5.3-codex': {
id: 'codex-gpt-5.3-codex',
label: 'GPT-5.3-Codex',
description: 'Latest frontier agentic coding model',
hasThinking: true,
supportsVision: true,
},
'codex-gpt-5.2-codex': {
id: 'codex-gpt-5.2-codex',
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering',
description: 'Frontier agentic coding model',
hasThinking: true,
supportsVision: true,
},
'codex-gpt-5.1-codex-max': {
id: 'codex-gpt-5.1-codex-max',
label: 'GPT-5.1-Codex-Max',
description: 'Optimized for long-horizon, agentic coding tasks in Codex',
description: 'Codex-optimized flagship for deep and fast reasoning',
hasThinking: true,
supportsVision: true,
},
'codex-gpt-5.1-codex-mini': {
id: 'codex-gpt-5.1-codex-mini',
label: 'GPT-5.1-Codex-Mini',
description: 'Smaller, more cost-effective version for faster workflows',
description: 'Optimized for codex. Cheaper, faster, but less capable',
hasThinking: false,
supportsVision: true,
},
'codex-gpt-5.2': {
id: 'codex-gpt-5.2',
label: 'GPT-5.2 (Codex)',
description: 'Best general agentic model for tasks across industries and domains via Codex',
description: 'Latest frontier model with improvements across knowledge, reasoning and coding',
hasThinking: true,
supportsVision: true,
},

View File

@@ -46,6 +46,7 @@ export type EventType =
| 'dev-server:started'
| 'dev-server:output'
| 'dev-server:stopped'
| 'dev-server:url-detected'
| 'test-runner:started'
| 'test-runner:progress'
| 'test-runner:output'

View File

@@ -196,6 +196,8 @@ export {
PROJECT_SETTINGS_VERSION,
THINKING_TOKEN_BUDGET,
getThinkingTokenBudget,
isAdaptiveThinkingModel,
getThinkingLevelsForModel,
// Event hook constants
EVENT_HOOK_TRIGGER_LABELS,
// Claude-compatible provider templates (new)

View File

@@ -72,10 +72,18 @@ export const CLAUDE_MODELS: ModelOption[] = [
* Official models from https://developers.openai.com/codex/models/
*/
export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
{
id: CODEX_MODEL_MAP.gpt53Codex,
label: 'GPT-5.3-Codex',
description: 'Latest frontier agentic coding model.',
badge: 'Premium',
provider: 'codex',
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt52Codex,
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering.',
description: 'Frontier agentic coding model.',
badge: 'Premium',
provider: 'codex',
hasReasoning: true,
@@ -83,7 +91,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
label: 'GPT-5.1-Codex-Max',
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
description: 'Codex-optimized flagship for deep and fast reasoning.',
badge: 'Premium',
provider: 'codex',
hasReasoning: true,
@@ -91,7 +99,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
{
id: CODEX_MODEL_MAP.gpt51CodexMini,
label: 'GPT-5.1-Codex-Mini',
description: 'Smaller, more cost-effective version for faster workflows.',
description: 'Optimized for codex. Cheaper, faster, but less capable.',
badge: 'Speed',
provider: 'codex',
hasReasoning: false,
@@ -99,7 +107,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
{
id: CODEX_MODEL_MAP.gpt52,
label: 'GPT-5.2',
description: 'Best general agentic model for tasks across industries and domains.',
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
badge: 'Balanced',
provider: 'codex',
hasReasoning: true,
@@ -141,6 +149,7 @@ export const THINKING_LEVELS: ThinkingLevelOption[] = [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'ultrathink', label: 'Ultrathink' },
{ id: 'adaptive', label: 'Adaptive' },
];
/**
@@ -154,6 +163,7 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
adaptive: 'Adaptive',
};
/**
@@ -211,6 +221,7 @@ export function getModelDisplayName(model: ModelAlias | string): string {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
[CODEX_MODEL_MAP.gpt53Codex]: 'GPT-5.3-Codex',
[CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex',
[CODEX_MODEL_MAP.gpt51CodexMax]: 'GPT-5.1-Codex-Max',
[CODEX_MODEL_MAP.gpt51CodexMini]: 'GPT-5.1-Codex-Mini',

View File

@@ -18,7 +18,7 @@ export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus'
export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
'claude-haiku': 'claude-haiku-4-5-20251001',
'claude-sonnet': 'claude-sonnet-4-5-20250929',
'claude-opus': 'claude-opus-4-5-20251101',
'claude-opus': 'claude-opus-4-6',
} as const;
/**
@@ -29,7 +29,7 @@ export const CLAUDE_CANONICAL_MAP: Record<ClaudeCanonicalId, string> = {
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-5-20250929',
opus: 'claude-opus-4-5-20251101',
opus: 'claude-opus-4-6',
} as const;
/**
@@ -50,15 +50,17 @@ export const LEGACY_CLAUDE_ALIAS_MAP: Record<string, ClaudeCanonicalId> = {
*/
export const CODEX_MODEL_MAP = {
// Recommended Codex-specific models
/** Most advanced agentic coding model for complex software engineering (default for ChatGPT users) */
/** Latest frontier agentic coding model */
gpt53Codex: 'codex-gpt-5.3-codex',
/** Frontier agentic coding model */
gpt52Codex: 'codex-gpt-5.2-codex',
/** Optimized for long-horizon, agentic coding tasks in Codex */
/** Codex-optimized flagship for deep and fast reasoning */
gpt51CodexMax: 'codex-gpt-5.1-codex-max',
/** Smaller, more cost-effective version for faster workflows */
/** Optimized for codex. Cheaper, faster, but less capable */
gpt51CodexMini: 'codex-gpt-5.1-codex-mini',
// General-purpose GPT models (also available in Codex)
/** Best general agentic model for tasks across industries and domains */
/** Latest frontier model with improvements across knowledge, reasoning and coding */
gpt52: 'codex-gpt-5.2',
/** Great for coding and agentic tasks across domains */
gpt51: 'codex-gpt-5.1',
@@ -71,6 +73,7 @@ export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP);
* These models can use reasoning.effort parameter
*/
export const REASONING_CAPABLE_MODELS = new Set([
CODEX_MODEL_MAP.gpt53Codex,
CODEX_MODEL_MAP.gpt52Codex,
CODEX_MODEL_MAP.gpt51CodexMax,
CODEX_MODEL_MAP.gpt52,
@@ -96,9 +99,9 @@ export function getAllCodexModelIds(): CodexModelId[] {
* Uses canonical prefixed IDs for consistent routing.
*/
export const DEFAULT_MODELS = {
claude: 'claude-opus-4-5-20251101',
claude: 'claude-opus-4-6',
cursor: 'cursor-auto', // Cursor's recommended default (with prefix)
codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model
codex: CODEX_MODEL_MAP.gpt53Codex, // GPT-5.3-Codex is the latest frontier agentic coding model
} as const;
export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP;

View File

@@ -213,7 +213,7 @@ export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
export type ServerLogLevel = 'error' | 'warn' | 'info' | 'debug';
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink' | 'adaptive';
/**
* SidebarStyle - Sidebar layout style options
@@ -237,6 +237,7 @@ export const THINKING_TOKEN_BUDGET: Record<ThinkingLevel, number | undefined> =
medium: 10000, // Light reasoning
high: 16000, // Complex tasks (recommended starting point)
ultrathink: 32000, // Maximum safe (above this risks timeouts)
adaptive: undefined, // Adaptive thinking (Opus 4.6) - SDK handles token allocation
};
/**
@@ -247,6 +248,26 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
return THINKING_TOKEN_BUDGET[level];
}
/**
* Check if a model uses adaptive thinking (Opus 4.6+)
* Adaptive thinking models let the SDK decide token allocation automatically.
*/
export function isAdaptiveThinkingModel(model: string): boolean {
return model.includes('opus-4-6') || model === 'claude-opus';
}
/**
* Get the available thinking levels for a given model.
* - Opus 4.6: Only 'none' and 'adaptive' (SDK handles token allocation)
* - Others: Full range of manual thinking levels
*/
export function getThinkingLevelsForModel(model: string): ThinkingLevel[] {
if (isAdaptiveThinkingModel(model)) {
return ['none', 'adaptive'];
}
return ['none', 'low', 'medium', 'high', 'ultrathink'];
}
/** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';