mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Merge remote-tracking branch 'upstream/v0.15.0rc' into refactor/auto-mode-service-gsxdsm
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,3 +98,5 @@ data/
|
||||
|
||||
# GSD planning docs (local-only)
|
||||
.planning/
|
||||
.mcp.json
|
||||
.planning
|
||||
|
||||
@@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
- `opus` → `claude-opus-4-6`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -118,6 +118,7 @@ RUN curl -fsSL https://opencode.ai/install | bash && \
|
||||
echo "=== Checking OpenCode CLI installation ===" && \
|
||||
ls -la /home/automaker/.local/bin/ && \
|
||||
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
|
||||
|
||||
USER root
|
||||
|
||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
||||
@@ -147,6 +148,15 @@ COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
|
||||
# Copy node_modules (includes symlinks to libs)
|
||||
COPY --from=server-builder /app/node_modules ./node_modules
|
||||
|
||||
# Install Playwright Chromium browser for AI agent verification tests
|
||||
# This adds ~300MB to the image but enables automated testing mode out of the box
|
||||
# Using the locally installed playwright ensures we use the pinned version from package-lock.json
|
||||
USER automaker
|
||||
RUN ./node_modules/.bin/playwright install chromium && \
|
||||
echo "=== Playwright Chromium installed ===" && \
|
||||
ls -la /home/automaker/.cache/ms-playwright/
|
||||
USER root
|
||||
|
||||
# Create data and projects directories
|
||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
||||
|
||||
|
||||
36
README.md
36
README.md
@@ -367,6 +367,42 @@ services:
|
||||
|
||||
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
|
||||
|
||||
##### Playwright for Automated Testing
|
||||
|
||||
The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly.
|
||||
|
||||
**No additional setup required** - Playwright verification works out of the box.
|
||||
|
||||
#### Optional: Persist browsers for manual updates
|
||||
|
||||
By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume.
|
||||
|
||||
**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them:
|
||||
|
||||
```bash
|
||||
# After adding the volume mount for the first time
|
||||
docker exec --user automaker -w /app automaker-server npx playwright install chromium
|
||||
```
|
||||
|
||||
Add this to your `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
- playwright-cache:/home/automaker/.cache/ms-playwright
|
||||
|
||||
volumes:
|
||||
playwright-cache:
|
||||
name: automaker-playwright-cache
|
||||
```
|
||||
|
||||
**Updating browsers manually:**
|
||||
|
||||
```bash
|
||||
docker exec --user automaker -w /app automaker-server npx playwright install chromium
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.32",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
@@ -45,6 +45,7 @@
|
||||
"yaml": "2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/cookie": "0.6.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
|
||||
@@ -121,21 +121,57 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
|
||||
(async () => {
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
logger.debug('[CREDENTIAL_CHECK] Starting credential detection...');
|
||||
logger.debug('[CREDENTIAL_CHECK] Environment variables:', {
|
||||
hasAnthropicKey,
|
||||
hasEnvOAuthToken,
|
||||
});
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasEnvOAuthToken) {
|
||||
logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Claude Code CLI authentication
|
||||
// Store indicators outside the try block so we can use them in the warning message
|
||||
let cliAuthIndicators: Awaited<ReturnType<typeof getClaudeAuthIndicators>> | null = null;
|
||||
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
cliAuthIndicators = await getClaudeAuthIndicators();
|
||||
const indicators = cliAuthIndicators;
|
||||
|
||||
// Log detailed credential detection results
|
||||
const { checks, ...indicatorSummary } = indicators;
|
||||
logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary);
|
||||
|
||||
logger.debug('[CREDENTIAL_CHECK] File check details:', checks);
|
||||
|
||||
const hasCliAuth =
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
||||
(indicators.hasCredentialsFile &&
|
||||
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
|
||||
|
||||
logger.debug('[CREDENTIAL_CHECK] Auth determination:', {
|
||||
hasCliAuth,
|
||||
reason: hasCliAuth
|
||||
? indicators.hasStatsCacheWithActivity
|
||||
? 'stats cache with activity'
|
||||
: indicators.hasSettingsFile && indicators.hasProjectsSessions
|
||||
? 'settings file + project sessions'
|
||||
: indicators.credentials?.hasOAuthToken
|
||||
? 'credentials file with OAuth token'
|
||||
: 'credentials file with API key'
|
||||
: 'no valid credentials found',
|
||||
});
|
||||
|
||||
if (hasCliAuth) {
|
||||
logger.info('✓ Claude Code CLI authentication detected');
|
||||
return;
|
||||
@@ -145,7 +181,7 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
logger.warn('Error checking for Claude Code CLI authentication:', error);
|
||||
}
|
||||
|
||||
// No authentication found - show warning
|
||||
// No authentication found - show warning with paths that were checked
|
||||
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
@@ -158,6 +194,33 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
|
||||
// Build paths checked summary from the indicators (if available)
|
||||
let pathsCheckedInfo = '';
|
||||
if (cliAuthIndicators) {
|
||||
const pathsChecked: string[] = [];
|
||||
|
||||
// Collect paths that were checked (paths are always populated strings)
|
||||
pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`);
|
||||
pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`);
|
||||
pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`);
|
||||
for (const credFile of cliAuthIndicators.checks.credentialFiles) {
|
||||
pathsChecked.push(`Credentials: ${credFile.path}`);
|
||||
}
|
||||
|
||||
if (pathsChecked.length > 0) {
|
||||
pathsCheckedInfo = `
|
||||
║ ║
|
||||
║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║
|
||||
${pathsChecked
|
||||
.map((p) => {
|
||||
const maxLen = BOX_CONTENT_WIDTH - 4;
|
||||
const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p;
|
||||
return `║ ${display.padEnd(maxLen)} ║`;
|
||||
})
|
||||
.join('\n')}`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${wHeader}║
|
||||
@@ -169,7 +232,7 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
║ ${w3}║
|
||||
║ ${w4}║
|
||||
║ ${w5}║
|
||||
║ ${w6}║
|
||||
║ ${w6}║${pathsCheckedInfo}
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
@@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
||||
/**
|
||||
* Build thinking options for SDK configuration.
|
||||
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
|
||||
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
|
||||
* decide its own reasoning depth.
|
||||
*
|
||||
* @param thinkingLevel - The thinking level to convert
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
|
||||
*/
|
||||
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
||||
if (!thinkingLevel || thinkingLevel === 'none') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
|
||||
// The model will use adaptive thinking by default
|
||||
if (thinkingLevel === 'adaptive') {
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Manual budget-based thinking for Haiku/Sonnet
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
|
||||
|
||||
@@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider {
|
||||
// claudeCompatibleProvider takes precedence over claudeApiProfile
|
||||
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
// Build thinking configuration
|
||||
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
|
||||
// Manual thinking (Haiku/Sonnet): use budget_tokens
|
||||
const maxThinkingTokens =
|
||||
thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
// Build Claude SDK options
|
||||
const sdkOptions: Options = {
|
||||
@@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider {
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
const models = [
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
modelString: 'claude-opus-4-5-20251101',
|
||||
id: 'claude-opus-4-6',
|
||||
name: 'Claude Opus 4.6',
|
||||
modelString: 'claude-opus-4-6',
|
||||
provider: 'anthropic',
|
||||
description: 'Most capable Claude model',
|
||||
description: 'Most capable Claude model with adaptive thinking',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
maxOutputTokens: 128000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
|
||||
@@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000;
|
||||
export const CODEX_MODELS: ModelDefinition[] = [
|
||||
// ========== Recommended Codex Models ==========
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
id: CODEX_MODEL_MAP.gpt53Codex,
|
||||
name: 'GPT-5.3-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt53Codex,
|
||||
provider: 'openai',
|
||||
description:
|
||||
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
|
||||
description: 'Latest frontier agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -33,12 +32,25 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
default: true,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
provider: 'openai',
|
||||
description: 'Frontier agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
name: 'GPT-5.1-Codex-Max',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
provider: 'openai',
|
||||
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
|
||||
description: 'Codex-optimized flagship for deep and fast reasoning.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
name: 'GPT-5.1-Codex-Mini',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
|
||||
provider: 'openai',
|
||||
description: 'Smaller, more cost-effective version for faster workflows.',
|
||||
description: 'Optimized for codex. Cheaper, faster, but less capable.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
@@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
name: 'GPT-5.2',
|
||||
modelString: CODEX_MODEL_MAP.gpt52,
|
||||
provider: 'openai',
|
||||
description: 'Best general agentic model for tasks across industries and domains.',
|
||||
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ProviderFactory {
|
||||
/**
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param options Optional settings
|
||||
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
|
||||
* @returns Provider instance for the model
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getClaudeAuthIndicators } from '@automaker/platform';
|
||||
import { getApiKey } from '../common.js';
|
||||
import {
|
||||
createSecureAuthEnv,
|
||||
@@ -320,9 +321,28 @@ export function createVerifyClaudeAuthHandler() {
|
||||
authMethod,
|
||||
});
|
||||
|
||||
// Determine specific auth type for success messages
|
||||
const effectiveAuthMethod = authMethod ?? 'api_key';
|
||||
let authType: 'oauth' | 'api_key' | 'cli' | undefined;
|
||||
if (authenticated) {
|
||||
if (effectiveAuthMethod === 'api_key') {
|
||||
authType = 'api_key';
|
||||
} else if (effectiveAuthMethod === 'cli') {
|
||||
// Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
authType = indicators.credentials?.hasOAuthToken ? 'oauth' : 'cli';
|
||||
} catch {
|
||||
// Fall back to generic CLI if credential check fails
|
||||
authType = 'cli';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
authType,
|
||||
error: errorMessage || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('model-resolver.ts', () => {
|
||||
|
||||
it("should resolve 'opus' alias to full model string", () => {
|
||||
const result = resolveModelString('opus');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(result).toBe('claude-opus-4-6');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
|
||||
);
|
||||
@@ -117,7 +117,7 @@ describe('model-resolver.ts', () => {
|
||||
describe('getEffectiveModel', () => {
|
||||
it('should prioritize explicit model over session and default', () => {
|
||||
const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(result).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('should use session model when explicit is not provided', () => {
|
||||
|
||||
@@ -491,5 +491,29 @@ describe('sdk-options.ts', () => {
|
||||
expect(options.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptive thinking for Opus 4.6', () => {
|
||||
it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
thinkingLevel: 'adaptive',
|
||||
});
|
||||
|
||||
expect(options.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
thinkingLevel: 'none',
|
||||
});
|
||||
|
||||
expect(options.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test prompt',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test/dir',
|
||||
systemPrompt: 'You are helpful',
|
||||
maxTurns: 10,
|
||||
@@ -71,7 +71,7 @@ describe('claude-provider.ts', () => {
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test prompt',
|
||||
options: expect.objectContaining({
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
systemPrompt: 'You are helpful',
|
||||
maxTurns: 10,
|
||||
cwd: '/test/dir',
|
||||
@@ -91,7 +91,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
abortController,
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Current message',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
conversationHistory,
|
||||
sdkSessionId: 'test-session-id',
|
||||
@@ -176,7 +176,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: arrayPrompt as any,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -366,12 +366,12 @@ describe('claude-provider.ts', () => {
|
||||
expect(models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should include Claude Opus 4.5', () => {
|
||||
it('should include Claude Opus 4.6', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-6');
|
||||
expect(opus).toBeDefined();
|
||||
expect(opus?.name).toBe('Claude Opus 4.5');
|
||||
expect(opus?.name).toBe('Claude Opus 4.6');
|
||||
expect(opus?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('claude-provider.ts', () => {
|
||||
it('should mark Opus as default', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-6');
|
||||
expect(opus?.default).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('provider-factory.ts', () => {
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
describe('Claude models (claude-* prefix)', () => {
|
||||
it('should return ClaudeProvider for claude-opus-4-5-20251101', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
|
||||
it('should return ClaudeProvider for claude-opus-4-6', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-6');
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('provider-factory.ts', () => {
|
||||
});
|
||||
|
||||
it('should be case-insensitive for claude models', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101');
|
||||
const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6');
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ The agent is configured with:
|
||||
|
||||
```javascript
|
||||
{
|
||||
model: "claude-opus-4-5-20251101",
|
||||
model: "claude-opus-4-6",
|
||||
maxTurns: 20,
|
||||
cwd: workingDirectory,
|
||||
allowedTools: [
|
||||
|
||||
@@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
||||
For safer operation, consider running Automaker in Docker. See the README for
|
||||
instructions.
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Already running in Docker? Try these troubleshooting steps:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
Ensure <code className="bg-muted px-1 rounded">IS_CONTAINERIZED=true</code> is
|
||||
set in your docker-compose environment
|
||||
</li>
|
||||
<li>
|
||||
Verify the server container has the environment variable:{' '}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
docker exec automaker-server printenv IS_CONTAINERIZED
|
||||
</code>
|
||||
</li>
|
||||
<li>Rebuild and restart containers if you recently changed the configuration</li>
|
||||
<li>
|
||||
Check the server logs for startup messages:{' '}
|
||||
<code className="bg-muted px-1 rounded">docker-compose logs server</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { cn } from '@/lib/utils';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
||||
import { supportsReasoningEffort } from '@automaker/types';
|
||||
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
||||
import {
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
@@ -264,7 +264,20 @@ export function AddFeatureDialog({
|
||||
}, [planningMode]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
// Normalize thinking level when switching between adaptive and non-adaptive models
|
||||
const isNewModelAdaptive =
|
||||
typeof entry.model === 'string' && isAdaptiveThinkingModel(entry.model);
|
||||
const currentLevel = entry.thinkingLevel || 'none';
|
||||
|
||||
if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') {
|
||||
// Switching TO Opus 4.6 with a manual level -> auto-switch to 'adaptive'
|
||||
setModelEntry({ ...entry, thinkingLevel: 'adaptive' });
|
||||
} else if (!isNewModelAdaptive && currentLevel === 'adaptive') {
|
||||
// Switching FROM Opus 4.6 with adaptive -> auto-switch to 'high'
|
||||
setModelEntry({ ...entry, thinkingLevel: 'high' });
|
||||
} else {
|
||||
setModelEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const buildFeatureData = (): FeatureData | null => {
|
||||
|
||||
@@ -167,7 +167,14 @@ export const ALL_MODELS: ModelOption[] = [
|
||||
...COPILOT_MODELS,
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = [
|
||||
'none',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'ultrathink',
|
||||
'adaptive',
|
||||
];
|
||||
|
||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: 'None',
|
||||
@@ -175,6 +182,7 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra',
|
||||
adaptive: 'Adaptive',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,19 +2,26 @@ import { Label } from '@/components/ui/label';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ThinkingLevel } from '@/store/app-store';
|
||||
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants';
|
||||
import { THINKING_LEVEL_LABELS } from './model-constants';
|
||||
import { getThinkingLevelsForModel } from '@automaker/types';
|
||||
|
||||
interface ThinkingLevelSelectorProps {
|
||||
selectedLevel: ThinkingLevel;
|
||||
onLevelSelect: (level: ThinkingLevel) => void;
|
||||
testIdPrefix?: string;
|
||||
/** Model ID is required for correct thinking level filtering.
|
||||
* Without it, adaptive thinking won't be available for Opus 4.6. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function ThinkingLevelSelector({
|
||||
selectedLevel,
|
||||
onLevelSelect,
|
||||
testIdPrefix = 'thinking-level',
|
||||
model,
|
||||
}: ThinkingLevelSelectorProps) {
|
||||
const levels = getThinkingLevelsForModel(model || '');
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="flex items-center gap-2 text-sm">
|
||||
@@ -22,7 +29,7 @@ export function ThinkingLevelSelector({
|
||||
Thinking Level
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{levels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
@@ -40,7 +47,9 @@ export function ThinkingLevelSelector({
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher levels give more time to reason through complex problems.
|
||||
{levels.includes('adaptive')
|
||||
? 'Adaptive thinking lets the model decide how much reasoning to use.'
|
||||
: 'Higher levels give more time to reason through complex problems.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isGroupSelected,
|
||||
getSelectedVariant,
|
||||
codexModelHasThinking,
|
||||
getThinkingLevelsForModel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
OPENCODE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
COPILOT_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
REASONING_EFFORT_LEVELS,
|
||||
REASONING_EFFORT_LABELS,
|
||||
@@ -1296,7 +1296,9 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(
|
||||
model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || ''
|
||||
).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1322,6 +1324,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
@@ -1402,7 +1405,9 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(
|
||||
model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || ''
|
||||
).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1428,6 +1433,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
@@ -1564,7 +1570,7 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(model.id).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1589,6 +1595,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
@@ -1685,7 +1692,7 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(model.id).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1710,6 +1717,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CodexModelId } from '@automaker/types';
|
||||
import { supportsReasoningEffort, type CodexModelId } from '@automaker/types';
|
||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CodexModelConfigurationProps {
|
||||
@@ -27,25 +27,30 @@ interface CodexModelInfo {
|
||||
}
|
||||
|
||||
const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
|
||||
'codex-gpt-5.3-codex': {
|
||||
id: 'codex-gpt-5.3-codex',
|
||||
label: 'GPT-5.3-Codex',
|
||||
description: 'Latest frontier agentic coding model',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'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',
|
||||
},
|
||||
'codex-gpt-5.2': {
|
||||
id: 'codex-gpt-5.2',
|
||||
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',
|
||||
},
|
||||
'codex-gpt-5.1': {
|
||||
id: 'codex-gpt-5.1',
|
||||
@@ -157,13 +162,3 @@ export function CodexModelConfiguration({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function supportsReasoningEffort(modelId: string): boolean {
|
||||
const reasoningModels = [
|
||||
'codex-gpt-5.2-codex',
|
||||
'codex-gpt-5.1-codex-max',
|
||||
'codex-gpt-5.2',
|
||||
'codex-gpt-5.1',
|
||||
];
|
||||
return reasoningModels.includes(modelId);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
// CLI Verification state
|
||||
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
|
||||
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
|
||||
const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null);
|
||||
|
||||
// API Key Verification state
|
||||
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
|
||||
@@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
const verifyCliAuth = useCallback(async () => {
|
||||
setCliVerificationStatus('verifying');
|
||||
setCliVerificationError(null);
|
||||
setCliAuthType(null);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
|
||||
if (result.authenticated && !hasLimitReachedError) {
|
||||
setCliVerificationStatus('verified');
|
||||
// Store the auth type for displaying specific success message
|
||||
const authType = result.authType === 'oauth' ? 'oauth' : 'cli';
|
||||
setCliAuthType(authType);
|
||||
setClaudeAuthStatus({
|
||||
authenticated: true,
|
||||
method: 'cli_authenticated',
|
||||
method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated',
|
||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||
oauthTokenValid: authType === 'oauth',
|
||||
});
|
||||
toast.success('Claude CLI authentication verified!');
|
||||
// Show specific success message based on auth type
|
||||
if (authType === 'oauth') {
|
||||
toast.success('Claude Code subscription detected and verified!');
|
||||
} else {
|
||||
toast.success('Claude CLI authentication verified!');
|
||||
}
|
||||
} else {
|
||||
setCliVerificationStatus('error');
|
||||
setCliVerificationError(
|
||||
@@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">CLI Authentication verified!</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{cliAuthType === 'oauth'
|
||||
? 'Claude Code subscription verified!'
|
||||
: 'CLI Authentication verified!'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your Claude CLI is working correctly.
|
||||
{cliAuthType === 'oauth'
|
||||
? 'Your Claude Code subscription is active and ready to use.'
|
||||
: 'Your Claude CLI is working correctly.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,18 +27,20 @@ export interface AgentTaskInfo {
|
||||
/**
|
||||
* Default model used by the feature executor
|
||||
*/
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-5-20251101';
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-6';
|
||||
|
||||
/**
|
||||
* Formats a model name for display
|
||||
*/
|
||||
export function formatModelName(model: string): string {
|
||||
// Claude models
|
||||
if (model.includes('opus-4-6') || model === 'claude-opus') return 'Opus 4.6';
|
||||
if (model.includes('opus')) return 'Opus 4.5';
|
||||
if (model.includes('sonnet')) return 'Sonnet 4.5';
|
||||
if (model.includes('haiku')) return 'Haiku 4.5';
|
||||
|
||||
// Codex/GPT models - specific formatting
|
||||
if (model === 'codex-gpt-5.3-codex') return 'GPT-5.3 Codex';
|
||||
if (model === 'codex-gpt-5.2-codex') return 'GPT-5.2 Codex';
|
||||
if (model === 'codex-gpt-5.2') return 'GPT-5.2';
|
||||
if (model === 'codex-gpt-5.1-codex-max') return 'GPT-5.1 Max';
|
||||
|
||||
@@ -1442,6 +1442,7 @@ interface SetupAPI {
|
||||
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
authType?: 'oauth' | 'api_key' | 'cli';
|
||||
error?: string;
|
||||
}>;
|
||||
getGhStatus?: () => Promise<{
|
||||
|
||||
@@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
authType?: 'oauth' | 'api_key' | 'cli';
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }),
|
||||
|
||||
|
||||
@@ -21,9 +21,13 @@ services:
|
||||
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode
|
||||
# - ~/.config/opencode:/home/automaker/.config/opencode
|
||||
|
||||
# Playwright browser cache - persists installed browsers across container restarts
|
||||
# Run 'npx playwright install --with-deps chromium' once, and it will persist
|
||||
# ===== Playwright Browser Cache (Optional) =====
|
||||
# Playwright Chromium is PRE-INSTALLED in the Docker image for automated testing.
|
||||
# Uncomment below to persist browser cache across container rebuilds (saves ~300MB download):
|
||||
# - playwright-cache:/home/automaker/.cache/ms-playwright
|
||||
#
|
||||
# To update Playwright browsers manually:
|
||||
# docker exec --user automaker -w /app automaker-server npx playwright install chromium
|
||||
environment:
|
||||
# Set root directory for all projects and file operations
|
||||
# Users can only create/open projects within this directory
|
||||
@@ -37,6 +41,7 @@ services:
|
||||
# - CURSOR_API_KEY=${CURSOR_API_KEY:-}
|
||||
|
||||
volumes:
|
||||
# Playwright cache volume (persists Chromium installs)
|
||||
# Playwright cache volume - optional, persists browser updates across container rebuilds
|
||||
# Uncomment if you mounted the playwright-cache volume above
|
||||
# playwright-cache:
|
||||
# name: automaker-playwright-cache
|
||||
|
||||
@@ -142,7 +142,7 @@ const modelId = resolveModelString('sonnet'); // → 'claude-sonnet-4-20250514'
|
||||
|
||||
- `haiku` → `claude-haiku-4-5` (fast, simple tasks)
|
||||
- `sonnet` → `claude-sonnet-4-20250514` (balanced, recommended)
|
||||
- `opus` → `claude-opus-4-5-20251101` (maximum capability)
|
||||
- `opus` → `claude-opus-4-6` (maximum capability)
|
||||
|
||||
### @automaker/dependency-resolver
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration.
|
||||
|
||||
Routes models that:
|
||||
|
||||
- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`)
|
||||
- Start with `"claude-"` (e.g., `"claude-opus-4-6"`)
|
||||
- Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"`
|
||||
|
||||
#### Authentication
|
||||
@@ -191,7 +191,7 @@ const provider = new ClaudeProvider();
|
||||
|
||||
const stream = provider.executeQuery({
|
||||
prompt: 'What is 2+2?',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/project/path',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
maxTurns: 20,
|
||||
@@ -701,7 +701,7 @@ Test provider interaction with services:
|
||||
```typescript
|
||||
describe('Provider Integration', () => {
|
||||
it('should work with AgentService', async () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-6');
|
||||
|
||||
// Test full workflow
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ Model alias mapping for Claude models.
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-20250514',
|
||||
opus: 'claude-opus-4-5-20251101',
|
||||
opus: 'claude-opus-4-6',
|
||||
} as const;
|
||||
```
|
||||
|
||||
@@ -223,7 +223,7 @@ Default models per provider.
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: 'claude-opus-4-5-20251101',
|
||||
claude: 'claude-opus-4-6',
|
||||
openai: 'gpt-5.2',
|
||||
} as const;
|
||||
```
|
||||
@@ -248,8 +248,8 @@ Resolve a model key/alias to a full model string.
|
||||
import { resolveModelString, DEFAULT_MODELS } from '../lib/model-resolver.js';
|
||||
|
||||
resolveModelString('opus');
|
||||
// Returns: "claude-opus-4-5-20251101"
|
||||
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101""
|
||||
// Returns: "claude-opus-4-6"
|
||||
// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-6""
|
||||
|
||||
resolveModelString('gpt-5.2');
|
||||
// Returns: "gpt-5.2"
|
||||
@@ -260,8 +260,8 @@ resolveModelString('claude-sonnet-4-20250514');
|
||||
// Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514"
|
||||
|
||||
resolveModelString('invalid-model');
|
||||
// Returns: "claude-opus-4-5-20251101"
|
||||
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101""
|
||||
// Returns: "claude-opus-4-6"
|
||||
// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-6""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +134,8 @@ export {
|
||||
findClaudeCliPath,
|
||||
getClaudeAuthIndicators,
|
||||
type ClaudeAuthIndicators,
|
||||
type FileCheckResult,
|
||||
type DirectoryCheckResult,
|
||||
findCodexCliPath,
|
||||
getCodexAuthIndicators,
|
||||
type CodexAuthIndicators,
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
761
libs/platform/tests/oauth-credential-detection.test.ts
Normal file
761
libs/platform/tests/oauth-credential-detection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -35,7 +35,7 @@
|
||||
"version": "0.13.0",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.32",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
@@ -657,9 +657,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.1.76",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.76.tgz",
|
||||
"integrity": "sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw==",
|
||||
"version": "0.2.32",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.32.tgz",
|
||||
"integrity": "sha512-8AtsSx/M9jxd0ihS08eqa7VireTEuwQy0i1+6ZJX93LECT6Svlf47dPJiAm7JB+BhVMmwTfQeS6x1akIcCfvbQ==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -675,7 +675,7 @@
|
||||
"@img/sharp-win32-x64": "^0.33.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1 || ^4.0.0"
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@automaker/dependency-resolver": {
|
||||
@@ -3949,9 +3949,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-sdk": {
|
||||
"version": "0.77.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.77.0.tgz",
|
||||
"integrity": "sha512-bvJQ4dASnZ7jgfxmseViQwdRupHxs0TwHSZFeYB0gpdOAXnWwDWdGJRCMyphLSHwExRp27JNOk7EBFVmZRBanQ==",
|
||||
"version": "0.98.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.98.0.tgz",
|
||||
"integrity": "sha512-TbPgrBpuSNMJyOXys0HNsh6UoP5VIHu1fVh2KDdACi5XyB0vuPtzBZC+qOsxHz7WXEQPFlomPLyxS6JnE5Okmg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
Reference in New Issue
Block a user