Files
lettabot/src/onboard.ts

936 lines
33 KiB
TypeScript

/**
* LettaBot Onboarding - Modular setup wizard with review/edit capability
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import * as p from '@clack/prompts';
const ENV_PATH = resolve(process.cwd(), '.env');
const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
// ============================================================================
// Config Types
// ============================================================================
interface OnboardConfig {
// Auth
authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip';
apiKey?: string;
baseUrl?: string;
billingTier?: string;
// Agent
agentChoice: 'new' | 'existing' | 'env' | 'skip';
agentId?: string;
agentName?: string;
// Model (only for new agents)
model?: string;
// Channels (with access control)
telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] };
whatsapp: { enabled: boolean; selfChat?: boolean; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
signal: { enabled: boolean; phone?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
gmail: { enabled: boolean; account?: string };
// Features
heartbeat: { enabled: boolean; interval?: string };
cron: boolean;
}
// ============================================================================
// Env Helpers
// ============================================================================
function loadEnv(): Record<string, string> {
const env: Record<string, string> = {};
if (existsSync(ENV_PATH)) {
const content = readFileSync(ENV_PATH, 'utf-8');
for (const line of content.split('\n')) {
if (line.startsWith('#') || !line.includes('=')) continue;
const [key, ...valueParts] = line.split('=');
env[key.trim()] = valueParts.join('=').trim();
}
}
return env;
}
function saveEnv(env: Record<string, string>): void {
// Start with .env.example as template, fall back to existing .env if example doesn't exist
let content = '';
if (existsSync(ENV_EXAMPLE_PATH)) {
content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8');
} else if (existsSync(ENV_PATH)) {
content = readFileSync(ENV_PATH, 'utf-8');
}
// Track which keys we've seen in the template to detect deletions
const keysInTemplate = new Set<string>();
for (const line of content.split('\n')) {
const match = line.match(/^#?\s*(\w+)=/);
if (match) keysInTemplate.add(match[1]);
}
// Update or add keys that exist in env
for (const [key, value] of Object.entries(env)) {
const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
} else {
content += `\n${key}=${value}`;
}
}
// Comment out keys that were in template but deleted from env
for (const key of keysInTemplate) {
if (!(key in env)) {
const regex = new RegExp(`^(${key}=.*)$`, 'm');
content = content.replace(regex, '# $1');
}
}
writeFileSync(ENV_PATH, content);
}
const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
// ============================================================================
// Step Functions
// ============================================================================
async function stepAuth(config: OnboardConfig, env: Record<string, string>): Promise<void> {
const { requestDeviceCode, pollForToken, LETTA_CLOUD_API_URL } = await import('./auth/oauth.js');
const { saveTokens, loadTokens, getOrCreateDeviceId, getDeviceName } = await import('./auth/tokens.js');
const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL;
const isLettaCloud = !baseUrl || baseUrl === LETTA_CLOUD_API_URL || baseUrl === 'https://api.letta.com';
const existingTokens = loadTokens();
const realApiKey = isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY;
const validOAuthToken = isLettaCloud ? existingTokens?.accessToken : undefined;
const hasExistingAuth = !!realApiKey || !!validOAuthToken;
const displayKey = realApiKey || validOAuthToken;
// Determine label based on credential type
const getAuthLabel = () => {
if (validOAuthToken) return 'Use existing OAuth';
if (realApiKey?.startsWith('sk-let-')) return 'Use API key';
return 'Use existing';
};
const authOptions = [
...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []),
...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []),
{ value: 'apikey', label: 'Enter API Key manually', hint: 'Paste your key' },
{ value: 'selfhosted', label: 'Enter self-hosted URL', hint: 'Local Letta server' },
{ value: 'skip', label: 'Skip', hint: 'Continue without auth' },
];
const authMethod = await p.select({
message: 'Authentication',
options: authOptions,
});
if (p.isCancel(authMethod)) { p.cancel('Setup cancelled'); process.exit(0); }
config.authMethod = authMethod as OnboardConfig['authMethod'];
if (authMethod === 'oauth') {
const spinner = p.spinner();
spinner.start('Requesting authorization...');
try {
const deviceData = await requestDeviceCode();
spinner.stop('Authorization requested');
p.note(
`Code: ${deviceData.user_code}\n` +
`URL: ${deviceData.verification_uri_complete}`,
'Open in Browser'
);
try {
const open = (await import('open')).default;
await open(deviceData.verification_uri_complete, { wait: false });
} catch {}
spinner.start('Waiting for authorization...');
const deviceId = getOrCreateDeviceId();
const deviceName = getDeviceName();
const tokens = await pollForToken(
deviceData.device_code,
deviceData.interval,
deviceData.expires_in,
deviceId,
deviceName,
);
spinner.stop('Authorized!');
const now = Date.now();
saveTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
tokenExpiresAt: now + tokens.expires_in * 1000,
deviceId,
deviceName,
});
config.apiKey = tokens.access_token;
env.LETTA_API_KEY = tokens.access_token;
} catch (err) {
spinner.stop('Authorization failed');
throw err;
}
} else if (authMethod === 'apikey') {
const apiKey = await p.text({
message: 'API Key',
placeholder: 'sk-...',
});
if (p.isCancel(apiKey)) { p.cancel('Setup cancelled'); process.exit(0); }
if (apiKey) {
config.apiKey = apiKey;
env.LETTA_API_KEY = apiKey;
}
} else if (authMethod === 'selfhosted') {
const serverUrl = await p.text({
message: 'Letta server URL',
placeholder: 'http://localhost:8283',
initialValue: 'http://localhost:8283',
});
if (p.isCancel(serverUrl)) { p.cancel('Setup cancelled'); process.exit(0); }
const url = serverUrl || 'http://localhost:8283';
config.baseUrl = url;
env.LETTA_BASE_URL = url;
process.env.LETTA_BASE_URL = url; // Set immediately so model listing works
// Clear any cloud API key since we're using self-hosted
delete env.LETTA_API_KEY;
delete process.env.LETTA_API_KEY;
} else if (authMethod === 'keep') {
// For OAuth tokens, refresh if needed
if (existingTokens?.refreshToken) {
const { isTokenExpired } = await import('./auth/tokens.js');
const { refreshAccessToken } = await import('./auth/oauth.js');
if (isTokenExpired(existingTokens)) {
const spinner = p.spinner();
spinner.start('Refreshing token...');
try {
const newTokens = await refreshAccessToken(
existingTokens.refreshToken,
existingTokens.deviceId,
getDeviceName(),
);
const now = Date.now();
saveTokens({
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token ?? existingTokens.refreshToken,
tokenExpiresAt: now + newTokens.expires_in * 1000,
deviceId: existingTokens.deviceId,
deviceName: existingTokens.deviceName,
});
config.apiKey = newTokens.access_token;
env.LETTA_API_KEY = newTokens.access_token;
spinner.stop('Token refreshed');
} catch {
spinner.stop('Token refresh failed');
p.log.warning('Your session may have expired. Try "Login to Letta Platform" to re-authenticate.');
}
} else {
// Token not expired, use existing
config.apiKey = existingTokens.accessToken;
env.LETTA_API_KEY = existingTokens.accessToken!;
}
} else if (realApiKey) {
// Using existing API key
config.apiKey = realApiKey;
env.LETTA_API_KEY = realApiKey;
}
}
// Validate connection (skip if 'skip' was chosen)
if (config.authMethod !== 'skip') {
const keyToValidate = config.apiKey || env.LETTA_API_KEY;
if (keyToValidate) {
process.env.LETTA_API_KEY = keyToValidate;
}
const spinner = p.spinner();
const serverLabel = config.baseUrl || 'Letta Cloud';
spinner.start(`Checking connection to ${serverLabel}...`);
try {
const { testConnection } = await import('./tools/letta-api.js');
const ok = await testConnection();
spinner.stop(ok ? `Connected to ${serverLabel}` : 'Connection issue');
if (!ok && config.authMethod === 'selfhosted') {
p.log.warn(`Could not connect to ${config.baseUrl}. Make sure the server is running.`);
}
} catch {
spinner.stop('Connection check skipped');
}
}
}
async function stepAgent(config: OnboardConfig, env: Record<string, string>): Promise<void> {
const { listAgents } = await import('./tools/letta-api.js');
const envAgentId = process.env.LETTA_AGENT_ID;
const agentOptions: Array<{ value: string; label: string; hint: string }> = [
{ value: 'new', label: 'Create new agent', hint: 'Start fresh' },
{ value: 'existing', label: 'Select existing', hint: 'From server' },
];
if (envAgentId) {
agentOptions.push({ value: 'env', label: 'Use LETTA_AGENT_ID', hint: envAgentId.slice(0, 15) + '...' });
}
agentOptions.push({ value: 'skip', label: 'Skip', hint: 'Keep current' });
const agentChoice = await p.select({
message: 'Agent',
options: agentOptions,
});
if (p.isCancel(agentChoice)) { p.cancel('Setup cancelled'); process.exit(0); }
config.agentChoice = agentChoice as OnboardConfig['agentChoice'];
if (agentChoice === 'existing') {
const searchQuery = await p.text({
message: 'Search by name (Enter for all)',
placeholder: 'my-agent',
});
if (p.isCancel(searchQuery)) { p.cancel('Setup cancelled'); process.exit(0); }
const spinner = p.spinner();
spinner.start('Fetching agents...');
const agents = await listAgents(searchQuery || undefined);
spinner.stop(`Found ${agents.length}`);
if (agents.length > 0) {
const selectedAgent = await p.select({
message: 'Select agent',
options: [
...agents.map(a => ({
value: a.id,
label: a.name,
hint: a.id.slice(0, 15) + '...',
})),
{ value: '__back__', label: '← Back', hint: '' },
],
});
if (p.isCancel(selectedAgent)) { p.cancel('Setup cancelled'); process.exit(0); }
if (selectedAgent === '__back__') {
// Re-run agent step from the beginning
return stepAgent(config, env);
}
config.agentId = selectedAgent as string;
const agent = agents.find(a => a.id === config.agentId);
config.agentName = agent?.name;
} else {
p.log.warning('No agents found.');
// Re-run agent step
return stepAgent(config, env);
}
} else if (agentChoice === 'env') {
config.agentId = envAgentId!;
} else if (agentChoice === 'new') {
const agentName = await p.text({
message: 'Agent name',
placeholder: 'LettaBot',
initialValue: env.AGENT_NAME || '',
});
if (p.isCancel(agentName)) { p.cancel('Setup cancelled'); process.exit(0); }
config.agentName = agentName || 'LettaBot';
}
}
async function stepModel(config: OnboardConfig, env: Record<string, string>): Promise<void> {
// Only for new agents
if (config.agentChoice !== 'new') return;
const { buildModelOptions, handleModelSelection, getBillingTier } = await import('./utils/model-selection.js');
const spinner = p.spinner();
// Determine if self-hosted (not Letta Cloud)
const isSelfHosted = config.authMethod === 'selfhosted';
// Fetch billing tier for Letta Cloud users
let billingTier: string | null = null;
if (!isSelfHosted) {
spinner.start('Checking account...');
// Pass the API key explicitly since it may not be in process.env yet
const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY;
billingTier = await getBillingTier(apiKey, isSelfHosted);
config.billingTier = billingTier ?? undefined;
spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`);
}
spinner.start('Fetching models...');
const modelOptions = await buildModelOptions({ billingTier, isSelfHosted });
spinner.stop('Models loaded');
// Show appropriate message for free tier
if (billingTier === 'free') {
p.log.info('Free plan: GLM and MiniMax models are free. Other models require BYOK (Bring Your Own Key).');
}
let selectedModel: string | null = null;
while (!selectedModel) {
const modelChoice = await p.select({
message: 'Select model',
options: modelOptions,
maxItems: 12,
});
if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); }
selectedModel = await handleModelSelection(modelChoice, p.text);
// If null (e.g., header selected), loop again
}
config.model = selectedModel;
}
async function stepChannels(config: OnboardConfig, env: Record<string, string>): Promise<void> {
// Check if signal-cli is installed
const signalInstalled = spawnSync('which', ['signal-cli'], { stdio: 'pipe' }).status === 0;
// Build channel options - show all channels, disabled ones have explanatory hints
const channelOptions: Array<{ value: string; label: string; hint: string }> = [
{ value: 'telegram', label: 'Telegram', hint: 'Recommended - easiest to set up' },
{ value: 'slack', label: 'Slack', hint: 'Socket Mode app' },
{ value: 'whatsapp', label: 'WhatsApp', hint: 'QR code pairing' },
{
value: 'signal',
label: 'Signal',
hint: signalInstalled ? 'signal-cli daemon' : '⚠️ signal-cli not installed'
},
];
// Don't pre-select any channels - let user explicitly choose
let channels: string[] = [];
while (true) {
const selectedChannels = await p.multiselect({
message: 'Select channels (space to toggle, enter to confirm)',
options: channelOptions,
required: false,
});
if (p.isCancel(selectedChannels)) { p.cancel('Setup cancelled'); process.exit(0); }
channels = selectedChannels as string[];
// Confirm if no channels selected
if (channels.length === 0) {
const skipChannels = await p.confirm({
message: 'No channels selected. Continue without any messaging channels?',
initialValue: false,
});
if (p.isCancel(skipChannels)) { p.cancel('Setup cancelled'); process.exit(0); }
if (skipChannels) break;
// Otherwise loop back to selection
} else {
break;
}
}
// Update enabled states
config.telegram.enabled = channels.includes('telegram');
config.slack.enabled = channels.includes('slack');
config.whatsapp.enabled = channels.includes('whatsapp');
// Handle Signal - warn if selected but not installed
if (channels.includes('signal') && !signalInstalled) {
p.log.warn('Signal selected but signal-cli is not installed. Install with: brew install signal-cli');
config.signal.enabled = false;
} else {
config.signal.enabled = channels.includes('signal');
}
// Configure each selected channel
if (config.telegram.enabled) {
p.note(
'1. Message @BotFather on Telegram\n' +
'2. Send /newbot and follow prompts\n' +
'3. Copy the bot token',
'Telegram Setup'
);
const token = await p.text({
message: 'Telegram Bot Token',
placeholder: '123456:ABC-DEF...',
initialValue: config.telegram.token || '',
});
if (!p.isCancel(token) && token) config.telegram.token = token;
// Access control
const dmPolicy = await p.select({
message: 'Telegram: Who can message the bot?',
options: [
{ value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' },
{ value: 'allowlist', label: 'Allowlist only', hint: 'Specific user IDs' },
{ value: 'open', label: 'Open', hint: 'Anyone (not recommended)' },
],
initialValue: config.telegram.dmPolicy || 'pairing',
});
if (!p.isCancel(dmPolicy)) {
config.telegram.dmPolicy = dmPolicy as 'pairing' | 'allowlist' | 'open';
if (dmPolicy === 'pairing') {
p.log.info('Users will get a code. Approve with: lettabot pairing approve telegram CODE');
} else if (dmPolicy === 'allowlist') {
const users = await p.text({
message: 'Allowed Telegram user IDs (comma-separated)',
placeholder: '123456789,987654321',
initialValue: config.telegram.allowedUsers?.join(',') || '',
});
if (!p.isCancel(users) && users) {
config.telegram.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean);
}
}
}
}
if (config.slack.enabled) {
p.note(
'See docs/slack-setup.md for full instructions.\n\n' +
'Quick reference at api.slack.com/apps:\n' +
'• Enable Socket Mode first\n' +
'• App Token: Basic Information → App-Level Tokens\n' +
'• Bot Token: OAuth & Permissions → Bot User OAuth Token',
'Slack Setup'
);
const appToken = await p.text({
message: 'Slack App Token (xapp-...)',
initialValue: config.slack.appToken || '',
});
if (!p.isCancel(appToken) && appToken) config.slack.appToken = appToken;
const botToken = await p.text({
message: 'Slack Bot Token (xoxb-...)',
initialValue: config.slack.botToken || '',
});
if (!p.isCancel(botToken) && botToken) config.slack.botToken = botToken;
// Slack access control (workspace already provides some isolation)
const restrictSlack = await p.confirm({
message: 'Slack: Restrict to specific users? (workspace already limits access)',
initialValue: (config.slack.allowedUsers?.length || 0) > 0,
});
if (!p.isCancel(restrictSlack) && restrictSlack) {
const users = await p.text({
message: 'Allowed Slack user IDs (comma-separated)',
placeholder: 'U01234567,U98765432',
initialValue: config.slack.allowedUsers?.join(',') || '',
});
if (!p.isCancel(users) && users) {
config.slack.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean);
}
}
}
if (config.whatsapp.enabled) {
p.note(
'QR code will appear on first run - scan with your phone.\n' +
'Phone: Settings → Linked Devices → Link a Device\n\n' +
'⚠️ Security: Links as a full device to your WhatsApp account.\n' +
'Can see ALL messages, not just ones sent to the bot.\n' +
'Consider using a dedicated number for better isolation.',
'WhatsApp'
);
const selfChat = await p.confirm({
message: 'WhatsApp: Self-chat mode? (Message Yourself)',
initialValue: config.whatsapp.selfChat ?? false,
});
if (!p.isCancel(selfChat)) config.whatsapp.selfChat = selfChat;
// Access control (important since WhatsApp has full account access)
const dmPolicy = await p.select({
message: 'WhatsApp: Who can message the bot?',
options: [
{ value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' },
{ value: 'allowlist', label: 'Allowlist only', hint: 'Specific phone numbers' },
{ value: 'open', label: 'Open', hint: '⚠️ Anyone (not recommended - full account access!)' },
],
initialValue: config.whatsapp.dmPolicy || 'pairing',
});
if (!p.isCancel(dmPolicy)) {
config.whatsapp.dmPolicy = dmPolicy as 'pairing' | 'allowlist' | 'open';
if (dmPolicy === 'pairing') {
p.log.info('Users will get a code. Approve with: lettabot pairing approve whatsapp CODE');
} else if (dmPolicy === 'allowlist') {
const users = await p.text({
message: 'Allowed phone numbers (comma-separated, with +)',
placeholder: '+15551234567,+15559876543',
initialValue: config.whatsapp.allowedUsers?.join(',') || '',
});
if (!p.isCancel(users) && users) {
config.whatsapp.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean);
}
}
}
}
if (config.signal.enabled) {
p.note(
'See docs/signal-setup.md for detailed instructions.\n' +
'Requires signal-cli registered with your phone number.\n\n' +
'⚠️ Security: Has full access to your Signal account.\n' +
'Can see all messages and send as you.',
'Signal Setup'
);
const phone = await p.text({
message: 'Signal phone number',
placeholder: '+1XXXXXXXXXX',
initialValue: config.signal.phone || '',
});
if (!p.isCancel(phone) && phone) config.signal.phone = phone;
// Access control
const dmPolicy = await p.select({
message: 'Signal: Who can message the bot?',
options: [
{ value: 'pairing', label: 'Pairing (recommended)', hint: 'Requires CLI approval' },
{ value: 'allowlist', label: 'Allowlist only', hint: 'Specific phone numbers' },
{ value: 'open', label: 'Open', hint: 'Anyone (not recommended)' },
],
initialValue: config.signal.dmPolicy || 'pairing',
});
if (!p.isCancel(dmPolicy)) {
config.signal.dmPolicy = dmPolicy as 'pairing' | 'allowlist' | 'open';
if (dmPolicy === 'pairing') {
p.log.info('Users will get a code. Approve with: lettabot pairing approve signal CODE');
} else if (dmPolicy === 'allowlist') {
const users = await p.text({
message: 'Allowed phone numbers (comma-separated, with +)',
placeholder: '+15551234567,+15559876543',
initialValue: config.signal.allowedUsers?.join(',') || '',
});
if (!p.isCancel(users) && users) {
config.signal.allowedUsers = users.split(',').map(s => s.trim()).filter(Boolean);
}
}
}
}
}
async function stepFeatures(config: OnboardConfig): Promise<void> {
// Heartbeat
const setupHeartbeat = await p.confirm({
message: 'Enable heartbeat? (periodic agent wake-ups)',
initialValue: config.heartbeat.enabled,
});
if (p.isCancel(setupHeartbeat)) { p.cancel('Setup cancelled'); process.exit(0); }
config.heartbeat.enabled = setupHeartbeat;
if (setupHeartbeat) {
const interval = await p.text({
message: 'Interval (minutes)',
placeholder: '30',
initialValue: config.heartbeat.interval || '30',
});
if (!p.isCancel(interval)) config.heartbeat.interval = interval || '30';
}
// Cron
const setupCron = await p.confirm({
message: 'Enable cron jobs?',
initialValue: config.cron,
});
if (!p.isCancel(setupCron)) config.cron = setupCron;
}
// ============================================================================
// Summary & Review
// ============================================================================
function showSummary(config: OnboardConfig): void {
const lines: string[] = [];
// Auth
const authLabel = {
keep: 'Keep existing',
oauth: 'OAuth login',
apikey: config.apiKey ? `API Key (${config.apiKey.slice(0, 10)}...)` : 'API Key',
selfhosted: config.baseUrl ? `Self-hosted (${config.baseUrl})` : 'Self-hosted',
skip: 'None',
}[config.authMethod];
lines.push(`Auth: ${authLabel}`);
// Agent
const agentLabel = config.agentId
? `${config.agentName || 'Selected'} (${config.agentId.slice(0, 12)}...)`
: config.agentName
? `New: ${config.agentName}`
: config.agentChoice === 'skip' ? 'Keep current' : 'None';
lines.push(`Agent: ${agentLabel}`);
// Model
if (config.model) {
lines.push(`Model: ${config.model}`);
}
// Channels
const channels: string[] = [];
if (config.telegram.enabled) channels.push('Telegram');
if (config.slack.enabled) channels.push('Slack');
if (config.whatsapp.enabled) channels.push(config.whatsapp.selfChat ? 'WhatsApp (self)' : 'WhatsApp');
if (config.signal.enabled) channels.push('Signal');
lines.push(`Channels: ${channels.length > 0 ? channels.join(', ') : 'None'}`);
// Features
const features: string[] = [];
if (config.heartbeat.enabled) features.push(`Heartbeat (${config.heartbeat.interval}m)`);
if (config.cron) features.push('Cron');
lines.push(`Features: ${features.length > 0 ? features.join(', ') : 'None'}`);
p.note(lines.join('\n'), 'Configuration');
}
type Section = 'auth' | 'agent' | 'channels' | 'features' | 'save';
async function reviewLoop(config: OnboardConfig, env: Record<string, string>): Promise<void> {
while (true) {
showSummary(config);
const choice = await p.select({
message: 'What would you like to do?',
options: [
{ value: 'save', label: 'Save and finish', hint: '' },
{ value: 'auth', label: 'Change authentication', hint: '' },
{ value: 'agent', label: 'Change agent', hint: '' },
{ value: 'channels', label: 'Change channels', hint: '' },
{ value: 'features', label: 'Change features', hint: '' },
],
});
if (p.isCancel(choice)) { p.cancel('Setup cancelled'); process.exit(0); }
if (choice === 'save') break;
// Re-run the selected section
if (choice === 'auth') await stepAuth(config, env);
else if (choice === 'agent') {
await stepAgent(config, env);
if (config.agentChoice === 'new') await stepModel(config, env);
}
else if (choice === 'channels') await stepChannels(config, env);
else if (choice === 'features') await stepFeatures(config);
}
}
// ============================================================================
// Main Onboard Function
// ============================================================================
export async function onboard(): Promise<void> {
const env = loadEnv();
p.intro('🤖 LettaBot Setup');
// Show server info
const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com';
const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server');
// Test server connection
const spinner = p.spinner();
spinner.start('Testing connection...');
try {
const res = await fetch(`${baseUrl}/v1/health`, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
spinner.stop('Connected to server');
} else {
spinner.stop('Server returned error');
p.log.warning(`Server responded with status ${res.status}`);
}
} catch (e) {
spinner.stop('Connection failed');
p.log.error(`Could not connect to ${baseUrl}`);
const continueAnyway = await p.confirm({ message: 'Continue anyway?', initialValue: false });
if (p.isCancel(continueAnyway) || !continueAnyway) {
p.cancel('Setup cancelled');
process.exit(1);
}
}
// Initialize config from existing env
const config: OnboardConfig = {
authMethod: 'skip',
telegram: {
enabled: !!env.TELEGRAM_BOT_TOKEN && !isPlaceholder(env.TELEGRAM_BOT_TOKEN),
token: isPlaceholder(env.TELEGRAM_BOT_TOKEN) ? undefined : env.TELEGRAM_BOT_TOKEN,
},
slack: {
enabled: !!env.SLACK_BOT_TOKEN,
appToken: env.SLACK_APP_TOKEN,
botToken: env.SLACK_BOT_TOKEN,
},
whatsapp: {
enabled: env.WHATSAPP_ENABLED === 'true',
selfChat: env.WHATSAPP_SELF_CHAT_MODE === 'true',
},
signal: {
enabled: !!env.SIGNAL_PHONE_NUMBER,
phone: env.SIGNAL_PHONE_NUMBER,
},
gmail: { enabled: false },
heartbeat: {
enabled: !!env.HEARTBEAT_INTERVAL_MIN,
interval: env.HEARTBEAT_INTERVAL_MIN,
},
cron: env.CRON_ENABLED === 'true',
agentChoice: 'skip',
agentName: env.AGENT_NAME,
model: env.MODEL,
};
// Run through all steps
await stepAuth(config, env);
await stepAgent(config, env);
await stepModel(config, env);
await stepChannels(config, env);
await stepFeatures(config);
// Review loop
await reviewLoop(config, env);
// Apply config to env
if (config.agentName) env.AGENT_NAME = config.agentName;
if (config.model) env.MODEL = config.model;
if (config.telegram.enabled && config.telegram.token) {
env.TELEGRAM_BOT_TOKEN = config.telegram.token;
if (config.telegram.dmPolicy) env.TELEGRAM_DM_POLICY = config.telegram.dmPolicy;
if (config.telegram.allowedUsers?.length) {
env.TELEGRAM_ALLOWED_USERS = config.telegram.allowedUsers.join(',');
} else {
delete env.TELEGRAM_ALLOWED_USERS;
}
} else {
delete env.TELEGRAM_BOT_TOKEN;
delete env.TELEGRAM_DM_POLICY;
delete env.TELEGRAM_ALLOWED_USERS;
}
if (config.slack.enabled) {
if (config.slack.appToken) env.SLACK_APP_TOKEN = config.slack.appToken;
if (config.slack.botToken) env.SLACK_BOT_TOKEN = config.slack.botToken;
if (config.slack.allowedUsers?.length) {
env.SLACK_ALLOWED_USERS = config.slack.allowedUsers.join(',');
} else {
delete env.SLACK_ALLOWED_USERS;
}
} else {
delete env.SLACK_APP_TOKEN;
delete env.SLACK_BOT_TOKEN;
delete env.SLACK_ALLOWED_USERS;
}
if (config.whatsapp.enabled) {
env.WHATSAPP_ENABLED = 'true';
if (config.whatsapp.selfChat) env.WHATSAPP_SELF_CHAT_MODE = 'true';
else delete env.WHATSAPP_SELF_CHAT_MODE;
if (config.whatsapp.dmPolicy) env.WHATSAPP_DM_POLICY = config.whatsapp.dmPolicy;
if (config.whatsapp.allowedUsers?.length) {
env.WHATSAPP_ALLOWED_USERS = config.whatsapp.allowedUsers.join(',');
} else {
delete env.WHATSAPP_ALLOWED_USERS;
}
} else {
delete env.WHATSAPP_ENABLED;
delete env.WHATSAPP_SELF_CHAT_MODE;
delete env.WHATSAPP_DM_POLICY;
delete env.WHATSAPP_ALLOWED_USERS;
}
if (config.signal.enabled && config.signal.phone) {
env.SIGNAL_PHONE_NUMBER = config.signal.phone;
if (config.signal.dmPolicy) env.SIGNAL_DM_POLICY = config.signal.dmPolicy;
if (config.signal.allowedUsers?.length) {
env.SIGNAL_ALLOWED_USERS = config.signal.allowedUsers.join(',');
} else {
delete env.SIGNAL_ALLOWED_USERS;
}
} else {
delete env.SIGNAL_PHONE_NUMBER;
delete env.SIGNAL_DM_POLICY;
delete env.SIGNAL_ALLOWED_USERS;
}
if (config.heartbeat.enabled && config.heartbeat.interval) {
env.HEARTBEAT_INTERVAL_MIN = config.heartbeat.interval;
} else {
delete env.HEARTBEAT_INTERVAL_MIN;
}
if (config.cron) {
env.CRON_ENABLED = 'true';
} else {
delete env.CRON_ENABLED;
}
// Helper to format access control status
const formatAccess = (policy?: string, allowedUsers?: string[]) => {
if (policy === 'pairing') return 'pairing';
if (policy === 'allowlist') return `allowlist (${allowedUsers?.length || 0} users)`;
if (policy === 'open') return '⚠️ open';
return 'pairing';
};
// Show summary
const summary = [
`Agent: ${config.agentId ? `${config.agentName} (${config.agentId.slice(0, 20)}...)` : config.agentName || '(will create on first message)'}`,
`Model: ${config.model || 'default'}`,
'',
'Channels:',
config.telegram.enabled ? ` ✓ Telegram (${formatAccess(config.telegram.dmPolicy, config.telegram.allowedUsers)})` : ' ✗ Telegram',
config.slack.enabled ? ` ✓ Slack ${config.slack.allowedUsers?.length ? `(${config.slack.allowedUsers.length} allowed users)` : '(workspace access)'}` : ' ✗ Slack',
config.whatsapp.enabled ? ` ✓ WhatsApp (${formatAccess(config.whatsapp.dmPolicy, config.whatsapp.allowedUsers)})` : ' ✗ WhatsApp',
config.signal.enabled ? ` ✓ Signal (${formatAccess(config.signal.dmPolicy, config.signal.allowedUsers)})` : ' ✗ Signal',
'',
'Features:',
config.heartbeat.enabled ? ` ✓ Heartbeat (${config.heartbeat.interval}min)` : ' ✗ Heartbeat',
config.cron ? ' ✓ Cron jobs' : ' ✗ Cron jobs',
].join('\n');
p.note(summary, 'Configuration Summary');
// Save
saveEnv(env);
p.log.success('Configuration saved to .env');
// Save agent ID with server URL
if (config.agentId) {
const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com';
writeFileSync(
resolve(process.cwd(), 'lettabot-agent.json'),
JSON.stringify({
agentId: config.agentId,
baseUrl: baseUrl,
createdAt: new Date().toISOString(),
}, null, 2)
);
p.log.success(`Agent ID saved: ${config.agentId} (${baseUrl})`);
}
p.outro('🎉 Setup complete! Run `lettabot server` to start.');
}