const ONBOARDING={status:null,step:0,steps:['system','setup','workspace','password','finish'],form:{provider:'openrouter',workspace:'',model:'',password:'',apiKey:'',baseUrl:''},active:false,probe:{status:'idle',error:null,detail:'',models:null,probedKey:''}}; // ── Onboarding base-URL probe (#1499) ─────────────────────────────────────── // Probes /models so the wizard can validate the configured endpoint // before persisting AND populate the model dropdown from the live catalog. // Probe state lives on ONBOARDING.probe; the dropdown render and the // nextOnboardingStep gate both consult it. let _onboardingProbeTimer=null; function _onboardingProbeKey(provider,baseUrl,apiKey){ return `${provider||''}|${(baseUrl||'').trim().replace(/\/+$/,'')}|${apiKey||''}`; } function _setOnboardingProbeState(patch){ ONBOARDING.probe={...ONBOARDING.probe,...patch}; // Re-render body so probe status / model dropdown reflect new state. _renderOnboardingBody(); } async function _runOnboardingProbe({force=false}={}){ const provider=ONBOARDING.form.provider; const cat=_getOnboardingSetupProvider(provider); if(!cat||!cat.requires_base_url){ _setOnboardingProbeState({status:'idle',error:null,detail:'',models:null,probedKey:''}); return ONBOARDING.probe; } const baseUrl=(ONBOARDING.form.baseUrl||'').trim(); if(!baseUrl){ _setOnboardingProbeState({status:'idle',error:null,detail:'',models:null,probedKey:''}); return ONBOARDING.probe; } const apiKey=(ONBOARDING.form.apiKey||'').trim(); const key=_onboardingProbeKey(provider,baseUrl,apiKey); if(!force&&ONBOARDING.probe.probedKey===key&&ONBOARDING.probe.status!=='probing'){ return ONBOARDING.probe; } _setOnboardingProbeState({status:'probing',error:null,detail:'',probedKey:key}); try{ const res=await api('/api/onboarding/probe',{method:'POST',body:JSON.stringify({provider,base_url:baseUrl,api_key:apiKey||undefined})}); if(res&&res.ok){ _setOnboardingProbeState({status:'ok',error:null,detail:'',models:Array.isArray(res.models)?res.models:[],probedKey:key}); // If the user hasn't picked a model yet (or their pick is no longer in // the list), default to the first probed model so Continue isn't blocked // on an empty selection. const stillPresent=ONBOARDING.form.model&&(res.models||[]).some(m=>m.id===ONBOARDING.form.model); if(!stillPresent&&(res.models||[]).length>0){ ONBOARDING.form.model=res.models[0].id; _renderOnboardingBody(); } }else{ const err=(res&&res.error)||'unreachable'; const detail=(res&&res.detail)||''; _setOnboardingProbeState({status:'error',error:err,detail,models:null,probedKey:key}); } }catch(e){ _setOnboardingProbeState({status:'error',error:'unreachable',detail:(e&&e.message)||String(e),models:null,probedKey:key}); } return ONBOARDING.probe; } function _scheduleOnboardingProbe(){ if(_onboardingProbeTimer)clearTimeout(_onboardingProbeTimer); _onboardingProbeTimer=setTimeout(()=>{_runOnboardingProbe();},400); } function _onboardingProbeMessage(probe){ if(!probe||probe.status==='idle')return ''; if(probe.status==='probing')return t('onboarding_probe_probing')||'Testing connection…'; if(probe.status==='ok'){ const n=(probe.models||[]).length; const tmpl=t('onboarding_probe_ok')||'Connected. {n} model(s) available.'; return tmpl.replace('{n}',String(n)); } // status === 'error' const errKey='onboarding_probe_error_'+probe.error; const localized=t(errKey); // i18n.js's `t()` returns the key itself when missing — fall back to a generic message. const heading=(localized&&localized!==errKey)?localized:(t('onboarding_probe_error_generic')||'Could not reach the configured base URL.'); const detail=probe.detail?` (${probe.detail})`:''; return heading+detail; } function _getOnboardingSetupProviders(){ return (((ONBOARDING.status||{}).setup||{}).providers)||[]; } function _getOnboardingSetupProvider(id){ return _getOnboardingSetupProviders().find(p=>p.id===id)||null; } function _getOnboardingSetupCategories(){ return (((ONBOARDING.status||{}).setup||{}).categories)||[]; } /** Render the provider
${banner}`; } function _renderOnboardingApiKeyField(){ // Renders the API-key input. For providers flagged `key_optional` in the // setup catalog (lmstudio, ollama, custom — typically self-hosted servers // that run keyless by default), the field shows an "(optional)" hint and // empty input is accepted on Continue. Pre-#1499-third-sub-bug-fix the // wizard required a non-empty string here even for keyless installs, which // forced users to type random gibberish to clear onboarding. const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider); const keyOptional=!!(provider&&provider.key_optional); const labelKey=keyOptional?'onboarding_api_key_label_optional':'onboarding_api_key_label'; const placeholderKey=keyOptional?'onboarding_api_key_placeholder_optional':'onboarding_api_key_placeholder'; const helpHtml=keyOptional?`

