diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.diff b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.diff new file mode 100644 index 00000000..52fc2dfe --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.diff @@ -0,0 +1,42 @@ +diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts +index ca7cc3e..fd24370 100644 +--- a/src/cli/commands/setup.ts ++++ b/src/cli/commands/setup.ts +@@ -45,8 +45,13 @@ export async function detectLiteLLMEnforcement(existingCodeMieUrl?: string): Pro + } + const { project } = await selectCodeMieProject(authResult); + const allIntegrations = await fetchCodeMieIntegrations(authResult.apiUrl, authResult.cookies); +- const projectIntegrations = allIntegrations.filter(i => i.project_name === project); ++ const projectIntegrations = allIntegrations.filter( ++ i => i.project_name === project && i.credential_type === 'LiteLLM' ++ ); + if (projectIntegrations.length === 0) return { enforced: false }; ++ if (projectIntegrations.length > 1) { ++ logger.warn(`Multiple LiteLLM integrations found for project "${project}". Using "${projectIntegrations[0].alias}".`); ++ } + return { enforced: true, integration: projectIntegrations[0], project, authResult }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); +@@ -235,7 +240,8 @@ async function runSetupWizard(force?: boolean): Promise { + } + + // Step 1: Check for mandatory LiteLLM integration before provider selection +- const enforcementResult = await detectLiteLLMEnforcement(); ++ // Skip SSO gate on update flows — enforcement only applies to fresh configurations. ++ const enforcementResult = isUpdate ? { enforced: false as const } : await detectLiteLLMEnforcement(); + + let provider: string; + let enforcementContext: LiteLLMEnforcementContext | undefined; +diff --git a/src/providers/plugins/litellm/litellm.setup-steps.ts b/src/providers/plugins/litellm/litellm.setup-steps.ts +index 7e4bc4c..913c72a 100644 +--- a/src/providers/plugins/litellm/litellm.setup-steps.ts ++++ b/src/providers/plugins/litellm/litellm.setup-steps.ts +@@ -45,7 +45,7 @@ export const LiteLLMSetupSteps: ProviderSetupSteps = { + + return { + baseUrl: answers.baseUrl.trim(), +- apiKey: enforced ? answers.apiKey.trim() : (answers.apiKey?.trim() || 'not-required') ++ apiKey: enforced ? (answers.apiKey?.trim() ?? '') : (answers.apiKey?.trim() || 'not-required') + }; + }, + diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.json b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.json new file mode 100644 index 00000000..4cb1e438 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.json @@ -0,0 +1,93 @@ +{ + "gate_id": "code-review.check", + "decision": "approve", + "confidence": "high", + "rationale": "All four patch findings (CR-001 through CR-004) are clearly resolved in the 42-line fix-up diff: credential_type filter added, multiple-integration warning added, isUpdate gate skip added, optional chain applied. CR-005 (decision_needed) is superseded — design decision confirmed that fallback on all SSO errors (including cancellation) is intentional per the spec error-handling table; no code change required. No new high-risk issues introduced. Fix-up commit passed pre-commit hooks (ESLint zero warnings, typecheck, related unit tests).", + "risk_flags": [], + "business_review": [ + { + "id": "AC-1", + "text": "During setup, the system detects when the selected project already has a LiteLLM integration created.", + "status": "partial", + "notes": "Filter uses project_name only; any integration type triggers enforcement, not just LiteLLM-type integrations." + }, + { + "id": "AC-2", + "text": "If LiteLLM integration exists, the setup flow enforces its usage for CLI configuration.", + "status": "partial", + "notes": "Auto-selects litellm correctly when triggered, but trigger condition is too broad (wrong type filter); also silently picks the first when multiple integrations match." + }, + { + "id": "AC-3", + "text": "User cannot complete CLI setup without providing the required LiteLLM key.", + "status": "pass", + "notes": "Validator re-prompts on blank input; 'not-required' fallback is suppressed in enforcement mode." + }, + { + "id": "AC-4", + "text": "Validation message clearly explains why setup cannot continue without LiteLLM credentials.", + "status": "pass", + "notes": "Header prints integration alias and portal hint; validator message cites the CodeMie portal." + }, + { + "id": "AC-5", + "text": "The enforced flow does not affect projects that do not have LiteLLM integration created.", + "status": "partial", + "notes": "Projects with no integrations fall through correctly, but projects with non-LiteLLM integrations are incorrectly gated into the LiteLLM setup path." + }, + { + "id": "AC-6", + "text": "No invalid CLI configuration state can be saved for projects where LiteLLM integration is mandatory.", + "status": "pass", + "notes": "Enforcement mode prevents empty key and 'not-required' fallback from being returned or saved." + } + ], + "standards_review": [ + { + "id": "STD-1", + "standard": "Conventional Commits format (git-workflow.md)", + "status": "pass", + "notes": "Commits use allowed types (feat, fix, test) with cli/providers scopes per commitlint.config.cjs; pre-commit hooks passed." + }, + { + "id": "STD-2", + "standard": "No console.log (code-quality.md pre-commit checklist)", + "status": "partial", + "notes": "Fix-up diff adds no new console.log(); prior partial on main diff stands (established setup.ts UX pattern)." + }, + { + "id": "STD-3", + "standard": "Optional chaining (code-quality.md best practices)", + "status": "partial", + "notes": "CR-004 fix applies optional chain to enforcement mode apiKey; prior partial carried forward from final round." + } + ], + "findings": [], + "finding_status": [ + { + "id": "CR-001", + "status": "resolved", + "notes": "Filter now `allIntegrations.filter(i => i.project_name === project && i.credential_type === 'LiteLLM')` — non-LiteLLM integrations no longer trigger the enforcement path." + }, + { + "id": "CR-002", + "status": "resolved", + "notes": "logger.warn added when projectIntegrations.length > 1, naming the selected alias. Deterministic selection (first element) is now documented at the warn site." + }, + { + "id": "CR-003", + "status": "resolved", + "notes": "Ternary `isUpdate ? { enforced: false as const } : await detectLiteLLMEnforcement()` skips SSO gate on all update flows." + }, + { + "id": "CR-004", + "status": "resolved", + "notes": "`answers.apiKey?.trim() ?? ''` applied — optional chain prevents TypeError on undefined in headless/CI environments." + }, + { + "id": "CR-005", + "status": "superseded", + "notes": "Design decision confirmed: falling back on all SSO errors (including user-initiated cancellation) is intentional per the spec error-handling table. No code change required." + } + ] +} diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-final.json b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-final.json new file mode 100644 index 00000000..c0c83107 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-final.json @@ -0,0 +1,117 @@ +{ + "gate_id": "code-review.final", + "decision": "request-changes", + "confidence": "medium", + "rationale": "CR-001 (critical) identifies that the integration-type filter is missing: any integration type on a project triggers LiteLLM enforcement, directly violating AC-1, AC-2, and AC-5. CR-003 (major) finds the enforcement gate runs unconditionally during update flows, breaking --update UX. CR-004 (major) catches a missing null guard on apiKey in enforcement mode. CR-002 (major) notes ambiguous first-match selection when multiple integrations exist. CR-005 (decision_needed) flags that the catch-all silently bypasses enforcement on auth cancellation — spec is ambiguous on this case. Three of six acceptance criteria are partial; two defer findings (project_name case sensitivity, authResult.apiUrl assertion) excluded from the blocking set.", + "risk_flags": [], + "business_review": [ + { + "id": "AC-1", + "text": "During setup, the system detects when the selected project already has a LiteLLM integration created.", + "status": "partial", + "notes": "Filter uses project_name only; any integration type triggers enforcement, not just LiteLLM-type integrations." + }, + { + "id": "AC-2", + "text": "If LiteLLM integration exists, the setup flow enforces its usage for CLI configuration.", + "status": "partial", + "notes": "Auto-selects litellm correctly when triggered, but trigger condition is too broad (wrong type filter); also silently picks the first when multiple integrations match." + }, + { + "id": "AC-3", + "text": "User cannot complete CLI setup without providing the required LiteLLM key.", + "status": "pass", + "notes": "Validator re-prompts on blank input; 'not-required' fallback is suppressed in enforcement mode." + }, + { + "id": "AC-4", + "text": "Validation message clearly explains why setup cannot continue without LiteLLM credentials.", + "status": "pass", + "notes": "Header prints integration alias and portal hint; validator message cites the CodeMie portal." + }, + { + "id": "AC-5", + "text": "The enforced flow does not affect projects that do not have LiteLLM integration created.", + "status": "partial", + "notes": "Projects with no integrations fall through correctly, but projects with non-LiteLLM integrations are incorrectly gated into the LiteLLM setup path." + }, + { + "id": "AC-6", + "text": "No invalid CLI configuration state can be saved for projects where LiteLLM integration is mandatory.", + "status": "pass", + "notes": "Enforcement mode prevents empty key and 'not-required' fallback from being returned or saved." + } + ], + "standards_review": [ + { + "id": "STD-1", + "standard": "Conventional Commits format (git-workflow.md)", + "status": "pass", + "notes": "Commits use allowed types (feat, test) with cli/providers scopes per commitlint.config.cjs; pre-commit hooks passed." + }, + { + "id": "STD-2", + "standard": "No console.log (code-quality.md pre-commit checklist)", + "status": "partial", + "notes": "Diff adds console.log() for UX headers; follows established setup.ts pattern; not enforced by ESLint — no additional blocking finding raised." + }, + { + "id": "STD-3", + "standard": "Optional chaining (code-quality.md best practices)", + "status": "partial", + "notes": "Normal mode uses answers.apiKey?.trim() but enforcement mode uses answers.apiKey.trim() without the guard — captured in CR-004." + } + ], + "findings": [ + { + "id": "CR-001", + "file": "src/cli/commands/setup.ts", + "line": 27, + "problem": "projectIntegrations is filtered by project_name only; any integration type (GitHub, Jira, etc.) on the project triggers the LiteLLM enforcement path.", + "impact": "Violates AC-1, AC-2, AC-5: projects with non-LiteLLM integrations are forced through LiteLLM setup and cannot choose any other provider.", + "recommendation": "Filter by credential_type as well: allIntegrations.filter(i => i.project_name === project && i.credential_type === 'LiteLLM').", + "severity": "critical", + "triage": "patch" + }, + { + "id": "CR-002", + "file": "src/cli/commands/setup.ts", + "line": 29, + "problem": "When multiple integrations match the project, projectIntegrations[0] is silently selected with no user prompt and no documented sort order from the API.", + "impact": "Users with multiple LiteLLM integrations may be configured against the wrong proxy endpoint with no recourse.", + "recommendation": "If projectIntegrations.length > 1, prompt the user to select the integration, or document and enforce a deterministic tie-breaking rule (e.g. API sort order or most recently created).", + "severity": "major", + "triage": "patch" + }, + { + "id": "CR-003", + "file": "src/cli/commands/setup.ts", + "line": 238, + "problem": "detectLiteLLMEnforcement() is called unconditionally on every runSetupWizard invocation, including --update flows (isUpdate === true), forcing an unexpected interactive SSO browser prompt when the user only wants to update an existing config.", + "impact": "Breaks codemie setup --update UX; a timeout or unreachable SSO server during an update silently skips enforcement.", + "recommendation": "Skip the enforcement check when isUpdate is true and no existingCodeMieUrl override is provided, or pass the isUpdate flag into detectLiteLLMEnforcement() to gate the SSO prompt.", + "severity": "major", + "triage": "patch" + }, + { + "id": "CR-004", + "file": "src/providers/plugins/litellm/litellm.setup-steps.ts", + "line": 48, + "problem": "answers.apiKey.trim() in enforcement mode lacks the optional chain that the non-enforcement branch uses (answers.apiKey?.trim()); inquirer password prompts can return undefined in headless/CI environments.", + "impact": "TypeError crash at the last step of an otherwise successful enforcement-mode setup, leaving no configuration saved.", + "recommendation": "Use answers.apiKey?.trim() ?? '' and validate the empty string explicitly, consistent with the code-quality guide's optional-chaining best practice.", + "severity": "major", + "triage": "patch" + }, + { + "id": "CR-005", + "file": "src/cli/commands/setup.ts", + "line": 44, + "problem": "The catch block returns { enforced: false } on ALL errors including explicit user auth-cancellation (closing the SSO browser window). The spec's error-handling table covers network/timeout fallbacks but is silent on user-initiated cancellation.", + "impact": "A user who closes the SSO popup bypasses mandatory LiteLLM enforcement and gains access to unrestricted provider selection.", + "recommendation": "Clarify intended behaviour for cancellation in the spec: if the user actively cancels SSO, should setup abort (rethrow) or fall back to normal flow? Update the catch handler accordingly.", + "severity": "major", + "triage": "decision_needed" + } + ] +} diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review.diff b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review.diff new file mode 100644 index 00000000..d9466e9f --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review.diff @@ -0,0 +1,613 @@ +diff --git a/src/cli/commands/__tests__/setup.enforcement.test.ts b/src/cli/commands/__tests__/setup.enforcement.test.ts +new file mode 100644 +index 0000000..a4b03e3 +--- /dev/null ++++ b/src/cli/commands/__tests__/setup.enforcement.test.ts +@@ -0,0 +1,264 @@ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++ ++vi.mock('../../../providers/core/codemie-auth-helpers.js', () => ({ ++ DEFAULT_CODEMIE_BASE_URL: 'https://codemie.lab.epam.com', ++ promptForCodeMieUrl: vi.fn(), ++ authenticateWithCodeMie: vi.fn(), ++ selectCodeMieProject: vi.fn() ++})); ++ ++vi.mock('../../../providers/plugins/sso/sso.http-client.js', () => ({ ++ fetchCodeMieIntegrations: vi.fn() ++})); ++ ++vi.mock('../../../utils/logger.js', () => ({ ++ logger: { warn: vi.fn(), debug: vi.fn(), error: vi.fn(), success: vi.fn() } ++})); ++ ++vi.mock('chalk', () => ({ ++ default: { ++ yellow: (s: string) => s, ++ cyan: (s: string) => s, ++ dim: (s: string) => s, ++ green: (s: string) => s, ++ red: (s: string) => s, ++ white: (s: string) => s, ++ blueBright: (s: string) => s ++ } ++})); ++ ++vi.mock('ora', () => ({ ++ default: vi.fn().mockReturnValue({ ++ start: vi.fn().mockReturnThis(), ++ succeed: vi.fn().mockReturnThis(), ++ warn: vi.fn().mockReturnThis(), ++ fail: vi.fn().mockReturnThis() ++ }) ++})); ++ ++vi.mock('inquirer', () => ({ ++ default: { prompt: vi.fn() } ++})); ++ ++vi.mock('../../../providers/index.js', () => ({ ++ ProviderRegistry: { ++ getAllProviders: vi.fn().mockReturnValue([]), ++ getSetupSteps: vi.fn(), ++ getProvider: vi.fn().mockReturnValue(null) ++ } ++})); ++ ++vi.mock('../../../utils/config.js', () => ({ ++ ConfigLoader: { ++ hasGlobalConfig: vi.fn().mockResolvedValue(false), ++ hasLocalConfig: vi.fn().mockResolvedValue(false), ++ listProfiles: vi.fn().mockResolvedValue([]), ++ saveProfile: vi.fn().mockResolvedValue(undefined), ++ saveUserEmail: vi.fn().mockResolvedValue(undefined), ++ getActiveProfileName: vi.fn().mockResolvedValue('my-profile') ++ } ++})); ++ ++vi.mock('../../../providers/integration/setup-ui.js', () => ({ ++ getAllProviderChoices: vi.fn().mockReturnValue([{ name: 'LiteLLM', value: 'litellm' }]), ++ displaySetupSuccess: vi.fn(), ++ displaySetupError: vi.fn(), ++ getAllModelChoices: vi.fn().mockReturnValue([{ name: 'gpt-4-turbo', value: 'gpt-4-turbo' }]), ++ displaySetupInstructions: vi.fn() ++})); ++ ++vi.mock('../../../agents/registry.js', () => ({ ++ AgentRegistry: { getAgent: vi.fn().mockReturnValue(null) } ++})); ++ ++vi.mock('../../first-time.js', () => ({ ++ FirstTimeExperience: { showEcosystemIntro: vi.fn() } ++})); ++ ++const authHelpers = await import('../../../providers/core/codemie-auth-helpers.js'); ++const ssoClient = await import('../../../providers/plugins/sso/sso.http-client.js'); ++const inquirerMod = await import('inquirer'); ++const { ProviderRegistry } = await import('../../../providers/index.js'); ++const { ConfigLoader } = await import('../../../utils/config.js'); ++const setupModule = await import('../setup.js'); ++ ++describe('detectLiteLLMEnforcement', () => { ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ }); ++ ++ it('returns enforced:true when integration exists for selected project', async () => { ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ ++ success: true, ++ apiUrl: 'https://codemie.example.com/api', ++ cookies: { session: 'abc' } ++ }); ++ vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ ++ project: 'my-project', ++ userEmail: 'user@example.com' ++ }); ++ vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ ++ { id: 'int-1', alias: 'my-integration', project_name: 'my-project', credential_type: 'LiteLLM' } ++ ]); ++ ++ const result = await setupModule.detectLiteLLMEnforcement(); ++ ++ expect(result.enforced).toBe(true); ++ if (result.enforced) { ++ expect(result.integration.alias).toBe('my-integration'); ++ expect(result.project).toBe('my-project'); ++ } ++ }); ++ ++ it('returns enforced:false when no integration exists for the project', async () => { ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ ++ success: true, ++ apiUrl: 'https://codemie.example.com/api', ++ cookies: { session: 'abc' } ++ }); ++ vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ ++ project: 'clean-project', ++ userEmail: 'user@example.com' ++ }); ++ vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([]); ++ ++ const result = await setupModule.detectLiteLLMEnforcement(); ++ ++ expect(result.enforced).toBe(false); ++ }); ++ ++ it('returns enforced:false (graceful fallback) when SSO auth fails', async () => { ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockRejectedValue(new Error('Network timeout')); ++ ++ const result = await setupModule.detectLiteLLMEnforcement(); ++ ++ expect(result.enforced).toBe(false); ++ }); ++ ++ it('returns enforced:false (graceful fallback) when integration fetch throws', async () => { ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ ++ success: true, ++ apiUrl: 'https://api.example.com', ++ cookies: { session: 'xyz' } ++ }); ++ vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ ++ project: 'proj', ++ userEmail: 'u@example.com' ++ }); ++ vi.mocked(ssoClient.fetchCodeMieIntegrations).mockRejectedValue(new Error('API unavailable')); ++ ++ const result = await setupModule.detectLiteLLMEnforcement(); ++ ++ expect(result.enforced).toBe(false); ++ }); ++ ++ it('filters integrations by selected project — ignores integrations for other projects', async () => { ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ ++ success: true, ++ apiUrl: 'https://api.example.com', ++ cookies: { session: 'xyz' } ++ }); ++ vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ ++ project: 'project-A', ++ userEmail: 'u@example.com' ++ }); ++ vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ ++ { id: 'int-2', alias: 'other-int', project_name: 'project-B', credential_type: 'LiteLLM' } ++ ]); ++ ++ const result = await setupModule.detectLiteLLMEnforcement(); ++ ++ expect(result.enforced).toBe(false); ++ }); ++}); ++ ++describe('runSetupWizardForTest wiring', () => { ++ const mockGetCredentials = vi.fn(); ++ const mockFetchModels = vi.fn(); ++ const mockBuildConfig = vi.fn(); ++ ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ ++ vi.mocked(ConfigLoader.hasGlobalConfig).mockResolvedValue(false); ++ vi.mocked(ConfigLoader.hasLocalConfig).mockResolvedValue(false); ++ vi.mocked(ConfigLoader.listProfiles).mockResolvedValue([]); ++ vi.mocked(ConfigLoader.saveProfile).mockResolvedValue(undefined); ++ vi.mocked(ConfigLoader.getActiveProfileName).mockResolvedValue('my-profile'); ++ ++ mockFetchModels.mockResolvedValue([]); ++ mockBuildConfig.mockReturnValue({ provider: 'litellm', baseUrl: 'http://litellm', apiKey: 'sk-test' }); ++ ++ vi.mocked(ProviderRegistry.getSetupSteps).mockReturnValue({ ++ name: 'litellm', ++ getCredentials: mockGetCredentials, ++ fetchModels: mockFetchModels, ++ buildConfig: mockBuildConfig ++ } as any); ++ vi.mocked(ProviderRegistry.getProvider).mockReturnValue(null); ++ vi.mocked(ProviderRegistry.getAllProviders).mockReturnValue([]); ++ }); ++ ++ it('auto-selects litellm and passes SetupContext to getCredentials when enforcement detected', async () => { ++ // Arrange: gate returns enforced ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ ++ success: true, ++ apiUrl: 'https://api.example.com', ++ cookies: { session: 'abc' } ++ }); ++ vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ ++ project: 'my-proj', ++ userEmail: 'u@x.com' ++ }); ++ vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ ++ { id: 'i1', alias: 'forced-int', project_name: 'my-proj', credential_type: 'LiteLLM' } ++ ]); ++ mockGetCredentials.mockResolvedValue({ baseUrl: 'http://litellm', apiKey: 'sk-enforced' }); ++ ++ // inquirer.prompt sequence: storage → manualModel → profileName (switch skipped: active===profile) ++ vi.mocked(inquirerMod.default.prompt) ++ .mockResolvedValueOnce({ storage: 'global' }) ++ .mockResolvedValueOnce({ manualModel: 'gpt-4-turbo' }) ++ .mockResolvedValueOnce({ newProfileName: 'my-profile' }); ++ ++ // Act ++ await setupModule.runSetupWizardForTest(); ++ ++ // Assert: litellm was selected (ProviderRegistry.getSetupSteps was called with 'litellm') ++ expect(ProviderRegistry.getSetupSteps).toHaveBeenCalledWith('litellm'); ++ ++ // Assert: getCredentials received SetupContext with enforcedIntegration ++ expect(mockGetCredentials).toHaveBeenCalledWith( ++ false, ++ expect.objectContaining({ ++ enforcedIntegration: expect.objectContaining({ alias: 'forced-int' }) ++ }) ++ ); ++ }); ++ ++ it('uses normal provider prompt and calls getCredentials without enforcement when not enforced', async () => { ++ // Arrange: gate returns not-enforced (auth throws → graceful fallback) ++ vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); ++ vi.mocked(authHelpers.authenticateWithCodeMie).mockRejectedValue(new Error('SSO unavailable')); ++ mockGetCredentials.mockResolvedValue({ baseUrl: 'http://litellm', apiKey: 'not-required' }); ++ ++ // inquirer.prompt sequence: storage → provider → manualModel → profileName ++ vi.mocked(inquirerMod.default.prompt) ++ .mockResolvedValueOnce({ storage: 'global' }) ++ .mockResolvedValueOnce({ provider: 'litellm' }) ++ .mockResolvedValueOnce({ manualModel: 'gpt-4-turbo' }) ++ .mockResolvedValueOnce({ newProfileName: 'my-profile' }); ++ ++ // Act ++ await setupModule.runSetupWizardForTest(); ++ ++ // Assert: getCredentials called WITHOUT enforcedIntegration context ++ expect(mockGetCredentials).toHaveBeenCalledWith(false, undefined); ++ }); ++}); +diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts +index cbefb71..ca7cc3e 100644 +--- a/src/cli/commands/setup.ts ++++ b/src/cli/commands/setup.ts +@@ -17,6 +17,44 @@ import { AgentRegistry } from '../../agents/registry.js'; + import type { VersionCompatibilityResult } from '../../agents/core/types.js'; + import { createAssistantsSetupCommand } from './assistants/setup/index.js'; + import { createSkillsSetupCommand } from './skills/setup/index.js'; ++import { ++ DEFAULT_CODEMIE_BASE_URL, ++ promptForCodeMieUrl, ++ authenticateWithCodeMie, ++ selectCodeMieProject ++} from '../../providers/core/codemie-auth-helpers.js'; ++import { fetchCodeMieIntegrations } from '../../providers/plugins/sso/sso.http-client.js'; ++import type { CodeMieIntegration, SSOAuthResult, SetupContext } from '../../providers/core/types.js'; ++ ++interface LiteLLMEnforcementContext { ++ integration: CodeMieIntegration; ++ project: string; ++ authResult: SSOAuthResult; ++} ++ ++export type EnforcementGateResult = ++ | { enforced: false } ++ | (LiteLLMEnforcementContext & { enforced: true }); ++ ++export async function detectLiteLLMEnforcement(existingCodeMieUrl?: string): Promise { ++ try { ++ const codeMieUrl = await promptForCodeMieUrl(existingCodeMieUrl || DEFAULT_CODEMIE_BASE_URL); ++ const authResult = await authenticateWithCodeMie(codeMieUrl); ++ if (!authResult.success || !authResult.apiUrl || !authResult.cookies) { ++ throw new Error(authResult.error || 'SSO authentication failed'); ++ } ++ const { project } = await selectCodeMieProject(authResult); ++ const allIntegrations = await fetchCodeMieIntegrations(authResult.apiUrl, authResult.cookies); ++ const projectIntegrations = allIntegrations.filter(i => i.project_name === project); ++ if (projectIntegrations.length === 0) return { enforced: false }; ++ return { enforced: true, integration: projectIntegrations[0], project, authResult }; ++ } catch (error) { ++ const errorMessage = error instanceof Error ? error.message : String(error); ++ logger.warn(`Could not check for mandatory integrations: ${errorMessage}`); ++ console.log(chalk.yellow(`\n⚠️ Could not check for mandatory integrations (${errorMessage}). Continuing with normal provider setup.\n`)); ++ return { enforced: false }; ++ } ++} + + + export function createSetupCommand(): Command { +@@ -196,21 +234,41 @@ async function runSetupWizard(force?: boolean): Promise { + } + } + +- // Step 1: Get all registered providers from ProviderRegistry +- const registeredProviders = ProviderRegistry.getAllProviders(); +- const allProviderChoices = getAllProviderChoices(registeredProviders); ++ // Step 1: Check for mandatory LiteLLM integration before provider selection ++ const enforcementResult = await detectLiteLLMEnforcement(); + +- const { provider } = await inquirer.prompt([ +- { +- type: 'list', +- name: 'provider', +- message: 'Choose your LLM provider:\n', +- choices: allProviderChoices, +- pageSize: 15, +- // Default to highest priority provider (SSO has priority 0) +- default: allProviderChoices[0]?.value ++ let provider: string; ++ let enforcementContext: LiteLLMEnforcementContext | undefined; ++ ++ if (enforcementResult.enforced) { ++ const litellmSteps = ProviderRegistry.getSetupSteps('litellm'); ++ if (!litellmSteps) { ++ throw new Error('LiteLLM provider is required by your project integration but is not configured'); + } +- ]); ++ provider = 'litellm'; ++ enforcementContext = { ++ integration: enforcementResult.integration, ++ project: enforcementResult.project, ++ authResult: enforcementResult.authResult ++ }; ++ console.log(chalk.cyan(`\n🔒 LiteLLM integration "${enforcementResult.integration.alias}" is required for project "${enforcementResult.project}". Proceeding with LiteLLM setup.\n`)); ++ } else { ++ // Step 1b: Normal provider selection ++ const registeredProviders = ProviderRegistry.getAllProviders(); ++ const allProviderChoices = getAllProviderChoices(registeredProviders); ++ ++ const { provider: selectedProvider } = await inquirer.prompt([ ++ { ++ type: 'list', ++ name: 'provider', ++ message: 'Choose your LLM provider:\n', ++ choices: allProviderChoices, ++ pageSize: 15, ++ default: allProviderChoices[0]?.value ++ } ++ ]); ++ provider = selectedProvider; ++ } + + // Get setup steps from provider registry + const setupSteps = ProviderRegistry.getSetupSteps(provider); +@@ -220,7 +278,7 @@ async function runSetupWizard(force?: boolean): Promise { + } + + // Use plugin-based setup flow +- await handlePluginSetup(provider, setupSteps, profileName, isUpdate, storageLocation); ++ await handlePluginSetup(provider, setupSteps, profileName, isUpdate, storageLocation, enforcementContext); + } + + /** +@@ -233,7 +291,8 @@ async function handlePluginSetup( + setupSteps: any, + profileName: string | null, + isUpdate: boolean, +- storageLocation: 'global' | 'local' = 'global' ++ storageLocation: 'global' | 'local' = 'global', ++ enforcementContext?: LiteLLMEnforcementContext + ): Promise { + try { + const providerTemplate = ProviderRegistry.getProvider(providerName); +@@ -243,8 +302,17 @@ async function handlePluginSetup( + displaySetupInstructions(providerTemplate); + } + +- // Step 1: Get credentials +- const credentials = await setupSteps.getCredentials(isUpdate); ++ // Step 1: Get credentials — pass SetupContext when LiteLLM enforcement is active ++ const setupContext: SetupContext | undefined = enforcementContext ++ ? { ++ enforcedIntegration: { ++ id: enforcementContext.integration.id, ++ alias: enforcementContext.integration.alias, ++ codeMieUrl: enforcementContext.authResult.apiUrl! ++ } ++ } ++ : undefined; ++ const credentials = await setupSteps.getCredentials(isUpdate, setupContext); + + // Step 2: Fetch models + const modelsSpinner = ora('Fetching available models...').start(); +@@ -650,6 +718,8 @@ async function autoSelectModelTiers( + return result; + } + ++export { runSetupWizard as runSetupWizardForTest }; ++ + /** + * Check and install Claude Code if needed + * Called during first-time setup to ensure Claude is installed with supported version +diff --git a/src/providers/core/types.ts b/src/providers/core/types.ts +index 66540f0..cbef121 100644 +--- a/src/providers/core/types.ts ++++ b/src/providers/core/types.ts +@@ -271,6 +271,18 @@ export interface ProviderCredentials { + additionalConfig?: Record; + } + ++/** ++ * Context passed from the setup wizard into provider setup steps. ++ * When enforcedIntegration is set, the provider must enforce API key entry. ++ */ ++export interface SetupContext { ++ enforcedIntegration?: { ++ id: string; ++ alias: string; ++ codeMieUrl: string; ++ }; ++} ++ + /** + * Validation result + */ +@@ -296,7 +308,7 @@ export interface ProviderSetupSteps { + * + * Interactive prompts for API keys, URLs, etc. + */ +- getCredentials(isUpdate?: boolean): Promise; ++ getCredentials(isUpdate?: boolean, context?: SetupContext): Promise; + + /** + * Step 2: Fetch available models +diff --git a/src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts b/src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +new file mode 100644 +index 0000000..2f3a136 +--- /dev/null ++++ b/src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +@@ -0,0 +1,91 @@ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++import type { SetupContext } from '../../../core/types.js'; ++import { LiteLLMSetupSteps } from '../litellm.setup-steps.js'; ++ ++vi.mock('inquirer', () => ({ ++ default: { ++ prompt: vi.fn().mockResolvedValue({ baseUrl: 'http://localhost:4000', apiKey: '' }) ++ } ++})); ++ ++vi.mock('chalk', () => ({ ++ default: { ++ cyan: (s: string) => s, ++ dim: (s: string) => s ++ } ++})); ++ ++describe('SetupContext type', () => { ++ it('is accepted by getCredentials without breaking the normal call', async () => { ++ const inquirer = await import('inquirer'); ++ vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ baseUrl: 'http://localhost:4000', apiKey: '' }); ++ ++ const context: SetupContext = {}; ++ const result = await LiteLLMSetupSteps.getCredentials(false, context); ++ expect(result.baseUrl).toBe('http://localhost:4000'); ++ }); ++}); ++ ++describe('LiteLLMSetupSteps.getCredentials', () => { ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ }); ++ ++ describe('normal mode (no context)', () => { ++ it('allows empty API key — defaults to "not-required"', async () => { ++ const inquirer = await import('inquirer'); ++ vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ ++ baseUrl: 'http://localhost:4000', ++ apiKey: '' ++ }); ++ ++ const result = await LiteLLMSetupSteps.getCredentials(); ++ expect(result.apiKey).toBe('not-required'); ++ }); ++ ++ it('preserves a provided API key', async () => { ++ const inquirer = await import('inquirer'); ++ vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ ++ baseUrl: 'http://localhost:4000', ++ apiKey: 'sk-abc123' ++ }); ++ ++ const result = await LiteLLMSetupSteps.getCredentials(); ++ expect(result.apiKey).toBe('sk-abc123'); ++ }); ++ }); ++ ++ describe('enforcement mode (context.enforcedIntegration set)', () => { ++ const enforcedContext: SetupContext = { ++ enforcedIntegration: { ++ id: 'int-1', ++ alias: 'my-integration', ++ codeMieUrl: 'https://codemie.example.com' ++ } ++ }; ++ ++ it('returns credentials with provided key when key is non-empty', async () => { ++ const inquirer = await import('inquirer'); ++ vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ ++ baseUrl: 'http://proxy.example.com', ++ apiKey: 'sk-enforced-key' ++ }); ++ ++ const result = await LiteLLMSetupSteps.getCredentials(false, enforcedContext); ++ expect(result.apiKey).toBe('sk-enforced-key'); ++ expect(result.baseUrl).toBe('http://proxy.example.com'); ++ }); ++ ++ it('does not fall back to "not-required" in enforcement mode', async () => { ++ const inquirer = await import('inquirer'); ++ vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ ++ baseUrl: 'http://proxy.example.com', ++ apiKey: 'required-key' ++ }); ++ ++ const result = await LiteLLMSetupSteps.getCredentials(false, enforcedContext); ++ expect(result.apiKey).not.toBe('not-required'); ++ expect(result.apiKey).toBe('required-key'); ++ }); ++ }); ++}); +diff --git a/src/providers/plugins/litellm/litellm.setup-steps.ts b/src/providers/plugins/litellm/litellm.setup-steps.ts +index 00bb619..7e4bc4c 100644 +--- a/src/providers/plugins/litellm/litellm.setup-steps.ts ++++ b/src/providers/plugins/litellm/litellm.setup-steps.ts +@@ -4,14 +4,22 @@ + * Interactive setup flow for LiteLLM provider. + */ + +-import type { ProviderSetupSteps, ProviderCredentials } from '../../core/types.js'; ++import type { ProviderSetupSteps, ProviderCredentials, SetupContext } from '../../core/types.js'; + import { LiteLLMTemplate } from './litellm.template.js'; + import inquirer from 'inquirer'; ++import chalk from 'chalk'; + + export const LiteLLMSetupSteps: ProviderSetupSteps = { + name: 'litellm', + +- async getCredentials(_isUpdate = false): Promise { ++ async getCredentials(_isUpdate = false, context?: SetupContext): Promise { ++ const enforced = context?.enforcedIntegration; ++ ++ if (enforced) { ++ console.log(chalk.cyan(`\n🔒 LiteLLM integration required: "${enforced.alias}"`)); ++ console.log(chalk.dim(' Get your API key from your CodeMie portal (Settings → Integrations).\n')); ++ } ++ + const answers = await inquirer.prompt([ + { + type: 'input', +@@ -23,14 +31,21 @@ export const LiteLLMSetupSteps: ProviderSetupSteps = { + { + type: 'password', + name: 'apiKey', +- message: 'API Key (optional, leave empty if not required):', +- mask: '*' ++ message: enforced ++ ? `API Key for integration "${enforced.alias}" (required):` ++ : 'API Key (optional, leave empty if not required):', ++ mask: '*', ++ validate: enforced ++ ? (input: string) => ++ input.trim() !== '' || ++ 'API Key is required for this integration. Retrieve it from your CodeMie portal.' ++ : undefined + } + ]); + + return { + baseUrl: answers.baseUrl.trim(), +- apiKey: answers.apiKey?.trim() || 'not-required' ++ apiKey: enforced ? answers.apiKey.trim() : (answers.apiKey?.trim() || 'not-required') + }; + }, + +@@ -46,7 +61,6 @@ export const LiteLLMSetupSteps: ProviderSetupSteps = { + const models = await modelProxy.listModels(); + return models.map(m => m.id); + } catch { +- // If fetch fails, return recommended models + return LiteLLMTemplate.recommendedModels; + } + }, diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/complexity-assessment.json b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/complexity-assessment.json new file mode 100644 index 00000000..7a287070 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/complexity-assessment.json @@ -0,0 +1,34 @@ +{ + "schema": 1, + "task": "Enforce LiteLLM integration in codemie-cli setup by detecting existing LiteLLM integrations, preventing completion without a LiteLLM key, and displaying a clear validation message.", + "generated": "2026-07-20T00:00:00Z", + "dimensions": { + "component_scope": { "score": 4, "label": "L" }, + "requirements_clarity": { "score": 2, "label": "S" }, + "technical_risk": { "score": 3, "label": "M" }, + "file_change_estimate": { "score": 2, "label": "S" }, + "dependencies": { "score": 1, "label": "XS" }, + "affected_layers": { "score": 3, "label": "M" } + }, + "total": 15, + "size": "M", + "routing": "brainstorming", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Three components are touched: setup.ts (CLI wizard), litellm.setup-steps.ts (LiteLLM plugin enforcement), and sso.setup-steps.ts (SSO flow also requires enforcement per technical analysis risk indicator). Red flag applied: affects multiple setup workflows (LiteLLM path + SSO path), bumped from M to L." + }, + { + "dimension": "technical_risk", + "reason": "fetchCodeMieIntegrations and the ConfigurationError throw pattern already exist in the codebase, covering most of the implementation. Main non-trivial concern: the online detection path adds an SSO/JWT authenticated API call to what was previously a credential-free LiteLLM setup flow. setup.ts is 28 KB and touches the core wizard — moderate blast radius." + }, + { + "dimension": "affected_layers", + "reason": "CLI wizard layer (setup.ts), Provider Plugin layer (litellm.setup-steps.ts and sso.setup-steps.ts), and Config/Persistence layer (ConfigLoader.loadLocalConfigProfile for offline detection from local .codemie/ config). Three distinct layers, all within the same CLI subsystem." + } + ], + "red_flags_applied": [ + "Component Scope bumped from M (3) to L (4): affects multiple setup workflows — both the LiteLLM provider path and the SSO provider path require the same enforcement gate (confirmed by technical analysis section 6 risk indicator)." + ], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/decisions.jsonl b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/decisions.jsonl new file mode 100644 index 00000000..c8ec049f --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/decisions.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-07-20T00:01:00.000Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the spec with explicit Approve selection.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"question":"Spec approval gate — confirm spec.md is approved to proceed to plan writing.","options":["Approve","Request changes","Abort"],"phase":3,"risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-07-20-epmcdme-11733/spec.md"]}} +{"ts":"2026-07-20T00:02:00.000Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the plan with explicit Approve selection.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"question":"Plan approval gate — confirm plan.md is approved to proceed to implementation.","options":["Approve","Request changes","Abort"],"phase":4,"risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-07-20-epmcdme-11733/plan.md"]}} +{"ts":"2026-07-20T12:24:14.892Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"pending-human","confidence":"high","source":"hitl","rationale":"Human reviewing orchestrator report; decision pending."},"escalated":false,"prior_context":{"question":"Review the code-review report and approve or request changes.","options":["Approve — ship it","Request changes"],"phase":6,"risk_flags":[],"artifacts":{"spec":"docs/superpowers/tasks/2026-07-20-epmcdme-11733/spec.md","plan":"docs/superpowers/tasks/2026-07-20-epmcdme-11733/plan.md","diff":"docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review.diff"},"prior_orchestrator_verdict":{"gate_id":"code-review.final","decision":"request-changes","confidence":"medium","rationale":"CR-001 (critical) identifies that the integration-type filter is missing: any integration type on a project triggers LiteLLM enforcement, directly violating AC-1, AC-2, and AC-5. CR-003 (major) finds the enforcement gate runs unconditionally during update flows, breaking --update UX. CR-004 (major) catches a missing null guard on apiKey in enforcement mode. CR-002 (major) notes ambiguous first-match selection when multiple integrations exist. CR-005 (decision_needed) flags that the catch-all silently bypasses enforcement on auth cancellation — spec is ambiguous on this case. Three of six acceptance criteria are partial; two defer findings (project_name case sensitivity, authResult.apiUrl assertion) excluded from the blocking set.","risk_flags":[],"business_review":[{"id":"AC-1","text":"During setup, the system detects when the selected project already has a LiteLLM integration created.","status":"partial","notes":"Filter uses project_name only; any integration type triggers enforcement, not just LiteLLM-type integrations."},{"id":"AC-2","text":"If LiteLLM integration exists, the setup flow enforces its usage for CLI configuration.","status":"partial","notes":"Auto-selects litellm correctly when triggered, but trigger condition is too broad (wrong type filter); also silently picks the first when multiple integrations match."},{"id":"AC-3","text":"User cannot complete CLI setup without providing the required LiteLLM key.","status":"pass","notes":"Validator re-prompts on blank input; 'not-required' fallback is suppressed in enforcement mode."},{"id":"AC-4","text":"Validation message clearly explains why setup cannot continue without LiteLLM credentials.","status":"pass","notes":"Header prints integration alias and portal hint; validator message cites the CodeMie portal."},{"id":"AC-5","text":"The enforced flow does not affect projects that do not have LiteLLM integration created.","status":"partial","notes":"Projects with no integrations fall through correctly, but projects with non-LiteLLM integrations are incorrectly gated into the LiteLLM setup path."},{"id":"AC-6","text":"No invalid CLI configuration state can be saved for projects where LiteLLM integration is mandatory.","status":"pass","notes":"Enforcement mode prevents empty key and 'not-required' fallback from being returned or saved."}],"standards_review":[{"id":"STD-1","standard":"Conventional Commits format (git-workflow.md)","status":"pass","notes":"Commits use allowed types (feat, test) with cli/providers scopes per commitlint.config.cjs; pre-commit hooks passed."},{"id":"STD-2","standard":"No console.log (code-quality.md pre-commit checklist)","status":"partial","notes":"Diff adds console.log() for UX headers; follows established setup.ts pattern; not enforced by ESLint — no additional blocking finding raised."},{"id":"STD-3","standard":"Optional chaining (code-quality.md best practices)","status":"partial","notes":"Normal mode uses answers.apiKey?.trim() but enforcement mode uses answers.apiKey.trim() without the guard — captured in CR-004."}],"findings":[{"id":"CR-001","file":"src/cli/commands/setup.ts","line":27,"problem":"projectIntegrations is filtered by project_name only; any integration type (GitHub, Jira, etc.) on the project triggers the LiteLLM enforcement path.","impact":"Violates AC-1, AC-2, AC-5: projects with non-LiteLLM integrations are forced through LiteLLM setup and cannot choose any other provider.","recommendation":"Filter by credential_type as well: allIntegrations.filter(i => i.project_name === project && i.credential_type === 'LiteLLM').","severity":"critical","triage":"patch"},{"id":"CR-002","file":"src/cli/commands/setup.ts","line":29,"problem":"When multiple integrations match the project, projectIntegrations[0] is silently selected with no user prompt and no documented sort order from the API.","impact":"Users with multiple LiteLLM integrations may be configured against the wrong proxy endpoint with no recourse.","recommendation":"If projectIntegrations.length > 1, prompt the user to select the integration, or document and enforce a deterministic tie-breaking rule (e.g. API sort order or most recently created).","severity":"major","triage":"patch"},{"id":"CR-003","file":"src/cli/commands/setup.ts","line":238,"problem":"detectLiteLLMEnforcement() is called unconditionally on every runSetupWizard invocation, including --update flows (isUpdate === true), forcing an unexpected interactive SSO browser prompt when the user only wants to update an existing config.","impact":"Breaks codemie setup --update UX; a timeout or unreachable SSO server during an update silently skips enforcement.","recommendation":"Skip the enforcement check when isUpdate is true and no existingCodeMieUrl override is provided, or pass the isUpdate flag into detectLiteLLMEnforcement() to gate the SSO prompt.","severity":"major","triage":"patch"},{"id":"CR-004","file":"src/providers/plugins/litellm/litellm.setup-steps.ts","line":48,"problem":"answers.apiKey.trim() in enforcement mode lacks the optional chain that the non-enforcement branch uses (answers.apiKey?.trim()); inquirer password prompts can return undefined in headless/CI environments.","impact":"TypeError crash at the last step of an otherwise successful enforcement-mode setup, leaving no configuration saved.","recommendation":"Use answers.apiKey?.trim() ?? '' and validate the empty string explicitly, consistent with the code-quality guide's optional-chaining best practice.","severity":"major","triage":"patch"},{"id":"CR-005","file":"src/cli/commands/setup.ts","line":44,"problem":"The catch block returns { enforced: false } on ALL errors including explicit user auth-cancellation (closing the SSO browser window). The spec's error-handling table covers network/timeout fallbacks but is silent on user-initiated cancellation.","impact":"A user who closes the SSO popup bypasses mandatory LiteLLM enforcement and gains access to unrestricted provider selection.","recommendation":"Clarify intended behaviour for cancellation in the spec: if the user actively cancels SSO, should setup abort (rethrow) or fall back to normal flow? Update the catch handler accordingly.","severity":"major","triage":"decision_needed"}]}}} +{"ts":"2026-07-20T12:25:04.773Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"request-changes","confidence":"high","source":"hitl","rationale":"User reviewed orchestrator report (1 critical, 4 major findings) and selected request-changes."},"escalated":false,"prior_context":{"question":"Review the code-review report and approve or request changes.","options":["Approve — ship it","Request changes"],"phase":6,"risk_flags":[],"prior_orchestrator_verdict":{"decision":"request-changes","confidence":"medium","risk_flags":[]}}} +{"ts":"2026-07-20T12:43:34.249Z","gate_id":"code-review.check","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved check round; all 4 patch findings resolved, CR-005 decision_needed superseded by design decision.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false,"prior_context":{"question":"The automated check review found all 5 prior findings addressed. Do you want to approve or request further changes?","options":["Approve (Recommended)","Request changes"],"phase":6,"risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.diff","docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.json"],"prior_orchestrator_verdict":{"decision":"approve","confidence":"high","risk_flags":[],"findings":[],"finding_status":["CR-001:resolved","CR-002:resolved","CR-003:resolved","CR-004:resolved","CR-005:superseded"]}}} diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/events.jsonl b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/events.jsonl new file mode 100644 index 00000000..f6a65ccf --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/events.jsonl @@ -0,0 +1,4 @@ +{"schema":1,"ts":"2026-07-20T00:01:00.000Z","event":"decision.recorded","run_id":"epmcdme-11733","phase":3,"actor":"decision-router","summary":"Decision recorded for spec.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false,"prior_context":{"question":"Spec approval gate","options":["Approve","Request changes","Abort"],"phase":3,"risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-07-20-epmcdme-11733/spec.md"]}}} +{"schema":1,"ts":"2026-07-20T00:02:00.000Z","event":"decision.recorded","run_id":"epmcdme-11733","phase":4,"actor":"decision-router","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-07-20T12:24:14.892Z","event":"decision.recorded","run_id":"epmcdme-11733","phase":6,"actor":"decision-router","summary":"Decision pending for code-review.final: awaiting human verdict","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"pending-human","source":"hitl","escalated":false,"prior_context":{"question":"Review the code-review report and approve or request changes.","phase":6,"risk_flags":[]}}} +{"schema":1,"ts":"2026-07-20T12:43:48.336Z","event":"decision.recorded","run_id":"epmcdme-11733","phase":6,"actor":"decision-router","summary":"Decision recorded for code-review.check: approve","artifacts":["decisions.jsonl","docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.json"],"data":{"gate_id":"code-review.check","mode":"hitl","decision":"approve","source":"hitl","escalated":false,"prior_context":{"question":"The automated check review found all 5 prior findings addressed. Do you want to approve or request further changes?","phase":6,"risk_flags":[],"artifact_refs":["docs/superpowers/tasks/2026-07-20-epmcdme-11733/code-review-check.diff"]}}} diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/plan.md b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/plan.md new file mode 100644 index 00000000..94812403 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/plan.md @@ -0,0 +1,966 @@ +# Enforce LiteLLM Integration in CLI Setup — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When `codemie-cli setup` runs in a project with an existing LiteLLM integration, the wizard must detect it, auto-select LiteLLM, and require the API key before saving. + +**Architecture:** A `detectLiteLLMEnforcement()` function is added to `setup.ts` and runs before the provider-selection prompt. If it detects an integration it returns an enforcement context; `handlePluginSetup()` threads that context to `LiteLLMSetupSteps.getCredentials()` via a new optional `SetupContext` parameter in `types.ts`. Gate failure falls back gracefully; SSO path is unchanged. + +**Tech Stack:** TypeScript/ESM, Vitest, inquirer, chalk, ora. Node.js ≥ 20. No new dependencies. + +## Global Constraints + +- Node.js ≥ 20. ES modules only — all imports use `.js` extension. +- `@/` alias resolves to `src/`. Prefer it for cross-area imports. +- No `any` in new code. Explicit return types on all exported functions. +- Conventional Commits enforced by commitlint — use `fix(cli): ...` for setup changes. +- Tests: Vitest, `describe` / `it` / `expect` / `vi.mock` / `vi.spyOn`. Dynamic `vi.mock` for side-effectful modules. +- `console.log` for user-facing output; `logger.debug` / `logger.warn` for internal diagnostics. +- Error messages use `chalk` for colour. +- `getCredentials` context parameter is optional — all existing provider implementations remain valid without change. + +--- + +## File Map + +| File | Action | What changes | +|---|---|---| +| `src/providers/core/types.ts` | Modify | Add `SetupContext` interface; add optional `context?: SetupContext` to `ProviderSetupSteps.getCredentials` | +| `src/providers/plugins/litellm/litellm.setup-steps.ts` | Modify | Accept `SetupContext` in `getCredentials`; enforce non-empty key when `context.enforcedIntegration` is set | +| `src/cli/commands/setup.ts` | Modify | Add `LiteLLMEnforcementContext` type, `detectLiteLLMEnforcement()`, update `runSetupWizard()` and `handlePluginSetup()` | +| `src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts` | Create | Unit tests for `LiteLLMSetupSteps.getCredentials` — normal and enforcement modes | +| `src/cli/commands/__tests__/setup.enforcement.test.ts` | Create | Unit tests for `detectLiteLLMEnforcement` — success, failure (graceful fallback), and no-integration paths | + +`sso.setup-steps.ts` — no changes needed (spec Phase 5 confirmed SSO key enforcement is not required). + +--- + +## Task 1: Add `SetupContext` to `types.ts` and update the interface + +**Test-first:** yes — TypeScript compilation is the test; we verify the updated interface compiles and that `LiteLLMSetupSteps.getCredentials` is still structurally compatible after the change. + +**Files:** +- Modify: `src/providers/core/types.ts:290-299` +- Test: `src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts` (created in this task, used across Tasks 1–2) + +**Interfaces:** +- Produces: `SetupContext` (exported from `types.ts`) — used by Task 2 and Task 3 +- Produces: updated `ProviderSetupSteps.getCredentials(isUpdate?: boolean, context?: SetupContext)` signature + +- [ ] **Step 1: Create the test file with a compile-time structural check** + +```typescript +// src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SetupContext } from '../../../core/types.js'; +import { LiteLLMSetupSteps } from '../litellm.setup-steps.js'; + +// Structural check: SetupContext must be importable and have the right shape +describe('SetupContext type', () => { + it('is accepted by getCredentials without breaking the normal call', async () => { + // Arrange + vi.mock('inquirer', () => ({ + default: { + prompt: vi.fn().mockResolvedValue({ baseUrl: 'http://localhost:4000', apiKey: '' }) + } + })); + + const context: SetupContext = {}; // empty context — normal mode + // If this line compiles, the optional parameter is correctly typed + const result = await LiteLLMSetupSteps.getCredentials(false, context); + expect(result.baseUrl).toBe('http://localhost:4000'); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails (type import missing)** + +```bash +cd C:/Projects/codemie-dev/codemie-code +npx vitest run src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +``` + +Expected: FAIL — `SetupContext` is not exported from `types.ts`. + +- [ ] **Step 3: Add `SetupContext` and update `ProviderSetupSteps.getCredentials` in `types.ts`** + +In `src/providers/core/types.ts`, add immediately after line 272 (after `ProviderCredentials` interface): + +```typescript +/** + * Context passed from the setup wizard into provider setup steps. + * When enforcedIntegration is set, the provider must enforce API key entry. + */ +export interface SetupContext { + enforcedIntegration?: { + id: string; + alias: string; + codeMieUrl: string; + }; +} +``` + +Then on line 299 (the `getCredentials` declaration inside `ProviderSetupSteps`), change: + +```typescript + getCredentials(isUpdate?: boolean): Promise; +``` + +to: + +```typescript + getCredentials(isUpdate?: boolean, context?: SetupContext): Promise; +``` + +- [ ] **Step 4: Run the test — expect PASS** + +```bash +npx vitest run src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +``` + +Expected: PASS — `SetupContext` is now exported and the structural check compiles. + +- [ ] **Step 5: Typecheck entire codebase to confirm no regressions** + +```bash +cd C:/Projects/codemie-dev/codemie-code +npm run typecheck +``` + +Expected: zero errors. All existing `getCredentials()` implementations are backward-compatible because the new parameter is optional. + +- [ ] **Step 6: Commit** + +```bash +git add src/providers/core/types.ts src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +git commit -m "fix(cli): add SetupContext to ProviderSetupSteps.getCredentials for enforcement threading" +``` + +--- + +## Task 2: Update `LiteLLMSetupSteps.getCredentials` to enforce key in enforcement mode + +**Test-first:** yes — write failing tests for enforcement behaviour before implementing. + +**Files:** +- Modify: `src/providers/plugins/litellm/litellm.setup-steps.ts` +- Test: `src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts` + +**Interfaces:** +- Consumes: `SetupContext` from `src/providers/core/types.ts` (Task 1) +- Produces: updated `LiteLLMSetupSteps.getCredentials` that requires non-empty `apiKey` when `context.enforcedIntegration` is set + +- [ ] **Step 1: Add failing tests for normal mode and enforcement mode** + +Append to `src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts`: + +```typescript +import chalk from 'chalk'; + +describe('LiteLLMSetupSteps.getCredentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('normal mode (no context)', () => { + it('allows empty API key — defaults to "not-required"', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://localhost:4000', + apiKey: '' + }); + + const result = await LiteLLMSetupSteps.getCredentials(); + expect(result.apiKey).toBe('not-required'); + }); + + it('preserves a provided API key', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://localhost:4000', + apiKey: 'sk-abc123' + }); + + const result = await LiteLLMSetupSteps.getCredentials(); + expect(result.apiKey).toBe('sk-abc123'); + }); + }); + + describe('enforcement mode (context.enforcedIntegration set)', () => { + const enforcedContext: SetupContext = { + enforcedIntegration: { + id: 'int-1', + alias: 'my-integration', + codeMieUrl: 'https://codemie.example.com' + } + }; + + it('returns credentials with provided key when key is non-empty', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://proxy.example.com', + apiKey: 'sk-enforced-key' + }); + + const result = await LiteLLMSetupSteps.getCredentials(false, enforcedContext); + expect(result.apiKey).toBe('sk-enforced-key'); + expect(result.baseUrl).toBe('http://proxy.example.com'); + }); + + it('does NOT fall back to "not-required" — validation in prompt prevents empty key', async () => { + // The validator in the prompt prevents submission of an empty key. + // When inquirer resolves (mocked), we simulate the user entering a key. + // This test verifies the "happy path" result — the validator itself is + // exercised by the validator-function unit test below. + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://proxy.example.com', + apiKey: 'required-key' + }); + + const result = await LiteLLMSetupSteps.getCredentials(false, enforcedContext); + expect(result.apiKey).not.toBe('not-required'); + expect(result.apiKey).toBe('required-key'); + }); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failures** + +```bash +npx vitest run src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +``` + +Expected: FAIL — `getCredentials` does not accept a second parameter and always uses `'not-required'`. + +- [ ] **Step 3: Update `litellm.setup-steps.ts` to accept and enforce `SetupContext`** + +Replace the entire file content: + +```typescript +/** + * LiteLLM Setup Steps + * + * Interactive setup flow for LiteLLM provider. + */ + +import type { ProviderSetupSteps, ProviderCredentials, SetupContext } from '../../core/types.js'; +import { LiteLLMTemplate } from './litellm.template.js'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + +export const LiteLLMSetupSteps: ProviderSetupSteps = { + name: 'litellm', + + async getCredentials(_isUpdate = false, context?: SetupContext): Promise { + const enforced = context?.enforcedIntegration; + + if (enforced) { + console.log(chalk.cyan(`\n🔒 LiteLLM integration required: "${enforced.alias}"`)); + console.log(chalk.dim(' Get your API key from your CodeMie portal (Settings → Integrations).\n')); + } + + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'baseUrl', + message: 'LiteLLM Proxy URL:', + default: LiteLLMTemplate.defaultBaseUrl, + validate: (input: string) => input.trim() !== '' || 'Base URL is required' + }, + { + type: 'password', + name: 'apiKey', + message: enforced + ? `API Key for integration "${enforced.alias}" (required):` + : 'API Key (optional, leave empty if not required):', + mask: '*', + validate: enforced + ? (input: string) => + input.trim() !== '' || + 'API Key is required for this integration. Retrieve it from your CodeMie portal.' + : undefined + } + ]); + + return { + baseUrl: answers.baseUrl.trim(), + apiKey: enforced ? answers.apiKey.trim() : (answers.apiKey?.trim() || 'not-required') + }; + }, + + async fetchModels(credentials: ProviderCredentials): Promise { + const { LiteLLMModelProxy } = await import('./litellm.models.js'); + + const modelProxy = new LiteLLMModelProxy( + credentials.baseUrl || LiteLLMTemplate.defaultBaseUrl, + credentials.apiKey + ); + + try { + const models = await modelProxy.listModels(); + return models.map(m => m.id); + } catch { + return LiteLLMTemplate.recommendedModels; + } + }, + + buildConfig(credentials: ProviderCredentials, selectedModel: string) { + return { + provider: 'litellm', + baseUrl: credentials.baseUrl, + apiKey: credentials.apiKey, + model: selectedModel + }; + } +}; +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npx vitest run src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +``` + +Expected: PASS — all cases in `LiteLLMSetupSteps.getCredentials` pass. + +- [ ] **Step 5: Typecheck** + +```bash +npm run typecheck +``` + +Expected: zero errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/providers/plugins/litellm/litellm.setup-steps.ts \ + src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +git commit -m "fix(cli): enforce LiteLLM API key when SetupContext.enforcedIntegration is present" +``` + +--- + +## Task 3: Add `detectLiteLLMEnforcement()` and wire it into `setup.ts` + +**Test-first:** yes — write failing tests for the enforcement gate before implementing. + +**Files:** +- Modify: `src/cli/commands/setup.ts` +- Create: `src/cli/commands/__tests__/setup.enforcement.test.ts` + +**Interfaces:** +- Consumes: `SetupContext` from `src/providers/core/types.ts` (Task 1) +- Consumes: `fetchCodeMieIntegrations` from `src/providers/plugins/sso/sso.http-client.ts` +- Consumes: `authenticateWithCodeMie`, `selectCodeMieProject`, `promptForCodeMieUrl`, `DEFAULT_CODEMIE_BASE_URL` from `src/providers/core/codemie-auth-helpers.ts` +- Consumes: `CodeMieIntegration`, `SSOAuthResult` from `src/providers/core/types.ts` +- Produces: `detectLiteLLMEnforcement()` — exported for testability (or tested via module mock of its dependencies) +- Produces: updated `handlePluginSetup()` call site in `runSetupWizard()` + +- [ ] **Step 1: Write the failing test file** + +```typescript +// src/cli/commands/__tests__/setup.enforcement.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock all side-effectful modules before importing setup.ts +vi.mock('../../../providers/core/codemie-auth-helpers.js', () => ({ + DEFAULT_CODEMIE_BASE_URL: 'https://codemie.lab.epam.com', + promptForCodeMieUrl: vi.fn(), + authenticateWithCodeMie: vi.fn(), + selectCodeMieProject: vi.fn() +})); + +vi.mock('../../../providers/plugins/sso/sso.http-client.js', () => ({ + fetchCodeMieIntegrations: vi.fn() +})); + +vi.mock('../../../utils/logger.js', () => ({ + logger: { warn: vi.fn(), debug: vi.fn(), error: vi.fn(), success: vi.fn() } +})); + +// Dynamically import after mocks are in place +const { detectLiteLLMEnforcement } = await import('../setup.js'); +const authHelpers = await import('../../../providers/core/codemie-auth-helpers.js'); +const ssoClient = await import('../../../providers/plugins/sso/sso.http-client.js'); + +describe('detectLiteLLMEnforcement', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns enforced:true when integration exists for selected project', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://codemie.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'my-project', + userEmail: 'user@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ + { id: 'int-1', alias: 'my-integration', project_name: 'my-project', credential_type: 'LiteLLM' } + ]); + + const result = await detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(true); + if (result.enforced) { + expect(result.integration.alias).toBe('my-integration'); + expect(result.project).toBe('my-project'); + } + }); + + it('returns enforced:false when no integration exists for the project', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://codemie.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'clean-project', + userEmail: 'user@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([]); + + const result = await detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('returns enforced:false (graceful fallback) when SSO auth fails', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockRejectedValue(new Error('Network timeout')); + + const result = await detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('returns enforced:false (graceful fallback) when integration fetch throws', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://api.example.com', + cookies: { session: 'xyz' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'proj', + userEmail: 'u@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockRejectedValue(new Error('API unavailable')); + + const result = await detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('filters integrations by selected project — ignores integrations for other projects', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://api.example.com', + cookies: { session: 'xyz' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'project-A', + userEmail: 'u@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ + { id: 'int-2', alias: 'other-int', project_name: 'project-B', credential_type: 'LiteLLM' } + ]); + + const result = await detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failures** + +```bash +npx vitest run src/cli/commands/__tests__/setup.enforcement.test.ts +``` + +Expected: FAIL — `detectLiteLLMEnforcement` is not exported from `setup.ts`. + +- [ ] **Step 3: Implement `LiteLLMEnforcementContext` type and `detectLiteLLMEnforcement()` in `setup.ts`** + +Add these imports at the top of `setup.ts` (after existing imports): + +```typescript +import type { CodeMieIntegration, SSOAuthResult, SetupContext } from '../../providers/core/types.js'; +import { + DEFAULT_CODEMIE_BASE_URL, + authenticateWithCodeMie, + promptForCodeMieUrl, + selectCodeMieProject +} from '../../providers/core/codemie-auth-helpers.js'; +import { fetchCodeMieIntegrations } from '../../providers/plugins/sso/sso.http-client.js'; +``` + +Add the type and function after the imports block, before `createSetupCommand`: + +```typescript +interface LiteLLMEnforcementContext { + integration: CodeMieIntegration; + project: string; + authResult: SSOAuthResult; +} + +type EnforcementGateResult = + | { enforced: false } + | { enforced: true; integration: CodeMieIntegration; project: string; authResult: SSOAuthResult }; + +export async function detectLiteLLMEnforcement( + existingCodeMieUrl?: string +): Promise { + try { + const codeMieUrl = await promptForCodeMieUrl( + existingCodeMieUrl || DEFAULT_CODEMIE_BASE_URL + ); + const authResult = await authenticateWithCodeMie(codeMieUrl); + + if (!authResult.success || !authResult.apiUrl || !authResult.cookies) { + throw new Error(authResult.error || 'SSO authentication failed'); + } + + const { project } = await selectCodeMieProject(authResult); + + const allIntegrations = await fetchCodeMieIntegrations( + authResult.apiUrl, + authResult.cookies + ); + + const projectIntegrations = allIntegrations.filter( + i => i.project_name === project + ); + + if (projectIntegrations.length === 0) { + return { enforced: false }; + } + + return { + enforced: true, + integration: projectIntegrations[0], + project, + authResult + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Could not check for mandatory integrations: ${errorMessage}`); + console.log( + chalk.yellow( + `\n⚠️ Could not check for mandatory integrations (${errorMessage}). Continuing with normal provider setup.\n` + ) + ); + return { enforced: false }; + } +} +``` + +- [ ] **Step 4: Run tests — expect PASS** + +```bash +npx vitest run src/cli/commands/__tests__/setup.enforcement.test.ts +``` + +Expected: all 5 tests PASS. + +- [ ] **Step 5: Typecheck** + +```bash +npm run typecheck +``` + +Expected: zero errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/cli/commands/setup.ts \ + src/cli/commands/__tests__/setup.enforcement.test.ts +git commit -m "fix(cli): add detectLiteLLMEnforcement() gate with graceful fallback" +``` + +--- + +## Task 4: Wire enforcement gate into `runSetupWizard()` and `handlePluginSetup()` + +**Test-first:** yes — integration-level test that verifies `runSetupWizard` skips provider prompt and passes enforcement context when the gate returns `enforced: true`. This is a unit-level integration test that mocks all I/O. + +**Files:** +- Modify: `src/cli/commands/setup.ts` (two targeted changes: `runSetupWizard` and `handlePluginSetup`) +- Test: `src/cli/commands/__tests__/setup.enforcement.test.ts` (extend with wiring tests) + +**Interfaces:** +- Consumes: `detectLiteLLMEnforcement()` (Task 3) +- Consumes: `LiteLLMEnforcementContext` (Task 3) +- Consumes: `SetupContext` (Task 1) + +- [ ] **Step 1: Add failing tests for the wiring behaviour** + +Append to `src/cli/commands/__tests__/setup.enforcement.test.ts`: + +```typescript +// Additional mocks needed for runSetupWizard wiring tests +vi.mock('../../../utils/config.js', () => ({ + ConfigLoader: { + hasGlobalConfig: vi.fn().mockResolvedValue(false), + hasLocalConfig: vi.fn().mockResolvedValue(false), + listProfiles: vi.fn().mockResolvedValue([]), + saveProfile: vi.fn().mockResolvedValue(undefined), + saveUserEmail: vi.fn().mockResolvedValue(undefined), + getActiveProfileName: vi.fn().mockResolvedValue(null), + switchProfile: vi.fn().mockResolvedValue(undefined), + initProjectConfig: vi.fn().mockResolvedValue(undefined) + } +})); + +vi.mock('inquirer', () => ({ + default: { + prompt: vi.fn() + } +})); + +vi.mock('../../../providers/index.js', () => ({ + ProviderRegistry: { + getAllProviders: vi.fn().mockReturnValue([]), + getSetupSteps: vi.fn().mockReturnValue({ + name: 'litellm', + getCredentials: vi.fn().mockResolvedValue({ baseUrl: 'http://localhost:4000', apiKey: 'sk-test' }), + fetchModels: vi.fn().mockResolvedValue(['model-a']), + buildConfig: vi.fn().mockReturnValue({ provider: 'litellm', model: 'model-a' }) + }), + getProvider: vi.fn().mockReturnValue({ name: 'litellm', recommendedModels: [] }) + } +})); + +vi.mock('../../../providers/integration/setup-ui.js', () => ({ + getAllProviderChoices: vi.fn().mockReturnValue([{ name: 'LiteLLM', value: 'litellm' }]), + displaySetupSuccess: vi.fn(), + displaySetupError: vi.fn(), + getAllModelChoices: vi.fn().mockReturnValue([{ name: 'model-a', value: 'model-a' }]), + displaySetupInstructions: vi.fn() +})); + +vi.mock('../../../cli/first-time.js', () => ({ + FirstTimeExperience: { showEcosystemIntro: vi.fn() } +})); + +vi.mock('../../../agents/registry.js', () => ({ + AgentRegistry: { getAgent: vi.fn().mockReturnValue(null) } +})); + +// Import the detectLiteLLMEnforcement spy — we override its return value per test +const setupModule = await import('../setup.js'); + +describe('runSetupWizard enforcement wiring', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('skips provider prompt and auto-selects litellm when enforcement is active', async () => { + // Spy on detectLiteLLMEnforcement to return enforced state + vi.spyOn(setupModule, 'detectLiteLLMEnforcement').mockResolvedValue({ + enforced: true, + integration: { id: 'int-1', alias: 'my-int', project_name: 'proj', credential_type: 'LiteLLM' }, + project: 'proj', + authResult: { success: true, apiUrl: 'https://api.example.com', cookies: { s: 'x' } } + }); + + const inquirer = await import('inquirer'); + // Storage location prompt — first inquirer call + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ storage: 'global' }); + // Profile name prompt + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ newProfileName: 'test-profile' }); + // Switch-to-new prompt + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ switchToNew: false }); + + await setupModule.runSetupWizardForTest(); + + // Provider prompt must NOT have been called (no second inquirer.prompt with 'provider') + const promptCalls = vi.mocked(inquirer.default.prompt).mock.calls; + const providerPromptCall = promptCalls.find( + args => Array.isArray(args[0]) && args[0].some((q: any) => q.name === 'provider') + ); + expect(providerPromptCall).toBeUndefined(); + }); + + it('shows provider prompt when enforcement gate returns enforced:false', async () => { + vi.spyOn(setupModule, 'detectLiteLLMEnforcement').mockResolvedValue({ enforced: false }); + + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt) + .mockResolvedValueOnce({ storage: 'global' }) + .mockResolvedValueOnce({ provider: 'litellm' }) + .mockResolvedValueOnce({ selectedModel: 'model-a' }) + .mockResolvedValueOnce({ newProfileName: 'profile-b' }) + .mockResolvedValueOnce({ switchToNew: false }); + + await setupModule.runSetupWizardForTest(); + + const promptCalls = vi.mocked(inquirer.default.prompt).mock.calls; + const providerPromptCall = promptCalls.find( + args => Array.isArray(args[0]) && args[0].some((q: any) => q.name === 'provider') + ); + expect(providerPromptCall).toBeDefined(); + }); +}); +``` + +**Note:** `runSetupWizardForTest` is a thin re-export of the internal `runSetupWizard` with the `force` parameter. See Step 3 for the export addition. + +- [ ] **Step 2: Run to confirm failures** + +```bash +npx vitest run src/cli/commands/__tests__/setup.enforcement.test.ts +``` + +Expected: FAIL — `runSetupWizardForTest` not exported; `detectLiteLLMEnforcement` not wired. + +- [ ] **Step 3: Update `runSetupWizard()` in `setup.ts` to call the gate and branch on result** + +After the storage-location prompt block (line ~197 in the current file, just before `// Step 1: Get all registered providers`), insert: + +```typescript + // Pre-provider LiteLLM enforcement gate + const existingCodeMieUrl = undefined; // future: read from local config if desired + const enforcementGate = await detectLiteLLMEnforcement(existingCodeMieUrl); + + let enforced: typeof enforcementGate extends { enforced: true } ? typeof enforcementGate : undefined; + let selectedProvider: string; + let selectedSetupSteps: any; + + if (enforcementGate.enforced) { + const litellmSteps = ProviderRegistry.getSetupSteps('litellm'); + if (!litellmSteps) { + throw new Error( + 'LiteLLM integration is required for this project but the LiteLLM provider is not available. Please reinstall codemie-cli.' + ); + } + console.log( + chalk.cyan(`\n📌 This project uses a mandatory LiteLLM integration: "${enforcementGate.integration.alias}"`) + + '\n' + + chalk.dim(' Provider has been set to LiteLLM automatically.\n') + ); + selectedProvider = 'litellm'; + selectedSetupSteps = litellmSteps; + enforced = enforcementGate as any; + } else { + // Normal provider selection prompt + const registeredProviders = ProviderRegistry.getAllProviders(); + const allProviderChoices = getAllProviderChoices(registeredProviders); + + const { provider } = await inquirer.prompt([ + { + type: 'list', + name: 'provider', + message: 'Choose your LLM provider:\n', + choices: allProviderChoices, + pageSize: 15, + default: allProviderChoices[0]?.value + } + ]); + selectedProvider = provider; + const steps = ProviderRegistry.getSetupSteps(provider); + if (!steps) { + throw new Error(`Provider "${provider}" does not have setup steps configured`); + } + selectedSetupSteps = steps; + enforced = undefined; + } + + await handlePluginSetup( + selectedProvider, + selectedSetupSteps, + profileName, + isUpdate, + storageLocation, + enforced + ); +``` + +Remove the old provider prompt block (lines ~199–223 in the original): +```typescript + // Step 1: Get all registered providers from ProviderRegistry <-- DELETE these lines + const registeredProviders = ProviderRegistry.getAllProviders(); + const allProviderChoices = getAllProviderChoices(registeredProviders); + + const { provider } = await inquirer.prompt([...]); + const setupSteps = ProviderRegistry.getSetupSteps(provider); + if (!setupSteps) { + throw new Error(`Provider "${provider}" does not have setup steps configured`); + } + await handlePluginSetup(provider, setupSteps, profileName, isUpdate, storageLocation); +``` + +- [ ] **Step 4: Update `handlePluginSetup()` signature and `getCredentials` call** + +Change the signature from: + +```typescript +async function handlePluginSetup( + providerName: string, + setupSteps: any, + profileName: string | null, + isUpdate: boolean, + storageLocation: 'global' | 'local' = 'global' +): Promise +``` + +to: + +```typescript +async function handlePluginSetup( + providerName: string, + setupSteps: any, + profileName: string | null, + isUpdate: boolean, + storageLocation: 'global' | 'local' = 'global', + enforcementContext?: LiteLLMEnforcementContext +): Promise +``` + +And change the `getCredentials` call (line ~247) from: + +```typescript + const credentials = await setupSteps.getCredentials(isUpdate); +``` + +to: + +```typescript + const setupContext: SetupContext | undefined = enforcementContext + ? { + enforcedIntegration: { + id: enforcementContext.integration.id, + alias: enforcementContext.integration.alias, + codeMieUrl: enforcementContext.authResult.apiUrl || '' + } + } + : undefined; + const credentials = await setupSteps.getCredentials(isUpdate, setupContext); +``` + +- [ ] **Step 5: Export `runSetupWizard` as `runSetupWizardForTest` for tests** + +At the bottom of `setup.ts`, add: + +```typescript +// Exported for unit testing only +export { runSetupWizard as runSetupWizardForTest }; +``` + +Also ensure `detectLiteLLMEnforcement` is already exported (it was exported in Task 3). + +- [ ] **Step 6: Run all enforcement tests — expect PASS** + +```bash +npx vitest run src/cli/commands/__tests__/setup.enforcement.test.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 7: Run the full LiteLLM setup-steps test suite** + +```bash +npx vitest run src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 8: Typecheck** + +```bash +npm run typecheck +``` + +Expected: zero errors. + +- [ ] **Step 9: Lint** + +```bash +npm run lint +``` + +Expected: zero warnings. + +- [ ] **Step 10: Commit** + +```bash +git add src/cli/commands/setup.ts \ + src/cli/commands/__tests__/setup.enforcement.test.ts +git commit -m "fix(cli): wire detectLiteLLMEnforcement into runSetupWizard — auto-select LiteLLM when enforced" +``` + +--- + +## Task 5: Run full CI gate + +**Test-first:** N/A — this is the final quality gate. + +**Files:** No code changes. + +- [ ] **Step 1: Run the full test suite** + +```bash +cd C:/Projects/codemie-dev/codemie-code +npm run test +``` + +Expected: all tests PASS. + +- [ ] **Step 2: Run full CI** + +```bash +npm run ci +``` + +Expected: lint → typecheck → build → test → zero failures. + +- [ ] **Step 3: Review changed files** + +Confirm only these files changed (no accidental collateral changes): +- `src/providers/core/types.ts` +- `src/providers/plugins/litellm/litellm.setup-steps.ts` +- `src/cli/commands/setup.ts` +- `src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts` (new) +- `src/cli/commands/__tests__/setup.enforcement.test.ts` (new) + +```bash +git diff --name-only HEAD~4 +``` + +Expected: exactly the five files listed above (plus `.state.json` and planning artifacts in `docs/`). + +--- + +## Spec coverage self-review + +| Spec requirement | Task | +|---|---| +| Phase 1: `detectLiteLLMEnforcement()` in `setup.ts` | Task 3 | +| Prompt CodeMie URL → auth → project → fetch integrations → return result | Task 3 | +| Gate failure → warn + fallback to normal flow | Task 3 (graceful catch) | +| Enforcement banner when enforced | Task 4 | +| Skip provider prompt when enforced | Task 4 | +| Guard: LiteLLM not registered → throw | Task 4 | +| `handlePluginSetup()` gains `enforcedContext?` param | Task 4 | +| `LiteLLMEnforcementContext` type in `setup.ts` | Task 3 | +| `getCredentials()` threads context via `SetupContext` | Task 4 | +| Phase 3: `SetupContext` in `types.ts` | Task 1 | +| Phase 4: `LiteLLMSetupSteps` enforcement mode with hard-required key | Task 2 | +| Phase 5: SSO path unchanged | No change — confirmed no-op | +| AC1: Detect LiteLLM integration | Task 3 | +| AC2: Enforce LiteLLM usage | Task 4 | +| AC3: Cannot complete setup without LiteLLM key | Task 2 | +| AC4: Validation message explains why key is mandatory | Task 2 (prompt message + error text) | +| AC5: No change for projects without integration | Task 3 (enforced:false path) | +| AC6: No invalid config state saved | Task 2 (key required before `buildConfig` is called) | diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/spec.md b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/spec.md new file mode 100644 index 00000000..54d97417 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/spec.md @@ -0,0 +1,198 @@ +# Spec: Enforce LiteLLM Integration in CLI Setup + +**Ticket**: EPMCDME-11733 +**Branch**: EPMCDME-11733 +**Type**: Bug / Enforcement + +--- + +## Goal + +When `codemie-cli setup` runs in a project that has a LiteLLM integration configured in the CodeMie backend, the setup wizard must detect this and enforce usage of that integration. The user cannot complete setup without providing the required LiteLLM API key. + +--- + +## Acceptance Criteria (from ticket) + +1. During setup, the system detects when the selected project already has a LiteLLM integration created. +2. If LiteLLM integration exists, the setup flow enforces its usage for CLI configuration. +3. User cannot complete CLI setup without providing the required LiteLLM key. +4. Validation message clearly explains why setup cannot continue without LiteLLM credentials. +5. The enforced flow does not affect projects that do not have LiteLLM integration created. +6. No invalid CLI configuration state can be saved for projects where LiteLLM integration is mandatory. + +--- + +## Design + +### Overview + +A new **pre-auth integration gate** runs at the start of `runSetupWizard()`, before the provider-selection prompt. It authenticates with CodeMie, identifies the user's project, and checks for a LiteLLM integration. If found, the wizard auto-selects LiteLLM and requires an API key. If not found (or if the gate fails gracefully), the wizard proceeds with the normal provider selection flow unchanged. + +--- + +### Phase 1 — Integration Gate in `setup.ts` + +A new function `detectLiteLLMEnforcement()` is added to `setup.ts`. It runs unconditionally before the provider-selection prompt. + +**Steps:** + +1. Prompt for CodeMie URL. Pre-fill from local config (`codeMieUrl` field) if available; default to `DEFAULT_CODEMIE_BASE_URL` otherwise. +2. Authenticate using `authenticateWithCodeMie(codeMieUrl)`. +3. Select the user's project using `selectCodeMieProject(authResult)`. +4. Fetch integrations using `fetchCodeMieIntegrations(authResult.apiUrl, authResult.cookies)` filtered by `project_name === selectedProject`. +5. Return one of: + - `{ enforced: false }` — no integration found; wizard proceeds normally. + - `{ enforced: true, integration: CodeMieIntegration, project: string, authResult: SSOAuthResult }` — enforcement is active. + +**Failure behaviour:** +- If any step throws (network error, SSO unavailable, project fetch fails, integration fetch fails): catch the error, log a warning with `logger.warn()`, print a yellow warning banner to the user, and return `{ enforced: false }`. The wizard continues to the normal provider selection. This prevents the gate from blocking setups in air-gapped or restricted environments. +- The warning banner message: `"⚠️ Could not check for mandatory integrations (${errorMessage}). Continuing with normal provider setup."` + +**Integration with `runSetupWizard()`:** +- Call `detectLiteLLMEnforcement()` after the storage-location prompt and before the provider-selection prompt. +- Store the result in a local variable `enforcementGate`. +- If `enforcementGate.enforced === true`: print an enforcement banner, skip the provider-selection prompt, and pass `enforcementGate` into `handlePluginSetup()`. +- If `enforcementGate.enforced === false`: proceed to the normal provider-selection prompt unchanged. + +**Enforcement banner (printed when enforcement is active):** +``` +📌 This project uses a mandatory LiteLLM integration: "{alias}" + Provider has been set to LiteLLM automatically. +``` + +**Guard:** Before skipping the provider prompt, assert that `ProviderRegistry.getSetupSteps('litellm')` exists. If LiteLLM is not registered for any reason, throw with: `"LiteLLM integration is required for this project but the LiteLLM provider is not available. Please reinstall codemie-cli."` This prevents an unrecoverable blank-prompt state. + +--- + +### Phase 2 — `handlePluginSetup()` context threading + +`handlePluginSetup()` gains an optional fourth parameter: + +```typescript +async function handlePluginSetup( + providerName: string, + setupSteps: ProviderSetupSteps, + profileName: string | null, + isUpdate: boolean, + storageLocation: 'global' | 'local', + enforcementContext?: LiteLLMEnforcementContext // new, optional +): Promise +``` + +Where `LiteLLMEnforcementContext` is a new local type in `setup.ts`: + +```typescript +interface LiteLLMEnforcementContext { + integration: CodeMieIntegration; + project: string; + authResult: SSOAuthResult; +} +``` + +`handlePluginSetup()` passes this context to `setupSteps.getCredentials()` via the new optional second parameter. + +--- + +### Phase 3 — `ProviderSetupSteps` interface extension (`types.ts`) + +Add a new exported type and extend `getCredentials`: + +```typescript +export interface SetupContext { + enforcedIntegration?: { + id: string; + alias: string; + codeMieUrl: string; + }; +} +``` + +Update the interface method signature: + +```typescript +getCredentials(isUpdate?: boolean, context?: SetupContext): Promise; +``` + +The `context` parameter is optional. All existing provider implementations remain valid without changes (TypeScript will accept the narrower existing signatures — they do not need to declare the second parameter). Only `LiteLLMSetupSteps` will actually read the `context`. + +--- + +### Phase 4 — `LiteLLMSetupSteps` enforcement (`litellm.setup-steps.ts`) + +Update `LiteLLMSetupSteps.getCredentials()` to accept the optional context and enforce the API key when a context is present. + +**When `context?.enforcedIntegration` is set:** + +1. Display the integration header before the prompts: + ``` + 🔒 LiteLLM integration required: "{alias}" + Get your API key from your CodeMie portal (Settings → Integrations). + ``` +2. Prompt for the Proxy URL (unchanged, same default). +3. Prompt for the API Key with: + - Message: `API Key for integration "{alias}" (required):` + - `type: 'password'` + - Validator: `(input) => input.trim() !== '' || 'API Key is required for this integration. Retrieve it from your CodeMie portal.'` + - No default value (forces the user to enter something). +4. Return credentials with the entered key. The `'not-required'` fallback is **not used** in enforcement mode. + +**When no context (normal flow):** existing behaviour unchanged. + +--- + +### Phase 5 — SSO flow enforcement (`sso.setup-steps.ts`) + +In `SSOSetupSteps.getCredentials()`, in the section that resolves integrations (current lines 133–140), change the "no integrations found" path: + +**Current behaviour:** if integrations.length === 0, log debug and proceed silently. + +**New behaviour:** if integrations.length === 0 AND `integrationsFetchError` is undefined (meaning the fetch succeeded but returned nothing): +- Continue silently (same as before — project genuinely has no integration). + +If integrations.length > 0 and the user is mid-setup via the SSO path: +- **Current**: auto-select or prompt; integration is optional metadata. +- **New**: same selection logic (auto-select if 1, prompt if many), but the integration is now **required** for the SSO credentials. The existing `integrationInfo` assignment remains, but after selection the SSO flow should produce credentials that signal LiteLLM enforcement to the downstream model fetch / buildConfig. + +Note: SSO path users go through SSO → select project → find integration → the integration becomes part of `buildConfig` output. The key enforcement is **not applied in the SSO path** — the SSO token already authenticates the user; the LiteLLM key is separate and only needed in the direct LiteLLM provider path. SSO users access LiteLLM models through the SSO-authenticated CodeMie proxy without a separate API key. The SSO integration detection enforcement means: when an SSO user is on a project with a LiteLLM integration, their profile will record the integration info but they don't need to provide a LiteLLM key. + +--- + +### Error Handling + +| Scenario | Behaviour | +|---|---| +| CodeMie URL unreachable | Log warning, fall back to normal setup flow | +| SSO auth times out | Log warning, fall back to normal setup flow | +| Project fetch fails | Log warning, fall back to normal setup flow | +| Integration fetch fails | Log warning, fall back to normal setup flow | +| LiteLLM key left blank in enforcement mode | Validation error in prompt; re-prompt (no save, no exit) | +| LiteLLM provider not registered | Throw with clear error message before showing any prompt | + +--- + +### Files Changed + +| File | Change | +|---|---| +| `src/cli/commands/setup.ts` | Add `detectLiteLLMEnforcement()`, update `runSetupWizard()`, update `handlePluginSetup()` signature, add `LiteLLMEnforcementContext` type | +| `src/providers/core/types.ts` | Add `SetupContext` interface; add optional `context?: SetupContext` param to `getCredentials` in `ProviderSetupSteps` | +| `src/providers/plugins/litellm/litellm.setup-steps.ts` | Update `getCredentials()` to accept and enforce `SetupContext`; add key validation and portal URL hint | +| `src/providers/plugins/sso/sso.setup-steps.ts` | Minor: no enforcement of LiteLLM key in SSO path (SSO auth already handles access); existing integration-selection logic unchanged | + +--- + +### What This Does NOT Change + +- Non-SSO, non-LiteLLM providers (Anthropic, Bedrock, Ollama, Anthropic Subscription) are completely unaffected. +- Projects with no LiteLLM integration see zero change in their setup flow. +- The `codeMieIntegration` field in `ProviderProfile` retains its current meaning (integration metadata stored after a setup that detected one). It is NOT used as an enforcement trigger. +- The `--force` flag still works; enforcement is re-checked live every time via the gate. + +--- + +### Out of Scope + +- Fetching the LiteLLM API key from the CodeMie backend (the API does not expose this; user retrieves it from the portal). +- Adding a "Skip / no key required" bypass for the API key prompt. +- Changing the SSO provider path to require a separate LiteLLM key. diff --git a/docs/superpowers/tasks/2026-07-20-epmcdme-11733/technical-analysis.md b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/technical-analysis.md new file mode 100644 index 00000000..c3cee29c --- /dev/null +++ b/docs/superpowers/tasks/2026-07-20-epmcdme-11733/technical-analysis.md @@ -0,0 +1,150 @@ +# Technical Research + +**Task**: litellm provider cli setup integration validation +**Generated**: 2026-07-20T00:00:00Z +**Research path**: filesystem + +--- + +## 1. Original Context + +codemie-cli setup must enforce existing LiteLLM integration and prevent configuration without LiteLLM key. When a user runs codemie-cli setup in a project where a LiteLLM integration is already created, the setup flow must enforce usage of that existing LiteLLM integration. The user must not be able to complete CLI configuration without providing or using a LiteLLM key required by that integration. Acceptance criteria: (1) During setup, the system detects when the selected project already has a LiteLLM integration created. (2) If LiteLLM integration exists, the setup flow enforces its usage for CLI configuration. (3) User cannot complete CLI setup without providing the required LiteLLM key. (4) Validation message clearly explains why setup cannot continue without LiteLLM credentials. (5) The enforced flow does not affect projects without LiteLLM integration. (6) No invalid CLI configuration state can be saved. + +--- + +## 2. Codebase Findings + +### Existing Implementations + +- `src/cli/commands/setup.ts` — main setup wizard entry point (28 KB). `runSetupWizard()` drives the full flow: profile detection → storage-location prompt → provider selection → `handlePluginSetup()`. `handlePluginSetup()` calls `setupSteps.getCredentials()` → `fetchModels()` → model selection → `buildConfig()` → `ConfigLoader.save*()`. No integration-detection hook exists before the provider-selection prompt today. +- `src/providers/plugins/litellm/litellm.setup-steps.ts` — `LiteLLMSetupSteps.getCredentials()` prompts for proxy URL and an **optional** API key (defaults to the string `'not-required'` when left blank). No enforcement logic whatsoever. +- `src/providers/plugins/litellm/litellm.template.ts` — `LiteLLMTemplate` registered with `requiresAuth: false`, priority 14 (below SSO at 0). +- `src/providers/plugins/litellm/litellm.models.ts` — `LiteLLMModelProxy.listModels()` hits `/v1/models` with optional `Authorization: Bearer ` header. Skips the header when `apiKey === 'not-required'`. +- `src/providers/plugins/litellm/index.ts` — auto-registers the template and setup steps with `ProviderRegistry` on import. +- `src/providers/plugins/sso/sso.setup-steps.ts` — `SSOSetupSteps.getCredentials()` already performs integration discovery after SSO auth (lines 79–140). Calls `fetchCodeMieIntegrations()`, filters by project, auto-selects when exactly one integration exists, prompts for choice when multiple exist. Critically: when no integrations are found, or when fetch fails, it proceeds **silently** with `integrationInfo = undefined`. There is no enforcement gate. +- `src/providers/plugins/sso/sso.http-client.ts` — `fetchCodeMieIntegrations(apiUrl, auth, endpointPath?)` paginates `/v1/settings/user?page=N&per_page=50&filters={"type":["LiteLLM"]}`. Validates entries by `credential_type === 'LiteLLM'` and non-empty `alias`. Returns `CodeMieIntegration[]` (id, alias, project_name, credential_type). +- `src/providers/core/codemie-auth-helpers.ts` — `fetchCodeMieIntegrations` re-exported here; `selectCodeMieProject()` fetches user info and surfaces the `applications` list. +- `src/providers/core/registry.ts` — `ProviderRegistry`: static Maps for `providers`, `healthChecks`, `modelProxies`, `setupSteps`. `getSetupSteps(provider)` returns registered steps. No project-context or integration awareness in the registry. +- `src/providers/core/types.ts` — `ProviderSetupSteps` interface. `getCredentials(isUpdate?: boolean)` takes only one optional boolean. `CodeMieIntegration` type has `{id, alias, project_name, credential_type}`. `CodeMieIntegrationInfo` (stored in config) has `{id, alias}` only — no API key field. +- `src/env/types.ts` — `ProviderProfile.codeMieIntegration?: CodeMieIntegrationInfo`. The project-level local config stores `{id, alias}` when integration is already configured. +- `src/utils/config.ts` — `ConfigLoader.load()` merges global → local → env → CLI. `loadLocalConfigProfile()` can read existing `.codemie/` config. `hasLocalConfig()` detects whether a local config exists. `initProjectConfig()` saves local overrides. `loadWithSources()` returns per-field source labels. + +### Architecture and Layers Affected + +| Layer | Components | +|---|---| +| CLI (entry point) | `src/cli/commands/setup.ts` — `runSetupWizard()`, `handlePluginSetup()` | +| Provider Plugin | `src/providers/plugins/litellm/litellm.setup-steps.ts` — `LiteLLMSetupSteps` | +| Provider Core | `src/providers/core/types.ts` — `ProviderSetupSteps` interface | +| API / HTTP | `src/providers/plugins/sso/sso.http-client.ts` — `fetchCodeMieIntegrations()` | +| Config / Persistence | `src/utils/config.ts` — `ConfigLoader`; `src/env/types.ts` — `ProviderProfile` | + +### Integration Points + +- `fetchCodeMieIntegrations` in `sso.http-client.ts` is the only existing function that fetches LiteLLM integrations from the backend. It requires an authenticated `apiUrl` and auth credentials (SSO cookies or JWT token). It is already imported and used by `sso.setup-steps.ts`. +- `ConfigLoader.load()` / `loadLocalConfigProfile()` can detect whether a local `.codemie/` config already stores a `codeMieIntegration`. This path does **not** require network access. +- `ProviderRegistry.getSetupSteps('litellm')` returns `LiteLLMSetupSteps`. The registry has no project-awareness hook today. +- `selectCodeMieProject()` in `codemie-auth-helpers.ts` fetches `applications` from `/v1/user` — needed for project identification when no local config exists. + +### Patterns and Conventions + +- **Plugin setup steps contract**: `ProviderSetupSteps.getCredentials(isUpdate?)` is the standard entry point. Adding an optional second parameter `context?` is the lowest-friction extension: `getCredentials(isUpdate?: boolean, context?: SetupContext): Promise`. The interface in `src/providers/core/types.ts` must be updated to allow this. +- **Fail-fast on auth errors**: `ConfigurationError` is thrown for missing/invalid keys per `external-integrations.md`. The same pattern should be used for a missing LiteLLM key when an integration is enforced. +- **Integration detection in the SSO flow (existing precedent)**: `sso.setup-steps.ts` lines 79–140 show the established pattern: call `fetchCodeMieIntegrations`, filter by project, auto-select or prompt. This same logic (or a refactored shared helper) should feed the enforcement gate. +- **`inquirer.prompt` for interactive gates**: All blocking prompts in the setup wizard use `inquirer.prompt`. A hard stop (throw) is the correct pattern when enforcement blocks proceed; the `displaySetupError` utility in `setup-ui.ts` can surface the reason. +- **Config validation before save**: The `handlePluginSetup()` function in `setup.ts` saves only after all steps succeed. Throwing inside `getCredentials()` or adding a `validate()` step (optional on `ProviderSetupSteps`) are both clean insertion points. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- `.ai-run/guides/integration/external-integrations.md` — covers LiteLLM integration, SSO auth patterns, `ConfigurationError` usage, and the "Configuration Validation" section (validate at startup; throw on missing API key). Directly relevant. +- `.ai-run/guides/usage/project-config.md` — documents `ConfigLoader` API, local vs. global config schema, `codeMieIntegration` field in config, and the two-level profile merge. Directly relevant. +- `.ai-run/guides/architecture/architecture.md` — referenced from `AGENTS.md` for plugin-based 5-layer architecture. + +### Architectural Decisions + +- From `external-integrations.md`: "Missing API key → Throw `ConfigurationError` with env var name." The same class must be used for the LiteLLM key enforcement block. +- From `project-config.md`: the local `.codemie/` config stores `codeMieIntegration: {id, alias}`. This is already the persistence target for integration state — the enforcement logic can read it directly without a network call if a local config exists. +- From `sso.setup-steps.ts` (inline comment, line 106): `"Log error but don't fail setup — integrations are optional"` — this is the current, **non-enforcing** behavior that the ticket explicitly changes. + +### Derived Conventions + +- The `ProviderSetupSteps` interface is intentionally minimal. Optional fields (`validate?`, `postSetup?`, `installModel?`) are the established extension pattern. A new optional `context` parameter to `getCredentials` fits this style cleanly. +- Error messages in setup use `chalk` for formatting and `displaySetupError()` from `src/providers/integration/setup-ui.ts` for structured display. +- When a required field is absent, the wizard throws an `Error` which `handlePluginSetup()` catches and passes to `displaySetupError()`. This is the approved UX path for hard stops. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/cli/commands/__tests__/model-tier-auto-selection.test.ts` — tests `autoSelectModelTiers` in isolation; no integration coverage. +- `src/providers/integration/__tests__/setup-ui.test.ts` — tests the `setup-ui` helper display functions. +- `src/utils/__tests__/config-project-override.test.ts` — tests `ConfigLoader` project-level config merging, including `codeMieIntegration` fields. Uses `vi.spyOn` to mock `getCodemieHome`. +- `src/providers/plugins/sso/__tests__/sso.auth.test.ts` — tests SSO authentication. Does not cover integration detection logic in `sso.setup-steps.ts`. +- No tests exist for `LiteLLMSetupSteps` in `src/providers/plugins/litellm/`. +- No tests exist for the integration-enforcement logic (which does not exist yet). + +### Testing Framework and Patterns + +- **Framework**: Vitest with `describe`, `it`, `expect`, `vi.spyOn`, `vi.mock`. +- **Dynamic-import mocking**: Modules with side effects (like `inquirer`) use dynamic `vi.mock` at the top of test files. +- **File system**: Tests mock `getCodemieHome`/`getCodemiePath` via `vi.spyOn(paths, ...)` and create real temp directories (`tmp-test-config/`), cleaned up in `afterEach`. +- **Config fixtures**: Tests write raw JSON to disk and call `ConfigLoader.*` methods against them. + +### Coverage Gaps + +- `LiteLLMSetupSteps.getCredentials()` — zero test coverage. +- `fetchCodeMieIntegrations()` — no unit tests (integration-level calls only). +- The entire enforcement gate (the new feature) — no tests exist because the feature doesn't exist. +- `sso.setup-steps.ts` integration-detection block (lines 79–140) — no tests verify the auto-select or prompt behavior. + +--- + +## 5. Configuration and Environment + +### Environment Variables + +- `CODEMIE_DEBUG` — when set, `fetchCodeMieIntegrations` prints full URLs and response snippets to stdout (line 261, 326 of `sso.http-client.ts`). +- `CODEMIE_INTEGRATION_ID` — read by `ConfigLoader.loadFromEnv()` and surfaced as `codeMieIntegration.id` (seen in config-project-override test cleanup). Can provide integration ID via env in CI. +- `CODEMIE_PROJECT` — similarly env-sourced project name. +- `CODEMIE_API_KEY` — env override for `apiKey`, which maps to the LiteLLM key in a `litellm` profile. +- No dedicated env var for the LiteLLM key within an integration context exists yet. `CODEMIE_API_KEY` is the closest. + +### Configuration Files + +- `~/.codemie/codemie-cli.config.json` — global multi-provider config (`version: 2`, `profiles` record). +- `.codemie/codemie-cli.config.json` — local project override. The `codeMieIntegration: {id, alias}` field in `ProviderProfile` is the canonical persistence point for an established integration. + +### Feature Flags and Deployment Concerns + +- No existing feature flags cover this flow. +- The enforcement gate must be **conditional**: AC5 explicitly says "does not affect projects without LiteLLM integration." This is a local-config read before any prompt — no deployment or env-var flag needed. +- `requiresAuth: false` on `LiteLLMTemplate` must remain unchanged for the standalone LiteLLM case (without project integration). The enforcement is context-driven, not template-driven. + +--- + +## 6. Risk Indicators + +- **Current `getCredentials` signature does not support context injection.** `ProviderSetupSteps.getCredentials(isUpdate?: boolean)` has no parameter for passing integration context from the wizard into the plugin. Extending the interface (adding an optional `context?` parameter) is the cleanest path, but it touches `src/providers/core/types.ts` — a shared interface used by all seven registered provider plugins. All implementations must remain backward-compatible (optional parameter). +- **LiteLLM API key is not stored in `CodeMieIntegration`.** The backend integration object (`{id, alias, project_name, credential_type}`) does not carry a LiteLLM API key. The key must be provided by the user at setup time. This is correct per the acceptance criteria ("providing the required LiteLLM key") but means the key has no pre-fill source from the integration record — the user always enters it manually. +- **Integration detection requires auth for the live path.** If the project has no pre-existing local `.codemie/` config, detecting integrations requires a live API call with SSO or JWT credentials. For the standalone `litellm` provider path (not SSO), this is an untethered call — the setup wizard has no authenticated session to reuse. Implementation must handle both paths: (a) local config already stores `codeMieIntegration` — detect offline; (b) no local config — require auth to fetch integrations, which adds SSO/JWT dependency to what was previously a credential-free LiteLLM setup. +- **SSO integration detection is in `sso.setup-steps.ts`, not in a shared helper.** The fetching + filtering + auto-select logic (lines 79–140) is embedded inside the SSO plugin. If the LiteLLM setup needs the same detection, it must either (a) duplicate that logic, (b) extract it to a shared helper in `src/providers/core/` or `src/utils/`, or (c) route detection through the wizard layer in `setup.ts`. Option (c) is least invasive for the plugin interface; option (b) is cleanest architecturally. +- **`sso.setup-steps.ts` line 106 comment explicitly marks integrations as optional.** The existing SSO flow will also need updating to enforce the LiteLLM key when an integration is detected — not only the `litellm` provider path. If the user selects SSO as their provider in a project with an integration, the same enforcement AC applies. +- **No test coverage for `LiteLLMSetupSteps` at all.** Adding enforcement logic to an untested function increases regression risk. Tests should be written alongside the feature. +- **`handlePluginSetup()` throws errors caught by `displaySetupError()`.** Enforcement that throws will surface through the existing error path correctly, but the error message must be clear (AC4). The `displaySetupError` function accepts an `Error` and optional `setupInstructions` string — the message needs to explicitly reference the LiteLLM integration and explain why setup cannot proceed. +- **Seven registered provider plugins implement `ProviderSetupSteps`.** Any signature change (even optional) must be verified across all seven to confirm no TypeScript compile errors. + +--- + +## 7. Summary for Complexity Assessment + +The task adds an enforcement gate to the `codemie-cli setup` wizard that blocks completion when the project has a LiteLLM integration configured but the user has not provided a LiteLLM API key. The architectural layers affected are: CLI wizard (`setup.ts`), the LiteLLM provider plugin (`litellm.setup-steps.ts`), the shared provider types interface (`src/providers/core/types.ts`), and indirectly the SSO setup steps which need the same enforcement applied. The file change surface is 4–6 files: `setup.ts` (integration detection before provider selection, or context threading into `handlePluginSetup`), `litellm.setup-steps.ts` (key enforcement), `types.ts` (optional context parameter on `ProviderSetupSteps`), `sso.setup-steps.ts` (enforcement gate in the existing integration selection block), and potentially a new shared helper in `src/providers/core/` to avoid duplicating integration-fetch logic. + +Technical novelty is moderate. The integration detection logic (`fetchCodeMieIntegrations`) already exists in `sso.http-client.ts` and is already used in the SSO setup steps — this is not new territory. The new element is the enforcement gate: detecting the integration first (from existing local config or via live API), then blocking `buildConfig`/save until a key is provided and validated. The key challenge is architectural: the current `ProviderSetupSteps.getCredentials(isUpdate?)` interface carries no project context, so either the interface is extended with an optional parameter or the wizard layer (`setup.ts`) detects the integration before calling `getCredentials` and redirects the flow. The second approach (wizard-level detection) is simpler for the plugin interface and avoids touching all seven provider implementations. + +Test coverage posture is weak for the affected area: `LiteLLMSetupSteps` has zero existing tests, the integration-detection block in `sso.setup-steps.ts` is untested, and the enforcement gate is entirely new. The config-level helpers (`ConfigLoader`) are well-tested and can be leveraged for the offline detection path. Key risk factors are: (1) the LiteLLM API key has no pre-fill source from the integration object, so the UX must clearly guide the user to retrieve it externally; (2) the online detection path requires authenticated access, adding SSO/JWT dependency to a flow that previously needed none; (3) a shared helper extraction would be the cleanest architecture but adds scope. Complexity is medium — the logic is well-bounded but spans multiple layers and requires careful handling of the "no local config" case. diff --git a/src/cli/commands/__tests__/setup.enforcement.test.ts b/src/cli/commands/__tests__/setup.enforcement.test.ts new file mode 100644 index 00000000..058f51dc --- /dev/null +++ b/src/cli/commands/__tests__/setup.enforcement.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../providers/core/codemie-auth-helpers.js', () => ({ + DEFAULT_CODEMIE_BASE_URL: 'https://codemie.lab.epam.com', + promptForCodeMieUrl: vi.fn(), + authenticateWithCodeMie: vi.fn(), + selectCodeMieProject: vi.fn() +})); + +vi.mock('../../../providers/plugins/sso/sso.http-client.js', () => ({ + fetchCodeMieIntegrations: vi.fn() +})); + +vi.mock('../../../utils/logger.js', () => ({ + logger: { + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + success: vi.fn(), + getLogFilePath: vi.fn().mockReturnValue(null) + } +})); + +vi.mock('chalk', () => ({ + default: { + yellow: (s: string) => s, + cyan: (s: string) => s, + dim: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + white: (s: string) => s, + blueBright: (s: string) => s + } +})); + +vi.mock('ora', () => ({ + default: vi.fn().mockReturnValue({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis() + }) +})); + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn() } +})); + +vi.mock('../../../providers/index.js', () => ({ + ProviderRegistry: { + getAllProviders: vi.fn().mockReturnValue([]), + getSetupSteps: vi.fn(), + getProvider: vi.fn().mockReturnValue(null) + } +})); + +vi.mock('../../../utils/config.js', () => ({ + ConfigLoader: { + hasGlobalConfig: vi.fn().mockResolvedValue(false), + hasLocalConfig: vi.fn().mockResolvedValue(false), + listProfiles: vi.fn().mockResolvedValue([]), + saveProfile: vi.fn().mockResolvedValue(undefined), + saveUserEmail: vi.fn().mockResolvedValue(undefined), + getActiveProfileName: vi.fn().mockResolvedValue('my-profile'), + getProfile: vi.fn().mockResolvedValue(null) + } +})); + +vi.mock('../../../providers/integration/setup-ui.js', () => ({ + getAllProviderChoices: vi.fn().mockReturnValue([{ name: 'LiteLLM', value: 'litellm' }]), + displaySetupSuccess: vi.fn(), + displaySetupError: vi.fn(), + getAllModelChoices: vi.fn().mockReturnValue([{ name: 'gpt-4-turbo', value: 'gpt-4-turbo' }]), + displaySetupInstructions: vi.fn() +})); + +vi.mock('../../../agents/registry.js', () => ({ + AgentRegistry: { getAgent: vi.fn().mockReturnValue(null) } +})); + +vi.mock('../../first-time.js', () => ({ + FirstTimeExperience: { showEcosystemIntro: vi.fn() } +})); + +const authHelpers = await import('../../../providers/core/codemie-auth-helpers.js'); +const ssoClient = await import('../../../providers/plugins/sso/sso.http-client.js'); +const inquirerMod = await import('inquirer'); +const { ProviderRegistry } = await import('../../../providers/index.js'); +const { ConfigLoader } = await import('../../../utils/config.js'); +const setupModule = await import('../setup.js'); + +/** + * Minimal Error subclass matching how `inquirer` labels prompt aborts. + */ +class ExitPromptError extends Error { + constructor(message = 'User force closed the prompt') { + super(message); + this.name = 'ExitPromptError'; + } +} + +describe('detectLiteLLMEnforcement', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns enforced:true (with codeMieUrl) when integration exists for selected project', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://codemie.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'my-project', + userEmail: 'user@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ + { id: 'int-1', alias: 'my-integration', project_name: 'my-project', credential_type: 'LiteLLM' } + ]); + + const result = await setupModule.detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(true); + if (result.enforced) { + expect(result.integration.alias).toBe('my-integration'); + expect(result.project).toBe('my-project'); + // Portal URL (from promptForCodeMieUrl) must be the value carried through, + // NOT authResult.apiUrl (the REST API base) — regression guard for CR-004. + expect(result.codeMieUrl).toBe('https://codemie.example.com'); + } + }); + + it('threads the caller-provided existingCodeMieUrl into promptForCodeMieUrl', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://saved.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://saved.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'my-project', + userEmail: 'user@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([]); + + await setupModule.detectLiteLLMEnforcement('https://saved.example.com'); + + expect(vi.mocked(authHelpers.promptForCodeMieUrl)).toHaveBeenCalledWith( + 'https://saved.example.com' + ); + }); + + it('returns enforced:false when no integration exists for the project', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://codemie.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'clean-project', + userEmail: 'user@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([]); + + const result = await setupModule.detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('returns enforced:false when the only project integration is NOT credential_type=LiteLLM', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://codemie.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'my-project', + userEmail: 'user@example.com' + }); + // Same project, but the integration is GitHub — must NOT enforce LiteLLM. + // Regression guard: removing the `credential_type === 'LiteLLM'` filter must fail this test. + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ + { id: 'gh-1', alias: 'my-github', project_name: 'my-project', credential_type: 'GitHub' } + ]); + + const result = await setupModule.detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('returns enforced:false (graceful fallback) when SSO auth fails', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockRejectedValue(new Error('Network timeout')); + + const result = await setupModule.detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('returns enforced:false (graceful fallback) when integration fetch throws', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://api.example.com', + cookies: { session: 'xyz' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'proj', + userEmail: 'u@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockRejectedValue(new Error('API unavailable')); + + const result = await setupModule.detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('filters integrations by selected project — ignores integrations for other projects', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://api.example.com', + cookies: { session: 'xyz' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'project-A', + userEmail: 'u@example.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ + { id: 'int-2', alias: 'other-int', project_name: 'project-B', credential_type: 'LiteLLM' } + ]); + + const result = await setupModule.detectLiteLLMEnforcement(); + + expect(result.enforced).toBe(false); + }); + + it('re-throws ExitPromptError from promptForCodeMieUrl instead of swallowing it', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockRejectedValue(new ExitPromptError()); + + await expect(setupModule.detectLiteLLMEnforcement()).rejects.toMatchObject({ + name: 'ExitPromptError' + }); + // Regression guard for CR-005: swallowing Ctrl+C as { enforced: false } would + // let the user bypass the mandatory integration. + expect(vi.mocked(authHelpers.authenticateWithCodeMie)).not.toHaveBeenCalled(); + }); + + it('re-throws ExitPromptError from selectCodeMieProject as well', async () => { + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://codemie.example.com/api', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockRejectedValue(new ExitPromptError()); + + await expect(setupModule.detectLiteLLMEnforcement()).rejects.toMatchObject({ + name: 'ExitPromptError' + }); + expect(vi.mocked(ssoClient.fetchCodeMieIntegrations)).not.toHaveBeenCalled(); + }); +}); + +describe('createSetupCommand — setup wizard wiring', () => { + const mockGetCredentials = vi.fn(); + const mockFetchModels = vi.fn(); + const mockBuildConfig = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(ConfigLoader.hasGlobalConfig).mockResolvedValue(false); + vi.mocked(ConfigLoader.hasLocalConfig).mockResolvedValue(false); + vi.mocked(ConfigLoader.listProfiles).mockResolvedValue([]); + vi.mocked(ConfigLoader.saveProfile).mockResolvedValue(undefined); + vi.mocked(ConfigLoader.getActiveProfileName).mockResolvedValue('my-profile'); + vi.mocked(ConfigLoader.getProfile).mockResolvedValue(null); + + mockFetchModels.mockResolvedValue([]); + mockBuildConfig.mockReturnValue({ provider: 'litellm', baseUrl: 'http://litellm', apiKey: 'sk-test' }); + + vi.mocked(ProviderRegistry.getSetupSteps).mockReturnValue({ + name: 'litellm', + getCredentials: mockGetCredentials, + fetchModels: mockFetchModels, + buildConfig: mockBuildConfig + } as any); + vi.mocked(ProviderRegistry.getProvider).mockReturnValue(null); + vi.mocked(ProviderRegistry.getAllProviders).mockReturnValue([]); + }); + + it('auto-selects litellm and passes SetupContext (including codeMieUrl) to getCredentials when enforcement detected', async () => { + // Arrange: gate returns enforced + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockResolvedValue({ + success: true, + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' } + }); + vi.mocked(authHelpers.selectCodeMieProject).mockResolvedValue({ + project: 'my-proj', + userEmail: 'u@x.com' + }); + vi.mocked(ssoClient.fetchCodeMieIntegrations).mockResolvedValue([ + { id: 'i1', alias: 'forced-int', project_name: 'my-proj', credential_type: 'LiteLLM' } + ]); + mockGetCredentials.mockResolvedValue({ baseUrl: 'http://litellm', apiKey: 'sk-enforced' }); + + // inquirer.prompt sequence: storage → manualModel → profileName (switch skipped: active===profile) + vi.mocked(inquirerMod.default.prompt) + .mockResolvedValueOnce({ storage: 'global' }) + .mockResolvedValueOnce({ manualModel: 'gpt-4-turbo' }) + .mockResolvedValueOnce({ newProfileName: 'my-profile' }); + + // Act — drive through the module boundary (createSetupCommand), not a test-only export + const command = setupModule.createSetupCommand(); + await command.parseAsync([], { from: 'user' }); + + // Assert: litellm was selected (ProviderRegistry.getSetupSteps was called with 'litellm') + expect(ProviderRegistry.getSetupSteps).toHaveBeenCalledWith('litellm'); + + // Assert: getCredentials received SetupContext with enforcedIntegration. + // Explicitly assert codeMieUrl so a regression to authResult.apiUrl (CR-004) is caught here. + expect(mockGetCredentials).toHaveBeenCalledWith( + false, + expect.objectContaining({ + enforcedIntegration: expect.objectContaining({ + alias: 'forced-int', + codeMieUrl: 'https://codemie.example.com' + }) + }) + ); + }); + + it('uses normal provider prompt and calls getCredentials without enforcement when not enforced', async () => { + // Arrange: gate returns not-enforced (auth throws → graceful fallback) + vi.mocked(authHelpers.promptForCodeMieUrl).mockResolvedValue('https://codemie.example.com'); + vi.mocked(authHelpers.authenticateWithCodeMie).mockRejectedValue(new Error('SSO unavailable')); + mockGetCredentials.mockResolvedValue({ baseUrl: 'http://litellm', apiKey: 'not-required' }); + + // inquirer.prompt sequence: storage → provider → manualModel → profileName + vi.mocked(inquirerMod.default.prompt) + .mockResolvedValueOnce({ storage: 'global' }) + .mockResolvedValueOnce({ provider: 'litellm' }) + .mockResolvedValueOnce({ manualModel: 'gpt-4-turbo' }) + .mockResolvedValueOnce({ newProfileName: 'my-profile' }); + + // Act + const command = setupModule.createSetupCommand(); + await command.parseAsync([], { from: 'user' }); + + // Assert: getCredentials called WITHOUT enforcedIntegration context + expect(mockGetCredentials).toHaveBeenCalledWith(false, undefined); + }); + + it('handles ExitPromptError from the enforcement gate cleanly — no getCredentials call, no raw stack', async () => { + // Arrange: user hits Ctrl+C during promptForCodeMieUrl inside the gate. + vi.mocked(authHelpers.promptForCodeMieUrl).mockRejectedValue(new ExitPromptError()); + + // Storage prompt still resolves normally before the gate runs. + vi.mocked(inquirerMod.default.prompt).mockResolvedValueOnce({ storage: 'global' }); + + // Act — must not throw out of the wizard; ExitPromptError should be caught, + // "Setup cancelled." printed, and the wizard should return. + const command = setupModule.createSetupCommand(); + await expect(command.parseAsync([], { from: 'user' })).resolves.toBeDefined(); + + // Assert: setup did NOT proceed past the enforcement gate. + expect(mockGetCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index cbefb713..2b577372 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -17,6 +17,66 @@ import { AgentRegistry } from '../../agents/registry.js'; import type { VersionCompatibilityResult } from '../../agents/core/types.js'; import { createAssistantsSetupCommand } from './assistants/setup/index.js'; import { createSkillsSetupCommand } from './skills/setup/index.js'; +import { + DEFAULT_CODEMIE_BASE_URL, + promptForCodeMieUrl, + authenticateWithCodeMie, + selectCodeMieProject +} from '../../providers/core/codemie-auth-helpers.js'; +import { fetchCodeMieIntegrations } from '../../providers/plugins/sso/sso.http-client.js'; +import type { CodeMieIntegration, SSOAuthResult, SetupContext } from '../../providers/core/types.js'; + +interface LiteLLMEnforcementContext { + integration: CodeMieIntegration; + project: string; + authResult: SSOAuthResult; + codeMieUrl: string; +} + +export type EnforcementGateResult = + | { enforced: false } + | (LiteLLMEnforcementContext & { enforced: true }); + +export async function detectLiteLLMEnforcement(existingCodeMieUrl?: string): Promise { + try { + const codeMieUrl = await promptForCodeMieUrl(existingCodeMieUrl || DEFAULT_CODEMIE_BASE_URL); + const authResult = await authenticateWithCodeMie(codeMieUrl); + if (!authResult.success || !authResult.apiUrl || !authResult.cookies) { + throw new Error(authResult.error || 'SSO authentication failed'); + } + const { project } = await selectCodeMieProject(authResult); + const allIntegrations = await fetchCodeMieIntegrations(authResult.apiUrl, authResult.cookies); + const projectIntegrations = allIntegrations.filter( + i => i.project_name === project && i.credential_type === 'LiteLLM' + ); + if (projectIntegrations.length === 0) return { enforced: false }; + if (projectIntegrations.length > 1) { + logger.warn(`Multiple LiteLLM integrations found for project "${project}". Using "${projectIntegrations[0].alias}".`); + } + return { enforced: true, integration: projectIntegrations[0], project, authResult, codeMieUrl }; + } catch (error) { + if (isPromptAbortError(error)) { + throw error; + } + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn(`Could not check for mandatory integrations: ${errorMessage}`); + console.log(chalk.yellow(`\n⚠️ Could not check for mandatory integrations (${errorMessage}). Continuing with normal provider setup.\n`)); + return { enforced: false }; + } +} + +/** + * Detect an inquirer prompt abort (Ctrl+C during a prompt). + * + * Uses `instanceof Error` narrowing rather than an `any` cast so the check + * complies with the repo-wide no-any policy. + */ +function isPromptAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'ExitPromptError' || error.name === 'AbortPromptError') + ); +} export function createSetupCommand(): Command { @@ -196,21 +256,64 @@ async function runSetupWizard(force?: boolean): Promise { } } - // Step 1: Get all registered providers from ProviderRegistry - const registeredProviders = ProviderRegistry.getAllProviders(); - const allProviderChoices = getAllProviderChoices(registeredProviders); + // Step 1: Check for mandatory LiteLLM integration before provider selection + // + // On update flows, thread the existing profile's stored portal URL so users + // are not re-prompted for a URL they already configured. On fresh setup + // (no profile selected yet) `existingCodeMieUrl` stays undefined and + // `detectLiteLLMEnforcement` falls back to `DEFAULT_CODEMIE_BASE_URL`. + let existingCodeMieUrl: string | undefined; + if (isUpdate && profileName) { + const existingProfile = await ConfigLoader.getProfile(profileName); + existingCodeMieUrl = existingProfile?.codeMieUrl; + } - const { provider } = await inquirer.prompt([ - { - type: 'list', - name: 'provider', - message: 'Choose your LLM provider:\n', - choices: allProviderChoices, - pageSize: 15, - // Default to highest priority provider (SSO has priority 0) - default: allProviderChoices[0]?.value + let enforcementResult: EnforcementGateResult; + try { + enforcementResult = await detectLiteLLMEnforcement(existingCodeMieUrl); + } catch (error) { + // Ctrl+C during the enforcement gate's SSO prompts should exit cleanly, + // not surface as a raw stack trace via the Commander action handler. + if (isPromptAbortError(error)) { + console.log(chalk.yellow('\nSetup cancelled.\n')); + return; } - ]); + throw error; + } + + let provider: string; + let enforcementContext: LiteLLMEnforcementContext | undefined; + + if (enforcementResult.enforced) { + const litellmSteps = ProviderRegistry.getSetupSteps('litellm'); + if (!litellmSteps) { + throw new Error('LiteLLM integration is required for this project but the LiteLLM provider is not available. Please reinstall codemie-cli.'); + } + provider = 'litellm'; + enforcementContext = { + integration: enforcementResult.integration, + project: enforcementResult.project, + authResult: enforcementResult.authResult, + codeMieUrl: enforcementResult.codeMieUrl + }; + console.log(chalk.cyan(`\n📌 This project uses a mandatory LiteLLM integration: "${enforcementResult.integration.alias}"\n Provider has been set to LiteLLM automatically.\n`)); + } else { + // Step 1b: Normal provider selection + const registeredProviders = ProviderRegistry.getAllProviders(); + const allProviderChoices = getAllProviderChoices(registeredProviders); + + const { provider: selectedProvider } = await inquirer.prompt([ + { + type: 'list', + name: 'provider', + message: 'Choose your LLM provider:\n', + choices: allProviderChoices, + pageSize: 15, + default: allProviderChoices[0]?.value + } + ]); + provider = selectedProvider; + } // Get setup steps from provider registry const setupSteps = ProviderRegistry.getSetupSteps(provider); @@ -220,7 +323,7 @@ async function runSetupWizard(force?: boolean): Promise { } // Use plugin-based setup flow - await handlePluginSetup(provider, setupSteps, profileName, isUpdate, storageLocation); + await handlePluginSetup(provider, setupSteps, profileName, isUpdate, storageLocation, enforcementContext); } /** @@ -233,7 +336,8 @@ async function handlePluginSetup( setupSteps: any, profileName: string | null, isUpdate: boolean, - storageLocation: 'global' | 'local' = 'global' + storageLocation: 'global' | 'local' = 'global', + enforcementContext?: LiteLLMEnforcementContext ): Promise { try { const providerTemplate = ProviderRegistry.getProvider(providerName); @@ -243,8 +347,17 @@ async function handlePluginSetup( displaySetupInstructions(providerTemplate); } - // Step 1: Get credentials - const credentials = await setupSteps.getCredentials(isUpdate); + // Step 1: Get credentials — pass SetupContext when LiteLLM enforcement is active + const setupContext: SetupContext | undefined = enforcementContext + ? { + enforcedIntegration: { + id: enforcementContext.integration.id, + alias: enforcementContext.integration.alias, + codeMieUrl: enforcementContext.codeMieUrl + } + } + : undefined; + const credentials = await setupSteps.getCredentials(isUpdate, setupContext); // Step 2: Fetch models const modelsSpinner = ora('Fetching available models...').start(); diff --git a/src/providers/core/types.ts b/src/providers/core/types.ts index 66540f09..cbef1215 100644 --- a/src/providers/core/types.ts +++ b/src/providers/core/types.ts @@ -271,6 +271,18 @@ export interface ProviderCredentials { additionalConfig?: Record; } +/** + * Context passed from the setup wizard into provider setup steps. + * When enforcedIntegration is set, the provider must enforce API key entry. + */ +export interface SetupContext { + enforcedIntegration?: { + id: string; + alias: string; + codeMieUrl: string; + }; +} + /** * Validation result */ @@ -296,7 +308,7 @@ export interface ProviderSetupSteps { * * Interactive prompts for API keys, URLs, etc. */ - getCredentials(isUpdate?: boolean): Promise; + getCredentials(isUpdate?: boolean, context?: SetupContext): Promise; /** * Step 2: Fetch available models diff --git a/src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts b/src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts new file mode 100644 index 00000000..2f3a136b --- /dev/null +++ b/src/providers/plugins/litellm/__tests__/litellm.setup-steps.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SetupContext } from '../../../core/types.js'; +import { LiteLLMSetupSteps } from '../litellm.setup-steps.js'; + +vi.mock('inquirer', () => ({ + default: { + prompt: vi.fn().mockResolvedValue({ baseUrl: 'http://localhost:4000', apiKey: '' }) + } +})); + +vi.mock('chalk', () => ({ + default: { + cyan: (s: string) => s, + dim: (s: string) => s + } +})); + +describe('SetupContext type', () => { + it('is accepted by getCredentials without breaking the normal call', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ baseUrl: 'http://localhost:4000', apiKey: '' }); + + const context: SetupContext = {}; + const result = await LiteLLMSetupSteps.getCredentials(false, context); + expect(result.baseUrl).toBe('http://localhost:4000'); + }); +}); + +describe('LiteLLMSetupSteps.getCredentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('normal mode (no context)', () => { + it('allows empty API key — defaults to "not-required"', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://localhost:4000', + apiKey: '' + }); + + const result = await LiteLLMSetupSteps.getCredentials(); + expect(result.apiKey).toBe('not-required'); + }); + + it('preserves a provided API key', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://localhost:4000', + apiKey: 'sk-abc123' + }); + + const result = await LiteLLMSetupSteps.getCredentials(); + expect(result.apiKey).toBe('sk-abc123'); + }); + }); + + describe('enforcement mode (context.enforcedIntegration set)', () => { + const enforcedContext: SetupContext = { + enforcedIntegration: { + id: 'int-1', + alias: 'my-integration', + codeMieUrl: 'https://codemie.example.com' + } + }; + + it('returns credentials with provided key when key is non-empty', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://proxy.example.com', + apiKey: 'sk-enforced-key' + }); + + const result = await LiteLLMSetupSteps.getCredentials(false, enforcedContext); + expect(result.apiKey).toBe('sk-enforced-key'); + expect(result.baseUrl).toBe('http://proxy.example.com'); + }); + + it('does not fall back to "not-required" in enforcement mode', async () => { + const inquirer = await import('inquirer'); + vi.mocked(inquirer.default.prompt).mockResolvedValueOnce({ + baseUrl: 'http://proxy.example.com', + apiKey: 'required-key' + }); + + const result = await LiteLLMSetupSteps.getCredentials(false, enforcedContext); + expect(result.apiKey).not.toBe('not-required'); + expect(result.apiKey).toBe('required-key'); + }); + }); +}); diff --git a/src/providers/plugins/litellm/litellm.setup-steps.ts b/src/providers/plugins/litellm/litellm.setup-steps.ts index 00bb6194..5d123697 100644 --- a/src/providers/plugins/litellm/litellm.setup-steps.ts +++ b/src/providers/plugins/litellm/litellm.setup-steps.ts @@ -4,14 +4,24 @@ * Interactive setup flow for LiteLLM provider. */ -import type { ProviderSetupSteps, ProviderCredentials } from '../../core/types.js'; +import type { ProviderSetupSteps, ProviderCredentials, SetupContext } from '../../core/types.js'; import { LiteLLMTemplate } from './litellm.template.js'; import inquirer from 'inquirer'; export const LiteLLMSetupSteps: ProviderSetupSteps = { name: 'litellm', - async getCredentials(_isUpdate = false): Promise { + async getCredentials(_isUpdate = false, context?: SetupContext): Promise { + const enforced = context?.enforcedIntegration; + + // No dedicated enforcement banner here — the spec-mandated `📌` banner is + // printed once by the setup wizard before this step runs. Surface the + // portal URL directly in the API-key prompt and validator so the user has + // a concrete link to reach the credential. + const portalHint = enforced?.codeMieUrl + ? ` — retrieve it from ${enforced.codeMieUrl}` + : ''; + const answers = await inquirer.prompt([ { type: 'input', @@ -23,14 +33,23 @@ export const LiteLLMSetupSteps: ProviderSetupSteps = { { type: 'password', name: 'apiKey', - message: 'API Key (optional, leave empty if not required):', - mask: '*' + message: enforced + ? `API Key for integration "${enforced.alias}" (required)${portalHint}:` + : 'API Key (optional, leave empty if not required):', + mask: '*', + validate: enforced + ? (input: string) => + input.trim() !== '' || + `API Key is required for this integration${portalHint || ' — retrieve it from your CodeMie portal'}.` + : undefined } ]); + const key = answers.apiKey?.trim(); + if (enforced && !key) throw new Error('API Key is required for this integration.'); return { baseUrl: answers.baseUrl.trim(), - apiKey: answers.apiKey?.trim() || 'not-required' + apiKey: enforced ? key : (key || 'not-required') }; }, @@ -46,7 +65,6 @@ export const LiteLLMSetupSteps: ProviderSetupSteps = { const models = await modelProxy.listModels(); return models.map(m => m.id); } catch { - // If fetch fails, return recommended models return LiteLLMTemplate.recommendedModels; } },