Files
lettabot/src/cli.ts
Cameron 6e8d1fc19d feat: add core config TUI editor (#522)
Co-authored-by: Letta Code <noreply@letta.com>
2026-03-10 11:51:05 -07:00

706 lines
24 KiB
JavaScript

#!/usr/bin/env node
/**
* LettaBot CLI
*
* Commands:
* lettabot onboard - Onboarding workflow (setup integrations, install skills)
* lettabot server - Run the bot server
* lettabot configure - Configure settings
*/
// Config loaded from lettabot.yaml (lazily, so debug/help commands can run with broken config)
import type { LettaBotConfig } from './config/index.js';
import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js';
let cachedConfig: LettaBotConfig | null = null;
function getConfig(): LettaBotConfig {
if (!cachedConfig) {
cachedConfig = loadAppConfigOrExit();
applyConfigToEnv(cachedConfig);
}
return cachedConfig;
}
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import updateNotifier from 'update-notifier';
import { Store } from './core/store.js';
// Get the directory where this CLI file is located (works with npx, global install, etc.)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Check for updates (runs in background, shows notification if update available)
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
updateNotifier({ pkg }).notify();
import * as readline from 'node:readline';
const args = process.argv.slice(2);
const command = args[0];
const subCommand = args[1];
// Check if value is a placeholder
const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
// Import onboard from separate module
import { onboard } from './onboard.js';
async function configure() {
const p = await import('@clack/prompts');
const { resolveConfigPath } = await import('./config/index.js');
const config = getConfig();
p.intro('🤖 LettaBot Configuration');
// Show current config from YAML
const configRows = [
['Server Mode', serverModeLabel(config.server.mode)],
['API Key', config.server.apiKey ? '✓ Set' : '✗ Not set'],
['Agent Name', config.agent.name],
['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'],
['Slack', config.channels.slack?.enabled ? '✓ Enabled' : '✗ Disabled'],
['Discord', config.channels.discord?.enabled ? '✓ Enabled' : '✗ Disabled'],
['Cron', config.features?.cron ? '✓ Enabled' : '✗ Disabled'],
['Heartbeat', config.features?.heartbeat?.enabled ? `${config.features.heartbeat.intervalMin}min` : '✗ Disabled'],
['BYOK Providers', config.providers?.length ? config.providers.map(p => p.name).join(', ') : 'None'],
];
const maxKeyLength = Math.max(...configRows.map(([key]) => key.length));
const summary = configRows
.map(([key, value]) => `${(key + ':').padEnd(maxKeyLength + 1)} ${value}`)
.join('\n');
p.note(summary, `Current Configuration (${resolveConfigPath()})`);
const choice = await p.select({
message: 'What would you like to do?',
options: [
{ value: 'onboard', label: 'Run setup wizard', hint: 'lettabot onboard' },
{ value: 'tui', label: 'Open TUI editor', hint: 'lettabot config tui' },
{ value: 'edit', label: 'Edit config file', hint: resolveConfigPath() },
{ value: 'exit', label: 'Exit', hint: '' },
],
});
if (p.isCancel(choice)) {
p.cancel('Configuration cancelled');
return;
}
switch (choice) {
case 'onboard':
await onboard();
break;
case 'tui': {
const { configTui } = await import('./cli/config-tui.js');
await configTui();
break;
}
case 'edit': {
const configPath = resolveConfigPath();
const editor = process.env.EDITOR || 'nano';
console.log(`Opening ${configPath} in ${editor}...`);
spawnSync(editor, [configPath], { stdio: 'inherit' });
break;
}
case 'exit':
break;
}
}
async function configEncode() {
const { resolveConfigPath, encodeConfigForEnv } = await import('./config/index.js');
const configPath = resolveConfigPath();
if (!existsSync(configPath)) {
console.error(`No config file found at ${configPath}`);
process.exit(1);
}
const content = readFileSync(configPath, 'utf-8');
const encoded = encodeConfigForEnv(content);
console.log('Set this environment variable on your cloud platform:\n');
console.log(`LETTABOT_CONFIG_YAML=${encoded}`);
console.log(`\nSource: ${configPath} (${content.length} bytes -> ${encoded.length} chars base64)`);
}
async function configDecode() {
if (!process.env.LETTABOT_CONFIG_YAML) {
console.error('LETTABOT_CONFIG_YAML is not set');
process.exit(1);
}
const { decodeYamlOrBase64 } = await import('./config/index.js');
console.log(decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML));
}
async function server() {
const { resolveConfigPath, hasInlineConfig } = await import('./config/index.js');
const configPath = resolveConfigPath();
// Check if configured (inline config or file)
if (!existsSync(configPath) && !hasInlineConfig()) {
console.log(`
No config file found. Searched locations:
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
2. LETTABOT_CONFIG env var (file path)
3. ./lettabot.yaml (project-local - recommended for local dev)
4. ./lettabot.yml
5. ~/.lettabot/config.yaml (user global)
6. ~/.lettabot/config.yml
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys.
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
`);
process.exit(1);
}
console.log('Starting LettaBot server...\n');
// Start the bot using the compiled JS
// Use __dirname to find main.js relative to this CLI file (works with npx, global install, etc.)
const mainPath = resolve(__dirname, 'main.js');
if (existsSync(mainPath)) {
spawn('node', [mainPath], {
stdio: 'inherit',
cwd: process.cwd(),
env: { ...process.env },
});
} else {
// Fallback to tsx for development - look for src/main.ts relative to package root
const packageRoot = resolve(__dirname, '..');
const mainTsPath = resolve(packageRoot, 'src/main.ts');
if (existsSync(mainTsPath)) {
spawn('npx', ['tsx', mainTsPath], {
stdio: 'inherit',
cwd: process.cwd(),
});
} else {
console.error('Error: Could not find main.js or main.ts');
console.error(` Looked for: ${mainPath}`);
console.error(` Looked for: ${mainTsPath}`);
process.exit(1);
}
}
}
// Pairing commands
async function pairingList(channel: string) {
const { listPairingRequests } = await import('./pairing/store.js');
const requests = await listPairingRequests(channel);
if (requests.length === 0) {
console.log(`No pending ${channel} pairing requests.`);
return;
}
console.log(`\nPending ${channel} pairing requests (${requests.length}):\n`);
console.log(' Code | User ID | Username | Requested');
console.log(' ----------|-------------------|-------------------|---------------------');
for (const r of requests) {
const username = r.meta?.username ? `@${r.meta.username}` : r.meta?.firstName || '-';
const date = new Date(r.createdAt).toLocaleString();
console.log(` ${r.code.padEnd(10)}| ${r.id.padEnd(18)}| ${username.padEnd(18)}| ${date}`);
}
console.log('');
}
async function pairingApprove(channel: string, code: string) {
const { approvePairingCode } = await import('./pairing/store.js');
const result = await approvePairingCode(channel, code);
if (!result) {
console.log(`No pending pairing request found for code: ${code}`);
process.exit(1);
}
const name = result.meta?.username ? `@${result.meta.username}` : result.meta?.firstName || result.userId;
console.log(`✓ Approved ${channel} sender: ${name} (${result.userId})`);
}
function showHelp() {
console.log(`
LettaBot - Multi-channel AI assistant with persistent memory
Usage: lettabot <command>
Commands:
onboard Setup wizard (integrations, skills, configuration)
server Start the bot server
configure View and edit configuration
config tui Interactive core config editor
config encode Encode config file as base64 for LETTABOT_CONFIG_YAML
config decode Decode and print LETTABOT_CONFIG_YAML env var
connect <provider> Connect model providers (e.g., chatgpt/codex)
model Interactive model selector
model show Show current agent model
model set <handle> Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929)
channels Manage channels (interactive menu)
channels list Show channel status
channels list-groups List group/channel IDs for Slack/Discord
channels add <ch> Add a channel (telegram, slack, discord, whatsapp, signal)
channels remove <ch> Remove a channel
logout Logout from Letta Platform (revoke OAuth tokens)
skills Configure which skills are enabled
skills status Show skills status
todo Manage per-agent to-dos
todo list List todos
todo add <text> Add a todo
todo complete <id> Mark a todo complete
todo remove <id> Remove a todo
todo snooze <id> Snooze a todo until a date
set-conversation <id> Set a specific conversation ID
reset-conversation Clear conversation ID (fixes corrupted conversations)
destroy Delete all local data and start fresh
pairing list <ch> List pending pairing requests
pairing approve <ch> <code> Approve a pairing code
help Show this help message
Examples:
lettabot onboard # First-time setup
lettabot server # Start the bot
lettabot config tui # Interactive core config editor
lettabot channels # Interactive channel management
lettabot channels add discord # Add Discord integration
lettabot channels remove telegram # Remove Telegram
lettabot todo add "Deliver morning report" --recurring "daily 8am"
lettabot todo list --actionable
lettabot pairing list telegram # Show pending Telegram pairings
lettabot pairing approve telegram ABCD1234 # Approve a pairing code
lettabot connect chatgpt # Connect ChatGPT subscription (via OAuth)
Environment:
LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys)
LETTA_API_KEY API key from app.letta.com
TELEGRAM_BOT_TOKEN Bot token from @BotFather
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
DISCORD_BOT_TOKEN Discord bot token
DISCORD_DM_POLICY DM access policy (pairing, allowlist, open)
SLACK_BOT_TOKEN Slack bot token (xoxb-...)
SLACK_APP_TOKEN Slack app token (xapp-...)
HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes
HEARTBEAT_SKIP_RECENT_USER_MIN Skip auto-heartbeats after user messages (0 disables)
CRON_ENABLED Enable cron jobs (true/false)
`);
}
function getDefaultTodoAgentKey(): string {
const config = getConfig();
const configuredName =
(config.agent?.name?.trim())
|| (config.agents?.length && config.agents[0].name?.trim())
|| 'LettaBot';
try {
const store = new Store('lettabot-agent.json', configuredName);
if (store.agentId) return store.agentId;
} catch {
// Ignore; fall back to configured name
}
return configuredName;
}
async function main() {
// Most commands expect config-derived env vars to be applied.
// Skip bootstrap for help/no-command and config encode/decode so these still work
// when the current config is broken.
if (
command &&
command !== 'help' &&
command !== '-h' &&
command !== '--help' &&
!(command === 'config' && (subCommand === 'encode' || subCommand === 'decode'))
) {
getConfig();
}
switch (command) {
case 'onboard':
case 'setup':
case 'init':
const nonInteractive = args.includes('--non-interactive') || args.includes('-n');
await onboard({ nonInteractive });
break;
case 'server':
case 'start':
case 'run':
await server();
break;
case 'configure':
case 'config':
if (subCommand === 'encode') {
await configEncode();
} else if (subCommand === 'decode') {
await configDecode();
} else if (subCommand === 'tui') {
const { configTui } = await import('./cli/config-tui.js');
await configTui();
} else {
await configure();
}
break;
case 'skills': {
const { showStatus, runSkillsSync, enableSkill, disableSkill } = await import('./skills/index.js');
switch (subCommand) {
case 'status':
await showStatus();
break;
case 'enable':
if (!args[2]) {
console.error('Usage: lettabot skills enable <name>');
process.exit(1);
}
enableSkill(args[2]);
break;
case 'disable':
if (!args[2]) {
console.error('Usage: lettabot skills disable <name>');
process.exit(1);
}
disableSkill(args[2]);
break;
default:
await runSkillsSync();
}
break;
}
case 'todo': {
const { todoCommand } = await import('./cli/todo.js');
await todoCommand(subCommand, args.slice(2), getDefaultTodoAgentKey());
break;
}
case 'model': {
const { modelCommand } = await import('./commands/model.js');
await modelCommand(subCommand, args[2]);
break;
}
case 'connect': {
const { runLettaConnect } = await import('./commands/letta-connect.js');
const requestedProvider = subCommand || 'chatgpt';
const providers = requestedProvider === 'chatgpt' ? ['chatgpt', 'codex'] : [requestedProvider];
const connected = await runLettaConnect(providers);
if (!connected) {
console.error(`Failed to run letta connect for provider: ${requestedProvider}`);
process.exit(1);
}
break;
}
case 'channels':
case 'channel': {
const { channelManagementCommand } = await import('./cli/channel-management.js');
await channelManagementCommand(subCommand, args[2], args.slice(3));
break;
}
case 'pairing': {
const channel = subCommand;
const action = args[2];
if (!channel) {
console.log('Usage: lettabot pairing <list|approve> <channel> [code]');
console.log('Example: lettabot pairing list telegram');
console.log('Example: lettabot pairing approve telegram ABCD1234');
process.exit(1);
}
// Support both "pairing list telegram" and "pairing telegram list"
if (channel === 'list' || channel === 'ls') {
const ch = action || args[3];
if (!ch) {
console.log('Usage: lettabot pairing list <channel>');
process.exit(1);
}
await pairingList(ch);
} else if (channel === 'approve') {
const ch = action;
const code = args[3];
if (!ch || !code) {
console.log('Usage: lettabot pairing approve <channel> <code>');
process.exit(1);
}
await pairingApprove(ch, code);
} else if (action === 'list' || action === 'ls') {
await pairingList(channel);
} else if (action === 'approve') {
const code = args[3];
if (!code) {
console.log('Usage: lettabot pairing approve <channel> <code>');
process.exit(1);
}
await pairingApprove(channel, code);
} else if (action) {
// Assume "lettabot pairing telegram ABCD1234" means approve
await pairingApprove(channel, action);
} else {
await pairingList(channel);
}
break;
}
case 'destroy': {
const { rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const p = await import('@clack/prompts');
const dataDir = getDataDir();
const workingDir = getWorkingDir();
const agentJsonPath = join(dataDir, 'lettabot-agent.json');
const skillsDir = join(workingDir, '.skills');
const cronJobsPath = getCronStorePath();
const legacyCronJobsPath = getLegacyCronStorePath();
p.intro('🗑️ Destroy LettaBot Data');
p.log.warn('This will delete:');
p.log.message(` • Agent store: ${agentJsonPath}`);
p.log.message(` • Skills: ${skillsDir}`);
p.log.message(` • Cron jobs: ${cronJobsPath}`);
if (legacyCronJobsPath !== cronJobsPath) {
p.log.message(` • Legacy cron jobs: ${legacyCronJobsPath}`);
}
p.log.message('');
p.log.message('Note: The agent on Letta servers will NOT be deleted.');
const confirmed = await p.confirm({
message: 'Are you sure you want to destroy all local data?',
initialValue: false,
});
if (!confirmed || p.isCancel(confirmed)) {
p.cancel('Cancelled');
break;
}
// Delete files
let deleted = 0;
if (existsSync(agentJsonPath)) {
rmSync(agentJsonPath);
p.log.success('Deleted lettabot-agent.json');
deleted++;
}
if (existsSync(skillsDir)) {
rmSync(skillsDir, { recursive: true });
p.log.success('Deleted .skills/');
deleted++;
}
if (existsSync(cronJobsPath)) {
rmSync(cronJobsPath);
p.log.success('Deleted cron-jobs.json');
deleted++;
}
if (legacyCronJobsPath !== cronJobsPath && existsSync(legacyCronJobsPath)) {
rmSync(legacyCronJobsPath);
p.log.success('Deleted legacy cron-jobs.json');
deleted++;
}
if (deleted === 0) {
p.log.info('Nothing to delete');
}
p.outro('✨ Done! Run `npx lettabot server` to create a fresh agent.');
break;
}
case 'set-conversation': {
const p = await import('@clack/prompts');
const config = getConfig();
const newConvId = subCommand;
if (!newConvId) {
console.error('Usage: lettabot set-conversation <conversation-id>');
process.exit(1);
}
p.intro('Set Conversation');
const configuredName =
(config.agent?.name?.trim())
|| (config.agents?.length && config.agents[0].name?.trim())
|| 'LettaBot';
const configuredAgents = (config.agents?.length ? config.agents : [{ name: configuredName }])
.map(agent => agent.name?.trim())
.filter((name): name is string => !!name);
const uniqueAgents = Array.from(new Set(configuredAgents));
let targetAgent = uniqueAgents[0];
if (uniqueAgents.length > 1) {
const choice = await p.select({
message: 'Which agent?',
options: uniqueAgents.map(name => ({ value: name, label: name })),
});
if (p.isCancel(choice)) {
p.cancel('Cancelled');
break;
}
targetAgent = choice as string;
}
const store = new Store('lettabot-agent.json', targetAgent);
const oldConvId = store.conversationId;
store.conversationId = newConvId;
if (oldConvId) {
p.log.info(`Previous conversation: ${oldConvId}`);
}
p.log.success(`Conversation set to: ${newConvId} (agent: ${targetAgent})`);
p.outro('Restart the server for the change to take effect.');
break;
}
case 'reset-conversation': {
const p = await import('@clack/prompts');
const config = getConfig();
p.intro('Reset Conversation');
const configuredName =
(config.agent?.name?.trim())
|| (config.agents?.length && config.agents[0].name?.trim())
|| 'LettaBot';
const configuredAgents = (config.agents?.length ? config.agents : [{ name: configuredName }])
.map(agent => agent.name?.trim())
.filter((name): name is string => !!name);
const uniqueAgents = Array.from(new Set(configuredAgents));
let targetAgents = uniqueAgents;
if (uniqueAgents.length > 1) {
const choice = await p.select({
message: 'Which agent should be reset?',
options: [
{ value: '__all__', label: 'All configured agents' },
...uniqueAgents.map(name => ({ value: name, label: name })),
],
});
if (p.isCancel(choice)) {
p.cancel('Cancelled');
break;
}
targetAgents = choice === '__all__' ? uniqueAgents : [choice as string];
}
const entries = targetAgents.map((name) => {
const store = new Store('lettabot-agent.json', name);
const info = store.getInfo();
const perChannelKeys = info.conversations ? Object.keys(info.conversations) : [];
return {
name,
store,
hasLegacy: !!info.conversationId,
perChannelKeys,
};
});
const hasAny = entries.some(entry => entry.hasLegacy || entry.perChannelKeys.length > 0);
if (!hasAny) {
p.log.info('No conversation IDs stored. Nothing to reset.');
break;
}
for (const entry of entries) {
if (entry.hasLegacy) {
p.log.warn(`Current conversation (${entry.name}): ${entry.store.conversationId}`);
} else if (entry.perChannelKeys.length > 0) {
p.log.warn(`Current per-channel conversations (${entry.name}): ${entry.perChannelKeys.length}`);
}
}
p.log.message('');
p.log.message('This will clear the conversation ID(s), causing the bot to create');
p.log.message('a new conversation on the next message. Use this if you see:');
p.log.message(' • "stop_reason: error" with empty responses');
p.log.message(' • Messages not reaching the agent');
p.log.message(' • Agent returning empty results');
p.log.message('');
p.log.message('The agent and its memory will be preserved.');
const confirmed = await p.confirm({
message: `Reset conversation${entries.length > 1 ? 's' : ''}?`,
initialValue: true,
});
if (!confirmed || p.isCancel(confirmed)) {
p.cancel('Cancelled');
break;
}
for (const entry of entries) {
entry.store.clearConversation();
}
p.log.success(`Conversation ID${entries.length > 1 ? 's' : ''} cleared`);
p.outro('Restart the server - a new conversation will be created on the next message.');
break;
}
case 'logout': {
const { revokeToken } = await import('./auth/oauth.js');
const { loadTokens, deleteTokens } = await import('./auth/tokens.js');
const p = await import('@clack/prompts');
p.intro('Logout from Letta Platform');
const tokens = loadTokens();
if (!tokens) {
p.log.info('No stored credentials found.');
break;
}
const spinner = p.spinner();
spinner.start('Revoking token...');
// Revoke the refresh token on the server
if (tokens.refreshToken) {
await revokeToken(tokens.refreshToken);
}
// Delete local tokens
deleteTokens();
spinner.stop('Logged out successfully');
p.log.info('Note: LETTA_API_KEY in .env was not modified. Remove it manually if needed.');
p.outro('Goodbye!');
break;
}
case 'help':
case '-h':
case '--help':
showHelp();
break;
case undefined:
console.log('Usage: lettabot <command>\n');
console.log('Commands: onboard, server, configure, connect, model, channels, skills, set-conversation, reset-conversation, destroy, help\n');
console.log('Run "lettabot help" for more information.');
break;
default:
console.log(`Unknown command: ${command}`);
console.log('Run "lettabot help" for usage.');
process.exit(1);
}
}
main().catch(console.error);