diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 5101f5a063..bf8f3e9515 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -180,6 +180,12 @@ async function handleZedAcpIntegration( return true; } +function hasExplicitProviderProfileSelector(argv: ParsedCliArgs): boolean { + return [argv.provider, argv.profile, argv.profileLoad].some( + (value) => typeof value === 'string' && value.trim().length > 0, + ); +} + /** * Construct the SINGLE foreground Agent (#2378) and dispatch the interactive or * non-interactive session. The spinner wraps agent construction, which (via @@ -217,6 +223,7 @@ async function constructForegroundAgentAndDispatch( recording, hasPipedInput, readStdinData, + suppressStartupWelcome: hasExplicitProviderProfileSelector(argv), }); } diff --git a/packages/cli/src/config/cliArgParser.ts b/packages/cli/src/config/cliArgParser.ts index 88c8869b57..bc1ee74421 100644 --- a/packages/cli/src/config/cliArgParser.ts +++ b/packages/cli/src/config/cliArgParser.ts @@ -59,6 +59,7 @@ export interface CliArgs { proxy: string | undefined; includeDirectories: string[] | undefined; profileLoad: string | undefined; + profile?: string | undefined; loadMemoryFromIncludeDirectories: boolean | undefined; ideMode: string | undefined; screenReader: boolean | undefined; @@ -179,13 +180,14 @@ function mapParsedArgsToCliArgs(result: Record): CliArgs { experimentalUi: result['experimentalUi'] as boolean | undefined, extensions: result['extensions'] as string[] | undefined, listExtensions: result['listExtensions'] as boolean | undefined, - provider: result['provider'] as string | undefined, + provider: pickLastRepeatedStringOption(result['provider']), key: result['key'] as string | undefined, keyfile: result['keyfile'] as string | undefined, baseurl: result['baseurl'] as string | undefined, proxy: result['proxy'] as string | undefined, includeDirectories: result['includeDirectories'] as string[] | undefined, - profileLoad: result['profileLoad'] as string | undefined, + profileLoad: pickLastRepeatedStringOption(result['profileLoad']), + profile: pickLastRepeatedStringOption(result['profile']), loadMemoryFromIncludeDirectories: result[ 'loadMemoryFromIncludeDirectories' ] as boolean | undefined, @@ -206,6 +208,14 @@ function mapParsedArgsToCliArgs(result: Record): CliArgs { }; } +function pickLastRepeatedStringOption(value: unknown): string | undefined { + if (Array.isArray(value)) { + const last = value[value.length - 1]; + return typeof last === 'string' ? last : undefined; + } + return typeof value === 'string' ? value : undefined; +} + /** Checks for subcommand dispatch (mcp, hooks, extensions) and exits if handled. */ function handleSubcommandExit(result: Record): void { const commands = result['_']; @@ -304,10 +314,9 @@ function validatePromptModeArgs(argv: Record): void { function validateRootArgs(argv: Record): true { validatePromptModeArgs(argv); - if ( - hasNonEmptyString(argv['profile']) && - hasNonEmptyString(argv['profileLoad']) - ) { + const profile = pickLastRepeatedStringOption(argv['profile']); + const profileLoad = pickLastRepeatedStringOption(argv['profileLoad']); + if (hasNonEmptyString(profile) && hasNonEmptyString(profileLoad)) { throw new Error( 'Cannot use both --profile and --profile-load. Use one at a time.', ); diff --git a/packages/cli/src/config/cliArgParser.welcomeSuppression.test.ts b/packages/cli/src/config/cliArgParser.welcomeSuppression.test.ts new file mode 100644 index 0000000000..c3258a122f --- /dev/null +++ b/packages/cli/src/config/cliArgParser.welcomeSuppression.test.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { parseArguments } from './cliArgParser.js'; +import type { Settings } from './settings.js'; + +const emptySettings: Settings = {}; + +function parseInSubprocess(args: string[]) { + const parserUrl = new URL('./cliArgParser.ts', import.meta.url).href; + const script = `process.argv = ${JSON.stringify(['node', 'llxprt', ...args])}; const { parseArguments } = await import(${JSON.stringify(parserUrl)}); await parseArguments({});`; + return spawnSync(process.execPath, ['--eval', script], { + encoding: 'utf8', + env: { ...process.env, LLXPRT_JSP_BOOTSTRAP_FILE: '' }, + }); +} + +describe('parseArguments selector and non-selector parsing', () => { + const originalArgv = process.argv; + + afterEach(() => { + process.argv = originalArgv; + }); + + it('parses --provider into CliArgs.provider', async () => { + process.argv = ['node', 'llxprt', '--provider', 'gemini']; + const argv = await parseArguments(emptySettings); + expect(argv.provider).toBe('gemini'); + expect(argv.profile).toBeUndefined(); + expect(argv.profileLoad).toBeUndefined(); + }); + + it('parses --profile into CliArgs.profile', async () => { + const inline = '{"provider":"x"}'; + process.argv = ['node', 'llxprt', '--profile', inline]; + const argv = await parseArguments(emptySettings); + expect(argv.profile).toBe(inline); + expect(argv.provider).toBeUndefined(); + expect(argv.profileLoad).toBeUndefined(); + }); + + it('parses --profile-load into CliArgs.profileLoad', async () => { + process.argv = ['node', 'llxprt', '--profile-load', 'my-profile']; + const argv = await parseArguments(emptySettings); + expect(argv.profileLoad).toBe('my-profile'); + expect(argv.provider).toBeUndefined(); + expect(argv.profile).toBeUndefined(); + }); + + it('keeps only the last value when --profile is repeated', async () => { + process.argv = [ + 'node', + 'llxprt', + '--profile', + 'first', + '--profile', + 'last', + ]; + const argv = await parseArguments(emptySettings); + expect(argv.profile).toBe('last'); + }); + + it('parses no selector when none is supplied', async () => { + process.argv = ['node', 'llxprt']; + const argv = await parseArguments(emptySettings); + expect(argv.provider).toBeUndefined(); + expect(argv.profile).toBeUndefined(); + expect(argv.profileLoad).toBeUndefined(); + }); + + it('parses --model without populating any selector', async () => { + process.argv = ['node', 'llxprt', '--model', 'gemini-2.5-flash']; + const argv = await parseArguments(emptySettings); + expect(argv.model).toBe('gemini-2.5-flash'); + expect(argv.provider).toBeUndefined(); + expect(argv.profile).toBeUndefined(); + expect(argv.profileLoad).toBeUndefined(); + }); + + it('parses --provider with an empty string value', async () => { + process.argv = ['node', 'llxprt', '--provider', '']; + const argv = await parseArguments(emptySettings); + expect(argv.provider).toBe(''); + }); + + it('parses --provider with a whitespace-only value', async () => { + process.argv = ['node', 'llxprt', '--provider', ' ']; + const argv = await parseArguments(emptySettings); + expect(argv.provider).toBe(' '); + }); + + it('parses --profile with an empty string value', async () => { + process.argv = ['node', 'llxprt', '--profile', '']; + const argv = await parseArguments(emptySettings); + expect(argv.profile).toBe(''); + }); + + it('parses --profile-load with an empty string value', async () => { + process.argv = ['node', 'llxprt', '--profile-load', '']; + const argv = await parseArguments(emptySettings); + expect(argv.profileLoad).toBe(''); + }); + + it('keeps only the last value when --provider is repeated', async () => { + process.argv = [ + 'node', + 'llxprt', + '--provider', + 'gemini', + '--provider', + 'openai', + ]; + const argv = await parseArguments(emptySettings); + expect(argv.provider).toBe('openai'); + }); + + it('keeps only the last value when --profile-load is repeated', async () => { + process.argv = [ + 'node', + 'llxprt', + '--profile-load', + 'first', + '--profile-load', + 'last', + ]; + const argv = await parseArguments(emptySettings); + expect(argv.profileLoad).toBe('last'); + }); + + it('rejects --profile-load when --profile is repeated', () => { + const result = parseInSubprocess([ + '--profile', + 'first', + '--profile', + 'last', + '--profile-load', + 'named', + ]); + expect({ + status: result.status, + hasConflictMessage: `${result.stdout}${result.stderr}`.includes( + 'Cannot use both --profile and --profile-load', + ), + }).toEqual({ status: 1, hasConflictMessage: true }); + }); + + it('rejects --profile when --profile-load is repeated', () => { + const result = parseInSubprocess([ + '--profile', + 'inline', + '--profile-load', + 'first', + '--profile-load', + 'last', + ]); + expect({ + status: result.status, + hasConflictMessage: `${result.stdout}${result.stderr}`.includes( + 'Cannot use both --profile and --profile-load', + ), + }).toEqual({ status: 1, hasConflictMessage: true }); + }); +}); diff --git a/packages/cli/src/session/interactiveUI.tsx b/packages/cli/src/session/interactiveUI.tsx index 2ed255ada5..43b6666824 100644 --- a/packages/cli/src/session/interactiveUI.tsx +++ b/packages/cli/src/session/interactiveUI.tsx @@ -153,6 +153,7 @@ export async function startInteractiveUI( resumedHistory?: IContent[], initialRecordingService?: SessionRecordingService, initialLockHandle?: LockHandle | null, + suppressStartupWelcome?: boolean, ) { const version = await getCliVersion(); @@ -212,6 +213,7 @@ export async function startInteractiveUI( resumedHistory={resumedHistory} initialRecordingService={initialRecordingService} initialLockHandle={initialLockHandle} + suppressStartupWelcome={suppressStartupWelcome} /> diff --git a/packages/cli/src/session/nonInteractiveSession.ts b/packages/cli/src/session/nonInteractiveSession.ts index 76246fae14..e511d59842 100644 --- a/packages/cli/src/session/nonInteractiveSession.ts +++ b/packages/cli/src/session/nonInteractiveSession.ts @@ -66,6 +66,7 @@ export interface SessionDispatchOptions { recording: SessionRecordingSetup; hasPipedInput: boolean; readStdinData: () => Promise; + suppressStartupWelcome?: boolean; } /** @@ -80,6 +81,7 @@ export async function dispatchInteractiveOrNonInteractive({ recording, hasPipedInput, readStdinData, + suppressStartupWelcome, }: SessionDispatchOptions): Promise { const input = config.getQuestion(); @@ -114,6 +116,7 @@ export async function dispatchInteractiveOrNonInteractive({ recording.resumedHistory ?? undefined, recording.recordingService, recording.resumedLockHandle, + suppressStartupWelcome, ); return; } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index e11b93eab3..d3ebfb2020 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -50,6 +50,7 @@ interface AppProps { initialRecordingService?: SessionRecordingService; /** @plan:PLAN-20260214-SESSIONBROWSER.P23 */ initialLockHandle?: LockHandle | null; + suppressStartupWelcome?: boolean; } /** diff --git a/packages/cli/src/ui/AppContainerRuntime.tsx b/packages/cli/src/ui/AppContainerRuntime.tsx index 6e63adb017..6f3d722160 100644 --- a/packages/cli/src/ui/AppContainerRuntime.tsx +++ b/packages/cli/src/ui/AppContainerRuntime.tsx @@ -61,6 +61,7 @@ export interface AppContainerRuntimeProps { initialRecordingService?: SessionRecordingService; /** @plan:PLAN-20260214-SESSIONBROWSER.P23 */ initialLockHandle?: LockHandle | null; + suppressStartupWelcome?: boolean; } type HookResults = { @@ -452,6 +453,7 @@ export const AppContainerRuntime = (props: AppContainerRuntimeProps) => { runtime: bootstrap.runtime, consoleMessages: bootstrap.consoleMessages, setLlxprtMdFileCount: bootstrap.setLlxprtMdFileCount, + suppressStartupWelcome: props.suppressStartupWelcome, }); const input = useAppInput( buildInputParams( diff --git a/packages/cli/src/ui/containers/AppContainer/hooks/useAppDialogs.ts b/packages/cli/src/ui/containers/AppContainer/hooks/useAppDialogs.ts index 2cb09fcdef..2bb91f8319 100644 --- a/packages/cli/src/ui/containers/AppContainer/hooks/useAppDialogs.ts +++ b/packages/cli/src/ui/containers/AppContainer/hooks/useAppDialogs.ts @@ -55,6 +55,7 @@ export interface AppDialogsParams { runtime: ReturnType; consoleMessages: ConsoleMessageItem[]; setLlxprtMdFileCount: (count: number) => void; + suppressStartupWelcome?: boolean; } function useDialogsState() { @@ -275,6 +276,7 @@ function useDialogsAuth( settings, isFolderTrustComplete: !folderTrust.isFolderTrustDialogOpen, agent: p.agent, + suppressStartup: p.suppressStartupWelcome === true, }); useIdeTrustEffect(config, st); const authProviders = useDialogsAuthProviders( diff --git a/packages/cli/src/ui/hooks/useWelcomeOnboarding.bun.tsx b/packages/cli/src/ui/hooks/useWelcomeOnboarding.bun.tsx new file mode 100644 index 0000000000..e296eb908f --- /dev/null +++ b/packages/cli/src/ui/hooks/useWelcomeOnboarding.bun.tsx @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { act } from 'react'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { vi } from '../../test-utils/bunTest.js'; +import { createMockSettings, renderHook } from '../../test-utils/render.js'; +import { createFakeAgent } from './agentStream/__tests__/helpers/createFakeAgent.js'; + +const stubRuntime = { + getCliProviderManager: () => ({ listProviders: () => [] }), + listAvailableModels: async () => [], + setActiveModel: async () => {}, +}; +vi.mock('../contexts/RuntimeContext.js', () => ({ + useRuntimeApi: () => stubRuntime, +})); + +import { useWelcomeOnboarding } from './useWelcomeOnboarding.js'; +import { + isWelcomeCompleted, + resetWelcomeConfigForTesting, +} from '../../config/welcomeConfig.js'; + +const agent = createFakeAgent([]); + +let tempConfigDir: string | undefined; + +function isolateWelcomeConfig(): string { + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'welcome-suppress-')); + const configPath = path.join(tempConfigDir, 'welcomeConfig.json'); + process.env.LLXPRT_CODE_WELCOME_CONFIG_PATH = configPath; + resetWelcomeConfigForTesting(); + return configPath; +} + +describe('useWelcomeOnboarding startup suppression', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.LLXPRT_CODE_WELCOME_CONFIG_PATH; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.LLXPRT_CODE_WELCOME_CONFIG_PATH; + } else { + process.env.LLXPRT_CODE_WELCOME_CONFIG_PATH = originalEnv; + } + resetWelcomeConfigForTesting(); + if (tempConfigDir) { + fs.rmSync(tempConfigDir, { recursive: true, force: true }); + tempConfigDir = undefined; + } + }); + + it('shows the welcome dialog when welcome is incomplete and not suppressed after trust', () => { + isolateWelcomeConfig(); + const { result } = renderHook(() => + useWelcomeOnboarding({ + settings: createMockSettings({}), + isFolderTrustComplete: true, + agent, + }), + ); + expect(result.current.showWelcome).toBe(true); + }); + + it('hides the welcome dialog at startup when an explicit selector suppresses it', () => { + isolateWelcomeConfig(); + const { result } = renderHook(() => + useWelcomeOnboarding({ + settings: createMockSettings({}), + isFolderTrustComplete: true, + agent, + suppressStartup: true, + }), + ); + expect(result.current.showWelcome).toBe(false); + }); + + it('does not show the dialog before folder trust completes even without suppression', () => { + isolateWelcomeConfig(); + const { result } = renderHook(() => + useWelcomeOnboarding({ + settings: createMockSettings({}), + isFolderTrustComplete: false, + agent, + }), + ); + expect(result.current.showWelcome).toBe(false); + }); + + it('keeps the dialog hidden when welcome is already completed', () => { + const configPath = isolateWelcomeConfig(); + fs.writeFileSync(configPath, JSON.stringify({ welcomeCompleted: true })); + resetWelcomeConfigForTesting(); + const { result } = renderHook(() => + useWelcomeOnboarding({ + settings: createMockSettings({}), + isFolderTrustComplete: true, + agent, + }), + ); + expect(result.current.showWelcome).toBe(false); + }); + + it('does not persist welcome completion when startup suppression is active', () => { + const configPath = isolateWelcomeConfig(); + const { result } = renderHook(() => + useWelcomeOnboarding({ + settings: createMockSettings({}), + isFolderTrustComplete: true, + agent, + suppressStartup: true, + }), + ); + expect(result.current.showWelcome).toBe(false); + expect(fs.existsSync(configPath)).toBe(false); + resetWelcomeConfigForTesting(); + expect(isWelcomeCompleted()).toBe(false); + }); + + it('reopen via resetAndReopen shows the dialog after a suppressed startup', () => { + isolateWelcomeConfig(); + const { result } = renderHook(() => + useWelcomeOnboarding({ + settings: createMockSettings({}), + isFolderTrustComplete: true, + agent, + suppressStartup: true, + }), + ); + expect(result.current.showWelcome).toBe(false); + act(() => { + result.current.actions.resetAndReopen(); + }); + expect(result.current.showWelcome).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/hooks/useWelcomeOnboarding.ts b/packages/cli/src/ui/hooks/useWelcomeOnboarding.ts index 10ca8bc5c5..12aafb75ce 100644 --- a/packages/cli/src/ui/hooks/useWelcomeOnboarding.ts +++ b/packages/cli/src/ui/hooks/useWelcomeOnboarding.ts @@ -56,6 +56,7 @@ export interface UseWelcomeOnboardingOptions { settings: LoadedSettings; isFolderTrustComplete: boolean; agent: Agent; + suppressStartup?: boolean; } export interface ModelInfo { @@ -406,9 +407,10 @@ export const useWelcomeOnboarding = ( options: UseWelcomeOnboardingOptions, ): UseWelcomeOnboardingReturn => { const { settings: _settings, isFolderTrustComplete, agent } = options; + const suppressStartup = options.suppressStartup === true; const runtime = useRuntimeApi(); const [welcomeCompleted, setWelcomeCompleted] = useState(() => - isWelcomeCompleted(), + suppressStartup ? true : isWelcomeCompleted(), ); // Only show welcome after folder trust is complete diff --git a/project-plans/issue3105/plan.md b/project-plans/issue3105/plan.md new file mode 100644 index 0000000000..4f23187332 --- /dev/null +++ b/project-plans/issue3105/plan.md @@ -0,0 +1,107 @@ +# Plan: Suppress Automatic Welcome Setup for Explicit CLI Provider Selection + +Plan ID: PLAN-20260806-ISSUE3105 +Generated: 2026-08-06 +Requirements: REQ-SUPPRESS-001, REQ-DEFAULT-001, REQ-MANUAL-001 + +## Scope and acceptance boundary + +This issue changes only automatic welcome-onboarding behavior at interactive startup. It does not alter provider/profile resolution, validation, precedence, authentication, persisted welcome completion, non-interactive behavior, or the manual `/setup` flow. It adds no dependency, workflow, subsystem, public API, or adjacent cleanup. + +### REQ-SUPPRESS-001: Explicit CLI selectors suppress automatic welcome setup + +**Requirement text:** An interactive launch with a valid, non-empty `--profile-load`, `--profile`, or `--provider` command-line value must not automatically display the welcome setup dialog, even when persisted welcome setup is incomplete. + +- GIVEN persisted welcome setup is incomplete and folder trust permits normal UI startup +- WHEN the parsed command line contains a valid non-empty `--profile-load`, `--profile`, or `--provider` value +- THEN the automatic welcome-completion check is bypassed for that startup +- AND the welcome setup dialog is not displayed +- AND suppression is session-local and does not persist welcome completion + +Each of the three flags is independently sufficient. Existing parser/profile/provider validation remains authoritative; this issue does not define new behavior for missing, empty, malformed, conflicting, or otherwise invalid selector values because those launches already fail or are handled before a usable interactive session. + +### REQ-DEFAULT-001: Existing welcome behavior remains the default + +**Requirement text:** Interactive launches without an explicit provider/profile selector must retain the existing welcome behavior. + +- GIVEN persisted welcome setup is incomplete +- WHEN none of `--profile-load`, `--profile`, or `--provider` is supplied with a valid non-empty value +- THEN the welcome setup dialog is displayed after folder trust completes + +A model-only argument, environment-selected provider/profile, settings default profile, or provider resolved from another non-command-line source does not suppress the welcome dialog. Existing completed-welcome behavior remains unchanged. + +### REQ-MANUAL-001: Manual setup remains available + +**Requirement text:** Startup suppression must not disable the existing `/setup` command. + +- GIVEN automatic welcome setup was suppressed for the current startup +- WHEN the existing `/setup` action resets and reopens onboarding +- THEN the welcome setup dialog is displayed + +## Preflight evidence + +- `packages/cli/src/config/cliArgParser.ts` parses `provider` and `profileLoad`, but currently omits the already-defined yargs `profile` value from `CliArgs` mapping. +- `packages/cli/src/cli.tsx` retains parsed command-line arguments through foreground-agent construction and is the composition root for session dispatch. +- `packages/cli/src/session/nonInteractiveSession.ts` is the existing interactive/non-interactive dispatch boundary and calls `startInteractiveUI` only for interactive sessions. +- `packages/cli/src/session/interactiveUI.tsx`, `packages/cli/src/ui/App.tsx`, `packages/cli/src/ui/AppContainerRuntime.tsx`, and `useAppDialogs.ts` are the direct internal prop path to `useWelcomeOnboarding`. +- `useWelcomeOnboarding` currently initializes its state by calling `isWelcomeCompleted()` and derives dialog visibility from that state plus folder trust. Its existing `resetAndReopen` action can preserve manual `/setup` behavior if suppression affects only initial state. +- Bun-native React hook infrastructure already exists through `packages/cli/src/test-utils/render.tsx`; no dependency or test-framework change is needed. + +## Test-first implementation sequence + +1. Add Bun/bun:test behavioral coverage for the real welcome hook: + - incomplete welcome plus startup suppression yields no dialog; + - incomplete welcome without suppression still yields the dialog after trust; + - startup suppression does not prevent `resetAndReopen` from showing the dialog; + - completed welcome remains hidden. +2. Add Bun/bun:test coverage proving parsed `--profile`, `--profile-load`, and `--provider` values reach the startup-suppression decision, while no selector and `--model` alone do not suppress. +3. Run the focused tests and record RED evidence before production edits. +4. Implement the minimum internal data threading from parsed CLI arguments to the welcome hook. Suppression must initialize the hook as completed for that mount without writing welcome configuration; `resetAndReopen` must continue to clear the in-memory state. +5. Run focused tests, relevant CLI package tests, formatting, lint, and type checking. +6. Validate the visible terminal behavior in the tmux harness for a selector launch and a no-selector launch with an incomplete isolated welcome config. + +Tests must assert rendered/hook-visible behavior rather than mock-call interactions. Infrastructure unrelated to the welcome decision may be replaced with minimal test fixtures, but the parser, suppression decision, and welcome hook behavior remain real. + +## Behavioral evidence + +Focused evidence must include Bun tests covering every accepted behavior and tmux-harness captures showing: + +- an incomplete isolated welcome configuration does not show `Welcome to llxprt!` when launched with each supported selector form that can be exercised safely; +- the same incomplete configuration does show the welcome dialog with no selector; +- `/setup` can open onboarding after a suppressed startup. + +## Full verification + +```bash +npm run test +npm run lint +npm run typecheck +npm run format +npm run build +bun scripts/start.ts --profile-load stepfun-37 "write me a haiku and nothing else" +``` + +Before push, run DeepThinker review and detached Open Code Review with `--timeout 20`, ensuring test files are included. Classify every finding as Blocker-Fix, In-scope-Fix, Reject, or Defer; resolve all Blocker-Fix and In-scope-Fix findings. Do not exceed two local OCR or two PR OCR reviews. + +## Completion conditions + +- Every accepted behavior has direct behavioral evidence. +- No provider/profile resolution or persistence semantics are changed. +- No dependency, workflow, agent memory, quality-tool, public abstraction, unrelated refactor, suppression directive, or lint/complexity weakening is introduced. +- Focused and full local verification pass on the candidate head. +- Reviews are complete and triaged, candidate-head CI is green, all required threads are resolved, ancestry is current, and the PR is conflict-free. + +## Implementation evidence + +RED observations recorded before production edits (focused Bun suites): + +- Repeated `--profile` parsing: before `pickLastRepeatedStringOption` mapping was added, yargs returned an array for `--profile a --profile b`, so `CliArgs.profile` was not a single string and the repeated-`--profile` parser expectation failed. +- Welcome suppression / manual reopen: before `suppressStartup` support was added, the welcome-onboarding hook expectations failed — a suppressed startup still reported `showWelcome === true`, and no path distinguished a suppressed startup from an incomplete one; `resetAndReopen` behavior was not exercised against a suppressed startup. + +Connected TUI evidence (parser → interactive render with a real isolated incomplete welcome config) was captured with the tmux harness: + +- `--profile-load stepfun-37` suppressed automatic welcome, and entering `/setup` opened `Welcome to llxprt!`: `/var/folders/qd/962lhrjj0232rjykgg3lgmrw0000gn/T/llxprt-tmux-harness-1786035305090` +- `--provider openai --model gpt-4o` suppressed automatic welcome: `/var/folders/qd/962lhrjj0232rjykgg3lgmrw0000gn/T/llxprt-tmux-harness-1786035360409` +- inline `--profile` with OpenAI provider/model suppressed automatic welcome: `/var/folders/qd/962lhrjj0232rjykgg3lgmrw0000gn/T/llxprt-tmux-harness-1786035369433` +- no selector displayed `Welcome to llxprt!`: `/var/folders/qd/962lhrjj0232rjykgg3lgmrw0000gn/T/llxprt-tmux-harness-1786035378455` +- `--model gpt-4o` alone displayed `Welcome to llxprt!`: `/var/folders/qd/962lhrjj0232rjykgg3lgmrw0000gn/T/llxprt-tmux-harness-1786035386997`