diff --git a/src/config/io.test.ts b/src/config/io.test.ts new file mode 100644 index 0000000..e9742f8 --- /dev/null +++ b/src/config/io.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import YAML from 'yaml'; +import { saveConfig, loadConfig } from './io.js'; +import { normalizeAgents } from './types.js'; +import type { LettaBotConfig } from './types.js'; + +describe('saveConfig with agents[] format', () => { + let tmpDir: string; + let configPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-config-test-')); + configPath = join(tmpDir, 'lettabot.yaml'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should write agents[] config without legacy agent/channels at top level', () => { + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'TestBot', + id: 'agent-abc123', + channels: { + telegram: { enabled: true, token: 'tg-token', dmPolicy: 'pairing' as const }, + }, + features: { + cron: true, + heartbeat: { enabled: true, intervalMin: 30 }, + }, + }], + }; + + saveConfig(config, configPath); + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw); + + // Should have agents[] at top level + expect(parsed.agents).toHaveLength(1); + expect(parsed.agents[0].name).toBe('TestBot'); + expect(parsed.agents[0].channels.telegram.token).toBe('tg-token'); + + // Should NOT have legacy agent/channels at top level + expect(parsed.agent).toBeUndefined(); + expect(parsed.channels).toBeUndefined(); + expect(parsed.features).toBeUndefined(); + }); + + it('should roundtrip agents[] config through save and loadConfig+normalizeAgents', () => { + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'MyBot', + id: 'agent-xyz', + channels: { + telegram: { enabled: true, token: 'tg-123', dmPolicy: 'open' as const }, + whatsapp: { enabled: true, selfChat: true, dmPolicy: 'pairing' as const }, + }, + features: { + cron: false, + heartbeat: { enabled: true, intervalMin: 15 }, + }, + }], + transcription: { + provider: 'openai' as const, + apiKey: 'whisper-key', + }, + }; + + saveConfig(config, configPath); + + // loadConfig reads from resolveConfigPath(), so we need to load manually + // and merge with defaults the same way loadConfig does + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw) as Partial; + const loaded: LettaBotConfig = { + server: { mode: 'cloud', ...parsed.server }, + agent: { name: 'LettaBot', ...parsed.agent }, + channels: { ...parsed.channels }, + ...parsed, + }; + + // normalizeAgents should pick up agents[] and ignore defaults + const agents = normalizeAgents(loaded); + + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('MyBot'); + expect(agents[0].id).toBe('agent-xyz'); + expect(agents[0].channels.telegram?.token).toBe('tg-123'); + expect(agents[0].channels.whatsapp?.selfChat).toBe(true); + expect(agents[0].features?.heartbeat?.intervalMin).toBe(15); + + // Global fields should survive + expect(loaded.transcription?.apiKey).toBe('whisper-key'); + }); + + it('should always include agent id in agents[] (onboarding contract)', () => { + // After onboarding, agent ID should always be present in the config. + // This test documents the contract: new configs have the ID eagerly set. + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'LettaBot', + id: 'agent-eagerly-created', + channels: { + telegram: { enabled: true, token: 'tg-token' }, + }, + features: { cron: false, heartbeat: { enabled: false } }, + }], + }; + + saveConfig(config, configPath); + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw); + + expect(parsed.agents[0].id).toBe('agent-eagerly-created'); + }); + + it('should preserve providers at top level, not inside agents', () => { + const config = { + server: { mode: 'cloud' as const, apiKey: 'test-key' }, + agents: [{ + name: 'TestBot', + channels: {}, + features: { cron: false, heartbeat: { enabled: false } }, + }], + providers: [{ + id: 'anthropic', + name: 'anthropic', + type: 'anthropic', + apiKey: 'sk-ant-test', + }], + }; + + saveConfig(config, configPath); + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(raw); + + expect(parsed.providers).toHaveLength(1); + expect(parsed.providers[0].name).toBe('anthropic'); + expect(parsed.agents[0].providers).toBeUndefined(); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index 44a690b..46f778a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -81,7 +81,7 @@ export function loadConfig(): LettaBotConfig { /** * Save config to YAML file */ -export function saveConfig(config: LettaBotConfig, path?: string): void { +export function saveConfig(config: Partial & Pick, path?: string): void { const configPath = path || resolveConfigPath(); // Ensure directory exists @@ -285,7 +285,7 @@ export function applyConfigToEnv(config: LettaBotConfig): void { /** * Create BYOK providers on Letta Cloud */ -export async function syncProviders(config: LettaBotConfig): Promise { +export async function syncProviders(config: Partial & Pick): Promise { if (config.server.mode !== 'cloud' || !config.server.apiKey) { return; } diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index d79acee..0d1a32a 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -374,4 +374,37 @@ describe('normalizeAgents', () => { expect(agents[0].displayName).toBe('💜 Signo'); expect(agents[1].displayName).toBe('👾 DevOps'); }); + + it('should normalize onboarding-generated agents[] config (no legacy agent/channels)', () => { + // This matches the shape that onboarding now writes: agents[] at top level, + // with no legacy agent/channels/features fields. + const config = { + server: { mode: 'cloud' as const }, + agents: [{ + name: 'LettaBot', + id: 'agent-abc123', + channels: { + telegram: { enabled: true, token: 'tg-token', dmPolicy: 'pairing' as const }, + whatsapp: { enabled: true, selfChat: true }, + }, + features: { + cron: true, + heartbeat: { enabled: true, intervalMin: 30 }, + }, + }], + // loadConfig() merges defaults for agent/channels, so they'll exist at runtime + agent: { name: 'LettaBot' }, + channels: {}, + } as LettaBotConfig; + + const agents = normalizeAgents(config); + + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('LettaBot'); + expect(agents[0].id).toBe('agent-abc123'); + expect(agents[0].channels.telegram?.token).toBe('tg-token'); + expect(agents[0].channels.whatsapp?.enabled).toBe(true); + expect(agents[0].features?.cron).toBe(true); + expect(agents[0].features?.heartbeat?.intervalMin).toBe(30); + }); }); diff --git a/src/onboard.ts b/src/onboard.ts index 2331324..286d947 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -7,7 +7,7 @@ import { resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import * as p from '@clack/prompts'; import { saveConfig, syncProviders } from './config/index.js'; -import type { LettaBotConfig, ProviderConfig } from './config/types.js'; +import type { AgentConfig, LettaBotConfig, ProviderConfig } from './config/types.js'; import { isLettaCloudUrl } from './utils/server.js'; import { CHANNELS, getChannelHint, isSignalCliInstalled, setupTelegram, setupSlack, setupDiscord, setupWhatsApp, setupSignal } from './channels/setup.js'; @@ -116,81 +116,86 @@ function readConfigFromEnv(existingConfig: any): any { async function saveConfigFromEnv(config: any, configPath: string): Promise { const { saveConfig } = await import('./config/index.js'); - const lettabotConfig: LettaBotConfig = { + const lettabotConfig: Partial & Pick = { server: { mode: isLettaCloudUrl(config.baseUrl) ? 'cloud' : 'selfhosted', baseUrl: config.baseUrl, apiKey: config.apiKey, }, - agent: { - id: config.agentId, + agents: [{ name: config.agentName, - // model is configured on the Letta agent server-side, not saved to config - }, - channels: { - telegram: config.telegram.enabled ? { - enabled: true, - token: config.telegram.botToken, - dmPolicy: config.telegram.dmPolicy, - allowedUsers: config.telegram.allowedUsers, - groupDebounceSec: config.telegram.groupDebounceSec, - groupPollIntervalMin: config.telegram.groupPollIntervalMin, - instantGroups: config.telegram.instantGroups, - listeningGroups: config.telegram.listeningGroups, - } : { enabled: false }, - - slack: config.slack.enabled ? { - enabled: true, - botToken: config.slack.botToken, - appToken: config.slack.appToken, - allowedUsers: config.slack.allowedUsers, - groupDebounceSec: config.slack.groupDebounceSec, - groupPollIntervalMin: config.slack.groupPollIntervalMin, - instantGroups: config.slack.instantGroups, - listeningGroups: config.slack.listeningGroups, - } : { enabled: false }, - - discord: config.discord.enabled ? { - enabled: true, - token: config.discord.botToken, - dmPolicy: config.discord.dmPolicy, - allowedUsers: config.discord.allowedUsers, - groupDebounceSec: config.discord.groupDebounceSec, - groupPollIntervalMin: config.discord.groupPollIntervalMin, - instantGroups: config.discord.instantGroups, - listeningGroups: config.discord.listeningGroups, - } : { enabled: false }, - - whatsapp: config.whatsapp.enabled ? { - enabled: true, - selfChat: config.whatsapp.selfChat, - dmPolicy: config.whatsapp.dmPolicy, - allowedUsers: config.whatsapp.allowedUsers, - groupDebounceSec: config.whatsapp.groupDebounceSec, - groupPollIntervalMin: config.whatsapp.groupPollIntervalMin, - instantGroups: config.whatsapp.instantGroups, - listeningGroups: config.whatsapp.listeningGroups, - } : { enabled: false }, - - signal: config.signal.enabled ? { - enabled: true, - phone: config.signal.phoneNumber, - selfChat: config.signal.selfChat, - dmPolicy: config.signal.dmPolicy, - allowedUsers: config.signal.allowedUsers, - groupDebounceSec: config.signal.groupDebounceSec, - groupPollIntervalMin: config.signal.groupPollIntervalMin, - instantGroups: config.signal.instantGroups, - listeningGroups: config.signal.listeningGroups, - } : { enabled: false }, - }, - features: { - cron: false, - heartbeat: { - enabled: false, - intervalMin: 60, + ...(config.agentId ? { id: config.agentId } : {}), + channels: { + ...(config.telegram.enabled ? { + telegram: { + enabled: true, + token: config.telegram.botToken, + dmPolicy: config.telegram.dmPolicy, + allowedUsers: config.telegram.allowedUsers, + groupDebounceSec: config.telegram.groupDebounceSec, + groupPollIntervalMin: config.telegram.groupPollIntervalMin, + instantGroups: config.telegram.instantGroups, + listeningGroups: config.telegram.listeningGroups, + } + } : {}), + ...(config.slack.enabled ? { + slack: { + enabled: true, + botToken: config.slack.botToken, + appToken: config.slack.appToken, + allowedUsers: config.slack.allowedUsers, + groupDebounceSec: config.slack.groupDebounceSec, + groupPollIntervalMin: config.slack.groupPollIntervalMin, + instantGroups: config.slack.instantGroups, + listeningGroups: config.slack.listeningGroups, + } + } : {}), + ...(config.discord.enabled ? { + discord: { + enabled: true, + token: config.discord.botToken, + dmPolicy: config.discord.dmPolicy, + allowedUsers: config.discord.allowedUsers, + groupDebounceSec: config.discord.groupDebounceSec, + groupPollIntervalMin: config.discord.groupPollIntervalMin, + instantGroups: config.discord.instantGroups, + listeningGroups: config.discord.listeningGroups, + } + } : {}), + ...(config.whatsapp.enabled ? { + whatsapp: { + enabled: true, + selfChat: config.whatsapp.selfChat, + dmPolicy: config.whatsapp.dmPolicy, + allowedUsers: config.whatsapp.allowedUsers, + groupDebounceSec: config.whatsapp.groupDebounceSec, + groupPollIntervalMin: config.whatsapp.groupPollIntervalMin, + instantGroups: config.whatsapp.instantGroups, + listeningGroups: config.whatsapp.listeningGroups, + } + } : {}), + ...(config.signal.enabled ? { + signal: { + enabled: true, + phone: config.signal.phoneNumber, + selfChat: config.signal.selfChat, + dmPolicy: config.signal.dmPolicy, + allowedUsers: config.signal.allowedUsers, + groupDebounceSec: config.signal.groupDebounceSec, + groupPollIntervalMin: config.signal.groupPollIntervalMin, + instantGroups: config.signal.instantGroups, + listeningGroups: config.signal.listeningGroups, + } + } : {}), }, - }, + features: { + cron: false, + heartbeat: { + enabled: false, + intervalMin: 60, + }, + }, + }], }; saveConfig(lettabotConfig); @@ -1495,6 +1500,44 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise {}); + } + installSkillsToAgent(agentId, { + cronEnabled: config.cron, + googleEnabled: config.google.enabled, + }); + + // Disable tool approvals + ensureNoToolApprovals(agentId).catch(() => {}); + + config.agentId = agentId; + spinner.stop(`Agent created: ${agentId}`); + } catch (err) { + spinner.stop('Failed to create agent'); + p.log.error(`${err}`); + p.log.info('The agent will be created on first message instead.'); + } + } + // Apply config to env if (config.agentName) env.AGENT_NAME = config.agentName; @@ -1624,18 +1667,10 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise 0) { - yamlConfig.providers = config.providers.map(p => ({ - id: p.id, - name: p.name, - type: p.id, // id is the type (anthropic, openai, etc.) - apiKey: p.apiKey, - })); - } + + // Convert to YAML config (multi-agent format) + const yamlConfig: Partial & Pick = { + server: { + mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud', + ...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.apiKey ? { apiKey: config.apiKey } : {}), + }, + agents: [agentConfig], + ...(config.transcription.enabled && config.transcription.apiKey ? { + transcription: { + provider: 'openai' as const, + apiKey: config.transcription.apiKey, + ...(config.transcription.model ? { model: config.transcription.model } : {}), + }, + } : {}), + ...(config.providers && config.providers.length > 0 ? { + providers: config.providers.map(p => ({ + id: p.id, + name: p.name, + type: p.id, + apiKey: p.apiKey, + })), + } : {}), + }; // Save YAML config (use project-local path) const savePath = resolve(process.cwd(), 'lettabot.yaml');