feat: add core config TUI editor (#522)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-10 11:51:05 -07:00
committed by GitHub
parent 0321558ee6
commit 6e8d1fc19d
5 changed files with 702 additions and 0 deletions

View File

@@ -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 <base64> # 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.

View File

@@ -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

View File

@@ -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 <provider> 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();
}

173
src/cli/config-tui.test.ts Normal file
View File

@@ -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');
});
});

490
src/cli/config-tui.ts Normal file
View File

@@ -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<AgentConfig['features']>;
source: 'agents' | 'legacy';
}
class InterceptedExit extends Error {
code: number;
constructor(code = 0) {
super(`process.exit(${code}) intercepted`);
this.code = code;
}
}
function deepClone<T>(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<AgentConfig['features']> {
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<void> {
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<void> {
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<unknown | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
p.note(formatCoreDraftSummary(draft, configPath), 'Draft Configuration');
}
export async function configTui(): Promise<void> {
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;
}
}
}