UNPKG

@elevenlabs/convai-cli

Version:

CLI tool to manage ElevenLabs conversational AI agents

1,214 lines (1,213 loc) 54.7 kB
#!/usr/bin/env node import { Command } from 'commander'; import path from 'path'; import fs from 'fs-extra'; import dotenv from 'dotenv'; import { calculateConfigHash, readAgentConfig, writeAgentConfig, loadLockFile, saveLockFile, getAgentFromLock, updateAgentInLock, updateToolInLock, getToolFromLock, toSnakeCaseKeys } from './utils.js'; import { getTemplateByName, getTemplateOptions } from './templates.js'; import { getElevenLabsClient, createAgentApi, updateAgentApi, listAgentsApi, getAgentApi, createToolApi } from './elevenlabs-api.js'; import { getApiKey, setApiKey, removeApiKey, isLoggedIn, getResidency, setResidency, LOCATIONS } from './config.js'; import { readToolsConfig, writeToolsConfig, writeToolConfig } from './tools.js'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')); const { version } = packageJson; import { render } from 'ink'; import React from 'react'; import InitView from './ui/views/InitView.js'; import SyncView from './ui/views/SyncView.js'; import LoginView from './ui/views/LoginView.js'; import AddAgentView from './ui/views/AddAgentView.js'; import StatusView from './ui/views/StatusView.js'; import WhoamiView from './ui/views/WhoamiView.js'; import ListAgentsView from './ui/views/ListAgentsView.js'; import LogoutView from './ui/views/LogoutView.js'; import ResidencyView from './ui/views/ResidencyView.js'; import HelpView from './ui/views/HelpView.js'; // Load environment variables dotenv.config(); const program = new Command(); // Default file names const AGENTS_CONFIG_FILE = "agents.json"; const TOOLS_CONFIG_FILE = "tools.json"; const LOCK_FILE = "convai.lock"; program .name('convai') .description('ElevenLabs Conversational AI Agent Manager CLI') .version(version) .configureHelp({ // Override the default help to use our Ink UI formatHelp: () => '' }) .helpOption('-h, --help', 'Display help information') .on('option:help', async () => { // Show Ink-based help view const { waitUntilExit } = render(React.createElement(HelpView)); await waitUntilExit(); process.exit(0); }); program .command('init') .description('Initialize a new agent management project') .argument('[path]', 'Path to initialize the project in', '.') .option('--no-ui', 'Disable interactive UI') .action(async (projectPath, options) => { try { if (options.ui !== false) { // Use Ink UI for initialization const { waitUntilExit } = render(React.createElement(InitView, { projectPath })); await waitUntilExit(); } else { // Fallback to original implementation const fullPath = path.resolve(projectPath); console.log(`Initializing project in ${fullPath}`); // Create directory if it doesn't exist await fs.ensureDir(fullPath); // Create agents.json file const agentsConfigPath = path.join(fullPath, AGENTS_CONFIG_FILE); if (await fs.pathExists(agentsConfigPath)) { console.log(`${AGENTS_CONFIG_FILE} already exists, skipping creation`); } else { const initialConfig = { agents: [] }; await writeAgentConfig(agentsConfigPath, initialConfig); console.log(`Created ${AGENTS_CONFIG_FILE}`); } // Create tools.json file const toolsConfigPath = path.join(fullPath, TOOLS_CONFIG_FILE); if (await fs.pathExists(toolsConfigPath)) { console.log(`${TOOLS_CONFIG_FILE} already exists, skipping creation`); } else { const initialToolsConfig = { tools: [] }; await writeToolsConfig(toolsConfigPath, initialToolsConfig); console.log(`Created ${TOOLS_CONFIG_FILE}`); } // Create agent_configs directory structure const configDirs = ['agent_configs/dev', 'agent_configs/staging', 'agent_configs/prod', 'tool_configs']; for (const dir of configDirs) { const dirPath = path.join(fullPath, dir); await fs.ensureDir(dirPath); console.log(`Created directory: ${dir}`); } // Create initial lock file const lockFilePath = path.join(fullPath, LOCK_FILE); if (await fs.pathExists(lockFilePath)) { console.log(`${LOCK_FILE} already exists, skipping creation`); } else { const initialLockData = { agents: {}, tools: {} }; await saveLockFile(lockFilePath, initialLockData); console.log(`Created ${LOCK_FILE}`); } // Create .env.example file const envExamplePath = path.join(fullPath, '.env.example'); if (!(await fs.pathExists(envExamplePath))) { const envExample = `# ElevenLabs API Key ELEVENLABS_API_KEY=your_api_key_here `; await fs.writeFile(envExamplePath, envExample); console.log('Created .env.example'); } console.log('\nProject initialized successfully!'); console.log('Next steps:'); console.log('1. Set your ElevenLabs API key: convai login'); console.log('2. Create an agent: convai add agent "My Agent" --template default'); console.log('3. Create tools: convai add webhook-tool "My Webhook" or convai add client-tool "My Client"'); console.log('4. Sync to ElevenLabs: convai sync'); } } catch (error) { console.error(`Error initializing project: ${error}`); process.exit(1); } }); program .command('login') .description('Login with your ElevenLabs API key') .option('--no-ui', 'Disable interactive UI') .action(async (options) => { try { if (options.ui !== false) { // Use Ink UI for login const { waitUntilExit } = render(React.createElement(LoginView)); await waitUntilExit(); } else { // Fallback to text-based login const { read } = await import('read'); const apiKey = await read({ prompt: 'Enter your ElevenLabs API key: ', silent: true, replace: '*' }); if (!apiKey || apiKey.trim() === '') { console.error('API key is required'); process.exit(1); } // Test the API key by making a simple request process.env.ELEVENLABS_API_KEY = apiKey.trim(); const client = await getElevenLabsClient(); try { await listAgentsApi(client, 1); console.log('API key verified successfully'); } catch (error) { if (error?.statusCode === 401 || error?.message?.includes('401')) { console.error('Invalid API key'); } else if (error?.code === 'ENOTFOUND' || error?.code === 'ETIMEDOUT' || error?.message?.includes('network')) { console.error('Network error: Unable to connect to ElevenLabs API'); } else { console.error('Error verifying API key:', error?.message || error); } process.exit(1); } await setApiKey(apiKey.trim()); console.log('Login successful! API key saved securely.'); } } catch (error) { console.error(`Error during login: ${error}`); process.exit(1); } }); program .command('logout') .description('Logout and remove stored API key') .option('--no-ui', 'Disable interactive UI') .action(async (options) => { try { if (options.ui !== false) { // Use Ink UI for logout const { waitUntilExit } = render(React.createElement(LogoutView)); await waitUntilExit(); } else { // Fallback to text-based logout const loggedIn = await isLoggedIn(); if (!loggedIn) { console.log('You are not logged in'); return; } await removeApiKey(); console.log('Logged out successfully. API key removed.'); } } catch (error) { console.error(`Error during logout: ${error}`); process.exit(1); } }); program .command('whoami') .description('Show current login status') .option('--no-ui', 'Disable interactive UI') .action(async (options) => { try { if (options.ui !== false) { // Use Ink UI for whoami const { waitUntilExit } = render(React.createElement(WhoamiView)); await waitUntilExit(); } else { // Fallback to text-based output const loggedIn = await isLoggedIn(); const apiKey = await getApiKey(); const residency = await getResidency(); if (loggedIn && apiKey) { const maskedKey = apiKey.slice(0, 8) + '...' + apiKey.slice(-4); console.log(`Logged in with API key: ${maskedKey}`); // Show source of API key if (process.env.ELEVENLABS_API_KEY) { console.log('Source: Environment variable'); } else { console.log('Source: Config file'); } console.log(`Residency: ${residency}`); } else { console.log('Not logged in'); console.log('Use "convai login" to authenticate'); } } } catch (error) { console.error(`Error checking login status: ${error}`); process.exit(1); } }); program .command('residency') .description('Set the API residency location') .argument('[residency]', `Residency location (${LOCATIONS.join(', ')})`) .option('--no-ui', 'Disable interactive UI') .action(async (residency, options) => { try { if (options.ui !== false && !residency) { // Use Ink UI for interactive residency selection const { waitUntilExit } = render(React.createElement(ResidencyView)); await waitUntilExit(); } else if (residency) { // Direct residency setting (with or without UI) function isValidLocation(value) { return LOCATIONS.includes(value); } if (!isValidLocation(residency)) { console.error(`Invalid residency: ${residency}`); console.error(`Valid options: ${LOCATIONS.join(', ')}`); process.exit(1); } if (options.ui !== false) { // Use UI even with direct argument const { waitUntilExit } = render(React.createElement(ResidencyView, { initialResidency: residency })); await waitUntilExit(); } else { // Fallback to text-based await setResidency(residency); console.log(`Residency set to: ${residency}`); } } else { // No residency provided and UI disabled - show current residency const currentResidency = await getResidency(); console.log(`Current residency: ${currentResidency || 'Not set (using default)'}`); console.log(`To set residency, use: convai residency <${LOCATIONS.join('|')}>`); } } catch (error) { console.error(`Error setting residency: ${error}`); process.exit(1); } }); const addCommand = program .command('add') .description('Add agents and tools'); addCommand .command('agent') .description('Add a new agent - creates config, uploads to ElevenLabs, and saves ID') .argument('<name>', 'Name of the agent to create') .option('--config-path <path>', 'Custom config path (optional)') .option('--template <template>', 'Template type to use', 'default') .option('--skip-upload', 'Create config file only, don\'t upload to ElevenLabs', false) .option('--env <environment>', 'Environment to create agent for', 'prod') .option('--no-ui', 'Disable interactive UI') .action(async (name, options) => { try { if (options.ui !== false && !options.configPath) { // Use Ink UI for agent creation const { waitUntilExit } = render(React.createElement(AddAgentView, { initialName: name, environment: options.env, template: options.template, skipUpload: options.skipUpload })); await waitUntilExit(); return; } // Check if agents.json exists const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { console.error('agents.json not found. Run \'convai init\' first.'); process.exit(1); } // Load existing config const agentsConfig = await readAgentConfig(agentsConfigPath); // Load lock file to check environment-specific agents const lockFilePath = path.resolve(LOCK_FILE); const lockData = await loadLockFile(lockFilePath); // Check if agent already exists for this specific environment const lockedAgent = getAgentFromLock(lockData, name, options.env); if (lockedAgent?.id) { console.error(`Agent '${name}' already exists for environment '${options.env}'`); process.exit(1); } // Check if agent name exists in agents.json let existingAgent = agentsConfig.agents.find(agent => agent.name === name); // Generate environment-specific config path if not provided let configPath = options.configPath; if (!configPath) { const safeName = name.toLowerCase().replace(/\s+/g, '_').replace(/[[\]]/g, ''); configPath = `agent_configs/${options.env}/${safeName}.json`; } // Create config directory and file const configFilePath = path.resolve(configPath); await fs.ensureDir(path.dirname(configFilePath)); // Create agent config using template let agentConfig; try { agentConfig = getTemplateByName(name, options.template); } catch (error) { console.error(`${error}`); process.exit(1); } await writeAgentConfig(configFilePath, agentConfig); console.log(`Created config file: ${configPath} (template: ${options.template})`); if (existingAgent) { console.log(`Agent '${name}' exists, adding new environment '${options.env}'`); } else { console.log(`Creating new agent '${name}' for environment '${options.env}'`); } if (options.skipUpload) { if (!existingAgent) { const newAgent = { name, environments: { [options.env]: { config: configPath } } }; agentsConfig.agents.push(newAgent); console.log(`Added agent '${name}' to agents.json (local only)`); } else { if (!existingAgent.environments) { const oldConfig = existingAgent.config || ''; existingAgent.environments = { default: { config: oldConfig } }; delete existingAgent.config; } existingAgent.environments[options.env] = { config: configPath }; console.log(`Added environment '${options.env}' to existing agent '${name}' (local only)`); } await writeAgentConfig(agentsConfigPath, agentsConfig); console.log(`Edit ${configPath} to customize your agent, then run 'convai sync --env ${options.env}' to upload`); return; } // Create agent in ElevenLabs console.log(`Creating agent '${name}' in ElevenLabs (environment: ${options.env})...`); const client = await getElevenLabsClient(); // Extract config components const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; let tags = agentConfig.tags || []; // Add environment tag if specified and not already present if (options.env && !tags.includes(options.env)) { tags = [...tags, options.env]; } // Create new agent const agentId = await createAgentApi(client, name, conversationConfig, platformSettings, tags); console.log(`Created agent in ElevenLabs with ID: ${agentId}`); if (!existingAgent) { const newAgent = { name, environments: { [options.env]: { config: configPath } } }; agentsConfig.agents.push(newAgent); console.log(`Added agent '${name}' to agents.json`); } else { if (!existingAgent.environments) { const oldConfig = existingAgent.config || ''; existingAgent.environments = { default: { config: oldConfig } }; delete existingAgent.config; } existingAgent.environments[options.env] = { config: configPath }; console.log(`Added environment '${options.env}' to existing agent '${name}'`); } // Save updated agents.json await writeAgentConfig(agentsConfigPath, agentsConfig); // Update lock file with environment-specific agent ID const configHash = calculateConfigHash(toSnakeCaseKeys(agentConfig)); updateAgentInLock(lockData, name, options.env, agentId, configHash); await saveLockFile(lockFilePath, lockData); console.log(`Edit ${configPath} to customize your agent, then run 'convai sync --env ${options.env}' to update`); } catch (error) { console.error(`Error creating agent: ${error}`); process.exit(1); } }); addCommand .command('webhook-tool') .description('Add a new webhook tool - creates config and uploads to ElevenLabs') .argument('<name>', 'Name of the webhook tool to create') .option('--config-path <path>', 'Custom config path (optional)') .option('--skip-upload', 'Create config file only, don\'t upload to ElevenLabs', false) .action(async (name, options) => { try { await addTool(name, 'webhook', options.configPath, options.skipUpload); } catch (error) { console.error(`Error creating webhook tool: ${error}`); process.exit(1); } }); addCommand .command('client-tool') .description('Add a new client tool - creates config and uploads to ElevenLabs') .argument('<name>', 'Name of the client tool to create') .option('--config-path <path>', 'Custom config path (optional)') .option('--skip-upload', 'Create config file only, don\'t upload to ElevenLabs', false) .action(async (name, options) => { try { await addTool(name, 'client', options.configPath, options.skipUpload); } catch (error) { console.error(`Error creating client tool: ${error}`); process.exit(1); } }); const templatesCommand = program .command('templates') .description('Manage agent templates'); templatesCommand .command('list') .description('List available agent templates') .action(() => { const templateOptions = getTemplateOptions(); console.log('Available Agent Templates:'); console.log('='.repeat(40)); for (const [templateName, description] of Object.entries(templateOptions)) { console.log(`\n${templateName}`); console.log(` ${description}`); } console.log('\nUse \'convai add <name> --template <template_name>\' to create an agent with a specific template'); }); templatesCommand .command('show') .description('Show the configuration for a specific template') .argument('<template>', 'Template name to show') .option('--agent-name <name>', 'Agent name to use in template', 'example_agent') .action((templateName, options) => { try { const templateConfig = getTemplateByName(options.agentName, templateName); console.log(`Template: ${templateName}`); console.log('='.repeat(40)); console.log(JSON.stringify(templateConfig, null, 2)); } catch (error) { console.error(`${error}`); process.exit(1); } }); program .command('sync') .description('Synchronize agents with ElevenLabs API when configs change') .option('--agent <name>', 'Specific agent name to sync (defaults to all agents)') .option('--dry-run', 'Show what would be done without making changes', false) .option('--env <environment>', 'Target specific environment (defaults to all environments)') .option('--no-ui', 'Disable interactive UI') .action(async (options) => { try { if (options.ui !== false) { // Use new Ink UI for sync const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { throw new Error('agents.json not found. Run \'init\' first.'); } const agentsConfig = await readAgentConfig(agentsConfigPath); // Filter agents if specific agent name provided let agentsToProcess = agentsConfig.agents; if (options.agent) { agentsToProcess = agentsConfig.agents.filter(agent => agent.name === options.agent); if (agentsToProcess.length === 0) { throw new Error(`Agent '${options.agent}' not found in configuration`); } } // Prepare agents for UI const syncAgents = agentsToProcess.map(agent => ({ name: agent.name, environment: options.env || 'all', configPath: agent.config || `agent_configs/${agent.name}.json`, status: 'pending' })); const { waitUntilExit } = render(React.createElement(SyncView, { agents: syncAgents, dryRun: options.dryRun })); await waitUntilExit(); } else { // Use existing non-UI sync await syncAgents(options.agent, options.dryRun, options.env); } } catch (error) { console.error(`Error during sync: ${error}`); process.exit(1); } }); program .command('status') .description('Show the status of agents') .option('--agent <name>', 'Specific agent name to check (defaults to all agents)') .option('--env <environment>', 'Environment to check status for (defaults to all environments)') .option('--no-ui', 'Disable interactive UI') .action(async (options) => { try { if (options.ui !== false) { // Use Ink UI for status display const { waitUntilExit } = render(React.createElement(StatusView, { agentName: options.agent, environment: options.env })); await waitUntilExit(); } else { await showStatus(options.agent, options.env); } } catch (error) { console.error(`Error showing status: ${error}`); process.exit(1); } }); program .command('watch') .description('Watch for config changes and auto-sync agents') .option('--agent <name>', 'Specific agent name to watch (defaults to all agents)') .option('--env <environment>', 'Environment to watch', 'prod') .option('--interval <seconds>', 'Check interval in seconds', '5') .action(async (options) => { try { await watchForChanges(options.agent, options.env, parseInt(options.interval)); } catch (error) { console.error(`Error in watch mode: ${error}`); process.exit(1); } }); program .command('list-agents') .description('List all configured agents') .option('--no-ui', 'Disable interactive UI') .action(async (options) => { try { if (options.ui !== false) { // Use Ink UI for list-agents const { waitUntilExit } = render(React.createElement(ListAgentsView)); await waitUntilExit(); } else { // Fallback to text-based listing await listConfiguredAgents(); } } catch (error) { console.error(`Error listing agents: ${error}`); process.exit(1); } }); program .command('fetch') .description('Fetch all agents from ElevenLabs workspace and add them to local configuration') .option('--agent <name>', 'Specific agent name pattern to search for') .option('--output-dir <dir>', 'Directory to store fetched agent configs', 'agent_configs') .option('--search <term>', 'Search agents by name') .option('--dry-run', 'Show what would be fetched without making changes', false) .option('--env <environment>', 'Environment to associate fetched agents with', 'prod') .action(async (options) => { try { await fetchAgents(options); } catch (error) { console.error(`Error fetching agents: ${error}`); process.exit(1); } }); program .command('widget') .description('Generate HTML widget snippet for an agent') .argument('<name>', 'Name of the agent to generate widget for') .option('--env <environment>', 'Environment to get agent ID from', 'prod') .action(async (name, options) => { try { await generateWidget(name, options.env); } catch (error) { console.error(`Error generating widget: ${error}`); process.exit(1); } }); // Helper functions async function addTool(name, type, configPath, skipUpload = false) { // Check if tools.json exists, create if not const toolsConfigPath = path.resolve(TOOLS_CONFIG_FILE); let toolsConfig; try { toolsConfig = await readToolsConfig(toolsConfigPath); } catch (error) { // Initialize tools.json if it doesn't exist toolsConfig = { tools: [] }; await writeToolsConfig(toolsConfigPath, toolsConfig); console.log(`Created ${TOOLS_CONFIG_FILE}`); } // Load lock file const lockFilePath = path.resolve(LOCK_FILE); const lockData = await loadLockFile(lockFilePath); // Check if tool already exists const existingTool = toolsConfig.tools.find(tool => tool.name === name); const lockedTool = getToolFromLock(lockData, name); if (existingTool && lockedTool?.id) { console.error(`Tool '${name}' already exists`); process.exit(1); } // Generate config path if not provided if (!configPath) { const safeName = name.toLowerCase().replace(/\s+/g, '_').replace(/[[\]]/g, ''); configPath = `tool_configs/${safeName}.json`; } // Create config directory and file const configFilePath = path.resolve(configPath); await fs.ensureDir(path.dirname(configFilePath)); // Create tool config using appropriate template let toolConfig; if (type === 'webhook') { toolConfig = { name, description: `${name} webhook tool`, type: 'webhook', api_schema: { url: 'https://api.example.com/webhook', method: 'POST', path_params_schema: [], query_params_schema: [], request_body_schema: { id: 'body', type: 'object', value_type: 'llm_prompt', description: 'Request body for the webhook', dynamic_variable: '', constant_value: '', required: true, properties: [] }, request_headers: [ { type: 'value', name: 'Content-Type', value: 'application/json' } ], auth_connection: null }, response_timeout_secs: 30, dynamic_variables: { dynamic_variable_placeholders: {} } }; } else { toolConfig = { name, description: `${name} client tool`, type: 'client', expects_response: false, response_timeout_secs: 30, parameters: [ { id: 'input', type: 'string', value_type: 'llm_prompt', description: 'Input parameter for the client tool', dynamic_variable: '', constant_value: '', required: true } ], dynamic_variables: { dynamic_variable_placeholders: {} } }; } await writeToolConfig(configFilePath, toolConfig); console.log(`Created config file: ${configPath}`); // Add to tools.json if not already present if (!existingTool) { const newTool = { name, type, config: configPath }; toolsConfig.tools.push(newTool); await writeToolsConfig(toolsConfigPath, toolsConfig); console.log(`Added tool '${name}' to tools.json`); } if (skipUpload) { console.log(`Edit ${configPath} to customize your tool, then run 'convai sync-tools' to upload`); return; } // Create tool in ElevenLabs console.log(`Creating ${type} tool '${name}' in ElevenLabs...`); const client = await getElevenLabsClient(); try { const response = await createToolApi(client, toolConfig); const toolId = response.toolId || `tool_${Date.now()}`; console.log(`Created tool in ElevenLabs with ID: ${toolId}`); // Update lock file const configHash = calculateConfigHash(toSnakeCaseKeys(toolConfig)); updateToolInLock(lockData, name, toolId, configHash); await saveLockFile(lockFilePath, lockData); console.log(`Edit ${configPath} to customize your tool, then run 'convai sync-tools' to update`); } catch (error) { console.error(`Error creating tool in ElevenLabs: ${error}`); process.exit(1); } } async function syncAgents(agentName, dryRun = false, environment) { // Load agents configuration const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { throw new Error('agents.json not found. Run \'init\' first.'); } const agentsConfig = await readAgentConfig(agentsConfigPath); const lockFilePath = path.resolve(LOCK_FILE); const lockData = await loadLockFile(lockFilePath); // Initialize ElevenLabs client let client; if (!dryRun) { client = await getElevenLabsClient(); } // Filter agents if specific agent name provided let agentsToProcess = agentsConfig.agents; if (agentName) { agentsToProcess = agentsConfig.agents.filter(agent => agent.name === agentName); if (agentsToProcess.length === 0) { throw new Error(`Agent '${agentName}' not found in configuration`); } } // Determine environments to sync let environmentsToSync = []; if (environment) { environmentsToSync = [environment]; } else { const envSet = new Set(); for (const agentDef of agentsToProcess) { if (agentDef.environments) { Object.keys(agentDef.environments).forEach(env => envSet.add(env)); } else { envSet.add('prod'); // Old format compatibility } } environmentsToSync = Array.from(envSet); if (environmentsToSync.length === 0) { console.log('No environments found to sync'); return; } console.log(`Syncing all environments: ${environmentsToSync.join(', ')}`); } let changesMade = false; for (const currentEnv of environmentsToSync) { console.log(`\nProcessing environment: ${currentEnv}`); for (const agentDef of agentsToProcess) { const agentDefName = agentDef.name; // Handle both old and new config structure let configPath; if (agentDef.environments) { if (currentEnv in agentDef.environments) { configPath = agentDef.environments[currentEnv].config; } else { console.log(`Warning: Agent '${agentDefName}' not configured for environment '${currentEnv}'`); continue; } } else { configPath = agentDef.config; if (!configPath) { console.log(`Warning: No config path found for agent '${agentDefName}'`); continue; } } // Check if config file exists if (!(await fs.pathExists(configPath))) { console.log(`Warning: Config file not found for ${agentDefName}: ${configPath}`); continue; } // Load agent config let agentConfig; try { agentConfig = await readAgentConfig(configPath); } catch (error) { console.log(`Error reading config for ${agentDefName}: ${error}`); continue; } // Calculate config hash const configHash = calculateConfigHash(toSnakeCaseKeys(agentConfig)); // Get environment-specific agent data from lock file const lockedAgent = getAgentFromLock(lockData, agentDefName, currentEnv); let needsUpdate = true; if (lockedAgent) { if (lockedAgent.hash === configHash) { needsUpdate = false; console.log(`${agentDefName}: No changes (environment: ${currentEnv})`); } else { console.log(`${agentDefName}: Config changed, will update (environment: ${currentEnv})`); } } else { console.log(`${agentDefName}: New environment detected, will create/update (environment: ${currentEnv})`); } if (!needsUpdate) { continue; } if (dryRun) { console.log(`[DRY RUN] Would update agent: ${agentDefName} (environment: ${currentEnv})`); continue; } // Perform API operation try { const agentId = lockedAgent?.id; // Extract config components const conversationConfig = agentConfig.conversation_config || {}; const platformSettings = agentConfig.platform_settings; let tags = agentConfig.tags || []; // Add environment tag if specified and not already present if (currentEnv && !tags.includes(currentEnv)) { tags = [...tags, currentEnv]; } const agentDisplayName = agentConfig.name || agentDefName; if (!agentId) { // Create new agent for this environment const newAgentId = await createAgentApi(client, agentDisplayName, conversationConfig, platformSettings, tags); console.log(`Created agent ${agentDefName} for environment '${currentEnv}' (ID: ${newAgentId})`); updateAgentInLock(lockData, agentDefName, currentEnv, newAgentId, configHash); } else { // Update existing environment-specific agent await updateAgentApi(client, agentId, agentDisplayName, conversationConfig, platformSettings, tags); console.log(`Updated agent ${agentDefName} for environment '${currentEnv}' (ID: ${agentId})`); updateAgentInLock(lockData, agentDefName, currentEnv, agentId, configHash); } changesMade = true; } catch (error) { console.log(`Error processing ${agentDefName}: ${error}`); } } } // Save lock file if changes were made if (changesMade && !dryRun) { await saveLockFile(lockFilePath, lockData); console.log('Updated lock file'); } } async function showStatus(agentName, environment) { const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { throw new Error('agents.json not found. Run \'init\' first.'); } const agentsConfig = await readAgentConfig(agentsConfigPath); const lockData = await loadLockFile(path.resolve(LOCK_FILE)); if (agentsConfig.agents.length === 0) { console.log('No agents configured'); return; } // Filter agents if specific agent name provided let agentsToShow = agentsConfig.agents; if (agentName) { agentsToShow = agentsConfig.agents.filter(agent => agent.name === agentName); if (agentsToShow.length === 0) { throw new Error(`Agent '${agentName}' not found in configuration`); } } // Determine environments to show let environmentsToShow = []; if (environment) { environmentsToShow = [environment]; console.log(`Agent Status (Environment: ${environment}):`); } else { const envSet = new Set(); for (const agentDef of agentsToShow) { if (agentDef.environments) { Object.keys(agentDef.environments).forEach(env => envSet.add(env)); } else { envSet.add('prod'); // Old format compatibility } } environmentsToShow = Array.from(envSet); console.log('Agent Status (All Environments):'); } console.log('='.repeat(50)); for (const agentDef of agentsToShow) { const agentNameCurrent = agentDef.name; for (const currentEnv of environmentsToShow) { // Handle both old and new config structure let configPath; if (agentDef.environments) { if (currentEnv in agentDef.environments) { configPath = agentDef.environments[currentEnv].config; } else { continue; // Skip if agent not configured for this environment } } else { configPath = agentDef.config; if (!configPath) { continue; } } // Get environment-specific agent ID from lock file const lockedAgent = getAgentFromLock(lockData, agentNameCurrent, currentEnv); const agentId = lockedAgent?.id || 'Not created for this environment'; console.log(`\n${agentNameCurrent}`); console.log(` Environment: ${currentEnv}`); console.log(` Agent ID: ${agentId}`); console.log(` Config: ${configPath}`); // Check config file status if (await fs.pathExists(configPath)) { try { const agentConfig = await readAgentConfig(configPath); const configHash = calculateConfigHash(toSnakeCaseKeys(agentConfig)); console.log(` Config Hash: ${configHash.substring(0, 8)}...`); // Check lock status for specified environment if (lockedAgent) { if (lockedAgent.hash === configHash) { console.log(` Status: Synced (${currentEnv})`); } else { console.log(` Status: Config changed (needs sync for ${currentEnv})`); } } else { console.log(` Status: New (needs sync for ${currentEnv})`); } } catch (error) { console.log(` Status: Config error: ${error}`); } } else { console.log(' Status: Config file not found'); } } } } async function watchForChanges(agentName, environment = 'prod', interval = 5) { console.log(`Watching for config changes (checking every ${interval}s)...`); if (agentName) { console.log(`Agent: ${agentName}`); } else { console.log('Agent: All agents'); } console.log(`Environment: ${environment}`); console.log('Press Ctrl+C to stop'); // Track file modification times const fileTimestamps = new Map(); const getFileMtime = async (filePath) => { try { const exists = await fs.pathExists(filePath); if (!exists) return 0; const stats = await fs.stat(filePath); return stats.mtime.getTime(); } catch { return 0; } }; const checkForChanges = async () => { // Load agents configuration const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { return false; } try { const agentsConfig = await readAgentConfig(agentsConfigPath); // Filter agents if specific agent name provided let agentsToWatch = agentsConfig.agents; if (agentName) { agentsToWatch = agentsConfig.agents.filter(agent => agent.name === agentName); } // Check agents.json itself const agentsMtime = await getFileMtime(agentsConfigPath); if (fileTimestamps.get(agentsConfigPath) !== agentsMtime) { fileTimestamps.set(agentsConfigPath, agentsMtime); console.log(`Detected change in ${AGENTS_CONFIG_FILE}`); return true; } // Check individual agent config files for (const agentDef of agentsToWatch) { const configPaths = []; if (agentDef.environments) { if (environment in agentDef.environments) { configPaths.push(agentDef.environments[environment].config); } } else { if (agentDef.config) { configPaths.push(agentDef.config); } } for (const configPath of configPaths) { if (await fs.pathExists(configPath)) { const configMtime = await getFileMtime(configPath); if (fileTimestamps.get(configPath) !== configMtime) { fileTimestamps.set(configPath, configMtime); console.log(`Detected change in ${configPath}`); return true; } } } } return false; } catch { return false; } }; // Initialize file timestamps await checkForChanges(); try { while (true) { if (await checkForChanges()) { console.log('Running sync...'); try { await syncAgents(agentName, false, environment); } catch (error) { console.log(`Error during sync: ${error}`); } } await new Promise(resolve => setTimeout(resolve, interval * 1000)); } } catch (error) { if (error.code === 'SIGINT') { console.log('\nStopping watch mode'); } else { throw error; } } } async function listConfiguredAgents() { const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { throw new Error('agents.json not found. Run \'init\' first.'); } const agentsConfig = await readAgentConfig(agentsConfigPath); if (agentsConfig.agents.length === 0) { console.log('No agents configured'); return; } console.log('Configured Agents:'); console.log('='.repeat(30)); agentsConfig.agents.forEach((agentDef, i) => { console.log(`${i + 1}. ${agentDef.name}`); if (agentDef.environments) { console.log(' Environments:'); Object.entries(agentDef.environments).forEach(([envName, envConfig]) => { console.log(` ${envName}: ${envConfig.config}`); }); } else { const configPath = agentDef.config || 'No config path'; console.log(` Config: ${configPath}`); } console.log(); }); } async function fetchAgents(options) { // Check if agents.json exists const agentsConfigPath = path.resolve(AGENTS_CONFIG_FILE); if (!(await fs.pathExists(agentsConfigPath))) { throw new Error('agents.json not found. Run \'convai init\' first.'); } const client = await getElevenLabsClient(); // Use agent option as search term if provided, otherwise use search parameter const searchTerm = options.agent || options.search; // Fetch all agents from ElevenLabs console.log('Fetching agents from ElevenLabs...'); const agentsList = await listAgentsApi(client, 30, searchTerm); if (agentsList.length === 0) { console.log('No agents found in your ElevenLabs workspace.'); return; } console.log(`Found ${agentsList.length} agent(s)`); // Load existing config const agentsConfig = await readAgentConfig(agentsConfigPath); const existingAgentNames = new Set(agentsConfig.agents.map(agent => agent.name)); // Load lock file to check for existing agent IDs per environment const lockFilePath = path.resolve(LOCK_FILE); const lockData = await loadLockFile(lockFilePath); const existingAgentIds = new Set(); // Collect all existing agent IDs across all environments Object.values(lockData.agents).forEach(environments => { Object.values(environments).forEach(envData => { if (envData.id) { existingAgentIds.add(envData.id); } }); }); let newAgentsAdded = 0; for (const agentMeta of agentsList) { const agentMetaTyped = agentMeta; const agentId = agentMetaTyped.agentId || agentMetaTyped.agent_id; if (!agentId) { console.log(`Warning: Skipping agent '${agentMetaTyped.name}' - no agent ID found`); continue; } let agentNameRemote = agentMetaTyped.name; // Skip if agent already exists by ID (in any environment) if (existingAgentIds.has(agentId)) { console.log(`Skipping '${agentNameRemote}' - already exists (ID: ${agentId})`); continue; } // Check for name conflicts if (existingAgentNames.has(agentNameRemote)) { let counter = 1; const originalName = agentNameRemote; while (existingAgentNames.has(agentNameRemote)) { agentNameRemote = `${originalName}_${counter}`; counter++; } console.log(`Warning: Name conflict: renamed '${originalName}' to '${agentNameRemote}'`); } if (options.dryRun) { console.log(`[DRY RUN] Would fetch agent: ${agentNameRemote} (ID: ${agentId}) for environment: ${options.env}`); continue; } try { // Fetch detailed agent configuration console.log(`Fetching config for '${agentNameRemote}'...`); const agentDetails = await getAgentApi(client, agentId); // Extract configuration components const agentDetailsTyped = agentDetails; const conversationConfig = agentDetailsTyped.conversationConfig || agentDetailsTyped.conversation_config || {}; const platformSettings = agentDetailsTyped.platformSettings || agentDetailsTyped.platform_settings || {}; const tags = agentDetailsTyped.tags || []; // Create agent config structure const agentConfig = { name: agentNameRemote, conversation_config: conversationConfig, platform_settings: platformSettings, tags }; // Generate config file path const safeName = agentNameRemote.toLowerCase().replace(/\s+/g, '_').replace(/[[\]]/g, ''); const configPath = `${options.outputDir}/${safeName}.json`; // Create config file const configFilePath = path.resolve(configPath); await fs.ensureDir(path.dirname(configFilePath)); await writeAgentConfig(configFilePath, agentConfig); // Create new agent entry for agents.json const newAgent = {