Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -217,6 +223,7 @@ async function constructForegroundAgentAndDispatch(
recording,
hasPipedInput,
readStdinData,
suppressStartupWelcome: hasExplicitProviderProfileSelector(argv),
});
}

Expand Down
21 changes: 15 additions & 6 deletions packages/cli/src/config/cliArgParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -179,13 +180,14 @@ function mapParsedArgsToCliArgs(result: Record<string, unknown>): 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']),
Comment thread
acoliver marked this conversation as resolved.
loadMemoryFromIncludeDirectories: result[
'loadMemoryFromIncludeDirectories'
] as boolean | undefined,
Expand All @@ -206,6 +208,14 @@ function mapParsedArgsToCliArgs(result: Record<string, unknown>): 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<string, unknown>): void {
const commands = result['_'];
Expand Down Expand Up @@ -304,10 +314,9 @@ function validatePromptModeArgs(argv: Record<string, unknown>): void {

function validateRootArgs(argv: Record<string, unknown>): 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.',
);
Expand Down
169 changes: 169 additions & 0 deletions packages/cli/src/config/cliArgParser.welcomeSuppression.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/session/interactiveUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export async function startInteractiveUI(
resumedHistory?: IContent[],
initialRecordingService?: SessionRecordingService,
initialLockHandle?: LockHandle | null,
suppressStartupWelcome?: boolean,
) {
const version = await getCliVersion();

Expand Down Expand Up @@ -212,6 +213,7 @@ export async function startInteractiveUI(
resumedHistory={resumedHistory}
initialRecordingService={initialRecordingService}
initialLockHandle={initialLockHandle}
suppressStartupWelcome={suppressStartupWelcome}
/>
</SettingsContext.Provider>
</ErrorBoundary>
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/session/nonInteractiveSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface SessionDispatchOptions {
recording: SessionRecordingSetup;
hasPipedInput: boolean;
readStdinData: () => Promise<string>;
suppressStartupWelcome?: boolean;
}

/**
Expand All @@ -80,6 +81,7 @@ export async function dispatchInteractiveOrNonInteractive({
recording,
hasPipedInput,
readStdinData,
suppressStartupWelcome,
}: SessionDispatchOptions): Promise<void> {
const input = config.getQuestion();

Expand Down Expand Up @@ -114,6 +116,7 @@ export async function dispatchInteractiveOrNonInteractive({
recording.resumedHistory ?? undefined,
recording.recordingService,
recording.resumedLockHandle,
suppressStartupWelcome,
);
return;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface AppProps {
initialRecordingService?: SessionRecordingService;
/** @plan:PLAN-20260214-SESSIONBROWSER.P23 */
initialLockHandle?: LockHandle | null;
suppressStartupWelcome?: boolean;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/AppContainerRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface AppContainerRuntimeProps {
initialRecordingService?: SessionRecordingService;
/** @plan:PLAN-20260214-SESSIONBROWSER.P23 */
initialLockHandle?: LockHandle | null;
suppressStartupWelcome?: boolean;
}

type HookResults = {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface AppDialogsParams {
runtime: ReturnType<typeof useRuntimeApi>;
consoleMessages: ConsoleMessageItem[];
setLlxprtMdFileCount: (count: number) => void;
suppressStartupWelcome?: boolean;
}

function useDialogsState() {
Expand Down Expand Up @@ -275,6 +276,7 @@ function useDialogsAuth(
settings,
isFolderTrustComplete: !folderTrust.isFolderTrustDialogOpen,
agent: p.agent,
suppressStartup: p.suppressStartupWelcome === true,
});
useIdeTrustEffect(config, st);
const authProviders = useDialogsAuthProviders(
Expand Down
Loading