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
1,464 changes: 1,464 additions & 0 deletions docs/plans/2026-01-03-welcome-onboarding.md

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions packages/cli/src/config/welcomeConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Vybestack LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import { USER_SETTINGS_DIR } from './paths.js';

export const WELCOME_CONFIG_FILENAME = 'welcomeConfig.json';

export function getWelcomeConfigPath(): string {
if (process.env['LLXPRT_CODE_WELCOME_CONFIG_PATH']) {
return process.env['LLXPRT_CODE_WELCOME_CONFIG_PATH'];
}
return path.join(USER_SETTINGS_DIR, WELCOME_CONFIG_FILENAME);
}

export interface WelcomeConfig {
welcomeCompleted: boolean;
completedAt?: string;
skipped?: boolean;
}

let cachedConfig: WelcomeConfig | undefined;

export function resetWelcomeConfigForTesting(): void {
cachedConfig = undefined;
}

export function loadWelcomeConfig(): WelcomeConfig {
if (cachedConfig) {
return cachedConfig;
}

const configPath = getWelcomeConfigPath();

try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf-8');
cachedConfig = JSON.parse(content) as WelcomeConfig;
return cachedConfig;
}
Comment on lines +39 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add runtime validation for parsed config structure.

The type assertion as WelcomeConfig at Line 42 assumes the JSON structure is valid without verification. If the file is corrupted or manually edited, this could lead to runtime errors elsewhere.

πŸ”Ž Proposed fix with validation
 export function loadWelcomeConfig(): WelcomeConfig {
   if (cachedConfig) {
     return cachedConfig;
   }
 
   const configPath = getWelcomeConfigPath();
 
   try {
     if (fs.existsSync(configPath)) {
       const content = fs.readFileSync(configPath, 'utf-8');
-      cachedConfig = JSON.parse(content) as WelcomeConfig;
+      const parsed = JSON.parse(content);
+      
+      // Validate structure
+      if (
+        typeof parsed === 'object' &&
+        parsed !== null &&
+        typeof parsed.welcomeCompleted === 'boolean'
+      ) {
+        cachedConfig = parsed as WelcomeConfig;
+      } else {
+        // Invalid structure, fall through to default
+        throw new Error('Invalid config structure');
+      }
       return cachedConfig;
     }
   } catch (_error) {
     // If parsing fails, return default
   }
 
   cachedConfig = { welcomeCompleted: false };
   return cachedConfig;
 }
πŸ€– Prompt for AI Agents
In packages/cli/src/config/welcomeConfig.ts around lines 39 to 44, the JSON is
parsed and cast to WelcomeConfig without runtime validation; replace the blind
type assertion with a validation step: after JSON.parse, validate that the
resulting object has the expected properties and types (e.g., required keys,
string/boolean/array shapes) using a small inline check or an existing
schema/validator function, log or handle parse/validation errors (delete or
ignore invalid file and return a safe default cachedConfig) instead of returning
a malformed object, and ensure cachedConfig is only assigned when validation
succeeds.

} catch (_error) {
// If parsing fails, return default
}

cachedConfig = { welcomeCompleted: false };
return cachedConfig;
}

export function saveWelcomeConfig(config: WelcomeConfig): void {
const configPath = getWelcomeConfigPath();

try {
const dirPath = path.dirname(configPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}

fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
cachedConfig = config;
} catch (error) {
console.error('Error saving welcome config:', error);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Replace console.error with the logging system.

The coding guidelines explicitly prohibit console.error and require using the sophisticated logging system. Log files are written to ~/.llxprt/debug/.

πŸ”Ž Proposed fix
+import { Logger } from '@vybestack/llxprt-code-core';
+
+const logger = new Logger('welcomeConfig');
+
 export function saveWelcomeConfig(config: WelcomeConfig): void {
   const configPath = getWelcomeConfigPath();
 
   try {
     const dirPath = path.dirname(configPath);
     if (!fs.existsSync(dirPath)) {
       fs.mkdirSync(dirPath, { recursive: true });
     }
 
     fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
       encoding: 'utf-8',
       mode: 0o600,
     });
     cachedConfig = config;
   } catch (error) {
-    console.error('Error saving welcome config:', error);
+    logger.error('Error saving welcome config:', error);
   }
 }

As per coding guidelines.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.error('Error saving welcome config:', error);
import { Logger } from '@vybestack/llxprt-code-core';
const logger = new Logger('welcomeConfig');
export function saveWelcomeConfig(config: WelcomeConfig): void {
const configPath = getWelcomeConfigPath();
try {
const dirPath = path.dirname(configPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
cachedConfig = config;
} catch (error) {
logger.error('Error saving welcome config:', error);
}
}
πŸ€– Prompt for AI Agents
In packages/cli/src/config/welcomeConfig.ts at line 68, replace the
console.error call with the project's logging system: import or obtain the
module logger used across the CLI (the central logger that writes to
~/.llxprt/debug/), remove the console.error invocation, and call logger.error
with a clear message and the caught error object so the error details are
recorded (e.g., logger.error("Error saving welcome config", error)); ensure the
logger import/usage follows the same pattern as other CLI modules.

}
}

export function markWelcomeCompleted(skipped: boolean = false): void {
saveWelcomeConfig({
welcomeCompleted: true,
completedAt: new Date().toISOString(),
skipped,
});
}

export function isWelcomeCompleted(): boolean {
return loadWelcomeConfig().welcomeCompleted;
}
28 changes: 28 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './hooks/useAuthCommand.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useWelcomeOnboarding } from './hooks/useWelcomeOnboarding.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
Expand Down Expand Up @@ -625,6 +626,19 @@ export const AppContainer = (props: AppContainerProps) => {
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, config, addItem);

