feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970)
- Feature Nous Portal as the primary onboarding card (Recommended tag, app logo, single pitch line); collapse other OAuth providers behind an "Other providers" disclosure whose open/closed state persists. - Surface OpenRouter as a one-click API-key option inside the disclosure; move "I have an API key" to a quiet bottom-right link. - Treat "no provider configured" as a normal onboarding state, not a red error banner (provider-setup-errors copy match). - Fix setup.runtime_check: it reported ready when the resolved runtime had an empty credential or only implicit Bedrock/IAM, so fresh installs never saw onboarding. Now requires a usable credential. - Auto-wire Windows fonts for WSL2 users so the renderer renders real Segoe UI instead of the DejaVu fallback; make WSL detection env-independent via the /proc kernel marker.
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
function isWslEnvironment(env = process.env, platform = process.platform) {
|
const fs = require('node:fs')
|
||||||
|
|
||||||
|
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
|
||||||
if (platform !== 'linux') return false
|
if (platform !== 'linux') return false
|
||||||
return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
|
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
|
||||||
|
return /microsoft|wsl/i.test(release)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsBinaryPathInWsl(filePath, options = {}) {
|
function isWindowsBinaryPathInWsl(filePath, options = {}) {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment }
|
|||||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||||
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
|
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
|
||||||
assert.equal(isWslEnvironment({}, 'linux'), false)
|
assert.equal(isWslEnvironment({}, 'linux', '6.6.87.2-microsoft-standard-WSL2'), true)
|
||||||
|
assert.equal(isWslEnvironment({}, 'linux', '6.6.87-generic'), false)
|
||||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
|
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -490,6 +490,44 @@ function openExternalUrl(rawUrl) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureWslWindowsFonts() {
|
||||||
|
if (!IS_WSL) return
|
||||||
|
|
||||||
|
const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(candidate).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!fontsDir) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d')
|
||||||
|
const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf')
|
||||||
|
let existing = ''
|
||||||
|
try {
|
||||||
|
existing = fs.readFileSync(confPath, 'utf8')
|
||||||
|
} catch {
|
||||||
|
existing = ''
|
||||||
|
}
|
||||||
|
if (existing.includes(fontsDir)) return
|
||||||
|
|
||||||
|
fs.mkdirSync(confDir, { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
confPath,
|
||||||
|
`<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>${fontsDir}</dir>\n</fontconfig>\n`
|
||||||
|
)
|
||||||
|
rememberLog(`[fonts] wired WSL Windows fonts for renderer: ${fontsDir}`)
|
||||||
|
|
||||||
|
const cache = spawn('fc-cache', ['-f', fontsDir], { detached: true, stdio: 'ignore' })
|
||||||
|
cache.on('error', () => undefined)
|
||||||
|
cache.unref()
|
||||||
|
} catch (error) {
|
||||||
|
rememberLog(`[fonts] WSL font setup skipped: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -1341,9 +1379,7 @@ function resolveHermesBackend(dashboardArgs) {
|
|||||||
shell: false
|
shell: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rememberLog(
|
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
|
||||||
`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Nothing usable yet -- signal the bootstrap runner that we need to
|
// 6. Nothing usable yet -- signal the bootstrap runner that we need to
|
||||||
@@ -3296,6 +3332,7 @@ app.whenReady().then(() => {
|
|||||||
Menu.setApplicationMenu(null)
|
Menu.setApplicationMenu(null)
|
||||||
}
|
}
|
||||||
installMediaPermissions()
|
installMediaPermissions()
|
||||||
|
ensureWslWindowsFonts()
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { OAuthProvider } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
|
||||||
|
|
||||||
|
import { Picker } from './desktop-onboarding-overlay'
|
||||||
|
|
||||||
|
function provider(id: string, name = id): OAuthProvider {
|
||||||
|
return {
|
||||||
|
cli_command: `hermes login ${id}`,
|
||||||
|
docs_url: `https://example.com/${id}`,
|
||||||
|
flow: 'pkce',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
status: { logged_in: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProviders(providers: OAuthProvider[]) {
|
||||||
|
$desktopOnboarding.set({
|
||||||
|
configured: false,
|
||||||
|
flow: { status: 'idle' },
|
||||||
|
mode: 'oauth',
|
||||||
|
providers,
|
||||||
|
reason: null,
|
||||||
|
requested: false
|
||||||
|
} satisfies DesktopOnboardingState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: OnboardingContext = { requestGateway: async () => undefined as never }
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
$desktopOnboarding.set({
|
||||||
|
configured: null,
|
||||||
|
flow: { status: 'idle' },
|
||||||
|
mode: 'oauth',
|
||||||
|
providers: null,
|
||||||
|
reason: null,
|
||||||
|
requested: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onboarding Picker', () => {
|
||||||
|
it('features Nous Portal and hides other providers behind a disclosure', () => {
|
||||||
|
setProviders([provider('anthropic', 'Anthropic Claude'), provider('nous', 'Nous Portal')])
|
||||||
|
render(<Picker ctx={ctx} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Nous Portal')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Recommended')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('Anthropic Claude')).toBeNull()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Other providers' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows every provider directly when Nous Portal is absent', () => {
|
||||||
|
setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')])
|
||||||
|
render(<Picker ctx={ctx} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
|
||||||
|
expect(screen.getByText('OpenAI Codex / ChatGPT')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('Other sign-in options')).toBeNull()
|
||||||
|
expect(screen.queryByText('Recommended')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,17 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { ModelPickerDialog } from '@/components/model-picker'
|
import { ModelPickerDialog } from '@/components/model-picker'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Check, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Terminal
|
||||||
|
} from '@/lib/icons'
|
||||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
||||||
@@ -99,9 +109,9 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
||||||
pkce: 'Opens your browser to sign in, then continues here.',
|
pkce: 'Opens your browser to sign in, then continues here',
|
||||||
device_code: 'Opens a verification page in your browser. Hermes connects automatically.',
|
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
||||||
external: 'Sign in once in your terminal, then come back to chat.'
|
external: 'Sign in once in your terminal, then come back to chat'
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||||
@@ -147,7 +157,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
|||||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||||
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="grid gap-5 p-6">
|
<div className="grid gap-3 p-5">
|
||||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||||
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,13 +219,13 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-6 py-5">
|
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
|
||||||
<Sparkles className="size-5" />
|
<Sparkles className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Welcome to Hermes</h2>
|
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Let's get you setup with Hermes Agent</h2>
|
||||||
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||||
Connect a model provider to start chatting. Most options take one click.
|
Connect a model provider to start chatting. Most options take one click.
|
||||||
</p>
|
</p>
|
||||||
@@ -225,8 +235,31 @@ function Header() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Picker({ ctx }: { ctx: OnboardingContext }) {
|
const FEATURED_ID = 'nous'
|
||||||
|
const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
|
||||||
|
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
|
||||||
|
|
||||||
|
const readShowAll = () => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SHOW_ALL_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistShowAll = (value: boolean) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SHOW_ALL_KEY, value ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable — degrade silently.
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||||
const { mode, providers } = useStore($desktopOnboarding)
|
const { mode, providers } = useStore($desktopOnboarding)
|
||||||
|
const [showAll, setShowAll] = useState(readShowAll)
|
||||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||||
const hasOauth = ordered.length > 0
|
const hasOauth = ordered.length > 0
|
||||||
|
|
||||||
@@ -234,42 +267,123 @@ function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
|
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (providers === null) {
|
||||||
|
return <Status>Looking up providers...</Status>
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
|
||||||
|
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||||
|
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||||
|
// Collapse the secondary providers behind a disclosure only when Nous
|
||||||
|
// Portal is present to anchor the choice — otherwise show the full list.
|
||||||
|
const collapsible = Boolean(featured) && rest.length > 0
|
||||||
|
const showRest = !collapsible || showAll
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-2">
|
||||||
{providers === null ? (
|
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
|
||||||
<Status>Looking up providers...</Status>
|
{showRest ? (
|
||||||
) : (
|
<>
|
||||||
ordered.map(provider => (
|
{rest.map(p => (
|
||||||
<ProviderRow key={provider.id} onSelect={p => void startProviderOAuth(p, ctx)} provider={provider} />
|
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||||
))
|
))}
|
||||||
)}
|
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
|
||||||
<FooterLink onClick={() => setOnboardingMode('apikey')}>I have an API key</FooterLink>
|
</>
|
||||||
|
) : null}
|
||||||
|
{collapsible ? (
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||||
|
onClick={() => setShowAll(persistShowAll(!showAll))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showAll ? 'Collapse' : 'Other providers'}
|
||||||
|
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setOnboardingMode('apikey')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
I have an API key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FooterLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
function FeaturedProviderRow({
|
||||||
|
onSelect,
|
||||||
|
provider
|
||||||
|
}: {
|
||||||
|
onSelect: (provider: OAuthProvider) => void
|
||||||
|
provider: OAuthProvider
|
||||||
|
}) {
|
||||||
|
const loggedIn = provider.status?.logged_in
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-2 text-center">
|
<button
|
||||||
<button
|
className={cn(
|
||||||
className="text-sm font-semibold text-foreground underline-offset-4 decoration-current/20 hover:underline"
|
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
|
||||||
onClick={onClick}
|
loggedIn && 'border-primary'
|
||||||
type="button"
|
)}
|
||||||
>
|
onClick={() => onSelect(provider)}
|
||||||
{children}
|
type="button"
|
||||||
</button>
|
>
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img alt="" className="size-5 shrink-0 rounded" src="/apple-touch-icon.png" />
|
||||||
|
<span className="text-base font-semibold">{providerTitle(provider)}</span>
|
||||||
|
{loggedIn ? (
|
||||||
|
<ConnectedTag />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
|
||||||
|
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectedTag() {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
<Check className="size-3" />
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-semibold">OpenRouter</span>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
|
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
|
||||||
const loggedIn = provider.status?.logged_in
|
const loggedIn = provider.status?.logged_in
|
||||||
const Trail = provider.flow === 'external' ? ExternalLink : ChevronRight
|
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-4 text-left transition hover:border-primary/40 hover:bg-accent/40',
|
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
|
||||||
loggedIn && 'border-primary/30'
|
loggedIn && 'border-primary/30'
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect(provider)}
|
onClick={() => onSelect(provider)}
|
||||||
@@ -278,12 +392,7 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
|
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
|
||||||
{loggedIn ? (
|
{loggedIn ? <ConnectedTag /> : null}
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
|
||||||
<Check className="size-3" />
|
|
||||||
Connected
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ describe('isProviderSetupErrorMessage', () => {
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true)
|
expect(isProviderSetupErrorMessage('No inference provider is configured.')).toBe(true)
|
||||||
|
expect(isProviderSetupErrorMessage('No Hermes provider is configured.')).toBe(true)
|
||||||
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
|
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const PROVIDER_SETUP_ERROR_RE =
|
const PROVIDER_SETUP_ERROR_RE =
|
||||||
/No inference provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i
|
/No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i
|
||||||
|
|
||||||
export function isProviderSetupErrorMessage(message: null | string | undefined): boolean {
|
export function isProviderSetupErrorMessage(message: null | string | undefined): boolean {
|
||||||
const text = message?.trim()
|
const text = message?.trim()
|
||||||
|
|||||||
@@ -1615,6 +1615,57 @@ def test_setup_status_reports_provider_config(monkeypatch):
|
|||||||
assert resp["result"]["provider_configured"] is False
|
assert resp["result"]["provider_configured"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_runtime_check_rejects_empty_runtime_key(monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||||
|
lambda requested=None: {
|
||||||
|
"provider": "openrouter",
|
||||||
|
"api_key": "",
|
||||||
|
"source": "env/config",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
|
||||||
|
|
||||||
|
assert resp["result"]["ok"] is False
|
||||||
|
assert resp["result"]["provider"] == "openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_runtime_check_allows_no_key_custom_runtime(monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||||
|
lambda requested=None: {
|
||||||
|
"provider": "custom",
|
||||||
|
"api_key": "no-key-required",
|
||||||
|
"source": "env/config",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
|
||||||
|
|
||||||
|
assert resp["result"]["ok"] is True
|
||||||
|
assert resp["result"]["provider"] == "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||||
|
lambda requested=None: {
|
||||||
|
"provider": "bedrock",
|
||||||
|
"api_key": "aws-sdk",
|
||||||
|
"source": "iam-role",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}})
|
||||||
|
|
||||||
|
assert resp["result"]["ok"] is False
|
||||||
|
assert resp["result"]["provider"] == "bedrock"
|
||||||
|
|
||||||
|
|
||||||
def test_complete_slash_includes_provider_alias():
|
def test_complete_slash_includes_provider_alias():
|
||||||
resp = server.handle_request(
|
resp = server.handle_request(
|
||||||
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
|
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
|
||||||
|
|||||||
@@ -5263,8 +5263,49 @@ def _(rid, params: dict) -> dict:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
|
from hermes_cli.auth import has_usable_secret
|
||||||
|
from hermes_cli.main import _has_any_provider_configured
|
||||||
|
|
||||||
runtime = resolve_runtime_provider(requested=None)
|
runtime = resolve_runtime_provider(requested=None)
|
||||||
|
provider_configured = bool(_has_any_provider_configured())
|
||||||
|
provider = runtime.get("provider") or "provider"
|
||||||
|
source = str(runtime.get("source") or "")
|
||||||
|
if not provider_configured and provider == "bedrock" and source in {
|
||||||
|
"iam-role",
|
||||||
|
"aws-sdk-default-chain",
|
||||||
|
}:
|
||||||
|
return _ok(
|
||||||
|
rid,
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"provider": provider,
|
||||||
|
"model": runtime.get("model"),
|
||||||
|
"source": source,
|
||||||
|
"error": "No Hermes provider is configured.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = runtime.get("api_key")
|
||||||
|
api_key_text = "" if callable(api_key) else str(api_key or "").strip()
|
||||||
|
credential_ok = (
|
||||||
|
callable(api_key)
|
||||||
|
or api_key_text in {"aws-sdk", "no-key-required"}
|
||||||
|
or has_usable_secret(api_key_text)
|
||||||
|
or bool(runtime.get("command"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not credential_ok:
|
||||||
|
return _ok(
|
||||||
|
rid,
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"provider": provider,
|
||||||
|
"model": runtime.get("model"),
|
||||||
|
"source": runtime.get("source"),
|
||||||
|
"error": f"No usable credentials found for {provider}.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
rid,
|
rid,
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user