From 01600812ce0d0e5272e180b68da995e3d4bcb1e6 Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Tue, 31 Mar 2026 19:02:26 +0300 Subject: [PATCH 1/4] feat(providers): add Anthropic Subscription provider for native Claude login Introduces a new `anthropic-subscription` provider that allows codemie-claude to run using Claude Code's existing native browser login (Anthropic subscription) instead of an API key. Refactors shared auth helpers into core to eliminate duplication, and adds unit test coverage for all new logic. Generated with AI Co-Authored-By: codemie-ai --- .gitignore | 1 + .gitleaks.toml | 3 +- package-lock.json | 14 ++ src/agents/core/BaseAgentAdapter.ts | 7 +- .../core/__tests__/model-tier-config.test.ts | 24 +++ .../__tests__/claude.provider-support.test.ts | 8 + src/agents/plugins/claude/claude.plugin.ts | 2 +- src/cli/commands/hook.ts | 42 ++--- src/cli/commands/setup.ts | 12 +- .../__tests__/codemie-auth-helpers.test.ts | 69 +++++++ src/providers/core/codemie-auth-helpers.ts | 177 ++++++++++++++++++ src/providers/core/index.ts | 9 + src/providers/core/types.ts | 9 + src/providers/index.ts | 2 + .../integration/__tests__/setup-ui.test.ts | 55 ++++++ .../anthropic-subscription.auth.test.ts | 39 ++++ ...anthropic-subscription.setup-steps.test.ts | 97 ++++++++++ .../anthropic-subscription.template.test.ts | 83 ++++++++ .../anthropic-subscription.auth.ts | 39 ++++ .../anthropic-subscription.setup-steps.ts | 125 +++++++++++++ .../anthropic-subscription.template.ts | 84 +++++++++ .../plugins/anthropic-subscription/index.ts | 10 + src/providers/plugins/jwt/jwt.setup-steps.ts | 2 +- .../proxy/plugins/sso.session-sync.plugin.ts | 17 +- .../plugins/sso/proxy/plugins/types.ts | 1 + .../plugins/sso/proxy/proxy-types.ts | 2 + src/providers/plugins/sso/proxy/sso.proxy.ts | 16 +- src/providers/plugins/sso/sso.auth.ts | 17 +- src/providers/plugins/sso/sso.http-client.ts | 135 +------------ src/providers/plugins/sso/sso.setup-steps.ts | 82 +------- src/providers/plugins/sso/sso.template.ts | 3 +- 31 files changed, 937 insertions(+), 249 deletions(-) create mode 100644 src/agents/plugins/claude/__tests__/claude.provider-support.test.ts create mode 100644 src/providers/core/__tests__/codemie-auth-helpers.test.ts create mode 100644 src/providers/core/codemie-auth-helpers.ts create mode 100644 src/providers/integration/__tests__/setup-ui.test.ts create mode 100644 src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts create mode 100644 src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts create mode 100644 src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts create mode 100644 src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts create mode 100644 src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts create mode 100644 src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts create mode 100644 src/providers/plugins/anthropic-subscription/index.ts diff --git a/.gitignore b/.gitignore index 9b675031..0792374e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ local/* MCP directories /.serena/ +.codemie \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml index 167d34a1..45f84294 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -11,5 +11,6 @@ useDefault = true description = "Exclude test files and build artifacts containing intentional fake secrets" paths = [ '''src/utils/__tests__/sanitize\.test\.ts$''', - '''dist/''' + '''dist/''', + '''\.idea/''' ] diff --git a/package-lock.json b/package-lock.json index 1e7e5e3c..fd600bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2286,6 +2286,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.29.tgz", "integrity": "sha512-BPoegTtIdZX4gl2kxcSXAlLrrJFl1cxeRsk9DM/wlIuvyPrFwjWqrEK5NwF5diDt5XSArhQxIFaifGAl4F7fgw==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -3603,6 +3604,7 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3712,6 +3714,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -4030,6 +4033,7 @@ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.10", "fflate": "^0.8.2", @@ -4079,6 +4083,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4839,6 +4844,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -5275,6 +5281,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7571,6 +7578,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-6.9.0.tgz", "integrity": "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -8892,6 +8900,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9048,6 +9057,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9129,6 +9139,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9222,6 +9233,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9235,6 +9247,7 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", @@ -9512,6 +9525,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 59ac3ec3..0d1dca35 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -796,10 +796,11 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const provider = ProviderRegistry.getProvider(providerName); const isSSOProvider = provider?.authType === 'sso'; const isJWTAuth = env.CODEMIE_AUTH_METHOD === 'jwt'; + const hasCodeMieSync = Boolean(env.CODEMIE_SYNC_API_URL && env.CODEMIE_URL); const isProxyEnabled = this.metadata.ssoConfig?.enabled ?? false; // Proxy needed for SSO cookie injection OR JWT bearer token injection - return (isSSOProvider || isJWTAuth) && isProxyEnabled; + return (isSSOProvider || isJWTAuth || hasCodeMieSync) && isProxyEnabled; } /** @@ -846,7 +847,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { jwtToken: env.CODEMIE_JWT_TOKEN || undefined, repository, branch: branch || undefined, - project: env.CODEMIE_PROJECT || undefined + project: env.CODEMIE_PROJECT || undefined, + syncApiUrl: env.CODEMIE_SYNC_API_URL || undefined, + syncCodeMieUrl: env.CODEMIE_URL || undefined }; } diff --git a/src/agents/core/__tests__/model-tier-config.test.ts b/src/agents/core/__tests__/model-tier-config.test.ts index fb8bed85..206ffddb 100644 --- a/src/agents/core/__tests__/model-tier-config.test.ts +++ b/src/agents/core/__tests__/model-tier-config.test.ts @@ -240,4 +240,28 @@ describe('ConfigLoader.exportProviderEnvVars', () => { expect(env.CODEMIE_SONNET_MODEL).toBeUndefined(); expect(env.CODEMIE_OPUS_MODEL).toBeUndefined(); }); + + it('should not export placeholder auth token for anthropic-subscription', async () => { + const { ConfigLoader } = await import('../../../utils/config.js'); + + const config = { + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-6', + apiKey: '', + authMethod: 'manual' as const, + codeMieUrl: 'https://codemie.lab.epam.com', + codeMieProject: 'codemie-platform', + }; + + const env = ConfigLoader.exportProviderEnvVars(config); + + expect(env.CODEMIE_PROVIDER).toBe('anthropic-subscription'); + expect(env.CODEMIE_BASE_URL).toBe('https://api.anthropic.com'); + expect(env.CODEMIE_API_KEY).toBe(''); + expect(env.CODEMIE_MODEL).toBe('claude-sonnet-4-6'); + expect(env.CODEMIE_URL).toBe('https://codemie.lab.epam.com'); + expect(env.CODEMIE_SYNC_API_URL).toBe('https://codemie.lab.epam.com/code-assistant-api'); + expect(env.CODEMIE_PROJECT).toBe('codemie-platform'); + }); }); diff --git a/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts b/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts new file mode 100644 index 00000000..1f6577bc --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { ClaudePluginMetadata } from '../claude.plugin.js'; + +describe('ClaudePluginMetadata', () => { + it('supports anthropic-subscription provider', () => { + expect(ClaudePluginMetadata.supportedProviders).toContain('anthropic-subscription'); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index ead772bb..a4a1ec92 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -84,7 +84,7 @@ export const ClaudePluginMetadata: AgentMetadata = { opusModel: ['ANTHROPIC_DEFAULT_OPUS_MODEL'], }, - supportedProviders: ['litellm', 'ai-run-sso', 'bedrock', 'bearer-auth'], + supportedProviders: ['litellm', 'ai-run-sso', 'bedrock', 'bearer-auth', 'anthropic-subscription'], blockedModelPatterns: [], recommendedModels: ['claude-sonnet-4-6', 'claude-4-opus', 'gpt-4.1'], diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 3345fd2b..51431318 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -70,6 +70,8 @@ export interface HookProcessingConfig { model?: string; /** SSO URL for credential loading */ ssoUrl?: string; + /** Optional dedicated CodeMie API URL for analytics sync */ + syncApiUrl?: string; } /** @@ -105,6 +107,7 @@ function getConfigValue(envKey: string, config?: HookProcessingConfig): string | 'CODEMIE_PROJECT': 'project', 'CODEMIE_MODEL': 'model', 'CODEMIE_URL': 'ssoUrl', + 'CODEMIE_SYNC_API_URL': 'syncApiUrl', }; const configKey = configMap[envKey]; if (configKey) { @@ -167,7 +170,7 @@ function initializeLoggerContext(): string { async function handleSessionStart(event: SessionStartEvent, _rawInput: string, sessionId: string, config?: HookProcessingConfig): Promise { // Create session record with correlation information await createSessionRecord(event, sessionId, config); - // Send session start metrics (SSO provider only) + // Send session start metrics when CodeMie analytics auth is configured await sendSessionStartMetrics(event, sessionId, event.session_id, config); // Sync CodeMie skills to Claude Code (.claude/skills/) await syncSkillsToClaude(event.cwd || process.cwd()); @@ -685,7 +688,7 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, /** * Helper: Send session start metrics to CodeMie backend - * Only works with ai-run-sso provider + * Works for providers that have CodeMie analytics authentication configured * * @param event - SessionStart event data * @param sessionId - The CodeMie session ID (for file operations) @@ -694,17 +697,12 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, */ async function sendSessionStartMetrics(event: SessionStartEvent, sessionId: string, agentSessionId: string, config?: HookProcessingConfig): Promise { try { - // Only send metrics for SSO provider - const provider = getConfigValue('CODEMIE_PROVIDER', config); - if (provider !== 'ai-run-sso') { - logger.debug('[hook:SessionStart] Skipping metrics (not SSO provider)'); - return; - } - // Get required configuration values const agentName = getConfigValue('CODEMIE_AGENT', config); + const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); - const apiUrl = getConfigValue('CODEMIE_BASE_URL', config); + const syncApiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config); + const apiUrl = syncApiUrl || getConfigValue('CODEMIE_BASE_URL', config); const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config); const model = getConfigValue('CODEMIE_MODEL', config); const project = getConfigValue('CODEMIE_PROJECT', config); @@ -763,7 +761,13 @@ async function sendSessionStartMetrics(event: SessionStartEvent, sessionId: stri } if (!cookieHeader) { - logger.info(`[hook:SessionStart] No SSO credentials available for ${ssoUrl}`); + if (syncApiUrl) { + logger.info( + `[hook:SessionStart] CodeMie analytics sync is configured for ${syncApiUrl}, but no stored credentials were found. Run: codemie profile login --url ${ssoUrl}` + ); + } else { + logger.info(`[hook:SessionStart] No SSO credentials available for ${ssoUrl}`); + } return; } @@ -794,7 +798,7 @@ async function sendSessionStartMetrics(event: SessionStartEvent, sessionId: stri { sessionId: agentSessionId, agentName, - provider, + provider: provider || 'unknown', project, model, startTime: Date.now(), @@ -945,7 +949,7 @@ async function renameSessionFiles(sessionId: string): Promise { /** * Helper: Send session end metrics to CodeMie backend - * Only works with ai-run-sso provider + * Works for providers that have CodeMie analytics authentication configured * * @param event - SessionEnd event data * @param sessionId - The CodeMie session ID (for file operations) @@ -954,17 +958,11 @@ async function renameSessionFiles(sessionId: string): Promise { */ async function sendSessionEndMetrics(event: SessionEndEvent, sessionId: string, agentSessionId: string, config?: HookProcessingConfig): Promise { try { - // Only send metrics for SSO provider - const provider = getConfigValue('CODEMIE_PROVIDER', config); - if (provider !== 'ai-run-sso') { - logger.debug('[hook:SessionEnd] Skipping metrics (not SSO provider)'); - return; - } - // Get required configuration values const agentName = getConfigValue('CODEMIE_AGENT', config); + const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); - const apiUrl = getConfigValue('CODEMIE_BASE_URL', config); + const apiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config) || getConfigValue('CODEMIE_BASE_URL', config); const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config); const model = getConfigValue('CODEMIE_MODEL', config); const project = getConfigValue('CODEMIE_PROJECT', config); @@ -1039,7 +1037,7 @@ async function sendSessionEndMetrics(event: SessionEndEvent, sessionId: string, { sessionId: agentSessionId, agentName, - provider, + provider: provider || 'unknown', project, model, startTime: session.startTime, diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 94d7b79a..f9608bd5 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -257,7 +257,17 @@ async function handlePluginSetup( } // Step 3: Model selection - const selectedModel = await promptForModelSelection(models, providerTemplate); + let selectedModel: string; + const preselectedModel = setupSteps.selectModel + ? await setupSteps.selectModel(credentials, models, providerTemplate) + : undefined; + + if (preselectedModel) { + selectedModel = preselectedModel; + console.log(chalk.green(`āœ“ Model selected automatically: ${chalk.bold(selectedModel)}`)); + } else { + selectedModel = await promptForModelSelection(models, providerTemplate); + } // Step 3.5: Install model if provider supports it (e.g., Ollama) if (providerTemplate?.supportsModelInstallation && setupSteps.installModel) { diff --git a/src/providers/core/__tests__/codemie-auth-helpers.test.ts b/src/providers/core/__tests__/codemie-auth-helpers.test.ts new file mode 100644 index 00000000..b7ccf2cb --- /dev/null +++ b/src/providers/core/__tests__/codemie-auth-helpers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { ensureApiBase, buildAuthHeaders } from '../codemie-auth-helpers.js'; + +describe('ensureApiBase', () => { + it('appends /code-assistant-api when missing', () => { + expect(ensureApiBase('https://codemie.example.com')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('removes trailing slash before appending suffix', () => { + expect(ensureApiBase('https://codemie.example.com/')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('does not double-append when suffix already present', () => { + expect(ensureApiBase('https://codemie.example.com/code-assistant-api')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('does not double-append when suffix present with trailing slash', () => { + expect(ensureApiBase('https://codemie.example.com/code-assistant-api/')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('handles path prefix before /code-assistant-api', () => { + const url = 'https://codemie.example.com/prefix/code-assistant-api'; + expect(ensureApiBase(url)).toBe(url); + }); +}); + +describe('buildAuthHeaders', () => { + it('builds cookie headers from SSO cookies object', () => { + const headers = buildAuthHeaders({ session: 'abc', token: 'xyz' }); + + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-CodeMie-Client']).toBe('codemie-cli'); + expect(headers.cookie).toBe('session=abc;token=xyz'); + expect(headers.authorization).toBeUndefined(); + }); + + it('builds Bearer authorization header from JWT string', () => { + const headers = buildAuthHeaders('my-jwt-token'); + + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-CodeMie-Client']).toBe('codemie-cli'); + expect(headers.authorization).toBe('Bearer my-jwt-token'); + expect(headers.cookie).toBeUndefined(); + }); + + it('includes CLI version in User-Agent and X-CodeMie-CLI headers', () => { + process.env.CODEMIE_CLI_VERSION = '1.2.3'; + const headers = buildAuthHeaders('token'); + + expect(headers['User-Agent']).toBe('codemie-cli/1.2.3'); + expect(headers['X-CodeMie-CLI']).toBe('codemie-cli/1.2.3'); + delete process.env.CODEMIE_CLI_VERSION; + }); + + it('falls back to unknown when CODEMIE_CLI_VERSION is not set', () => { + delete process.env.CODEMIE_CLI_VERSION; + const headers = buildAuthHeaders('token'); + + expect(headers['User-Agent']).toBe('codemie-cli/unknown'); + }); +}); diff --git a/src/providers/core/codemie-auth-helpers.ts b/src/providers/core/codemie-auth-helpers.ts new file mode 100644 index 00000000..e17083ae --- /dev/null +++ b/src/providers/core/codemie-auth-helpers.ts @@ -0,0 +1,177 @@ +import inquirer from 'inquirer'; +import { HTTPClient } from './base/http-client.js'; +import type { SSOAuthResult } from './types.js'; +import { logger } from '../../utils/logger.js'; +import { ConfigurationError } from '../../utils/errors.js'; + +export const DEFAULT_CODEMIE_BASE_URL = 'https://codemie.lab.epam.com'; + +export interface CodeMieUserInfo { + userId: string; + name: string; + username: string; + isAdmin: boolean; + applications: string[]; + applications_admin: string[]; + applicationsAdmin?: string[]; + picture: string; + knowledgeBases: string[]; + userType?: string; +} + +export function ensureApiBase(rawUrl: string): string { + let base = rawUrl.replace(/\/$/, ''); + if (!/\/code-assistant-api(\/|$)/i.test(base)) { + base = `${base}/code-assistant-api`; + } + return base; +} + +export function buildAuthHeaders(auth: Record | string): Record { + const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': `codemie-cli/${cliVersion}`, + 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, + 'X-CodeMie-Client': 'codemie-cli' + }; + + if (typeof auth === 'string') { + headers.authorization = `Bearer ${auth}`; + } else { + headers.cookie = Object.entries(auth) + .map(([key, value]) => `${key}=${value}`) + .join(';'); + } + + return headers; +} + +export async function promptForCodeMieUrl( + defaultUrl: string = DEFAULT_CODEMIE_BASE_URL, + message: string = 'CodeMie organization URL:' +): Promise { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'codeMieUrl', + message, + default: defaultUrl, + validate: (input: string) => { + if (!input.trim()) { + return 'CodeMie URL is required'; + } + if (!input.startsWith('http://') && !input.startsWith('https://')) { + return 'Please enter a valid URL starting with http:// or https://'; + } + return true; + } + } + ]); + + return answers.codeMieUrl.trim(); +} + +export async function authenticateWithCodeMie( + codeMieUrl: string, + timeout: number = 120000 +): Promise { + const { CodeMieSSO } = await import('../plugins/sso/sso.auth.js'); + const sso = new CodeMieSSO(); + return sso.authenticate({ + codeMieUrl, + timeout + }); +} + +/* eslint-disable no-redeclare */ +export function fetchCodeMieUserInfo( + apiUrl: string, + cookies: Record +): Promise; +export function fetchCodeMieUserInfo( + apiUrl: string, + jwtToken: string +): Promise; +export async function fetchCodeMieUserInfo( + apiUrl: string, + auth: Record | string +): Promise { +/* eslint-enable no-redeclare */ + const headers = buildAuthHeaders(auth); + const url = `${apiUrl}/v1/user`; + + const client = new HTTPClient({ + timeout: 10000, + maxRetries: 3, + rejectUnauthorized: false + }); + + const response = await client.getRaw(url, headers); + + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode === 401 || response.statusCode === 403) { + throw new Error('Authentication failed - invalid or expired credentials'); + } + throw new Error(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); + } + + const userInfo = JSON.parse(response.data) as CodeMieUserInfo; + + if (!Array.isArray(userInfo.applications_admin) && Array.isArray(userInfo.applicationsAdmin)) { + userInfo.applications_admin = userInfo.applicationsAdmin; + } + + if (!userInfo || !Array.isArray(userInfo.applications) || !Array.isArray(userInfo.applications_admin)) { + throw new Error('Invalid user info response: missing applications arrays'); + } + + return userInfo; +} + +export async function selectCodeMieProject(authResult: SSOAuthResult): Promise { + if (!authResult.apiUrl || !authResult.cookies) { + throw new ConfigurationError('API URL or cookies not found in authentication result'); + } + + const userInfo = await fetchCodeMieUserInfo( + authResult.apiUrl, + authResult.cookies + ); + + const applications = userInfo.applications || []; + const applicationsAdmin = userInfo.applications_admin || []; + const allProjects = [...new Set([...applications, ...applicationsAdmin])]; + + if (allProjects.length === 0) { + throw new ConfigurationError('No projects found for your account. Please contact your administrator.'); + } + + const sortedProjects = allProjects.sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }) + ); + + if (sortedProjects.length === 1) { + const selectedProject = sortedProjects[0]; + logger.success(`Auto-selected project: ${selectedProject}`); + return selectedProject; + } + + logger.info(`Found ${sortedProjects.length} accessible project(s)`); + + const projectAnswers = await inquirer.prompt([ + { + type: 'list', + name: 'project', + message: 'Select your project:', + choices: sortedProjects.map(project => ({ + name: project, + value: project + })), + pageSize: 15 + } + ]); + + logger.success(`Selected project: ${projectAnswers.project}`); + return projectAnswers.project; +} diff --git a/src/providers/core/index.ts b/src/providers/core/index.ts index 66d80b78..592a6ead 100644 --- a/src/providers/core/index.ts +++ b/src/providers/core/index.ts @@ -33,3 +33,12 @@ export { BaseHealthCheck } from './base/BaseHealthCheck.js'; export { BaseModelProxy } from './base/BaseModelProxy.js'; export { HTTPClient } from './base/http-client.js'; export type { HTTPClientConfig, HTTPResponse } from './base/http-client.js'; +export { + DEFAULT_CODEMIE_BASE_URL, + fetchCodeMieUserInfo, + ensureApiBase, + buildAuthHeaders, + promptForCodeMieUrl, + authenticateWithCodeMie, + selectCodeMieProject +} from './codemie-auth-helpers.js'; diff --git a/src/providers/core/types.ts b/src/providers/core/types.ts index ba84d3f7..d515b503 100644 --- a/src/providers/core/types.ts +++ b/src/providers/core/types.ts @@ -283,6 +283,15 @@ export interface ProviderSetupSteps { */ fetchModels(credentials: ProviderCredentials): Promise; + /** + * Optional: choose model programmatically and skip interactive model selection + */ + selectModel?( + credentials: ProviderCredentials, + models: string[], + template?: ProviderTemplate + ): Promise; + /** * Step 3: Build final configuration * diff --git a/src/providers/index.ts b/src/providers/index.ts index 09577838..83cd62c5 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -36,6 +36,7 @@ import './plugins/sso/index.js'; import './plugins/jwt/index.js'; import './plugins/litellm/index.js'; import './plugins/bedrock/index.js'; +import './plugins/anthropic-subscription/index.js'; // Re-export plugin modules for direct access if needed export * as Ollama from './plugins/ollama/index.js'; @@ -43,3 +44,4 @@ export * as SSO from './plugins/sso/index.js'; export * as JWT from './plugins/jwt/index.js'; export * as LiteLLM from './plugins/litellm/index.js'; export * as Bedrock from './plugins/bedrock/index.js'; +export * as AnthropicSubscription from './plugins/anthropic-subscription/index.js'; diff --git a/src/providers/integration/__tests__/setup-ui.test.ts b/src/providers/integration/__tests__/setup-ui.test.ts new file mode 100644 index 00000000..a10adf8f --- /dev/null +++ b/src/providers/integration/__tests__/setup-ui.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { ProviderTemplate } from '../../core/types.js'; +import { getAllProviderChoices } from '../setup-ui.js'; + +describe('setup-ui', () => { + describe('getAllProviderChoices', () => { + it('sorts providers by priority and keeps Anthropic Subscription before Ollama', () => { + const providers: ProviderTemplate[] = [ + { + name: 'ollama', + displayName: 'Ollama', + description: 'Local models', + defaultBaseUrl: 'http://localhost:11434', + requiresAuth: false, + authType: 'none', + recommendedModels: ['qwen2.5-coder'], + capabilities: ['streaming'], + supportsModelInstallation: true, + }, + { + name: 'anthropic-subscription', + displayName: 'Anthropic Subscription', + description: 'Native Claude Code authentication', + defaultBaseUrl: 'https://api.anthropic.com', + requiresAuth: false, + authType: 'none', + priority: 16, + recommendedModels: ['claude-sonnet-4-6'], + capabilities: ['streaming', 'tools'], + supportsModelInstallation: false, + }, + { + name: 'ai-run-sso', + displayName: 'CodeMie SSO', + description: 'Enterprise SSO Authentication', + defaultBaseUrl: 'https://codemie.lab.epam.com', + requiresAuth: true, + authType: 'sso', + priority: 0, + recommendedModels: ['claude-sonnet-4-6'], + capabilities: ['streaming', 'tools', 'sso-auth'], + supportsModelInstallation: false, + } + ]; + + const choices = getAllProviderChoices(providers); + + expect(choices.map(choice => choice.value)).toEqual([ + 'ai-run-sso', + 'anthropic-subscription', + 'ollama', + ]); + }); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts new file mode 100644 index 00000000..4be18acb --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { parseClaudeAuthStatus } from '../anthropic-subscription.auth.js'; +import { ConfigurationError } from '../../../../utils/errors.js'; + +describe('parseClaudeAuthStatus', () => { + it('parses logged-in Claude auth status', () => { + const status = parseClaudeAuthStatus(JSON.stringify({ + loggedIn: true, + authMethod: 'oauth', + apiProvider: 'firstParty' + })); + + expect(status.loggedIn).toBe(true); + expect(status.authMethod).toBe('oauth'); + expect(status.apiProvider).toBe('firstParty'); + }); + + it('parses logged-out Claude auth status', () => { + const status = parseClaudeAuthStatus(JSON.stringify({ + loggedIn: false, + authMethod: 'none', + apiProvider: 'firstParty' + })); + + expect(status.loggedIn).toBe(false); + expect(status.authMethod).toBe('none'); + }); + + it('throws ConfigurationError for invalid JSON', () => { + expect(() => parseClaudeAuthStatus('not-json')).toThrow(ConfigurationError); + expect(() => parseClaudeAuthStatus('not-json')).toThrow( + 'Failed to parse Claude auth status output' + ); + }); + + it('throws ConfigurationError for empty string', () => { + expect(() => parseClaudeAuthStatus('')).toThrow(ConfigurationError); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts new file mode 100644 index 00000000..beec6665 --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { AnthropicSubscriptionSetupSteps } from '../anthropic-subscription.setup-steps.js'; +import { AnthropicSubscriptionTemplate } from '../anthropic-subscription.template.js'; + +describe('AnthropicSubscriptionSetupSteps', () => { + describe('selectModel', () => { + it('auto-selects the first model when CodeMie analytics is enabled', async () => { + const selectedModel = await AnthropicSubscriptionSetupSteps.selectModel?.( + { + additionalConfig: { + codeMieUrl: 'https://codemie.lab.epam.com' + } + }, + ['claude-sonnet-4-6', 'claude-opus-4-6'] + ); + + expect(selectedModel).toBe('claude-sonnet-4-6'); + }); + + it('keeps interactive model selection when CodeMie analytics is not enabled', async () => { + const selectedModel = await AnthropicSubscriptionSetupSteps.selectModel?.( + { + additionalConfig: {} + }, + ['claude-sonnet-4-6', 'claude-opus-4-6'] + ); + + expect(selectedModel).toBeNull(); + }); + + it('falls back to template recommended model when models list is empty and CodeMie URL is set', async () => { + const selectedModel = await AnthropicSubscriptionSetupSteps.selectModel?.( + { additionalConfig: { codeMieUrl: 'https://codemie.lab.epam.com' } }, + [] + ); + + expect(selectedModel).toBe(AnthropicSubscriptionTemplate.recommendedModels[0]); + }); + }); + + describe('fetchModels', () => { + it('returns the template recommended models list', async () => { + const models = await AnthropicSubscriptionSetupSteps.fetchModels({}); + + expect(models).toEqual(AnthropicSubscriptionTemplate.recommendedModels); + }); + }); + + describe('buildConfig', () => { + it('builds config with provider and model when no CodeMie URL is configured', () => { + const config = AnthropicSubscriptionSetupSteps.buildConfig( + { + baseUrl: 'https://api.anthropic.com', + apiKey: '', + additionalConfig: { authMethod: 'manual' } + }, + 'claude-sonnet-4-6' + ); + + expect(config.provider).toBe('anthropic-subscription'); + expect(config.model).toBe('claude-sonnet-4-6'); + expect(config.apiKey).toBe(''); + expect(config.authMethod).toBe('manual'); + expect(config.codeMieUrl).toBeUndefined(); + expect(config.codeMieProject).toBeUndefined(); + }); + + it('includes codeMieUrl and codeMieProject when CodeMie analytics is enabled', () => { + const config = AnthropicSubscriptionSetupSteps.buildConfig( + { + baseUrl: 'https://api.anthropic.com', + apiKey: '', + additionalConfig: { + authMethod: 'manual', + codeMieUrl: 'https://codemie.lab.epam.com', + codeMieProject: 'my-project' + } + }, + 'claude-opus-4-6' + ); + + expect(config.provider).toBe('anthropic-subscription'); + expect(config.model).toBe('claude-opus-4-6'); + expect(config.codeMieUrl).toBe('https://codemie.lab.epam.com'); + expect(config.codeMieProject).toBe('my-project'); + }); + + it('falls back to template defaultBaseUrl when credentials have no baseUrl', () => { + const config = AnthropicSubscriptionSetupSteps.buildConfig( + { additionalConfig: { authMethod: 'manual' } }, + 'claude-sonnet-4-6' + ); + + expect(config.baseUrl).toBe(AnthropicSubscriptionTemplate.defaultBaseUrl); + }); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts new file mode 100644 index 00000000..12ea7350 --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { AnthropicSubscriptionTemplate } from '../anthropic-subscription.template.js'; + +describe('AnthropicSubscriptionTemplate', () => { + it('has the correct provider name', () => { + expect(AnthropicSubscriptionTemplate.name).toBe('anthropic-subscription'); + }); + + it('requires no API key (authType none)', () => { + expect(AnthropicSubscriptionTemplate.requiresAuth).toBe(false); + expect(AnthropicSubscriptionTemplate.authType).toBe('none'); + }); + + it('points to the Anthropic API base URL', () => { + expect(AnthropicSubscriptionTemplate.defaultBaseUrl).toBe('https://api.anthropic.com'); + }); + + it('includes recommended Claude models', () => { + expect(AnthropicSubscriptionTemplate.recommendedModels).toContain('claude-sonnet-4-6'); + expect(AnthropicSubscriptionTemplate.recommendedModels.length).toBeGreaterThan(0); + }); + + describe('agentHooks - claude beforeRun', () => { + it('removes ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY from env', async () => { + const env: Record = { + ANTHROPIC_AUTH_TOKEN: 'some-token', + ANTHROPIC_API_KEY: 'some-key', + OTHER_VAR: 'keep-me' + }; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + expect(hook).toBeDefined(); + + const result = await hook!.beforeRun!(env); + + expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.OTHER_VAR).toBe('keep-me'); + }); + + it('is a no-op when auth vars are not set', async () => { + const env: Record = { SOME_VAR: 'value' }; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + const result = await hook!.beforeRun!(env); + + expect(result).toEqual({ SOME_VAR: 'value' }); + }); + }); + + describe('exportEnvVars', () => { + it('exports CODEMIE_API_KEY as empty string', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({} as any); + + expect(env.CODEMIE_API_KEY).toBe(''); + }); + + it('exports CODEMIE_URL and CODEMIE_SYNC_API_URL when codeMieUrl is set', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({ + codeMieUrl: 'https://codemie.example.com' + } as any); + + expect(env.CODEMIE_URL).toBe('https://codemie.example.com'); + expect(env.CODEMIE_SYNC_API_URL).toContain('code-assistant-api'); + }); + + it('exports CODEMIE_PROJECT when codeMieProject is set', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({ + codeMieUrl: 'https://codemie.example.com', + codeMieProject: 'my-project' + } as any); + + expect(env.CODEMIE_PROJECT).toBe('my-project'); + }); + + it('does not export CODEMIE_URL or CODEMIE_PROJECT when not configured', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({} as any); + + expect(env.CODEMIE_URL).toBeUndefined(); + expect(env.CODEMIE_PROJECT).toBeUndefined(); + }); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts new file mode 100644 index 00000000..da7fb99a --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts @@ -0,0 +1,39 @@ +import { commandExists } from '../../../utils/processes.js'; +import { exec } from '../../../utils/exec.js'; +import { AgentInstallationError, ConfigurationError } from '../../../utils/errors.js'; + +interface ClaudeAuthStatus { + loggedIn?: boolean; + authMethod?: string; + apiProvider?: string; +} + +export async function ensureClaudeCliAvailable(): Promise { + const hasClaude = await commandExists('claude'); + if (!hasClaude) { + throw new AgentInstallationError('claude', 'Claude Code CLI is not installed. Run: codemie install claude'); + } +} + +export async function runClaudeBrowserLogin(): Promise { + await exec('claude', ['auth', 'login'], { + timeout: 300000, + interactive: true + }); +} + +export function parseClaudeAuthStatus(raw: string): ClaudeAuthStatus { + try { + return JSON.parse(raw) as ClaudeAuthStatus; + } catch { + throw new ConfigurationError('Failed to parse Claude auth status output'); + } +} + +export async function getClaudeAuthStatus(): Promise { + const result = await exec('claude', ['auth', 'status', '--json'], { + timeout: 10000 + }); + + return parseClaudeAuthStatus(result.stdout); +} diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts new file mode 100644 index 00000000..3c87d2d5 --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts @@ -0,0 +1,125 @@ +/** + * Anthropic Subscription Setup Steps + * + * Interactive setup flow for native Claude Code authentication. + */ + +import inquirer from 'inquirer'; +import type { ProviderCredentials, ProviderSetupSteps } from '../../core/types.js'; +import { ProviderRegistry } from '../../core/registry.js'; +import type { CodeMieConfigOptions } from '../../../env/types.js'; +import { logger } from '../../../utils/logger.js'; +import { ConfigurationError } from '../../../utils/errors.js'; +import { AnthropicSubscriptionTemplate } from './anthropic-subscription.template.js'; +import { + DEFAULT_CODEMIE_BASE_URL, + authenticateWithCodeMie, + promptForCodeMieUrl, + selectCodeMieProject +} from '../../core/codemie-auth-helpers.js'; +import { + ensureClaudeCliAvailable, + getClaudeAuthStatus, + runClaudeBrowserLogin +} from './anthropic-subscription.auth.js'; + +export const AnthropicSubscriptionSetupSteps: ProviderSetupSteps = { + name: 'anthropic-subscription', + + async getCredentials(_isUpdate = false): Promise { + logger.info('Anthropic Subscription Setup'); + logger.info('This provider uses Claude Code native browser authentication.'); + logger.info('CodeMie will not store an Anthropic API key for this profile.'); + + await ensureClaudeCliAvailable(); + + const currentStatus = await getClaudeAuthStatus(); + if (!currentStatus.loggedIn) { + logger.info('Claude Code is not authenticated yet.'); + logger.info('Launching native Claude browser login...'); + await runClaudeBrowserLogin(); + } else { + logger.success('Claude Code is already authenticated'); + } + + const finalStatus = await getClaudeAuthStatus(); + if (!finalStatus.loggedIn) { + throw new ConfigurationError('Claude Code authentication is required. Complete `claude auth login` and run setup again.'); + } + + logger.success(`Claude native auth confirmed (${finalStatus.authMethod || 'unknown'})`); + + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'enableCodeMieAnalytics', + message: 'Login to CodeMie platform to enable analytics sync?', + default: false + } + ]); + + let codeMieUrl: string | undefined; + let codeMieProject: string | undefined; + + if (answers.enableCodeMieAnalytics) { + codeMieUrl = await promptForCodeMieUrl( + DEFAULT_CODEMIE_BASE_URL, + 'CodeMie platform URL for analytics sync:' + ); + + logger.info('Authenticating to CodeMie platform...'); + const authResult = await authenticateWithCodeMie(codeMieUrl, 120000); + + if (!authResult.success) { + throw new ConfigurationError(`CodeMie authentication failed: ${authResult.error || 'Unknown error'}`); + } + + logger.success('CodeMie authentication successful'); + logger.info('Fetching available projects...'); + codeMieProject = await selectCodeMieProject(authResult); + logger.success('Analytics sync enabled for CodeMie platform'); + } + + return { + baseUrl: AnthropicSubscriptionTemplate.defaultBaseUrl, + apiKey: '', + additionalConfig: { + authMethod: 'manual', + codeMieUrl, + codeMieProject + } + }; + }, + + async fetchModels(_credentials: ProviderCredentials): Promise { + return [...AnthropicSubscriptionTemplate.recommendedModels]; + }, + + async selectModel( + credentials: ProviderCredentials, + models: string[] + ): Promise { + if (credentials.additionalConfig?.codeMieUrl) { + return models[0] || AnthropicSubscriptionTemplate.recommendedModels[0] || 'claude-sonnet-4-6'; + } + + return null; + }, + + buildConfig( + credentials: ProviderCredentials, + selectedModel: string + ): Partial { + return { + provider: 'anthropic-subscription', + baseUrl: credentials.baseUrl || AnthropicSubscriptionTemplate.defaultBaseUrl, + apiKey: '', + model: selectedModel, + authMethod: 'manual', + codeMieUrl: credentials.additionalConfig?.codeMieUrl as string | undefined, + codeMieProject: credentials.additionalConfig?.codeMieProject as string | undefined, + }; + } +}; + +ProviderRegistry.registerSetupSteps('anthropic-subscription', AnthropicSubscriptionSetupSteps); diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts new file mode 100644 index 00000000..566ef16c --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts @@ -0,0 +1,84 @@ +/** + * Anthropic Subscription Provider Template + * + * Template definition for native Claude Code authentication using + * an existing Anthropic subscription login. + * + * Auto-registers on import via registerProvider(). + */ + +import type { ProviderTemplate } from '../../core/types.js'; +import { registerProvider } from '../../core/decorators.js'; +import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; + +export const AnthropicSubscriptionTemplate = registerProvider({ + name: 'anthropic-subscription', + displayName: 'Anthropic Subscription', + description: 'Native Claude Code authentication using your Claude subscription', + defaultBaseUrl: 'https://api.anthropic.com', + requiresAuth: false, + authType: 'none', + priority: 16, + defaultProfileName: 'anthropic-subscription', + recommendedModels: [ + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-4-5-haiku', + ], + capabilities: ['streaming', 'tools', 'function-calling', 'vision'], + supportsModelInstallation: false, + supportsStreaming: true, + + agentHooks: { + 'claude': { + async beforeRun(env) { + // Native Claude subscription auth relies on Claude Code's stored login. + // Any explicit token env var overrides that flow and causes 401s. + delete env.ANTHROPIC_AUTH_TOKEN; + delete env.ANTHROPIC_API_KEY; + return env; + } + } + }, + + // Claude Code should use its own stored login/session instead of a placeholder token. + exportEnvVars: (config) => { + const env: Record = { + // transformEnvVars() runs before beforeRun(), and beforeRun() removes agent auth vars + // for native Claude auth before the Claude process is spawned. + CODEMIE_API_KEY: '', + }; + + if (config.codeMieUrl) { + env.CODEMIE_URL = config.codeMieUrl; + env.CODEMIE_SYNC_API_URL = ensureApiBase(config.codeMieUrl); + } + if (config.codeMieProject) { + env.CODEMIE_PROJECT = config.codeMieProject; + } + + return env; + }, + + setupInstructions: ` +# Anthropic Subscription Setup Instructions + +Use this option when Claude Code is already authenticated with your Anthropic account +and you want CodeMie to use that native login flow directly. + +## Prerequisites + +1. Install Claude Code +2. Authenticate Claude Code with your Anthropic subscription + +\`\`\`bash +claude auth login +\`\`\` + +## Notes + +- No API key is stored in CodeMie for this provider +- Claude Code uses its existing local authentication/session +- This provider is intended for native \`codemie-claude\` usage +` +}); diff --git a/src/providers/plugins/anthropic-subscription/index.ts b/src/providers/plugins/anthropic-subscription/index.ts new file mode 100644 index 00000000..014e35ab --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/index.ts @@ -0,0 +1,10 @@ +/** + * Anthropic Subscription Provider - Complete Provider Implementation + * + * Native Claude Code authentication via an existing Anthropic subscription. + * Auto-registers with ProviderRegistry on import. + */ + +export { AnthropicSubscriptionTemplate } from './anthropic-subscription.template.js'; +export { AnthropicSubscriptionSetupSteps } from './anthropic-subscription.setup-steps.js'; + diff --git a/src/providers/plugins/jwt/jwt.setup-steps.ts b/src/providers/plugins/jwt/jwt.setup-steps.ts index f037350b..2a6da1ee 100644 --- a/src/providers/plugins/jwt/jwt.setup-steps.ts +++ b/src/providers/plugins/jwt/jwt.setup-steps.ts @@ -13,7 +13,7 @@ import type { AuthValidationResult } from '../../core/types.js'; import type { CodeMieConfigOptions } from '../../../env/types.js'; -import { ensureApiBase } from '../sso/sso.http-client.js'; +import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; export const JWTBearerSetupSteps: ProviderSetupSteps = { name: 'bearer-auth', diff --git a/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts b/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts index c9327ae5..4323c58e 100644 --- a/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts @@ -25,6 +25,7 @@ import { logger } from '../../../../../utils/logger.js'; import type { ProcessingContext } from '../../session/BaseProcessor.js'; import { SessionSyncer } from '../../session/SessionSyncer.js'; import type { SSOCredentials } from '../../../../core/types.js'; +import { ConfigurationError } from '../../../../../utils/errors.js'; export class SSOSessionSyncPlugin implements ProxyPlugin { id = '@codemie/sso-session-sync'; @@ -33,28 +34,30 @@ export class SSOSessionSyncPlugin implements ProxyPlugin { priority = 100; // Run after logging (priority 50) async createInterceptor(context: PluginContext): Promise { + const syncCredentials = context.syncCredentials || context.credentials; + // Only create interceptor if we have necessary context if (!context.config.sessionId) { logger.debug('[SSOSessionSyncPlugin] Skipping: Session ID not available'); - throw new Error('Session ID not available (session sync disabled)'); + throw new ConfigurationError('Session ID not available (session sync disabled)'); } // Guard: skip if credentials are JWT (not SSO) - if (!context.credentials || !('cookies' in context.credentials)) { + if (!syncCredentials || !('cookies' in syncCredentials)) { logger.debug('[SSOSessionSyncPlugin] Skipping: Not SSO credentials'); - throw new Error('SSO credentials not available (session sync disabled)'); + throw new ConfigurationError('SSO credentials not available (session sync disabled)'); } if (!context.config.clientType) { logger.debug('[SSOSessionSyncPlugin] Skipping: Client type not available'); - throw new Error('Client type not available (session sync disabled)'); + throw new ConfigurationError('Client type not available (session sync disabled)'); } // Check if sync is enabled (from config or env var) const syncEnabled = this.isSyncEnabled(context); if (!syncEnabled) { logger.debug('[SSOSessionSyncPlugin] Skipping: Session sync disabled by configuration'); - throw new Error('Session sync disabled by configuration'); + throw new ConfigurationError('Session sync disabled by configuration'); } logger.debug('[SSOSessionSyncPlugin] Initializing unified session sync'); @@ -63,11 +66,11 @@ export class SSOSessionSyncPlugin implements ProxyPlugin { const dryRun = this.isDryRunEnabled(context); // Cast credentials to SSOCredentials (already validated above) - const ssoCredentials = context.credentials as SSOCredentials; + const ssoCredentials = syncCredentials as SSOCredentials; return new SSOSessionSyncInterceptor( context.config.sessionId, - context.config.targetApiUrl, + context.config.syncApiUrl || context.config.targetApiUrl, ssoCredentials.cookies, context.config.clientType, context.config.version, diff --git a/src/providers/plugins/sso/proxy/plugins/types.ts b/src/providers/plugins/sso/proxy/plugins/types.ts index 6f917d1c..6af72c0a 100644 --- a/src/providers/plugins/sso/proxy/plugins/types.ts +++ b/src/providers/plugins/sso/proxy/plugins/types.ts @@ -47,6 +47,7 @@ export interface PluginContext { config: ProxyConfig; logger: typeof logger; credentials?: SSOCredentials | JWTCredentials; + syncCredentials?: SSOCredentials | JWTCredentials; profileConfig?: CodeMieConfigOptions; // Full profile config (read once at CLI level) [key: string]: unknown; // Extensible } diff --git a/src/providers/plugins/sso/proxy/proxy-types.ts b/src/providers/plugins/sso/proxy/proxy-types.ts index fca8b75c..77892b9e 100644 --- a/src/providers/plugins/sso/proxy/proxy-types.ts +++ b/src/providers/plugins/sso/proxy/proxy-types.ts @@ -28,6 +28,8 @@ export interface ProxyConfig { repository?: string; // Repository name (parent/current format) for header injection branch?: string; // Git branch at startup for header injection project?: string; // CodeMie project name for header injection + syncApiUrl?: string; // Optional CodeMie API URL for analytics/session sync + syncCodeMieUrl?: string; // Optional CodeMie org URL for credential lookup } /** diff --git a/src/providers/plugins/sso/proxy/sso.proxy.ts b/src/providers/plugins/sso/proxy/sso.proxy.ts index 6bf8de03..88c3ed8d 100644 --- a/src/providers/plugins/sso/proxy/sso.proxy.ts +++ b/src/providers/plugins/sso/proxy/sso.proxy.ts @@ -26,6 +26,7 @@ import { createServer, Server, IncomingMessage, ServerResponse } from 'http'; import { randomUUID } from 'crypto'; import { URL } from 'url'; import { ProviderRegistry } from '../../../core/registry.js'; +import type { JWTCredentials, SSOCredentials } from '../../../core/types.js'; import { logger } from '../../../../utils/logger.js'; import { ProxyHTTPClient } from './proxy-http-client.js'; import { ProxyConfig, ProxyContext } from './proxy-types.js'; @@ -60,7 +61,8 @@ export class CodeMieProxy { const authMethod = this.config.authMethod || 'sso'; // Default: SSO for backward compat // 2. Load credentials based on auth method - let credentials: any = null; + let credentials: SSOCredentials | JWTCredentials | null = null; + let syncCredentials: SSOCredentials | JWTCredentials | null = null; if (authMethod === 'jwt') { // JWT path: token from CLI arg, env var, or credential store @@ -94,11 +96,23 @@ export class CodeMieProxy { } } + if (this.config.syncCodeMieUrl) { + const { CodeMieSSO } = await import('../sso.auth.js'); + const sso = new CodeMieSSO(); + syncCredentials = await sso.getStoredCredentials(this.config.syncCodeMieUrl); + if (!syncCredentials) { + logger.debug( + `[CodeMieProxy] Analytics sync is configured for ${this.config.syncCodeMieUrl}, but no stored credentials were found. Re-authenticate with: codemie profile login --url ${this.config.syncCodeMieUrl}` + ); + } + } + // 3. Build plugin context (includes profile config read once at CLI level) const pluginContext: PluginContext = { config: this.config, logger, credentials: credentials || undefined, + syncCredentials: syncCredentials || undefined, profileConfig: this.config.profileConfig }; diff --git a/src/providers/plugins/sso/sso.auth.ts b/src/providers/plugins/sso/sso.auth.ts index a90efe6b..1bc6858a 100644 --- a/src/providers/plugins/sso/sso.auth.ts +++ b/src/providers/plugins/sso/sso.auth.ts @@ -11,6 +11,7 @@ import open from 'open'; import chalk from 'chalk'; import type { SSOAuthConfig, SSOAuthResult, SSOCredentials } from '../../core/types.js'; import { CredentialStore } from '../../../utils/security.js'; +import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; /** * Normalize URL to base (protocol + host) @@ -68,7 +69,7 @@ export class CodeMieSSO { const port = await this.startLocalServer(); // 2. Construct SSO URL (following plugin pattern) - const codeMieBase = this.ensureApiBase(config.codeMieUrl); + const codeMieBase = ensureApiBase(config.codeMieUrl); const ssoUrl = `${codeMieBase}/v1/auth/login/${port}`; // 3. Launch browser @@ -167,18 +168,6 @@ export class CodeMieSSO { await store.clearSSOCredentials(baseUrl); } - /** - * Ensure API base URL has correct format - */ - private ensureApiBase(rawUrl: string): string { - let base = rawUrl.replace(/\/$/, ''); - // If user entered only host, append the known API context - if (!/\/code-assistant-api(\/|$)/i.test(base)) { - base = `${base}/code-assistant-api`; - } - return base; - } - /** * Start local HTTP server for OAuth callback */ @@ -295,7 +284,7 @@ export class CodeMieSSO { } // Try to fetch config.js to resolve actual API URL - let apiUrl = this.ensureApiBase(this.codeMieUrl); + let apiUrl = ensureApiBase(this.codeMieUrl); try { const configResponse = await fetch(`${apiUrl}/config.js`, { headers: { diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 32c8b737..5ca5126f 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -4,24 +4,14 @@ * CodeMie-specific HTTP client with SSO cookie handling */ -import { HTTPClient } from '../../core/base/http-client.js'; import type { CodeMieModel, CodeMieIntegration, CodeMieIntegrationsResponse } from '../../core/types.js'; - -/** - * User info response from /v1/user endpoint - */ -export interface CodeMieUserInfo { - userId: string; - name: string; - username: string; - isAdmin: boolean; - applications: string[]; - applications_admin: string[]; - applicationsAdmin?: string[]; - picture: string; - knowledgeBases: string[]; - userType?: string; -} +import { HTTPClient } from '../../core/base/http-client.js'; +import { + fetchCodeMieUserInfo, + buildAuthHeaders +} from '../../core/codemie-auth-helpers.js'; +export { fetchCodeMieUserInfo }; +export type { CodeMieUserInfo } from '../../core/codemie-auth-helpers.js'; /** * CodeMie API endpoints @@ -35,52 +25,6 @@ export const CODEMIE_ENDPOINTS = { AUTH_LOGIN: '/v1/auth/login' } as const; -/** - * Internal helper: build auth headers from cookies or JWT token - */ -function buildAuthHeaders(auth: Record | string): Record { - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - const headers: Record = { - 'Content-Type': 'application/json', - 'User-Agent': `codemie-cli/${cliVersion}`, - 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, - 'X-CodeMie-Client': 'codemie-cli' - }; - - if (typeof auth === 'string') { - // JWT token (string) - headers['authorization'] = `Bearer ${auth}`; - } else { - // SSO cookies (object) - existing behavior - headers['cookie'] = Object.entries(auth) - .map(([key, value]) => `${key}=${value}`) - .join(';'); - } - - return headers; -} - -/** - * Ensure API base URL has correct format with /code-assistant-api suffix - * - * @param rawUrl - Raw URL from user input (e.g., https://codemie.lab.epam.com) - * @returns Normalized URL with /code-assistant-api suffix - * - * @example - * ensureApiBase('https://codemie.lab.epam.com') - * // => 'https://codemie.lab.epam.com/code-assistant-api' - * - * ensureApiBase('https://codemie.lab.epam.com/code-assistant-api') - * // => 'https://codemie.lab.epam.com/code-assistant-api' - */ -export function ensureApiBase(rawUrl: string): string { - let base = rawUrl.replace(/\/$/, ''); // Remove trailing slash - // If URL doesn't have /code-assistant-api suffix, append it - if (!/\/code-assistant-api(\/|$)/i.test(base)) { - base = `${base}/code-assistant-api`; - } - return base; -} /** * Full model descriptor returned by GET /v1/llm_models?include_all=true @@ -182,8 +126,8 @@ export async function fetchCodeMieModels( const url = `${apiUrl}${CODEMIE_ENDPOINTS.MODELS}`; const client = new HTTPClient({ - timeout: 10000, - maxRetries: 3, + timeout: 30000, + maxRetries: 5, rejectUnauthorized: false }); @@ -224,67 +168,6 @@ export async function fetchCodeMieModels( return filteredModels; } -/** - * Fetch user information including accessible applications (supports both cookies and JWT) - * - * @param apiUrl - CodeMie API base URL - * @param auth - SSO session cookies or JWT token - * @returns User info with applications array - * @throws Error if request fails or response invalid - * - * Overload 1: SSO cookies (backward compatible - existing callers unchanged) - * Overload 2: JWT token string (new) - */ -/* eslint-disable no-redeclare */ -export function fetchCodeMieUserInfo( - apiUrl: string, - cookies: Record -): Promise; -export function fetchCodeMieUserInfo( - apiUrl: string, - jwtToken: string -): Promise; -export async function fetchCodeMieUserInfo( - apiUrl: string, - auth: Record | string -): Promise { - const headers = buildAuthHeaders(auth); - const url = `${apiUrl}${CODEMIE_ENDPOINTS.USER}`; - - const client = new HTTPClient({ - timeout: 10000, - maxRetries: 3, - rejectUnauthorized: false - }); - - const response = await client.getRaw(url, headers); - - // Handle HTTP errors - if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { - if (response.statusCode === 401 || response.statusCode === 403) { - throw new Error('Authentication failed - invalid or expired credentials'); - } - throw new Error(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); - } - - // Parse response - const userInfo = JSON.parse(response.data) as CodeMieUserInfo; - - // Normalize applications_admin: support both snake_case and camelCase variants - // applications_admin has higher priority; fall back to applicationsAdmin if missing - if (!Array.isArray(userInfo.applications_admin) && Array.isArray(userInfo.applicationsAdmin)) { - userInfo.applications_admin = userInfo.applicationsAdmin; - } - - // Validate response structure - if (!userInfo || !Array.isArray(userInfo.applications) || !Array.isArray(userInfo.applications_admin)) { - throw new Error('Invalid user info response: missing applications arrays'); - } - - return userInfo; -} -/* eslint-enable no-redeclare */ - /** * Fetch application details (non-blocking, best-effort) - supports both cookies and JWT * diff --git a/src/providers/plugins/sso/sso.setup-steps.ts b/src/providers/plugins/sso/sso.setup-steps.ts index b55cfb16..6f054405 100644 --- a/src/providers/plugins/sso/sso.setup-steps.ts +++ b/src/providers/plugins/sso/sso.setup-steps.ts @@ -20,11 +20,16 @@ import type { } from '../../core/types.js'; import type { CodeMieConfigOptions, CodeMieIntegrationInfo } from '../../../env/types.js'; import { ProviderRegistry } from '../../core/registry.js'; -import { SSOTemplate } from './sso.template.js'; import { CodeMieSSO } from './sso.auth.js'; import { SSOModelProxy } from './sso.models.js'; -import { fetchCodeMieUserInfo, fetchCodeMieModels, fetchCodeMieIntegrations } from './sso.http-client.js'; +import { fetchCodeMieModels, fetchCodeMieIntegrations } from './sso.http-client.js'; import { logger } from '../../../utils/logger.js'; +import { + DEFAULT_CODEMIE_BASE_URL, + authenticateWithCodeMie, + promptForCodeMieUrl, + selectCodeMieProject +} from '../../core/codemie-auth-helpers.js'; /** * SSO setup steps implementation @@ -38,34 +43,11 @@ export const SSOSetupSteps: ProviderSetupSteps = { * Prompts for CodeMie URL and performs browser-based authentication */ async getCredentials(): Promise { - // Prompt for CodeMie URL - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'codeMieUrl', - message: 'CodeMie organization URL:', - default: SSOTemplate.defaultBaseUrl, - validate: (input: string) => { - if (!input.trim()) { - return 'CodeMie URL is required'; - } - if (!input.startsWith('http://') && !input.startsWith('https://')) { - return 'Please enter a valid URL starting with http:// or https://'; - } - return true; - } - } - ]); - - const codeMieUrl = answers.codeMieUrl.trim(); + const codeMieUrl = await promptForCodeMieUrl(DEFAULT_CODEMIE_BASE_URL); // Authenticate via browser console.log(chalk.cyan('\nšŸ” Authenticating via browser...\n')); - const sso = new CodeMieSSO(); - const authResult = await sso.authenticate({ - codeMieUrl, - timeout: 120000 // 2 minutes - }); + const authResult = await authenticateWithCodeMie(codeMieUrl, 120000); if (!authResult.success) { throw new Error(`SSO authentication failed: ${authResult.error || 'Unknown error'}`); @@ -84,51 +66,7 @@ export const SSOSetupSteps: ProviderSetupSteps = { throw new Error('API URL or cookies not found in authentication result'); } - // Fetch user's accessible applications - const userInfo = await fetchCodeMieUserInfo( - authResult.apiUrl, - authResult.cookies - ); - - // Merge applications and applicationsAdmin arrays (deduplicated) - const applications = userInfo.applications || []; - const applicationsAdmin = userInfo.applications_admin || []; - const allProjects = [...new Set([...applications, ...applicationsAdmin])]; - - // Validate that user has at least one project - if (allProjects.length === 0) { - throw new Error('No projects found for your account. Please contact your administrator.'); - } - - // Sort projects alphabetically (case-insensitive) - const sortedProjects = allProjects.sort((a, b) => - a.localeCompare(b, undefined, { sensitivity: 'base' }) - ); - - // Auto-select if only one project - if (sortedProjects.length === 1) { - selectedProject = sortedProjects[0]; - console.log(chalk.green(`āœ“ Auto-selected project: ${chalk.bold(selectedProject)}\n`)); - } else { - // Multiple projects - prompt user to select - console.log(chalk.dim(`Found ${sortedProjects.length} accessible project(s)\n`)); - - const projectAnswers = await inquirer.prompt([ - { - type: 'list', - name: 'project', - message: 'Select your project:', - choices: sortedProjects.map(proj => ({ - name: proj, - value: proj - })), - pageSize: 15 - } - ]); - - selectedProject = projectAnswers.project; - console.log(chalk.green(`āœ“ Selected project: ${chalk.bold(selectedProject)}\n`)); - } + selectedProject = await selectCodeMieProject(authResult); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.log(chalk.red(`āœ— Project selection failed: ${errorMsg}\n`)); diff --git a/src/providers/plugins/sso/sso.template.ts b/src/providers/plugins/sso/sso.template.ts index bb5077b3..def6aa81 100644 --- a/src/providers/plugins/sso/sso.template.ts +++ b/src/providers/plugins/sso/sso.template.ts @@ -10,12 +10,13 @@ import type { ProviderTemplate } from '../../core/types.js'; import type { AgentConfig } from '../../../agents/core/types.js'; import { registerProvider } from '../../core/index.js'; +import { DEFAULT_CODEMIE_BASE_URL } from '../../core/codemie-auth-helpers.js'; export const SSOTemplate = registerProvider({ name: 'ai-run-sso', displayName: 'CodeMie SSO', description: 'Enterprise SSO Authentication with centralized model management', - defaultBaseUrl: 'https://codemie.lab.epam.com', // Default CodeMie URL + defaultBaseUrl: DEFAULT_CODEMIE_BASE_URL, requiresAuth: true, authType: 'sso', priority: 0, // Highest priority (shown first) From d87be2b8bae2e14adc8c7c642a900993f0eb70e6 Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Wed, 8 Apr 2026 17:02:42 +0300 Subject: [PATCH 2/4] feat(providers): finalize Anthropic Subscription provider with routing guard and model tier support - Add proxy routing guard integration tests for anthropic-subscription provider - Extend anthropic-subscription template with model tier configuration support - Update BaseAgentAdapter to handle anthropic-subscription provider routing - Add model-tier-config tests for BaseAgentAdapter - Update hook command with anthropic-subscription provider support - Extend config utilities for subscription provider detection - Update README with Anthropic Subscription provider documentation Generated with AI Co-Authored-By: codemie-ai --- .codemie/codemie-cli.config.json | 46 +----- README.md | 28 ++++ src/agents/core/BaseAgentAdapter.ts | 13 +- .../core/__tests__/BaseAgentAdapter.test.ts | 97 ++++++++++- .../core/__tests__/model-tier-config.test.ts | 48 ++++++ src/cli/commands/hook.ts | 17 +- .../anthropic-subscription.template.test.ts | 153 +++++++++++++++--- .../anthropic-subscription.template.ts | 62 ++++++- src/utils/config.ts | 18 +++ tests/integration/proxy-routing-guard.test.ts | 137 ++++++++++++++++ 10 files changed, 540 insertions(+), 79 deletions(-) create mode 100644 tests/integration/proxy-routing-guard.test.ts diff --git a/.codemie/codemie-cli.config.json b/.codemie/codemie-cli.config.json index 0676810f..35979474 100644 --- a/.codemie/codemie-cli.config.json +++ b/.codemie/codemie-cli.config.json @@ -1,47 +1,5 @@ { "version": 2, - "activeProfile": "epm-cdme", - "profiles": { - "epm-cdme": { - "codeMieProject": "epm-cdme", - "provider": "ai-run-sso", - "codeMieUrl": "https://codemie.lab.epam.com", - "apiKey": "sso-provided", - "baseUrl": "https://codemie.lab.epam.com/code-assistant-api", - "model": "claude-sonnet-4-6", - "haikuModel": "claude-haiku-4-5-20251001", - "sonnetModel": "claude-sonnet-4-6", - "opusModel": "claude-opus-4-6-20260205", - "name": "epm-cdme", - "codemieAssistants": [ - { - "id": "05959338-06de-477d-9cc3-08369f858057", - "name": "AI/Run FAQ", - "slug": "codemie-onboarding", - "description": "This is smart CodeMie assistant which can help you with onboarding process.\nCodeMie can answer to all you questions about capabilities, usage and so on.", - "project": "codemie", - "registeredAt": "2026-03-18T18:38:32.179Z", - "registrationMode": "skill" - }, - { - "id": "0368dce9-3987-49ac-b12e-41ce45623a20", - "name": "SonarQube MCP Analyzer", - "slug": "sonarqube-mcp-analyzer", - "description": "A highly specialized assistant designed to analyze SonarQube reports using SonarQube MCP Server tools. It processes report links, interpreting all available metrics such as the number and types of issues, severities, affected code snippets, coverage details, and more. Serving both direct users and other AI Assistants, it delivers in-depth insights and actionable recommendations on code quality, technical debt, and coverage improvement.", - "project": "epm-cdme", - "registeredAt": "2026-03-18T18:38:32.180Z", - "registrationMode": "skill" - }, - { - "id": "f14e801a-1e6c-4d2a-ab70-f59795c11a1b", - "name": "BriAnnA", - "slug": "brianna", - "description": "Business Analyst Assistant - expert to work with Jira. Used for creating/getting/managing Jira tickets in EPM-CDME project (Epics, Stories, Tasks, and Bugs). Main role is to analyze requirements from the request, clarify additional questions if necessary, generate requirements with the description structure defined in the prompt and additional details from the request, and create tickets in EPM-CDME project Jira. The Assistant uses Generic Jira tool for Jira tickets creation.", - "project": "epm-cdme", - "registeredAt": "2026-03-18T18:38:32.181Z", - "registrationMode": "skill" - } - ] - } - } + "activeProfile": "antropic", + "profiles": {} } \ No newline at end of file diff --git a/README.md b/README.md index db82945f..df1543b8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ CodeMie CLI is the all-in-one AI coding assistant for developers. - šŸ” **Enterprise Ready** - SSO and JWT authentication, audit logging, and role-based access. - ⚔ **Productivity Boost** - Code review, refactoring, test generation, and bug fixing. - šŸŽÆ **Profile Management** - Manage work, personal, and team configurations separately. +- 🧩 **CodeMie Assistants in Claude** - Connect your available CodeMie assistants as Claude subagents or skills. - šŸ“Š **Usage Analytics** - Track and analyze AI usage across all agents with detailed insights. - šŸ”§ **CI/CD Workflows** - Automated code review, fixes, and feature implementation. @@ -212,6 +213,33 @@ Auto-updates are automatically disabled to maintain version control. CodeMie not For more detailed information on the available agents, see the [Agents Documentation](docs/AGENTS.md). +### CodeMie Assistants as Claude Skills or Subagents + +CodeMie can connect assistants available in your CodeMie account directly into Claude Code. Register them as Claude subagents and call them with `@slug`, or register them as Claude skills and invoke them with `/slug`. + +```bash +# Pick assistants from your CodeMie account and choose how to register them +codemie setup assistants +``` + +During setup, choose: +- **Claude Subagents** - register selected assistants as `@slug` +- **Claude Skills** - register selected assistants as `/slug` +- **Manual Configuration** - choose skill or subagent per assistant + +After registration, use them from Claude Code: + +```text +@api-reviewer Review this authentication flow +/release-checklist prepare a release checklist for this branch +``` + +You can also message a registered assistant directly through CodeMie: + +```bash +codemie assistants chat "assistant-id" "Review this API design" +``` + ### Claude Code Built-in Commands When using Claude Code (`codemie-claude`), you get access to powerful built-in commands for project documentation: diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 0d1dca35..d370678a 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -794,13 +794,20 @@ export abstract class BaseAgentAdapter implements AgentAdapter { if (!providerName) return false; const provider = ProviderRegistry.getProvider(providerName); + + // Providers with no authentication requirement never route through the proxy. + // This also guards against stale CODEMIE_AUTH_METHOD='jwt' values persisting + // in process.env from a previous JWT-authenticated session (written by + // Object.assign(process.env, env) at the end of run()). + if (provider?.authType === 'none') return false; + const isSSOProvider = provider?.authType === 'sso'; const isJWTAuth = env.CODEMIE_AUTH_METHOD === 'jwt'; - const hasCodeMieSync = Boolean(env.CODEMIE_SYNC_API_URL && env.CODEMIE_URL); const isProxyEnabled = this.metadata.ssoConfig?.enabled ?? false; - // Proxy needed for SSO cookie injection OR JWT bearer token injection - return (isSSOProvider || isJWTAuth || hasCodeMieSync) && isProxyEnabled; + // Proxy is only for model API authentication/forwarding. Analytics sync can + // be configured independently and must not force native providers through it. + return (isSSOProvider || isJWTAuth) && isProxyEnabled; } /** diff --git a/src/agents/core/__tests__/BaseAgentAdapter.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.test.ts index f06ace5b..bdd48c6f 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.test.ts @@ -1,7 +1,30 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { BaseAgentAdapter } from '../BaseAgentAdapter.js'; import type { AgentMetadata } from '../types.js'; +// Provide a minimal ProviderRegistry stub so shouldUseProxy can look up authType +// without needing real provider templates to be registered. +// registerProvider / registerSetupSteps / registerHealthCheck must also be stubbed +// because provider templates call them as side-effects when their modules are imported +// transitively through BaseAgentAdapter. +vi.mock('../../../providers/core/registry.js', () => { + const providers: Record = { + 'anthropic-subscription': { authType: 'none' }, + 'ai-run-sso': { authType: 'sso' }, + 'bearer-auth': { authType: 'jwt' }, + }; + return { + ProviderRegistry: { + registerProvider: vi.fn((t: any) => t), + registerSetupSteps: vi.fn(), + registerHealthCheck: vi.fn(), + registerModelProxy: vi.fn(), + getProvider: vi.fn((name: string) => providers[name]), + getProviderNames: vi.fn(() => Object.keys(providers)), + }, + }; +}); + /** * Test adapter that extends BaseAgentAdapter * Used to test protected methods and metadata access @@ -123,4 +146,76 @@ describe('BaseAgentAdapter', () => { expect(adapter.getMetadata().lifecycle).toBe(lifecycle); }); }); + + describe('proxy selection', () => { + // Shared metadata with ssoConfig enabled (same as the real claude plugin) + const proxyCapableMetadata: AgentMetadata = { + name: 'test', + displayName: 'Test Agent', + description: 'Test agent for unit testing', + npmPackage: null, + cliCommand: null, + envMapping: {}, + supportedProviders: ['anthropic-subscription', 'ai-run-sso', 'bearer-auth'], + ssoConfig: { enabled: true, clientType: 'codemie-claude' }, + }; + + it('does not enable the model proxy just because CodeMie analytics sync is configured', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'anthropic-subscription', + CODEMIE_URL: 'https://codemie.lab.epam.com', + CODEMIE_SYNC_API_URL: 'https://codemie.lab.epam.com/code-assistant-api', + })).toBe(false); + }); + + it('does not start proxy for authType:none even when CODEMIE_AUTH_METHOD=jwt is stale in env', () => { + // Regression guard for the stale-env contamination bug: + // A previous JWT session writes CODEMIE_AUTH_METHOD=jwt to process.env. + // The next anthropic-subscription run must NOT start the proxy. + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'anthropic-subscription', + CODEMIE_AUTH_METHOD: 'jwt', // stale value from a prior JWT session + CODEMIE_URL: 'https://codemie.lab.epam.com', + CODEMIE_SYNC_API_URL: 'https://codemie.lab.epam.com/code-assistant-api', + })).toBe(false); + }); + + it('does not start proxy when CODEMIE_PROVIDER is absent', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + expect((adapter as any).shouldUseProxy({})).toBe(false); + }); + + it('starts proxy for SSO provider when ssoConfig is enabled', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'ai-run-sso', + })).toBe(true); + }); + + it('starts proxy for JWT auth method on a non-native provider', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'bearer-auth', + CODEMIE_AUTH_METHOD: 'jwt', + })).toBe(true); + }); + + it('does not start proxy when ssoConfig is disabled even for an SSO provider', () => { + const noProxyMetadata: AgentMetadata = { + ...proxyCapableMetadata, + ssoConfig: { enabled: false, clientType: 'codemie-claude' }, + }; + const adapter = new TestAdapter(noProxyMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'ai-run-sso', + })).toBe(false); + }); + }); }); diff --git a/src/agents/core/__tests__/model-tier-config.test.ts b/src/agents/core/__tests__/model-tier-config.test.ts index 206ffddb..5c8cd31d 100644 --- a/src/agents/core/__tests__/model-tier-config.test.ts +++ b/src/agents/core/__tests__/model-tier-config.test.ts @@ -264,4 +264,52 @@ describe('ConfigLoader.exportProviderEnvVars', () => { expect(env.CODEMIE_SYNC_API_URL).toBe('https://codemie.lab.epam.com/code-assistant-api'); expect(env.CODEMIE_PROJECT).toBe('codemie-platform'); }); + + describe('CODEMIE_AUTH_METHOD export (stale-env contamination guard)', () => { + it('exports CODEMIE_AUTH_METHOD=manual for anthropic-subscription', async () => { + const { ConfigLoader } = await import('../../../utils/config.js'); + + const env = ConfigLoader.exportProviderEnvVars({ + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + apiKey: '', + authMethod: 'manual', + }); + + expect(env.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + + it('exports CODEMIE_AUTH_METHOD="" when authMethod is not set', async () => { + const { ConfigLoader } = await import('../../../utils/config.js'); + + const env = ConfigLoader.exportProviderEnvVars({ + provider: 'openai', + baseUrl: 'https://api.openai.com', + apiKey: 'sk-test', + // authMethod intentionally absent + }); + + expect(env.CODEMIE_AUTH_METHOD).toBe(''); + }); + + it('CODEMIE_AUTH_METHOD overrides stale jwt value when merged into env', async () => { + // Simulates what BaseAgentAdapter does: + // env = { ...process.env (stale), ...envOverrides (fresh) } + // The fresh exportProviderEnvVars output must win over the stale value. + const { ConfigLoader } = await import('../../../utils/config.js'); + + const staleProcessEnv = { CODEMIE_AUTH_METHOD: 'jwt' }; + + const providerEnv = ConfigLoader.exportProviderEnvVars({ + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + apiKey: '', + authMethod: 'manual', + }); + + const mergedEnv = { ...staleProcessEnv, ...providerEnv }; + + expect(mergedEnv.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + }); }); diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 51431318..748cb632 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -237,10 +237,13 @@ async function handleSessionEnd(event: SessionEndEvent, sessionId: string, confi */ async function syncPendingDataToAPI(sessionId: string, agentSessionId: string, config?: HookProcessingConfig): Promise { try { - // Only sync for SSO provider const provider = getConfigValue('CODEMIE_PROVIDER', config); - if (provider !== 'ai-run-sso') { - logger.debug('[hook:SessionEnd] Skipping API sync (not SSO provider)'); + const ssoUrl = getConfigValue('CODEMIE_URL', config); + const syncApiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config); + const hasCodeMieAnalyticsAuth = Boolean(ssoUrl && syncApiUrl); + + if (provider !== 'ai-run-sso' && !hasCodeMieAnalyticsAuth) { + logger.debug('[hook:SessionEnd] Skipping API sync (CodeMie analytics auth not configured)'); return; } @@ -382,7 +385,7 @@ async function buildProcessingContext( // Get configuration values from config object or environment variables const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); - const apiUrl = getConfigValue('CODEMIE_BASE_URL', config) || ''; + const apiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config) || getConfigValue('CODEMIE_BASE_URL', config) || ''; const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config) || '0.0.0'; const clientType = getConfigValue('CODEMIE_CLIENT_TYPE', config) || 'codemie-cli'; @@ -390,8 +393,10 @@ async function buildProcessingContext( let cookies = config?.cookies || ''; let apiKey: string | undefined = config?.apiKey; - // If SSO provider and credentials not provided in config, try to load them - if (provider === 'ai-run-sso' && ssoUrl && apiUrl && !cookies) { + // If CodeMie analytics auth is configured and credentials are not provided, + // try to load the stored SSO cookies. This also supports native providers + // such as anthropic-subscription, where CodeMie auth is analytics-only. + if ((provider === 'ai-run-sso' || ssoUrl) && ssoUrl && apiUrl && !cookies) { try { const { CodeMieSSO } = await import('../../providers/plugins/sso/sso.auth.js'); const sso = new CodeMieSSO(); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts index 12ea7350..0119fd4b 100644 --- a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts @@ -1,6 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { AnthropicSubscriptionTemplate } from '../anthropic-subscription.template.js'; +const { mockInstall, mockGetAgent } = vi.hoisted(() => ({ + mockInstall: vi.fn(), + mockGetAgent: vi.fn(), +})); + +vi.mock('../../../../agents/registry.js', () => ({ + AgentRegistry: { getAgent: mockGetAgent }, +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})); + describe('AnthropicSubscriptionTemplate', () => { it('has the correct provider name', () => { expect(AnthropicSubscriptionTemplate.name).toBe('anthropic-subscription'); @@ -20,45 +33,147 @@ describe('AnthropicSubscriptionTemplate', () => { expect(AnthropicSubscriptionTemplate.recommendedModels.length).toBeGreaterThan(0); }); - describe('agentHooks - claude beforeRun', () => { - it('removes ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY from env', async () => { + describe('agentHooks - beforeRun (*)', () => { + beforeEach(() => { + mockInstall.mockResolvedValue({ success: true, targetPath: '/tmp/codemie-claude-plugin' }); + mockGetAgent.mockReturnValue({ getExtensionInstaller: () => ({ install: mockInstall }) }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('strips Anthropic auth vars and does not mutate the original env', async () => { const env: Record = { ANTHROPIC_AUTH_TOKEN: 'some-token', ANTHROPIC_API_KEY: 'some-key', - OTHER_VAR: 'keep-me' + ANTHROPIC_BASE_URL: 'http://localhost:1234', + OTHER_VAR: 'keep-me', }; - const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; - expect(hook).toBeDefined(); - - const result = await hook!.beforeRun!(env); + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.ANTHROPIC_BASE_URL).toBeUndefined(); expect(result.OTHER_VAR).toBe('keep-me'); + + // Must not mutate the caller's object + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('some-token'); + expect(env.ANTHROPIC_API_KEY).toBe('some-key'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:1234'); + }); + + it('sets CODEMIE_CLAUDE_EXTENSION_DIR when installer succeeds', async () => { + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!({}, { agent: 'claude' }); + + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBe('/tmp/codemie-claude-plugin'); + }); + + it('returns env unchanged for non-Claude agents', async () => { + const env: Record = { + ANTHROPIC_AUTH_TOKEN: 'some-token', + OTHER_VAR: 'keep-me', + }; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'gemini' }); + + expect(result).toBe(env); // exact same reference — no copy created + }); + + it('strips vars and continues when agent is not in the registry', async () => { + mockGetAgent.mockReturnValue(undefined); + + const env: Record = { ANTHROPIC_API_KEY: 'key' }; + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBeUndefined(); + }); + + it('strips vars and continues when agent has no extension installer', async () => { + mockGetAgent.mockReturnValue({ getExtensionInstaller: () => undefined }); + + const env: Record = { ANTHROPIC_API_KEY: 'key' }; + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBeUndefined(); + }); + + it('still sets CODEMIE_CLAUDE_EXTENSION_DIR when install reports failure', async () => { + mockInstall.mockResolvedValue({ success: false, targetPath: '/tmp/codemie-claude-plugin', error: 'disk full' }); + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!({}, { agent: 'claude' }); + + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBe('/tmp/codemie-claude-plugin'); + }); + + it('strips vars and does not throw when installer throws', async () => { + mockInstall.mockRejectedValue(new Error('ENOENT')); + + const env: Record = { ANTHROPIC_API_KEY: 'key' }; + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBeUndefined(); + }); + }); + + describe('agentHooks - enrichArgs (claude)', () => { + const originalEnv = process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + } else { + process.env.CODEMIE_CLAUDE_EXTENSION_DIR = originalEnv; + } + }); + + it('prepends --plugin-dir when CODEMIE_CLAUDE_EXTENSION_DIR is set', () => { + process.env.CODEMIE_CLAUDE_EXTENSION_DIR = '/tmp/codemie-claude-plugin'; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + const result = hook!.enrichArgs!(['--verbose'], { agent: 'claude' }); + + expect(result).toEqual(['--plugin-dir', '/tmp/codemie-claude-plugin', '--verbose']); + }); + + it('returns args unchanged when CODEMIE_CLAUDE_EXTENSION_DIR is not set', () => { + delete process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + const result = hook!.enrichArgs!(['--verbose'], { agent: 'claude' }); + + expect(result).toEqual(['--verbose']); }); - it('is a no-op when auth vars are not set', async () => { - const env: Record = { SOME_VAR: 'value' }; + it('does not inject --plugin-dir when it is already present in args', () => { + process.env.CODEMIE_CLAUDE_EXTENSION_DIR = '/tmp/codemie-claude-plugin'; const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; - const result = await hook!.beforeRun!(env); + const result = hook!.enrichArgs!(['--plugin-dir', '/custom/path', '--verbose'], { agent: 'claude' }); - expect(result).toEqual({ SOME_VAR: 'value' }); + expect(result).toEqual(['--plugin-dir', '/custom/path', '--verbose']); }); }); describe('exportEnvVars', () => { - it('exports CODEMIE_API_KEY as empty string', () => { + it('always exports CODEMIE_API_KEY as empty string', () => { const env = AnthropicSubscriptionTemplate.exportEnvVars!({} as any); - expect(env.CODEMIE_API_KEY).toBe(''); }); it('exports CODEMIE_URL and CODEMIE_SYNC_API_URL when codeMieUrl is set', () => { - const env = AnthropicSubscriptionTemplate.exportEnvVars!({ - codeMieUrl: 'https://codemie.example.com' - } as any); + const env = AnthropicSubscriptionTemplate.exportEnvVars!({ codeMieUrl: 'https://codemie.example.com' } as any); expect(env.CODEMIE_URL).toBe('https://codemie.example.com'); expect(env.CODEMIE_SYNC_API_URL).toContain('code-assistant-api'); @@ -67,13 +182,13 @@ describe('AnthropicSubscriptionTemplate', () => { it('exports CODEMIE_PROJECT when codeMieProject is set', () => { const env = AnthropicSubscriptionTemplate.exportEnvVars!({ codeMieUrl: 'https://codemie.example.com', - codeMieProject: 'my-project' + codeMieProject: 'my-project', } as any); expect(env.CODEMIE_PROJECT).toBe('my-project'); }); - it('does not export CODEMIE_URL or CODEMIE_PROJECT when not configured', () => { + it('omits CODEMIE_URL and CODEMIE_PROJECT when not configured', () => { const env = AnthropicSubscriptionTemplate.exportEnvVars!({} as any); expect(env.CODEMIE_URL).toBeUndefined(); diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts index 566ef16c..307103a2 100644 --- a/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts @@ -8,6 +8,7 @@ */ import type { ProviderTemplate } from '../../core/types.js'; +import type { AgentConfig } from '../../../agents/core/types.js'; import { registerProvider } from '../../core/decorators.js'; import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; @@ -30,13 +31,62 @@ export const AnthropicSubscriptionTemplate = registerProvider( supportsStreaming: true, agentHooks: { - 'claude': { - async beforeRun(env) { + '*': { + async beforeRun(env: NodeJS.ProcessEnv, config: AgentConfig): Promise { + if (config.agent !== 'claude') { + return env; + } + + // Return a copy so callers that hold a reference to the original env are not affected. + const updated = { ...env }; + // Native Claude subscription auth relies on Claude Code's stored login. - // Any explicit token env var overrides that flow and causes 401s. - delete env.ANTHROPIC_AUTH_TOKEN; - delete env.ANTHROPIC_API_KEY; - return env; + // Explicit Anthropic API/proxy env vars override that flow and can cause 401s. + delete updated.ANTHROPIC_AUTH_TOKEN; + delete updated.ANTHROPIC_API_KEY; + delete updated.ANTHROPIC_BASE_URL; + + // Reuse the Claude Code plugin hooks so local metrics/conversation files are + // produced even though model traffic is not proxied through CodeMie. + // + // Dynamic import avoids a circular dependency: AgentRegistry imports all plugins + // (including this provider template) as side effects, so a static top-level import + // here would form a cycle. The dynamic import defers resolution until runtime when + // the registry is already fully initialised. + try { + const { AgentRegistry } = await import('../../../agents/registry.js'); + const agent = AgentRegistry.getAgent('claude'); + const installer = (agent as any)?.getExtensionInstaller?.(); + + if (installer) { + const result = await installer.install(); + updated.CODEMIE_CLAUDE_EXTENSION_DIR = result.targetPath; + + if (!result.success) { + const { logger } = await import('../../../utils/logger.js'); + logger.warn(`[claude] Extension installation returned failure: ${result.error || 'unknown error'}`); + logger.warn('[claude] Continuing without extension - hooks may not be available'); + } + } + } catch (error) { + const { logger } = await import('../../../utils/logger.js'); + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error(`[claude] Extension installation threw exception: ${errorMsg}`); + logger.warn('[claude] Continuing without extension - hooks may not be available'); + } + + return updated; + } + }, + 'claude': { + enrichArgs(args: string[], _config: AgentConfig): string[] { + const pluginDir = process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + + if (!pluginDir || args.some(arg => arg === '--plugin-dir')) { + return args; + } + + return ['--plugin-dir', pluginDir, ...args]; } } }, diff --git a/src/utils/config.ts b/src/utils/config.ts index e57205eb..29fb4c94 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -96,6 +96,18 @@ export class ConfigLoader { delete filteredEnvConfig.provider; filtered.push('provider'); } + if (!cliOverrides.codeMieUrl && filteredEnvConfig.codeMieUrl) { + delete filteredEnvConfig.codeMieUrl; + filtered.push('codeMieUrl'); + } + if (!cliOverrides.authMethod && filteredEnvConfig.authMethod) { + delete filteredEnvConfig.authMethod; + filtered.push('authMethod'); + } + if (!cliOverrides.codeMieIntegration && filteredEnvConfig.codeMieIntegration) { + delete filteredEnvConfig.codeMieIntegration; + filtered.push('codeMieIntegration'); + } if (filtered.length > 0 && config.debug) { console.log(`[ConfigLoader] Profile protection: filtered environment vars: ${filtered.join(', ')}`); @@ -1115,6 +1127,12 @@ export class ConfigLoader { if (config.timeout) env.CODEMIE_TIMEOUT = String(config.timeout); if (config.debug) env.CODEMIE_DEBUG = String(config.debug); + // Always export CODEMIE_AUTH_METHOD so that a stale 'jwt' value written to + // process.env by a previous JWT-authenticated session cannot bleed into the + // current session and trigger proxy usage for non-JWT providers. + // Falls back to '' (no auth method) when the provider doesn't set one. + env.CODEMIE_AUTH_METHOD = config.authMethod ?? ''; + // Provider-specific environment variables (pluggable) // Each provider defines its own exportEnvVars function if (providerTemplate?.exportEnvVars) { diff --git a/tests/integration/proxy-routing-guard.test.ts b/tests/integration/proxy-routing-guard.test.ts new file mode 100644 index 00000000..40145e4d --- /dev/null +++ b/tests/integration/proxy-routing-guard.test.ts @@ -0,0 +1,137 @@ +/** + * Integration tests: proxy routing guard for anthropic-subscription + * + * Regression suite for the stale-CODEMIE_AUTH_METHOD bug: + * + * A previous JWT-authenticated session writes CODEMIE_AUTH_METHOD=jwt to + * process.env via Object.assign(process.env, env) in BaseAgentAdapter.run(). + * The next anthropic-subscription run must NOT start the CodeMie proxy even + * though the stale env var is present. + * + * Two defences are exercised together (as in production): + * 1. exportProviderEnvVars always emits CODEMIE_AUTH_METHOD so envOverrides + * always wins over stale process.env values. + * 2. shouldUseProxy early-returns false for providers with authType === 'none'. + * + * @group integration + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ConfigLoader } from '../../src/utils/config.js'; +import { BaseAgentAdapter } from '../../src/agents/core/BaseAgentAdapter.js'; +import type { AgentMetadata } from '../../src/agents/core/types.js'; + +// Importing the template registers anthropic-subscription in ProviderRegistry +// so that shouldUseProxy can resolve its authType without mocks. +import '../../src/providers/plugins/anthropic-subscription/index.js'; + +// --------------------------------------------------------------------------- +// Minimal concrete adapter used to call the private shouldUseProxy method +// --------------------------------------------------------------------------- +class TestAdapter extends BaseAgentAdapter { + async run(): Promise { /* no-op */ } + + callShouldUseProxy(env: NodeJS.ProcessEnv): boolean { + return (this as any).shouldUseProxy(env); + } +} + +const adapterMetadata: AgentMetadata = { + name: 'claude', + displayName: 'Claude Code', + description: 'Test adapter', + npmPackage: null, + cliCommand: null, + envMapping: { baseUrl: ['ANTHROPIC_BASE_URL'], apiKey: ['ANTHROPIC_AUTH_TOKEN'] }, + supportedProviders: ['anthropic-subscription', 'ai-run-sso', 'bearer-auth'], + ssoConfig: { enabled: true, clientType: 'codemie-claude' }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const STALE_JWT_ENV: NodeJS.ProcessEnv = { CODEMIE_AUTH_METHOD: 'jwt' }; + +const ANTHROPIC_SUBSCRIPTION_CONFIG = { + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + apiKey: '', + authMethod: 'manual' as const, + model: 'claude-sonnet-4-6', + codeMieUrl: 'https://codemie.lab.epam.com', + codeMieProject: 'my-project', +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('Proxy routing guard — anthropic-subscription with stale JWT env', () => { + let savedAuthMethod: string | undefined; + + beforeEach(() => { + savedAuthMethod = process.env.CODEMIE_AUTH_METHOD; + }); + + afterEach(() => { + if (savedAuthMethod === undefined) { + delete process.env.CODEMIE_AUTH_METHOD; + } else { + process.env.CODEMIE_AUTH_METHOD = savedAuthMethod; + } + }); + + it('exportProviderEnvVars emits CODEMIE_AUTH_METHOD=manual for anthropic-subscription', () => { + const providerEnv = ConfigLoader.exportProviderEnvVars(ANTHROPIC_SUBSCRIPTION_CONFIG); + expect(providerEnv.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + + it('merging exportProviderEnvVars over stale process.env neutralises CODEMIE_AUTH_METHOD=jwt', () => { + const providerEnv = ConfigLoader.exportProviderEnvVars(ANTHROPIC_SUBSCRIPTION_CONFIG); + + // Replicate what BaseAgentAdapter.run() does at line ~471 + const mergedEnv: NodeJS.ProcessEnv = { ...STALE_JWT_ENV, ...providerEnv }; + + expect(mergedEnv.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + + it('shouldUseProxy returns false for anthropic-subscription even when CODEMIE_AUTH_METHOD=jwt is stale in process.env', () => { + // Simulate a stale env from a previous JWT session + process.env.CODEMIE_AUTH_METHOD = 'jwt'; + + const providerEnv = ConfigLoader.exportProviderEnvVars(ANTHROPIC_SUBSCRIPTION_CONFIG); + + // Replicate BaseAgentAdapter.run() env assembly + const env: NodeJS.ProcessEnv = { ...process.env, ...providerEnv }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(false); + }); + + it('shouldUseProxy returns false for anthropic-subscription even with explicit CODEMIE_AUTH_METHOD=jwt in env', () => { + // Defence-in-depth: authType:none guard fires before isJWTAuth is checked + const env: NodeJS.ProcessEnv = { + CODEMIE_PROVIDER: 'anthropic-subscription', + CODEMIE_AUTH_METHOD: 'jwt', + }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(false); + }); + + it('shouldUseProxy still returns true for an SSO provider (regression)', () => { + const env: NodeJS.ProcessEnv = { CODEMIE_PROVIDER: 'ai-run-sso' }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(true); + }); + + it('shouldUseProxy still returns true for bearer-auth with CODEMIE_AUTH_METHOD=jwt (regression)', () => { + const env: NodeJS.ProcessEnv = { + CODEMIE_PROVIDER: 'bearer-auth', + CODEMIE_AUTH_METHOD: 'jwt', + }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(true); + }); +}); From 93279e8901c039fa62398b39b93778b7ef64a475 Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Wed, 8 Apr 2026 17:48:10 +0300 Subject: [PATCH 3/4] fix: return code --- .codemie/codemie-cli.config.json | 46 ++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/.codemie/codemie-cli.config.json b/.codemie/codemie-cli.config.json index 35979474..0676810f 100644 --- a/.codemie/codemie-cli.config.json +++ b/.codemie/codemie-cli.config.json @@ -1,5 +1,47 @@ { "version": 2, - "activeProfile": "antropic", - "profiles": {} + "activeProfile": "epm-cdme", + "profiles": { + "epm-cdme": { + "codeMieProject": "epm-cdme", + "provider": "ai-run-sso", + "codeMieUrl": "https://codemie.lab.epam.com", + "apiKey": "sso-provided", + "baseUrl": "https://codemie.lab.epam.com/code-assistant-api", + "model": "claude-sonnet-4-6", + "haikuModel": "claude-haiku-4-5-20251001", + "sonnetModel": "claude-sonnet-4-6", + "opusModel": "claude-opus-4-6-20260205", + "name": "epm-cdme", + "codemieAssistants": [ + { + "id": "05959338-06de-477d-9cc3-08369f858057", + "name": "AI/Run FAQ", + "slug": "codemie-onboarding", + "description": "This is smart CodeMie assistant which can help you with onboarding process.\nCodeMie can answer to all you questions about capabilities, usage and so on.", + "project": "codemie", + "registeredAt": "2026-03-18T18:38:32.179Z", + "registrationMode": "skill" + }, + { + "id": "0368dce9-3987-49ac-b12e-41ce45623a20", + "name": "SonarQube MCP Analyzer", + "slug": "sonarqube-mcp-analyzer", + "description": "A highly specialized assistant designed to analyze SonarQube reports using SonarQube MCP Server tools. It processes report links, interpreting all available metrics such as the number and types of issues, severities, affected code snippets, coverage details, and more. Serving both direct users and other AI Assistants, it delivers in-depth insights and actionable recommendations on code quality, technical debt, and coverage improvement.", + "project": "epm-cdme", + "registeredAt": "2026-03-18T18:38:32.180Z", + "registrationMode": "skill" + }, + { + "id": "f14e801a-1e6c-4d2a-ab70-f59795c11a1b", + "name": "BriAnnA", + "slug": "brianna", + "description": "Business Analyst Assistant - expert to work with Jira. Used for creating/getting/managing Jira tickets in EPM-CDME project (Epics, Stories, Tasks, and Bugs). Main role is to analyze requirements from the request, clarify additional questions if necessary, generate requirements with the description structure defined in the prompt and additional details from the request, and create tickets in EPM-CDME project Jira. The Assistant uses Generic Jira tool for Jira tickets creation.", + "project": "epm-cdme", + "registeredAt": "2026-03-18T18:38:32.181Z", + "registrationMode": "skill" + } + ] + } + } } \ No newline at end of file From 7366c6d630e1fa49586684132798635764f1f4e7 Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Thu, 16 Apr 2026 16:12:16 +0300 Subject: [PATCH 4/4] fix: after code review --- src/agents/core/BaseAgentAdapter.ts | 2 +- .../core/__tests__/BaseAgentAdapter.test.ts | 60 +++++ src/cli/commands/hook.ts | 3 +- src/cli/commands/setup.ts | 2 +- .../__tests__/codemie-auth-helpers.test.ts | 226 +++++++++++++++++- src/providers/core/codemie-auth-helpers.ts | 15 +- .../anthropic-subscription.template.ts | 2 +- 7 files changed, 296 insertions(+), 14 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 8c768aab..2d189275 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -861,7 +861,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { sessionId: env.CODEMIE_SESSION_ID, version: env.CODEMIE_CLI_VERSION, profileConfig, - authMethod: (env.CODEMIE_AUTH_METHOD as 'sso' | 'jwt') || undefined, + authMethod: (env.CODEMIE_AUTH_METHOD === 'sso' || env.CODEMIE_AUTH_METHOD === 'jwt') ? env.CODEMIE_AUTH_METHOD : undefined, jwtToken: env.CODEMIE_JWT_TOKEN || undefined, repository, branch: branch || undefined, diff --git a/src/agents/core/__tests__/BaseAgentAdapter.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.test.ts index bdd48c6f..c37ac267 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.test.ts @@ -218,4 +218,64 @@ describe('BaseAgentAdapter', () => { })).toBe(false); }); }); + + describe('buildProxyConfig authMethod guard', () => { + const proxyCapableMetadata: AgentMetadata = { + name: 'test', + displayName: 'Test Agent', + description: 'Test agent for unit testing', + npmPackage: null, + cliCommand: null, + envMapping: {}, + supportedProviders: ['ai-run-sso'], + ssoConfig: { enabled: true, clientType: 'codemie-claude' }, + }; + + const baseEnv = { + CODEMIE_BASE_URL: 'https://api.example.com', + CODEMIE_PROVIDER: 'ai-run-sso', + }; + + it('maps sso auth method correctly', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'sso', + }); + expect(config.authMethod).toBe('sso'); + }); + + it('maps jwt auth method correctly', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'jwt', + }); + expect(config.authMethod).toBe('jwt'); + }); + + it('sets authMethod to undefined for manual auth method', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'manual', + }); + expect(config.authMethod).toBeUndefined(); + }); + + it('sets authMethod to undefined when CODEMIE_AUTH_METHOD is not set', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig(baseEnv); + expect(config.authMethod).toBeUndefined(); + }); + + it('sets authMethod to undefined for unknown auth methods', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'api-key', + }); + expect(config.authMethod).toBeUndefined(); + }); + }); }); diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index d31fdef9..2ce03493 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -383,7 +383,6 @@ async function buildProcessingContext( config?: HookProcessingConfig ): Promise { // Get configuration values from config object or environment variables - const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); const apiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config) || getConfigValue('CODEMIE_BASE_URL', config) || ''; const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config) || '0.0.0'; @@ -396,7 +395,7 @@ async function buildProcessingContext( // If CodeMie analytics auth is configured and credentials are not provided, // try to load the stored SSO cookies. This also supports native providers // such as anthropic-subscription, where CodeMie auth is analytics-only. - if ((provider === 'ai-run-sso' || ssoUrl) && ssoUrl && apiUrl && !cookies) { + if (ssoUrl && apiUrl && !cookies) { try { const { CodeMieSSO } = await import('../../providers/plugins/sso/sso.auth.js'); const sso = new CodeMieSSO(); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index f9608bd5..daf376e2 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -264,7 +264,7 @@ async function handlePluginSetup( if (preselectedModel) { selectedModel = preselectedModel; - console.log(chalk.green(`āœ“ Model selected automatically: ${chalk.bold(selectedModel)}`)); + logger.success(`Model selected automatically: ${selectedModel}`); } else { selectedModel = await promptForModelSelection(models, providerTemplate); } diff --git a/src/providers/core/__tests__/codemie-auth-helpers.test.ts b/src/providers/core/__tests__/codemie-auth-helpers.test.ts index b7ccf2cb..542a5dfa 100644 --- a/src/providers/core/__tests__/codemie-auth-helpers.test.ts +++ b/src/providers/core/__tests__/codemie-auth-helpers.test.ts @@ -1,5 +1,23 @@ -import { describe, expect, it } from 'vitest'; -import { ensureApiBase, buildAuthHeaders } from '../codemie-auth-helpers.js'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ConfigurationError } from '../../../utils/errors.js'; + +// Mock HTTPClient before importing the module under test +const mockGetRaw = vi.fn(); +vi.mock('../base/http-client.js', () => ({ + HTTPClient: class { + getRaw = mockGetRaw; + }, +})); + +vi.mock('../../../utils/logger.js', () => ({ + logger: { success: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn() }, +})); + +import { ensureApiBase, buildAuthHeaders, fetchCodeMieUserInfo, selectCodeMieProject } from '../codemie-auth-helpers.js'; describe('ensureApiBase', () => { it('appends /code-assistant-api when missing', () => { @@ -67,3 +85,207 @@ describe('buildAuthHeaders', () => { expect(headers['User-Agent']).toBe('codemie-cli/unknown'); }); }); + +describe('fetchCodeMieUserInfo', () => { + beforeEach(() => { + mockGetRaw.mockReset(); + }); + + it('throws ConfigurationError on 401 response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 401, + statusMessage: 'Unauthorized', + data: '', + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow(ConfigurationError); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow('Authentication failed - invalid or expired credentials'); + }); + + it('throws ConfigurationError on 403 response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 403, + statusMessage: 'Forbidden', + data: '', + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', 'jwt-token') + ).rejects.toThrow(ConfigurationError); + }); + + it('throws ConfigurationError on 500 response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 500, + statusMessage: 'Internal Server Error', + data: '', + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow(ConfigurationError); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow('Failed to fetch user info: 500 Internal Server Error'); + }); + + it('throws ConfigurationError when response is missing applications arrays', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ userId: '1', name: 'Test', username: 'test' }), + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow(ConfigurationError); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow('Invalid user info response: missing applications arrays'); + }); + + it('normalizes applicationsAdmin to applications_admin', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: ['proj-a'], + applicationsAdmin: ['proj-b'], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }); + expect(result.applications_admin).toEqual(['proj-b']); + }); + + it('returns user info on successful response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test User', + username: 'tuser', + isAdmin: false, + applications: ['project-a', 'project-b'], + applications_admin: ['project-a'], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }); + expect(result.userId).toBe('1'); + expect(result.applications).toEqual(['project-a', 'project-b']); + expect(result.applications_admin).toEqual(['project-a']); + }); +}); + +describe('selectCodeMieProject', () => { + beforeEach(() => { + mockGetRaw.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('throws ConfigurationError when apiUrl is missing', async () => { + await expect( + selectCodeMieProject({ cookies: { session: 'abc' } } as any) + ).rejects.toThrow(ConfigurationError); + }); + + it('throws ConfigurationError when cookies are missing', async () => { + await expect( + selectCodeMieProject({ apiUrl: 'https://api.example.com' } as any) + ).rejects.toThrow(ConfigurationError); + }); + + it('auto-selects single project and prints to console', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: ['only-project'], + applications_admin: [], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await selectCodeMieProject({ + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' }, + } as any); + + expect(result).toBe('only-project'); + // Verify console.log was called (interactive UX feedback) + expect(console.log).toHaveBeenCalled(); + const logCall = (console.log as any).mock.calls[0][0]; + expect(logCall).toContain('Auto-selected project'); + }); + + it('throws ConfigurationError when no projects are found', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: [], + applications_admin: [], + picture: '', + knowledgeBases: [], + }), + }); + + await expect( + selectCodeMieProject({ + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' }, + } as any) + ).rejects.toThrow('No projects found for your account'); + }); + + it('deduplicates projects from applications and applications_admin', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: ['shared-project'], + applications_admin: ['shared-project'], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await selectCodeMieProject({ + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' }, + } as any); + + // Only one project after dedup → auto-selected + expect(result).toBe('shared-project'); + }); +}); diff --git a/src/providers/core/codemie-auth-helpers.ts b/src/providers/core/codemie-auth-helpers.ts index e17083ae..9f654a32 100644 --- a/src/providers/core/codemie-auth-helpers.ts +++ b/src/providers/core/codemie-auth-helpers.ts @@ -1,7 +1,7 @@ import inquirer from 'inquirer'; +import chalk from 'chalk'; import { HTTPClient } from './base/http-client.js'; import type { SSOAuthResult } from './types.js'; -import { logger } from '../../utils/logger.js'; import { ConfigurationError } from '../../utils/errors.js'; export const DEFAULT_CODEMIE_BASE_URL = 'https://codemie.lab.epam.com'; @@ -104,6 +104,7 @@ export async function fetchCodeMieUserInfo( const client = new HTTPClient({ timeout: 10000, maxRetries: 3, + // Enterprise on-premises CodeMie deployments commonly use self-signed certificates. rejectUnauthorized: false }); @@ -111,9 +112,9 @@ export async function fetchCodeMieUserInfo( if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode === 401 || response.statusCode === 403) { - throw new Error('Authentication failed - invalid or expired credentials'); + throw new ConfigurationError('Authentication failed - invalid or expired credentials'); } - throw new Error(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); + throw new ConfigurationError(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); } const userInfo = JSON.parse(response.data) as CodeMieUserInfo; @@ -123,7 +124,7 @@ export async function fetchCodeMieUserInfo( } if (!userInfo || !Array.isArray(userInfo.applications) || !Array.isArray(userInfo.applications_admin)) { - throw new Error('Invalid user info response: missing applications arrays'); + throw new ConfigurationError('Invalid user info response: missing applications arrays'); } return userInfo; @@ -153,11 +154,11 @@ export async function selectCodeMieProject(authResult: SSOAuthResult): Promise( try { const { AgentRegistry } = await import('../../../agents/registry.js'); const agent = AgentRegistry.getAgent('claude'); - const installer = (agent as any)?.getExtensionInstaller?.(); + const installer = agent?.getExtensionInstaller?.(); if (installer) { const result = await installer.install();