${esc(t('onboarding_api_key_help_keyless')||'')}

`:''; return `${helpHtml}`; } function _getOnboardingSelectedModel(){ return ONBOARDING.form.model||''; } function _renderOnboardingModelField(){ const choices=_getOnboardingProviderModelChoices(); if(ONBOARDING.form.provider==='custom'){ return `

${t('onboarding_custom_model_help')}

`; } const options=choices.map(m=>``).join(''); return `

${t('onboarding_workspace_help')}

`; } function _renderOnboardingProviderOAuthField(provider){ if(!provider||provider.oauth_provider!=='anthropic')return ''; return `
🔑
Use Claude Code OAuth instead

Claude Code subscription credentials are not the same as an Anthropic API key. Use this path only when you want Hermes to use Claude Code credentials already available on the server, or start a short polling flow while you complete claude setup-token on the host.

`; } function _providerStatusLabel(system){ if(system.chat_ready) return t('onboarding_check_provider_ready'); if(system.provider_configured) return t('onboarding_check_provider_partial'); return t('onboarding_check_provider_pending'); } function _renderOnboardingBody(){ const body=$('onboardingBody'); if(!body||!ONBOARDING.status)return; const key=ONBOARDING.steps[ONBOARDING.step]; const system=ONBOARDING.status.system||{}; const settings=ONBOARDING.status.settings||{}; const setup=ONBOARDING.status.setup||{}; const nextBtn=$('onboardingNextBtn'); const backBtn=$('onboardingBackBtn'); if(backBtn) backBtn.style.display=ONBOARDING.step>0?'':'none'; if(nextBtn) nextBtn.textContent=key==='finish'?t('onboarding_open'):t('onboarding_continue'); if(key==='system'){ const hermesOk=system.hermes_found&&system.imports_ok; const setupOk=!!system.chat_ready; _setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn')); body.innerHTML=`
${t('onboarding_check_agent')}${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}
${t('onboarding_check_provider')}${_providerStatusLabel(system)}
${t('onboarding_check_password')}${settings.password_enabled?t('onboarding_check_password_enabled'):t('onboarding_check_password_disabled')}

${t('onboarding_config_file')} ${esc(system.config_path||t('onboarding_unknown'))}

${t('onboarding_env_file')} ${esc(system.env_path||t('onboarding_unknown'))}

${esc(system.provider_note||'')}

