From 6e8d1fc19ddc1804132904177079edd5f5b27a55 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 10 Mar 2026 11:51:05 -0700 Subject: [PATCH] feat: add core config TUI editor (#522) Co-authored-by: Letta Code --- docs/cli-tools.md | 24 ++ docs/configuration.md | 4 + src/cli.ts | 11 + src/cli/config-tui.test.ts | 173 +++++++++++++ src/cli/config-tui.ts | 490 +++++++++++++++++++++++++++++++++++++ 5 files changed, 702 insertions(+) create mode 100644 src/cli/config-tui.test.ts create mode 100644 src/cli/config-tui.ts diff --git a/docs/cli-tools.md b/docs/cli-tools.md index 6f8fb20..54951ef 100644 --- a/docs/cli-tools.md +++ b/docs/cli-tools.md @@ -3,6 +3,30 @@ LettaBot ships with a few small CLIs that the agent can invoke via Bash, or you can run manually. They use the same config/credentials as the bot server. +## lettabot config + +Manage your `lettabot.yaml` configuration. + +```bash +lettabot config # Show current config summary + menu +lettabot config tui # Interactive core config editor +lettabot config encode # Encode config as base64 (for cloud deploy) +lettabot config decode # Decode base64 config back to YAML +``` + +### Interactive TUI editor + +`lettabot config tui` opens an interactive editor for the most common settings: + +- **Server auth** -- switch between API/Docker mode, set API key or base URL +- **Agent identity** -- change agent name and ID +- **Channels** -- enable/disable channels and run their setup wizards +- **Features** -- toggle cron, heartbeat (with interval), and memfs + +The TUI loads your existing config, lets you edit fields interactively, shows a summary of changes, and saves back to the same file. Non-core fields (providers, attachments, secondary agents, etc.) are preserved through the round-trip. + +From the `lettabot config` menu, you can also choose "Open TUI editor" or "Edit config file" to open the raw YAML in your `$EDITOR`. + ## lettabot-message Send a message to the most recent chat, or target a specific channel/chat. diff --git a/docs/configuration.md b/docs/configuration.md index 679da2b..234aeaa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,10 @@ For local installs, either: - Create `~/.lettabot/config.yaml` for global config, or - Set `export LETTABOT_CONFIG=/path/to/your/config.yaml` +### Interactive Editor + +Run `lettabot config tui` for an interactive editor that covers server auth, agent identity, channels, and features. See [CLI Tools](./cli-tools.md#lettabot-config) for details. + ## Example Configuration ```yaml diff --git a/src/cli.ts b/src/cli.ts index ab103d1..77a61f1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -80,6 +80,7 @@ async function configure() { 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: '' }, ], @@ -94,6 +95,11 @@ async function configure() { 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'; @@ -227,6 +233,7 @@ 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 Connect model providers (e.g., chatgpt/codex) @@ -257,6 +264,7 @@ Commands: 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 @@ -332,6 +340,9 @@ async function main() { 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(); } diff --git a/src/cli/config-tui.test.ts b/src/cli/config-tui.test.ts new file mode 100644 index 0000000..51ed0a1 --- /dev/null +++ b/src/cli/config-tui.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { + applyCoreDraft, + extractCoreDraft, + formatCoreDraftSummary, + getCoreDraftWarnings, + type CoreConfigDraft, +} from './config-tui.js'; +import type { LettaBotConfig } from '../config/types.js'; + +function makeBaseConfig(): LettaBotConfig { + return { + server: { + mode: 'api', + apiKey: 'sk-base', + }, + agent: { + name: 'Legacy Agent', + id: 'legacy-id', + }, + channels: { + telegram: { + enabled: true, + token: 'telegram-token', + }, + }, + features: { + cron: false, + heartbeat: { + enabled: true, + intervalMin: 30, + }, + }, + providers: [ + { + id: 'openai', + name: 'OpenAI', + type: 'openai', + apiKey: 'provider-key', + }, + ], + attachments: { + maxMB: 20, + maxAgeDays: 14, + }, + }; +} + +describe('config TUI helpers', () => { + it('extractCoreDraft uses primary agent when agents[] exists', () => { + const config: LettaBotConfig = { + ...makeBaseConfig(), + agents: [ + { + name: 'Primary', + id: 'agent-1', + channels: { + discord: { enabled: true, token: 'discord-token' }, + }, + features: { + cron: true, + heartbeat: { enabled: false, intervalMin: 10 }, + }, + }, + { + name: 'Secondary', + channels: { + telegram: { enabled: true, token: 'secondary' }, + }, + }, + ], + }; + + const draft = extractCoreDraft(config); + expect(draft.source).toBe('agents'); + expect(draft.agent.name).toBe('Primary'); + expect(draft.agent.id).toBe('agent-1'); + expect(draft.channels.discord?.enabled).toBe(true); + expect(draft.features.cron).toBe(true); + expect(draft.features.heartbeat?.enabled).toBe(false); + }); + + it('extractCoreDraft falls back to legacy top-level fields', () => { + const draft = extractCoreDraft(makeBaseConfig()); + expect(draft.source).toBe('legacy'); + expect(draft.agent.name).toBe('Legacy Agent'); + expect(draft.channels.telegram?.enabled).toBe(true); + expect(draft.features.heartbeat?.intervalMin).toBe(30); + }); + + it('applyCoreDraft updates only primary agent and preserves others', () => { + const config: LettaBotConfig = { + ...makeBaseConfig(), + agents: [ + { + name: 'Primary', + id: 'agent-1', + channels: { telegram: { enabled: true, token: 'primary-token' } }, + features: { cron: false, heartbeat: { enabled: true, intervalMin: 20 } }, + }, + { + name: 'Secondary', + id: 'agent-2', + channels: { discord: { enabled: true, token: 'secondary-token' } }, + features: { cron: true, heartbeat: { enabled: false } }, + }, + ], + }; + const draft = extractCoreDraft(config); + draft.agent.name = 'Updated Primary'; + draft.agent.id = 'agent-1b'; + draft.channels.telegram = { enabled: false }; + draft.features.cron = true; + draft.server.mode = 'docker'; + draft.server.baseUrl = 'http://localhost:8283'; + + const updated = applyCoreDraft(config, draft); + expect(updated.server.mode).toBe('docker'); + expect(updated.server.baseUrl).toBe('http://localhost:8283'); + expect(updated.agents?.[0].name).toBe('Updated Primary'); + expect(updated.agents?.[0].id).toBe('agent-1b'); + expect(updated.agents?.[0].channels.telegram?.enabled).toBe(false); + expect(updated.agents?.[1].name).toBe('Secondary'); + expect(updated.agents?.[1].channels.discord?.token).toBe('secondary-token'); + expect(updated.providers?.[0].id).toBe('openai'); + expect(updated.attachments?.maxMB).toBe(20); + }); + + it('applyCoreDraft updates legacy top-level fields when agents[] absent', () => { + const config = makeBaseConfig(); + const draft = extractCoreDraft(config); + draft.agent.name = 'Updated Legacy'; + draft.agent.id = undefined; + draft.features.cron = true; + draft.channels.telegram = { enabled: false }; + + const updated = applyCoreDraft(config, draft); + expect(updated.agent.name).toBe('Updated Legacy'); + expect(updated.agent.id).toBeUndefined(); + expect(updated.features?.cron).toBe(true); + expect(updated.channels.telegram?.enabled).toBe(false); + expect(updated.providers?.[0].name).toBe('OpenAI'); + }); + + it('getCoreDraftWarnings flags missing API key and no enabled channels', () => { + const draft: CoreConfigDraft = { + server: { mode: 'api', apiKey: undefined, baseUrl: undefined }, + agent: { name: 'A' }, + channels: { + telegram: { enabled: false }, + }, + features: { + cron: false, + heartbeat: { enabled: false, intervalMin: 60 }, + }, + source: 'legacy', + }; + + const warnings = getCoreDraftWarnings(draft); + expect(warnings).toContain('Server mode is api, but API key is empty.'); + expect(warnings).toContain('No channels are enabled.'); + }); + + it('formatCoreDraftSummary includes key sections', () => { + const draft = extractCoreDraft(makeBaseConfig()); + const summary = formatCoreDraftSummary(draft, '/tmp/lettabot.yaml'); + expect(summary).toContain('Config Path:'); + expect(summary).toContain('Server Mode:'); + expect(summary).toContain('Agent Name:'); + expect(summary).toContain('Enabled Channels:'); + expect(summary).toContain('/tmp/lettabot.yaml'); + }); +}); diff --git a/src/cli/config-tui.ts b/src/cli/config-tui.ts new file mode 100644 index 0000000..08f7f2b --- /dev/null +++ b/src/cli/config-tui.ts @@ -0,0 +1,490 @@ +import * as p from '@clack/prompts'; +import { + isApiServerMode, + loadAppConfigOrExit, + resolveConfigPath, + saveConfig, + serverModeLabel, +} from '../config/index.js'; +import type { AgentConfig, LettaBotConfig, ServerMode } from '../config/types.js'; +import { + CHANNELS, + getChannelHint, + getSetupFunction, + type ChannelId, +} from '../channels/setup.js'; + +type CoreServerMode = 'api' | 'docker'; + +export interface CoreConfigDraft { + server: { + mode: CoreServerMode; + baseUrl?: string; + apiKey?: string; + }; + agent: { + name: string; + id?: string; + }; + channels: AgentConfig['channels']; + features: NonNullable; + source: 'agents' | 'legacy'; +} + +class InterceptedExit extends Error { + code: number; + + constructor(code = 0) { + super(`process.exit(${code}) intercepted`); + this.code = code; + } +} + +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function normalizeServerMode(mode?: ServerMode): CoreServerMode { + return isApiServerMode(mode) ? 'api' : 'docker'; +} + +function getPrimaryAgent(config: LettaBotConfig): AgentConfig | null { + if (Array.isArray(config.agents) && config.agents.length > 0) { + return config.agents[0]; + } + return null; +} + +function normalizeFeatures(source?: AgentConfig['features']): NonNullable { + const features = deepClone(source ?? {}); + return { + ...features, + cron: typeof features.cron === 'boolean' ? features.cron : false, + heartbeat: { + enabled: features.heartbeat?.enabled ?? false, + intervalMin: features.heartbeat?.intervalMin ?? 60, + skipRecentUserMin: features.heartbeat?.skipRecentUserMin, + prompt: features.heartbeat?.prompt, + promptFile: features.heartbeat?.promptFile, + target: features.heartbeat?.target, + }, + }; +} + +function normalizeChannels(source?: AgentConfig['channels']): AgentConfig['channels'] { + return deepClone(source ?? {}); +} + +export function extractCoreDraft(config: LettaBotConfig): CoreConfigDraft { + const primary = getPrimaryAgent(config); + const source = primary ? 'agents' : 'legacy'; + + return { + server: { + mode: normalizeServerMode(config.server.mode), + baseUrl: config.server.baseUrl, + apiKey: config.server.apiKey, + }, + agent: { + name: (primary?.name || config.agent.name || 'LettaBot').trim() || 'LettaBot', + id: primary?.id ?? config.agent.id, + }, + channels: normalizeChannels(primary?.channels ?? config.channels), + features: normalizeFeatures(primary?.features ?? config.features), + source, + }; +} + +export function applyCoreDraft(baseConfig: LettaBotConfig, draft: CoreConfigDraft): LettaBotConfig { + const next = deepClone(baseConfig); + + next.server = { + ...next.server, + mode: draft.server.mode, + baseUrl: draft.server.baseUrl, + apiKey: draft.server.apiKey, + }; + + if (Array.isArray(next.agents) && next.agents.length > 0) { + const [primary, ...rest] = next.agents; + const updatedPrimary: AgentConfig = { + ...primary, + name: draft.agent.name, + channels: normalizeChannels(draft.channels), + features: normalizeFeatures(draft.features), + }; + + if (draft.agent.id) { + updatedPrimary.id = draft.agent.id; + } else { + delete updatedPrimary.id; + } + + next.agents = [updatedPrimary, ...rest]; + } else { + next.agent = { + ...next.agent, + name: draft.agent.name, + }; + + if (draft.agent.id) { + next.agent.id = draft.agent.id; + } else { + delete next.agent.id; + } + + next.channels = normalizeChannels(draft.channels); + next.features = normalizeFeatures(draft.features); + } + + return next; +} + +function isChannelEnabled(config: unknown): boolean { + return !!config && typeof config === 'object' && (config as { enabled?: boolean }).enabled === true; +} + +function getEnabledChannelIds(channels: AgentConfig['channels']): ChannelId[] { + return CHANNELS + .map((channel) => channel.id) + .filter((channelId) => isChannelEnabled(channels[channelId])); +} + +export function getCoreDraftWarnings(draft: CoreConfigDraft): string[] { + const warnings: string[] = []; + + if (draft.server.mode === 'api' && !draft.server.apiKey?.trim()) { + warnings.push('Server mode is api, but API key is empty.'); + } + + if (getEnabledChannelIds(draft.channels).length === 0) { + warnings.push('No channels are enabled.'); + } + + return warnings; +} + +function formatChannelsSummary(draft: CoreConfigDraft): string { + const enabled = getEnabledChannelIds(draft.channels); + if (!enabled.length) return 'None'; + return enabled.map((id) => CHANNELS.find((channel) => channel.id === id)?.displayName ?? id).join(', '); +} + +export function formatCoreDraftSummary(draft: CoreConfigDraft, configPath: string): string { + const rows: Array<[string, string]> = [ + ['Config Path', configPath], + ['Server Mode', serverModeLabel(draft.server.mode)], + ['API Key', draft.server.apiKey ? '✓ Set' : '✗ Not set'], + ['Docker Base URL', draft.server.baseUrl || '(unset)'], + ['Agent Name', draft.agent.name], + ['Agent ID', draft.agent.id || '(new/auto)'], + ['Enabled Channels', formatChannelsSummary(draft)], + ['Cron', draft.features.cron ? '✓ Enabled' : '✗ Disabled'], + [ + 'Heartbeat', + draft.features.heartbeat?.enabled + ? `✓ ${draft.features.heartbeat.intervalMin ?? 60}min` + : '✗ Disabled', + ], + ]; + + const max = Math.max(...rows.map(([label]) => label.length)); + return rows.map(([label, value]) => `${(label + ':').padEnd(max + 2)}${value}`).join('\n'); +} + +function hasDraftChanged(initial: CoreConfigDraft, current: CoreConfigDraft): boolean { + return JSON.stringify(initial) !== JSON.stringify(current); +} + +async function editServerAuth(draft: CoreConfigDraft): Promise { + const mode = await p.select({ + message: 'Select server mode', + options: [ + { value: 'api', label: 'API', hint: 'Use Letta API key authentication' }, + { value: 'docker', label: 'Docker/Self-hosted', hint: 'Use local/self-hosted base URL' }, + ], + initialValue: draft.server.mode, + }); + + if (p.isCancel(mode)) return; + draft.server.mode = mode as CoreServerMode; + + if (draft.server.mode === 'api') { + const apiKey = await p.text({ + message: 'API key (blank to unset)', + placeholder: 'sk-...', + initialValue: draft.server.apiKey ?? '', + }); + if (p.isCancel(apiKey)) return; + draft.server.apiKey = apiKey.trim() || undefined; + } else { + const baseUrl = await p.text({ + message: 'Base URL', + placeholder: 'http://localhost:8283', + initialValue: draft.server.baseUrl ?? 'http://localhost:8283', + validate: (value) => { + const trimmed = value.trim(); + if (!trimmed) return 'Base URL is required in docker mode'; + if (!/^https?:\/\//.test(trimmed)) return 'Base URL must start with http:// or https://'; + return undefined; + }, + }); + if (p.isCancel(baseUrl)) return; + draft.server.baseUrl = baseUrl.trim(); + } +} + +async function editAgent(draft: CoreConfigDraft): Promise { + const name = await p.text({ + message: 'Agent name', + initialValue: draft.agent.name, + validate: (value) => { + if (!value.trim()) return 'Agent name is required'; + return undefined; + }, + }); + if (p.isCancel(name)) return; + + const id = await p.text({ + message: 'Agent ID (optional)', + placeholder: 'agent-xxxx', + initialValue: draft.agent.id ?? '', + }); + if (p.isCancel(id)) return; + + draft.agent.name = name.trim(); + draft.agent.id = id.trim() || undefined; +} + +async function runChannelSetupSafely(channelId: ChannelId, existing?: unknown): Promise { + const setup = getSetupFunction(channelId); + const originalExit = process.exit; + + (process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => { + throw new InterceptedExit(code ?? 0); + }) as (code?: number) => never; + + try { + return await setup(existing); + } catch (error) { + if (error instanceof InterceptedExit) { + if (error.code === 0) return undefined; + throw new Error(`Channel setup exited with code ${error.code}`); + } + throw error; + } finally { + (process as unknown as { exit: typeof process.exit }).exit = originalExit; + } +} + +async function configureChannel(draft: CoreConfigDraft, channelId: ChannelId): Promise { + const current = draft.channels[channelId]; + const enabled = isChannelEnabled(current); + + const action = await p.select({ + message: `${CHANNELS.find((channel) => channel.id === channelId)?.displayName || channelId} settings`, + options: enabled + ? [ + { value: 'edit', label: 'Edit settings', hint: getChannelHint(channelId) }, + { value: 'disable', label: 'Disable channel', hint: 'Set enabled=false' }, + { value: 'back', label: 'Back', hint: '' }, + ] + : [ + { value: 'enable', label: 'Enable and configure', hint: getChannelHint(channelId) }, + { value: 'back', label: 'Back', hint: '' }, + ], + }); + + if (p.isCancel(action) || action === 'back') return; + + if (action === 'disable') { + const confirmed = await p.confirm({ + message: `Disable ${channelId}?`, + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) return; + draft.channels[channelId] = { enabled: false } as AgentConfig['channels'][ChannelId]; + return; + } + + const result = await runChannelSetupSafely(channelId, current); + if (!result) { + p.log.info(`${channelId} setup cancelled.`); + return; + } + draft.channels[channelId] = result as AgentConfig['channels'][ChannelId]; +} + +async function editChannels(draft: CoreConfigDraft): Promise { + while (true) { + const selected = await p.select({ + message: 'Select a channel to edit', + options: [ + ...CHANNELS.map((channel) => { + const enabled = isChannelEnabled(draft.channels[channel.id]); + return { + value: channel.id, + label: `${enabled ? '✓' : '✗'} ${channel.displayName}`, + hint: enabled ? 'enabled' : getChannelHint(channel.id), + }; + }), + { value: 'back', label: 'Back', hint: '' }, + ], + }); + + if (p.isCancel(selected) || selected === 'back') return; + await configureChannel(draft, selected as ChannelId); + } +} + +async function editFeatures(draft: CoreConfigDraft): Promise { + const cron = await p.confirm({ + message: 'Enable cron?', + initialValue: !!draft.features.cron, + }); + if (p.isCancel(cron)) return; + draft.features.cron = cron; + + const heartbeatEnabled = await p.confirm({ + message: 'Enable heartbeat?', + initialValue: !!draft.features.heartbeat?.enabled, + }); + if (p.isCancel(heartbeatEnabled)) return; + draft.features.heartbeat = { + ...draft.features.heartbeat, + enabled: heartbeatEnabled, + }; + + if (heartbeatEnabled) { + const interval = await p.text({ + message: 'Heartbeat interval minutes', + placeholder: '60', + initialValue: String(draft.features.heartbeat.intervalMin ?? 60), + validate: (value) => { + const parsed = Number(value.trim()); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 'Enter a positive number'; + } + return undefined; + }, + }); + if (p.isCancel(interval)) return; + draft.features.heartbeat.intervalMin = Number(interval.trim()); + } +} + +async function reviewDraft(draft: CoreConfigDraft, configPath: string): Promise { + p.note(formatCoreDraftSummary(draft, configPath), 'Draft Configuration'); +} + +export async function configTui(): Promise { + const configPath = resolveConfigPath(); + const loaded = loadAppConfigOrExit(); + const draft = extractCoreDraft(loaded); + const initial = deepClone(draft); + + p.intro('⚙️ LettaBot Config TUI (Core)'); + + while (true) { + const enabledChannels = getEnabledChannelIds(draft.channels).length; + const changed = hasDraftChanged(initial, draft); + + const choice = await p.select({ + message: 'What would you like to edit?', + options: [ + { + value: 'server', + label: 'Server/Auth', + hint: `${serverModeLabel(draft.server.mode)}${draft.server.mode === 'api' ? '' : ` • ${draft.server.baseUrl || 'unset'}`}`, + }, + { + value: 'agent', + label: 'Agent', + hint: draft.agent.name, + }, + { + value: 'channels', + label: 'Channels', + hint: `${enabledChannels} enabled`, + }, + { + value: 'features', + label: 'Features', + hint: `cron ${draft.features.cron ? 'on' : 'off'} • heartbeat ${draft.features.heartbeat?.enabled ? 'on' : 'off'}`, + }, + { + value: 'review', + label: 'Review Draft', + hint: changed ? 'unsaved changes' : 'no changes', + }, + { + value: 'save', + label: 'Save & Exit', + hint: configPath, + }, + { + value: 'exit', + label: 'Exit Without Saving', + hint: '', + }, + ], + }); + + if (p.isCancel(choice) || choice === 'exit') { + if (hasDraftChanged(initial, draft)) { + const discard = await p.confirm({ + message: 'Discard unsaved changes?', + initialValue: false, + }); + if (p.isCancel(discard) || !discard) continue; + } + p.outro('Exited without saving.'); + return; + } + + if (choice === 'server') { + await editServerAuth(draft); + continue; + } + + if (choice === 'agent') { + await editAgent(draft); + continue; + } + + if (choice === 'channels') { + await editChannels(draft); + continue; + } + + if (choice === 'features') { + await editFeatures(draft); + continue; + } + + if (choice === 'review') { + await reviewDraft(draft, configPath); + continue; + } + + if (choice === 'save') { + const warnings = getCoreDraftWarnings(draft); + if (warnings.length > 0) { + p.note(warnings.map((warning) => `• ${warning}`).join('\n'), 'Pre-save Warnings'); + } + + const confirmSave = await p.confirm({ + message: `Save changes to ${configPath}?`, + initialValue: true, + }); + + if (p.isCancel(confirmSave) || !confirmSave) continue; + + const updated = applyCoreDraft(loaded, draft); + saveConfig(updated, configPath); + p.log.success(`Saved configuration to ${configPath}`); + p.outro('Run `lettabot server` to apply changes.'); + return; + } + } +}