feat: add guided /provider setup
This commit is contained in:
12
src/commands/provider/index.ts
Normal file
12
src/commands/provider/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
|
||||
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'provider',
|
||||
description: 'Set up and save a third-party provider profile for OpenClaude',
|
||||
get immediate() {
|
||||
return shouldInferenceConfigCommandBeImmediate()
|
||||
},
|
||||
load: () => import('./provider.js'),
|
||||
} satisfies Command
|
||||
228
src/commands/provider/provider.test.tsx
Normal file
228
src/commands/provider/provider.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { expect, test } from 'bun:test'
|
||||
import React from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { createRoot, render, useApp } from '../../ink.js'
|
||||
import { AppStateProvider } from '../../state/AppState.js'
|
||||
import {
|
||||
buildCurrentProviderSummary,
|
||||
buildProfileSaveMessage,
|
||||
getProviderWizardDefaults,
|
||||
TextEntryDialog,
|
||||
} from './provider.js'
|
||||
|
||||
const SYNC_START = '\x1B[?2026h'
|
||||
const SYNC_END = '\x1B[?2026l'
|
||||
|
||||
function extractLastFrame(output: string): string {
|
||||
let lastFrame: string | null = null
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < output.length) {
|
||||
const start = output.indexOf(SYNC_START, cursor)
|
||||
if (start === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
const contentStart = start + SYNC_START.length
|
||||
const end = output.indexOf(SYNC_END, contentStart)
|
||||
if (end === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
const frame = output.slice(contentStart, end)
|
||||
if (frame.trim().length > 0) {
|
||||
lastFrame = frame
|
||||
}
|
||||
cursor = end + SYNC_END.length
|
||||
}
|
||||
|
||||
return lastFrame ?? output
|
||||
}
|
||||
|
||||
async function renderFinalFrame(node: React.ReactNode): Promise<string> {
|
||||
let output = ''
|
||||
const { stdout, stdin, getOutput } = createTestStreams()
|
||||
|
||||
const instance = await render(node, {
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
|
||||
await instance.waitUntilExit()
|
||||
return stripAnsi(extractLastFrame(getOutput()))
|
||||
}
|
||||
|
||||
function createTestStreams(): {
|
||||
stdout: PassThrough
|
||||
stdin: PassThrough & {
|
||||
isTTY: boolean
|
||||
setRawMode: (mode: boolean) => void
|
||||
ref: () => void
|
||||
unref: () => void
|
||||
}
|
||||
getOutput: () => string
|
||||
} {
|
||||
let output = ''
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough() as PassThrough & {
|
||||
isTTY: boolean
|
||||
setRawMode: (mode: boolean) => void
|
||||
ref: () => void
|
||||
unref: () => void
|
||||
}
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => {}
|
||||
stdin.ref = () => {}
|
||||
stdin.unref = () => {}
|
||||
;(stdout as unknown as { columns: number }).columns = 120
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stdin,
|
||||
getOutput: () => output,
|
||||
}
|
||||
}
|
||||
|
||||
function StepChangeHarness(): React.ReactNode {
|
||||
const { exit } = useApp()
|
||||
const [step, setStep] = React.useState<'api' | 'model'>('api')
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (step === 'api') {
|
||||
setStep('model')
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(exit, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [exit, step])
|
||||
|
||||
return (
|
||||
<AppStateProvider>
|
||||
<TextEntryDialog
|
||||
title="Provider"
|
||||
subtitle={step === 'api' ? 'API key step' : 'Model step'}
|
||||
description="Enter the next value"
|
||||
initialValue={step === 'api' ? 'stale-secret-key' : 'fresh-model-name'}
|
||||
mask={step === 'api' ? '*' : undefined}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</AppStateProvider>
|
||||
)
|
||||
}
|
||||
|
||||
test('TextEntryDialog resets its input state when initialValue changes', async () => {
|
||||
const output = await renderFinalFrame(<StepChangeHarness />)
|
||||
|
||||
expect(output).toContain('Model step')
|
||||
expect(output).toContain('fresh-model-name')
|
||||
expect(output).not.toContain('stale-secret-key')
|
||||
})
|
||||
|
||||
test('wizard step remount prevents a typed API key from leaking into the next field', async () => {
|
||||
const { stdout, stdin, getOutput } = createTestStreams()
|
||||
const root = await createRoot({
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<TextEntryDialog
|
||||
resetStateKey="api"
|
||||
title="Provider"
|
||||
subtitle="API key step"
|
||||
description="Enter the API key"
|
||||
initialValue=""
|
||||
mask="*"
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
|
||||
await Bun.sleep(25)
|
||||
stdin.write('sk-secret-12345678')
|
||||
await Bun.sleep(25)
|
||||
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<TextEntryDialog
|
||||
resetStateKey="model"
|
||||
title="Provider"
|
||||
subtitle="Model step"
|
||||
description="Enter the model"
|
||||
initialValue=""
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
|
||||
await Bun.sleep(25)
|
||||
root.unmount()
|
||||
stdin.end()
|
||||
stdout.end()
|
||||
await Bun.sleep(25)
|
||||
|
||||
const output = stripAnsi(extractLastFrame(getOutput()))
|
||||
expect(output).toContain('Model step')
|
||||
expect(output).not.toContain('sk-secret-12345678')
|
||||
})
|
||||
|
||||
test('buildProfileSaveMessage maps provider fields without echoing secrets', () => {
|
||||
const message = buildProfileSaveMessage(
|
||||
'openai',
|
||||
{
|
||||
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
},
|
||||
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
|
||||
)
|
||||
|
||||
expect(message).toContain('Saved OpenAI-compatible profile.')
|
||||
expect(message).toContain('Model: gpt-4o')
|
||||
expect(message).toContain('Endpoint: https://api.openai.com/v1')
|
||||
expect(message).toContain('Credentials: configured')
|
||||
expect(message).not.toContain('sk-secret-12345678')
|
||||
})
|
||||
|
||||
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
||||
const summary = buildCurrentProviderSummary({
|
||||
processEnv: {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||
OPENAI_MODEL: 'sk-secret-12345678',
|
||||
OPENAI_BASE_URL: 'sk-secret-12345678',
|
||||
},
|
||||
persisted: null,
|
||||
})
|
||||
|
||||
expect(summary.providerLabel).toBe('OpenAI-compatible')
|
||||
expect(summary.modelLabel).toBe('sk-...5678')
|
||||
expect(summary.endpointLabel).toBe('sk-...5678')
|
||||
})
|
||||
|
||||
test('getProviderWizardDefaults ignores poisoned current provider values', () => {
|
||||
const defaults = getProviderWizardDefaults({
|
||||
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||
OPENAI_MODEL: 'sk-secret-12345678',
|
||||
OPENAI_BASE_URL: 'sk-secret-12345678',
|
||||
GEMINI_API_KEY: 'AIzaSecret12345678',
|
||||
GEMINI_MODEL: 'AIzaSecret12345678',
|
||||
})
|
||||
|
||||
expect(defaults.openAIModel).toBe('gpt-4o')
|
||||
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
|
||||
expect(defaults.geminiModel).toBe('gemini-2.0-flash')
|
||||
})
|
||||
1148
src/commands/provider/provider.tsx
Normal file
1148
src/commands/provider/provider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user