// Welcome onboarding - shown after folder trust, before other dialogs
const {
showWelcome: isWelcomeDialogOpen,
state: welcomeState,
actions: welcomeActions,
availableProviders: welcomeAvailableProviders,
availableModels: welcomeAvailableModels,
triggerAuth: triggerWelcomeAuth,
} = useWelcomeOnboarding({
settings,
isFolderTrustComplete: !isFolderTrustDialogOpen && !isRestarting,
});

const { needsRestart: ideNeedsRestart } = useIdeTrustListener(config);
useEffect(() => {
if (ideNeedsRestart) {
Expand Down Expand Up @@ -1483,6 +1497,7 @@ export const AppContainer = (props: AppContainerProps) => {
!isProviderModelDialogOpen &&
!isToolsDialogOpen &&
!showPrivacyNotice &&
!isWelcomeDialogOpen &&
geminiClient
) {
submitQuery(initialPrompt);
Expand All @@ -1499,6 +1514,7 @@ export const AppContainer = (props: AppContainerProps) => {
isProviderModelDialogOpen,
isToolsDialogOpen,
showPrivacyNotice,
isWelcomeDialogOpen,
geminiClient,
]);

Expand Down Expand Up @@ -1632,6 +1648,12 @@ export const AppContainer = (props: AppContainerProps) => {
isRestarting,
isTrustedFolder: config.isTrustedFolder(),

// Welcome onboarding
isWelcomeDialogOpen,
welcomeState,
welcomeAvailableProviders,
welcomeAvailableModels,

// Input history
inputHistory: inputHistoryStore.inputHistory,

Expand Down Expand Up @@ -1709,6 +1731,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Folder trust dialog
handleFolderTrustSelect,

// Welcome onboarding
welcomeActions,
triggerWelcomeAuth,

// Permissions dialog
openPermissionsDialog,
closePermissionsDialog,
Expand Down Expand Up @@ -1792,6 +1818,8 @@ export const AppContainer = (props: AppContainerProps) => {
handleToolsSelect,
exitToolsDialog,
handleFolderTrustSelect,
welcomeActions,
triggerWelcomeAuth,
openPermissionsDialog,
closePermissionsDialog,
openLoggingDialog,
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/ui/components/DialogManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useCallback } from 'react';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
// import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js'; // TODO: Not yet ported from upstream
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { WelcomeDialog } from './WelcomeOnboarding/WelcomeDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
Expand Down Expand Up @@ -98,6 +99,17 @@ export const DialogManager = ({
/>
);
}
if (uiState.isWelcomeDialogOpen) {
return (
<WelcomeDialog
state={uiState.welcomeState}
actions={uiActions.welcomeActions}
availableProviders={uiState.welcomeAvailableProviders}
availableModels={uiState.welcomeAvailableModels}
triggerAuth={uiActions.triggerWelcomeAuth}
/>
);
}
if (uiState.shellConfirmationRequest) {
return (
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/ui/components/WelcomeOnboarding/AuthMethodStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2025 Vybestack LLC
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useMemo, useCallback } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from '../shared/RadioButtonSelect.js';

// Providers that support OAuth
const OAUTH_PROVIDERS = new Set(['anthropic', 'openai', 'gemini', 'qwen']);

const API_KEY_URLS: Record<string, string> = {
anthropic: 'console.anthropic.com/settings/keys',
openai: 'platform.openai.com/api-keys',
gemini: 'aistudio.google.com/app/apikey',
deepseek: 'platform.deepseek.com/api_keys',
qwen: 'dashscope.console.aliyun.com/apiKey',
};

type AuthMethod = 'oauth' | 'api_key' | 'back';

interface AuthMethodStepProps {
provider: string;
onSelect: (method: 'oauth' | 'api_key') => void;
onBack: () => void;
error?: string;
isFocused?: boolean;
}

export const AuthMethodStep: React.FC<AuthMethodStepProps> = ({
provider,
onSelect,
onBack,
error,
isFocused = true,
}) => {
const supportsOAuth = OAUTH_PROVIDERS.has(provider);
const apiKeyUrl = API_KEY_URLS[provider];

const options: Array<RadioSelectItem<AuthMethod>> = useMemo(() => {
const opts: Array<RadioSelectItem<AuthMethod>> = [];

if (supportsOAuth) {
opts.push({
label: 'OAuth (Recommended - secure & easy)',
value: 'oauth',
key: 'oauth',
});
}

opts.push({
label: 'API Key',
value: 'api_key',
key: 'api_key',
});

opts.push({
label: '← Back to provider selection',
value: 'back',
key: 'back',
});

return opts;
}, [supportsOAuth]);

const handleSelect = useCallback(
(value: AuthMethod) => {
if (value === 'back') {
onBack();
} else {
onSelect(value);
}
},
[onBack, onSelect],
);

const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1);

return (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1}>
<Text bold color={Colors.AccentCyan}>
Step 3 of 5: Choose Authentication Method
</Text>
<Text> </Text>
<Text>How would you like to authenticate with {providerDisplay}?</Text>
</Box>

{error && (
<Box marginBottom={1}>
<Text color={Colors.AccentRed}>{error}</Text>
</Box>
)}

<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>

{apiKeyUrl && (
<Box marginTop={1}>
<Text color={Colors.Gray}>Get API key at: {apiKeyUrl}</Text>
</Box>
)}

<Box marginTop={1}>
<Text color={Colors.Gray}>
Use ↑↓ to navigate, Enter to select, Esc to skip
</Text>
</Box>
</Box>
);
};
Loading
Loading