${system.current_provider?`

${t('onboarding_current_provider')} ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}

`:''} ${system.current_base_url?`

${t('onboarding_base_url_label')} ${esc(system.current_base_url)}

`:''} ${system.missing_modules&&system.missing_modules.length?`

${t('onboarding_missing_imports')} ${esc(system.missing_modules.join(', '))}

`:''}
`; return; } if(key==='setup'){ const selectedId=ONBOARDING.form.provider; const groupedOptions=_renderProviderSelectOptions(selectedId); const provider=_getOnboardingSetupProvider(selectedId)||_getOnboardingSetupProviders()[0]||null; const showBaseUrl=provider&&provider.requires_base_url; const keyHelp=provider ? (provider.id==='anthropic' ? 'Anthropic API key path: paste an Anthropic Console API key here. This is separate from a Claude Code subscription; use the Claude Code OAuth card if you want subscription credentials instead.' : `${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`) : ''; // OAuth provider path: configured via CLI, no API key input needed. const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth; const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||''; if(currentIsOauth){ const isReady=!!(ONBOARDING.status.system||{}).chat_ready; const providerLabel=esc(currentProviderName); const codexOauthPendingBody=currentProviderName==='openai-codex' ? 'This instance is configured to use openai-codex, which uses OAuth rather than an API key. Use the button below to authenticate with ChatGPT, then continue once provider status refreshes.' : t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel); if(isReady){ _setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success'); body.innerHTML=`
${t('onboarding_oauth_provider_ready_title')}

${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}

${t('onboarding_oauth_switch_hint')}

${_renderOnboardingApiKeyField()} ${_renderOnboardingBaseUrlField(showBaseUrl)}

${keyHelp}

`; } else { _setOnboardingNotice(t('onboarding_notice_setup_required'),'warn'); body.innerHTML=`
${t('onboarding_oauth_provider_not_ready_title')}

${codexOauthPendingBody}

${currentProviderName==='openai-codex'?`
`:''}

${t('onboarding_oauth_switch_hint')}

${_renderOnboardingApiKeyField()} ${_renderOnboardingBaseUrlField(showBaseUrl)}

${keyHelp}

`; } return; } _setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info'); body.innerHTML=` ${_renderOnboardingApiKeyField()} ${_renderOnboardingProviderOAuthField(provider)} ${_renderOnboardingBaseUrlField(showBaseUrl)}

${keyHelp}

${showBaseUrl?`

${t('onboarding_base_url_help')}

`:''}

${esc(setup.unsupported_note||'')||''}

`; return; } if(key==='workspace'){ const workspaceOptions=_getOnboardingWorkspaceChoices().map(ws=>``).join(''); _setOnboardingNotice(t('onboarding_notice_workspace'), 'info'); body.innerHTML=` ${_renderOnboardingModelField()}`; const wsSel=$('onboardingWorkspaceSelect'); if(wsSel && ONBOARDING.form.workspace) wsSel.value=ONBOARDING.form.workspace; const modelSel=$('onboardingModelSelect'); if(modelSel && ONBOARDING.form.model) modelSel.value=ONBOARDING.form.model; return; } if(key==='password'){ _setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info'); body.innerHTML=`

${t('onboarding_password_help')}

`; return; } const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider); _setOnboardingNotice(t('onboarding_notice_finish'), 'success'); body.innerHTML=`
${t('onboarding_provider_label')}${esc((provider&&provider.label)||ONBOARDING.form.provider||t('onboarding_not_set'))}
${t('onboarding_model_label')}${esc(_getOnboardingSelectedModel()||t('onboarding_not_set'))}
${t('onboarding_workspace_label')}${esc(ONBOARDING.form.workspace||t('onboarding_not_set'))}
${t('onboarding_check_password')}${t(_getOnboardingPasswordSummaryKey(settings))}
${ONBOARDING.form.baseUrl?`

${t('onboarding_base_url_label')} ${esc(ONBOARDING.form.baseUrl)}

`:''}

${t('onboarding_finish_help')}

`; } function _getOnboardingPasswordSummaryKey(settings){ const hasExistingPassword=!!(settings&&settings.password_enabled); const hasNewPassword=!!((ONBOARDING.form.password||'').trim()); if(hasNewPassword) return hasExistingPassword?'onboarding_password_will_replace':'onboarding_password_will_enable'; return hasExistingPassword?'onboarding_password_keep_existing':'onboarding_password_remains_disabled'; } function syncOnboardingWorkspaceSelect(value){ ONBOARDING.form.workspace=value; const input=$('onboardingWorkspaceInput'); if(input) input.value=value; } function syncOnboardingProvider(value){ const provider=_getOnboardingSetupProvider(value); ONBOARDING.form.provider=value; if(provider){ if(!ONBOARDING.form.model || !_getOnboardingProviderModelChoices().some(m=>m.id===ONBOARDING.form.model) || value==='custom'){ ONBOARDING.form.model=provider.default_model||''; } if(provider.requires_base_url){ ONBOARDING.form.baseUrl=ONBOARDING.form.baseUrl||provider.default_base_url||''; }else{ ONBOARDING.form.baseUrl=provider.default_base_url||''; } } _renderOnboardingBody(); } async function loadOnboardingWizard(){ try{ const status=await api('/api/onboarding/status'); ONBOARDING.status=status; const current=((status.setup||{}).current)||{}; ONBOARDING.form.provider=current.provider||'openrouter'; ONBOARDING.form.workspace=(status.workspaces&&status.workspaces.last)||status.settings.default_workspace||''; ONBOARDING.form.model=status.settings.default_model||current.model||''; ONBOARDING.form.password=''; ONBOARDING.form.apiKey=''; ONBOARDING.form.baseUrl=current.base_url||''; ONBOARDING.active=!status.completed; if(!ONBOARDING.active) return false; $('onboardingOverlay').style.display='flex'; _renderOnboardingSteps(); _renderOnboardingBody(); return true; }catch(e){ console.warn('onboarding status failed',e); return false; } } function prevOnboardingStep(){ if(ONBOARDING.step===0)return; ONBOARDING.step--; _renderOnboardingSteps(); _renderOnboardingBody(); } async function _saveOnboardingProviderSetup(){ const provider=(ONBOARDING.form.provider||'').trim(); const model=(ONBOARDING.form.model||'').trim(); const apiKey=(ONBOARDING.form.apiKey||'').trim(); const baseUrl=(ONBOARDING.form.baseUrl||'').trim(); const current=_getOnboardingCurrentSetup(); const isUnchanged=current.provider===provider&&((current.model||'')===model)&&((current.base_url||'')===baseUrl); // Skip the POST when nothing changed. We also skip when the provider is // unsupported/OAuth-based and already working — chat_ready may be false for // providers not in the quick-setup list (e.g. minimax-cn) even though they are // fully configured. Posting in that case would either be a no-op (the server // just marks complete for unsupported providers) or could silently overwrite // config.yaml if the user accidentally changed the provider dropdown. const currentIsOauth=!!(ONBOARDING.status&&ONBOARDING.status.setup&&ONBOARDING.status.setup.current_is_oauth); if(isUnchanged && !apiKey && ((ONBOARDING.status.system||{}).chat_ready || currentIsOauth)) return; const body={provider,model}; if(apiKey) body.api_key=apiKey; if(baseUrl) body.base_url=baseUrl; const status=await api('/api/onboarding/setup',{method:'POST',body:JSON.stringify(body)}); ONBOARDING.status=status; } async function _saveOnboardingDefaults(){ const workspace=(ONBOARDING.form.workspace||'').trim(); const model=(ONBOARDING.form.model||'').trim(); const password=(ONBOARDING.form.password||'').trim(); if(!workspace) throw new Error(t('onboarding_error_choose_workspace')); if(!model) throw new Error(t('onboarding_error_choose_model')); const known=_getOnboardingWorkspaceChoices().some(ws=>ws.path===workspace); if(!known){ await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path:workspace})}); } // Model persisted by /api/onboarding/setup — no /api/default-model call needed here const body={default_workspace:workspace}; if(password) body._set_password=password; const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); if(ONBOARDING.status){ ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled}; } try{localStorage.setItem('hermes-webui-model',model)}catch{} if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect')); } async function _finishOnboarding(){ await _saveOnboardingProviderSetup(); await _saveOnboardingDefaults(); const done=await api('/api/onboarding/complete',{method:'POST',body:'{}'}); ONBOARDING.status=done; ONBOARDING.active=false; $('onboardingOverlay').style.display='none'; showToast(t('onboarding_complete')); await loadWorkspaceList(); if(typeof renderSessionList==='function') await renderSessionList(); if(!S.session && typeof newSession==='function'){ await newSession(true); await renderSessionList(); } } async function skipOnboarding(){ try{ // Mark onboarding completed server-side without changing any config await api('/api/onboarding/complete',{method:'POST',body:'{}'}); ONBOARDING.active=false; $('onboardingOverlay').style.display='none'; showToast(t('onboarding_skipped')||'Setup skipped'); }catch(e){ _setOnboardingNotice((e.message||String(e)),'warn'); } } async function nextOnboardingStep(){ try{ if(ONBOARDING.steps[ONBOARDING.step]==='setup'){ ONBOARDING.form.provider=(($('onboardingProviderSelect')||{}).value||ONBOARDING.form.provider||'').trim(); ONBOARDING.form.apiKey=(($('onboardingApiKeyInput')||{}).value||'').trim(); ONBOARDING.form.baseUrl=(($('onboardingBaseUrlInput')||{}).value||ONBOARDING.form.baseUrl||'').trim(); if(!ONBOARDING.form.provider) throw new Error(t('onboarding_error_provider_required')); if(ONBOARDING.form.provider==='custom' && !ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required')); // For self-hosted providers (requires_base_url=True), gate Continue on a // successful probe of /models — otherwise the wizard would // happily persist an unreachable URL and finish in 200ms with no // outbound HTTP, exactly the bug in #1499. Run the probe synchronously // here, then check status; the probe is idempotent & cached on // (provider, baseUrl, apiKey) so this rarely triggers a second network // call when the user already saw a green banner. const cat=_getOnboardingSetupProvider(ONBOARDING.form.provider); if(cat&&cat.requires_base_url){ if(!ONBOARDING.form.baseUrl) throw new Error(t('onboarding_error_base_url_required')); await _runOnboardingProbe(); if(ONBOARDING.probe.status!=='ok'){ // Surface the same localized error string the inline banner shows. const msg=_onboardingProbeMessage(ONBOARDING.probe)||t('onboarding_error_probe_failed')||'Could not reach the configured base URL.'; throw new Error(msg); } } } if(ONBOARDING.steps[ONBOARDING.step]==='workspace'){ ONBOARDING.form.workspace=(($('onboardingWorkspaceInput')||{}).value||ONBOARDING.form.workspace||'').trim(); ONBOARDING.form.model=(($('onboardingModelInput')||{}).value||($('onboardingModelSelect')||{}).value||ONBOARDING.form.model||'').trim(); if(!ONBOARDING.form.workspace) throw new Error(t('onboarding_error_workspace_required')); if(!ONBOARDING.form.model) throw new Error(t('onboarding_error_model_required')); } if(ONBOARDING.steps[ONBOARDING.step]==='password'){ ONBOARDING.form.password=(($('onboardingPasswordInput')||{}).value||'').trim(); } if(ONBOARDING.step===ONBOARDING.steps.length-1){ await _finishOnboarding(); return; } ONBOARDING.step++; _renderOnboardingSteps(); _renderOnboardingBody(); }catch(e){ _setOnboardingNotice(e.message||String(e),'warn'); } } /* ── Codex OAuth device-code flow ── */ let _codexOAuthPollTimer=null; let _codexOAuthFlowId=null; function _clearCodexOAuthPoll(){ if(_codexOAuthPollTimer){clearTimeout(_codexOAuthPollTimer);_codexOAuthPollTimer=null;} } function _setCodexOAuthButton(enabled){ const btn=$('codexOAuthBtn'); if(btn){btn.disabled=!enabled;btn.textContent=enabled?t('oauth_login_codex'):'...';} } async function copyCodexOAuthCode(code){ try{ await navigator.clipboard.writeText(code||''); showToast('Code copied'); }catch(e){ showToast(code||''); } } async function cancelCodexOAuth(){ const flowDiv=$('codexOAuthFlow'); const flowId=_codexOAuthFlowId; _clearCodexOAuthPoll(); _codexOAuthFlowId=null; if(flowId){ try{await api('/api/onboarding/oauth/cancel',{method:'POST',body:JSON.stringify({flow_id:flowId})});}catch(e){} } _setCodexOAuthButton(true); if(flowDiv){ flowDiv.innerHTML=`
OAuth login cancelled

Start again whenever you're ready.

`; } } function _renderCodexOAuthTerminal(status,message){ const flowDiv=$('codexOAuthFlow'); if(!flowDiv)return; const ok=status==='success'; const icon=ok?'✅':status==='expired'?'⌛':status==='cancelled'?'⏹':'❌'; const title=ok?t('oauth_codex_success'):(status==='expired'?t('oauth_codex_expired'):(status==='cancelled'?'OAuth login cancelled':t('oauth_codex_error'))); flowDiv.innerHTML=`
${icon}
${title}

${esc(message||'')}

`; } async function _pollCodexOAuth(){ const flowId=_codexOAuthFlowId; if(!flowId)return; try{ const resp=await api('/api/onboarding/oauth/poll?flow_id='+encodeURIComponent(flowId)); const status=(resp&&resp.status)||'error'; if(status==='pending'){ _codexOAuthPollTimer=setTimeout(_pollCodexOAuth,3000); return; } _clearCodexOAuthPoll(); _codexOAuthFlowId=null; _setCodexOAuthButton(true); if(status==='success'){ _renderCodexOAuthTerminal('success','Credentials saved to the Hermes credential pool. Refreshing provider status…'); showToast(t('oauth_codex_success')); try{await loadOnboardingWizard();}catch(e){} }else if(status==='expired'){ _renderCodexOAuthTerminal('expired','The code expired. Start a new login flow to try again.'); }else if(status==='cancelled'){ _renderCodexOAuthTerminal('cancelled','The login flow was cancelled.'); }else{ _renderCodexOAuthTerminal('error',(resp&&resp.error)||'OAuth login failed. Please try again.'); } }catch(e){ _clearCodexOAuthPoll(); _codexOAuthFlowId=null; _setCodexOAuthButton(true); _renderCodexOAuthTerminal('error',(e&&e.message)||String(e)); } } async function startCodexOAuth(){ const flowDiv=$('codexOAuthFlow'); if(!flowDiv)return; _clearCodexOAuthPoll(); _codexOAuthFlowId=null; _setCodexOAuthButton(false); flowDiv.style.display='block'; flowDiv.innerHTML=`
${t('oauth_codex_polling')}

Starting device-code flow…

`; try{ const resp=await api('/api/onboarding/oauth/start',{method:'POST',body:JSON.stringify({provider:'openai-codex'})}); if(resp.error) throw new Error(resp.error); const{flow_id,user_code,verification_uri}=resp; if(!flow_id||!user_code||!verification_uri) throw new Error('Invalid OAuth response'); _codexOAuthFlowId=flow_id; flowDiv.innerHTML=`
📋
${t('oauth_codex_step1')}

${esc(verification_uri)}

${t('oauth_codex_step2')}

${esc(user_code)}

${t('oauth_codex_polling')}

`; _codexOAuthPollTimer=setTimeout(_pollCodexOAuth,Math.max(1000,Number(resp.poll_interval_seconds||3)*1000)); }catch(e){ _clearCodexOAuthPoll(); _codexOAuthFlowId=null; _renderCodexOAuthTerminal('error',(e&&e.message)||String(e)); _setCodexOAuthButton(true); } } /* ── Anthropic / Claude Code credential-link flow ── */ let _anthropicOAuthPollTimer=null; let _anthropicOAuthFlowId=null; function _clearAnthropicOAuthPoll(){ if(_anthropicOAuthPollTimer){clearTimeout(_anthropicOAuthPollTimer);_anthropicOAuthPollTimer=null;} } function _setAnthropicOAuthButton(enabled){ const btn=$('anthropicOAuthBtn'); if(btn){btn.disabled=!enabled;btn.textContent=enabled?'Login with Claude Code':'...';} } async function cancelAnthropicOAuth(){ const flowDiv=$('anthropicOAuthFlow'); const flowId=_anthropicOAuthFlowId; _clearAnthropicOAuthPoll(); _anthropicOAuthFlowId=null; if(flowId){ try{await api('/api/onboarding/oauth/cancel',{method:'POST',body:JSON.stringify({flow_id:flowId,provider:'anthropic'})});}catch(e){} } _setAnthropicOAuthButton(true); if(flowDiv){ flowDiv.innerHTML=`
Claude Code OAuth cancelled

Start again whenever you're ready.

`; } } function _renderAnthropicOAuthTerminal(status,message){ const flowDiv=$('anthropicOAuthFlow'); if(!flowDiv)return; const ok=status==='success'; const icon=ok?'✅':status==='expired'?'⌛':status==='cancelled'?'⏹':'❌'; const title=ok?'Claude Code OAuth linked':(status==='expired'?'Claude Code polling expired':(status==='cancelled'?'Claude Code OAuth cancelled':'Claude Code OAuth failed')); flowDiv.style.display='block'; flowDiv.innerHTML=`
${icon}
${title}

${esc(message||'')}

`; } async function _pollAnthropicOAuth(){ const flowId=_anthropicOAuthFlowId; if(!flowId)return; try{ const resp=await api('/api/onboarding/oauth/poll?flow_id='+encodeURIComponent(flowId)); const status=(resp&&resp.status)||'error'; if(status==='pending'){ _anthropicOAuthPollTimer=setTimeout(_pollAnthropicOAuth,3000); return; } _clearAnthropicOAuthPoll(); _anthropicOAuthFlowId=null; _setAnthropicOAuthButton(true); if(status==='success'){ _renderAnthropicOAuthTerminal('success','Hermes is now linked to Claude Code credentials. Refreshing provider status…'); showToast('Claude Code OAuth linked'); try{await loadOnboardingWizard();}catch(e){} }else if(status==='expired'){ _renderAnthropicOAuthTerminal('expired','Claude Code credentials were not detected before this flow expired. Start a new flow to try again.'); }else if(status==='cancelled'){ _renderAnthropicOAuthTerminal('cancelled','The login flow was cancelled.'); }else{ _renderAnthropicOAuthTerminal('error',(resp&&resp.error)||'Claude Code OAuth linking failed. Please try again.'); } }catch(e){ _clearAnthropicOAuthPoll(); _anthropicOAuthFlowId=null; _setAnthropicOAuthButton(true); _renderAnthropicOAuthTerminal('error',(e&&e.message)||String(e)); } } async function startAnthropicOAuth(){ const flowDiv=$('anthropicOAuthFlow'); if(!flowDiv)return; _clearAnthropicOAuthPoll(); _anthropicOAuthFlowId=null; _setAnthropicOAuthButton(false); flowDiv.style.display='block'; flowDiv.innerHTML=`
Checking Claude Code credentials…

Hermes is checking for existing Claude Code OAuth credentials on this server.

`; try{ const resp=await api('/api/onboarding/oauth/start',{method:'POST',body:JSON.stringify({provider:'anthropic'})}); if(resp.error) throw new Error(resp.error); const{flow_id,status,action_required}=resp; if(!flow_id) throw new Error('Invalid OAuth response'); _anthropicOAuthFlowId=flow_id; if(status==='success'){ _clearAnthropicOAuthPoll(); _anthropicOAuthFlowId=null; _setAnthropicOAuthButton(true); _renderAnthropicOAuthTerminal('success','Hermes is now linked to Claude Code credentials. Refreshing provider status…'); showToast('Claude Code OAuth linked'); try{await loadOnboardingWizard();}catch(e){} return; } flowDiv.innerHTML=`
🖥️
Complete Claude Code login on this host

${esc(action_required||"Run 'claude setup-token' on the server, then return here. Hermes will detect the credential automatically.")}

claude setup-token

Waiting for Claude Code credentials...

`; _anthropicOAuthPollTimer=setTimeout(_pollAnthropicOAuth,Math.max(1000,Number(resp.poll_interval_seconds||3)*1000)); }catch(e){ _clearAnthropicOAuthPoll(); _anthropicOAuthFlowId=null; _renderAnthropicOAuthTerminal('error',(e&&e.message)||String(e)); _setAnthropicOAuthButton(true); } }