feat: add core config TUI editor (#522)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
11
src/cli.ts
11
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 <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
173
src/cli/config-tui.test.ts
Normal 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
490
src/cli/config-tui.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user