diff --git a/src/commands/agent/commands/delete.ts b/src/commands/agent/commands/delete.ts index 2dc42f1..24515fe 100644 --- a/src/commands/agent/commands/delete.ts +++ b/src/commands/agent/commands/delete.ts @@ -115,21 +115,33 @@ export function registerDeleteCommand(agentCmd: Command): void { console.log(chalk.red('⚠️ This action cannot be undone!')); console.log(chalk.dim('─'.repeat(50))); + // Check if confirmation should be skipped + const isNonInteractive = process.env.XPANDER_NON_INTERACTIVE === 'true'; + const skipConfirmation = updatedOptions.confirm || isNonInteractive; + // Confirm deletion - const confirmation = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmed', - message: `Are you sure you want to permanently delete this agent?`, - default: false, - }, - ]); - - if (!confirmation.confirmed) { + if (!skipConfirmation) { + const confirmation = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: `Are you sure you want to permanently delete this agent?`, + default: false, + }, + ]); + + if (!confirmation.confirmed) { + console.log( + chalk.blue('\n✓ Operation cancelled. Agent was not deleted.'), + ); + return; + } + } else { console.log( - chalk.blue('\n✓ Operation cancelled. Agent was not deleted.'), + chalk.yellow( + '→ Skipping confirmation (non-interactive mode or --confirm flag set)', + ), ); - return; } // Create a spinner for deletion diff --git a/src/commands/agent/index.ts b/src/commands/agent/index.ts index 54427b7..eff2145 100644 --- a/src/commands/agent/index.ts +++ b/src/commands/agent/index.ts @@ -64,25 +64,28 @@ export function agent(program: Command): void { .alias('d') .description('Deploy agent') .option('--profile ', 'Profile to use') + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) .option('--confirm', 'Skip confirmation prompts') .option('--skip-local-tests', 'Skip local Docker container tests') - .action(async (agentId, options) => { + .action(async (agentId, options, command) => { const { createClient } = await import('../../utils/client'); - const { getAgentIdFromEnvOrSelection } = await import( - '../../utils/agent-resolver' - ); const { deployAgent } = await import('./interactive/deploy'); + + // Check if --profile was explicitly provided in command line + const profileExplicitlySet = + command.parent?.rawArgs.includes('--profile'); + const client = createClient(options.profile); - const resolvedAgentId = await getAgentIdFromEnvOrSelection( - client, - agentId, - ); - if (!resolvedAgentId) return; await deployAgent( client, - resolvedAgentId, + agentId, options.confirm, options.skipLocalTests, + options.path, + profileExplicitlySet || false, // Use profile credentials if --profile was explicitly set ); }); @@ -90,31 +93,43 @@ export function agent(program: Command): void { .command('dev [agent]') .description('Run agent locally') .option('--profile ', 'Profile to use') - .action(async (agentId) => { + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) + .action(async (agentId, options) => { const { startAgent } = await import('./interactive/dev'); - await startAgent(agentId); + await startAgent(agentId, options.path); }); agentCmd .command('restart [agent]') .description('Restart agent') .option('--profile ', 'Profile to use') + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) .action(async (agentId, options) => { const { restartAgent } = await import('../restart'); const { createClient } = await import('../../utils/client'); const client = createClient(options.profile); - await restartAgent(client, agentId); + await restartAgent(client, agentId, options.path); }); agentCmd .command('stop [agent]') .description('Stop agent') .option('--profile ', 'Profile to use') + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) .action(async (agentId, options) => { const { stopAgent } = await import('../stop'); const { createClient } = await import('../../utils/client'); const client = createClient(options.profile); - await stopAgent(client, agentId); + await stopAgent(client, agentId, options.path); }); // Register graph and tools commands diff --git a/src/commands/agent/interactive/deploy.ts b/src/commands/agent/interactive/deploy.ts index 74ac06c..9906493 100644 --- a/src/commands/agent/interactive/deploy.ts +++ b/src/commands/agent/interactive/deploy.ts @@ -5,9 +5,13 @@ import ora from 'ora'; import { XpanderClient } from '../../../utils/client'; import { ensureAgentIsInitialized, + fileExists, pathIsEmpty, } from '../../../utils/custom-agents'; -import { uploadAndDeploy } from '../../../utils/custom_agents_utils/deploymentManagement'; +import { + stopDeployment, + uploadAndDeploy, +} from '../../../utils/custom_agents_utils/deploymentManagement'; import { buildAndSaveDockerImage } from '../../../utils/custom_agents_utils/docker'; import { getXpanderConfigFromEnvFile } from '../../../utils/custom_agents_utils/generic'; import { configureLogsCommand } from '../../logs'; @@ -17,6 +21,8 @@ export async function deployAgent( _agentId?: string, skipDeploymentConfirmation: boolean = false, skipLocalTests: boolean = false, + workingDirectory?: string, + useProfileCredentials: boolean = false, ) { console.log('\n'); console.log(chalk.bold.blue('✨ Agent deployment')); @@ -24,30 +30,10 @@ export async function deployAgent( const isNonInteractive = process.env.XPANDER_NON_INTERACTIVE === 'true'; - if (!skipDeploymentConfirmation && !isNonInteractive) { - const { shouldDeploy } = await inquirer.prompt([ - { - type: 'confirm', - name: 'shouldDeploy', - message: 'Are you sure you want to deploy your AI Agent?', - default: true, - }, - ]); - if (!shouldDeploy) { - return; - } - } else if (!skipDeploymentConfirmation && isNonInteractive) { - console.log( - chalk.yellow( - '→ Running in non-interactive mode, proceeding with deployment', - ), - ); - } - const deploymentSpinner = ora(`Initializing deployment...`).start(); try { - // Check if current folder is empty - const currentDirectory = process.cwd(); + // Use provided path or default to current directory + const currentDirectory = workingDirectory || process.cwd(); if (await pathIsEmpty(currentDirectory)) { deploymentSpinner.fail( 'Current workdir is no initialized, initialize your agent first.', @@ -55,6 +41,33 @@ export async function deployAgent( return; } + // Check if .env file exists, if not create it with CLI credentials + const fs = await import('fs/promises'); + const path = await import('path'); + const envPath = path.join(currentDirectory, '.env'); + const envExists = await fileExists(envPath); + + if (!envExists) { + deploymentSpinner.info( + '.env file not found, creating it with CLI credentials', + ); + + // Get credentials from client (which uses profile or default) + const credentials = { + api_key: client.apiKey, + organization_id: client.orgId, + }; + + // Create .env file with CLI credentials + const envContent = `XPANDER_API_KEY=${credentials.api_key} +XPANDER_ORGANIZATION_ID=${credentials.organization_id} +XPANDER_AGENT_ID= +`; + await fs.writeFile(envPath, envContent); + deploymentSpinner.info('Created .env file with CLI credentials'); + deploymentSpinner.start(); + } + // check for configuration and required files const isInitialized = await ensureAgentIsInitialized( currentDirectory, @@ -64,12 +77,202 @@ export async function deployAgent( return; } - const config = await getXpanderConfigFromEnvFile(currentDirectory); + // Try to read config from .env file (may be missing AGENT_ID) + let config: { + api_key?: string; + organization_id?: string; + agent_id?: string; + }; + try { + config = await getXpanderConfigFromEnvFile(currentDirectory); + } catch (error: any) { + // If only AGENT_ID is missing, that's ok - we'll prompt for it + if (error.message && error.message.includes('XPANDER_AGENT_ID')) { + deploymentSpinner.info( + 'No agent ID found in .env file, will prompt for selection', + ); + // Try to read just the credentials + const envContent = await fs.readFile(envPath, 'utf-8'); + const envVars = Object.fromEntries( + envContent + .split('\n') + .map((line) => line.trim()) + .filter( + (line) => line && !line.startsWith('#') && line.includes('='), + ) + .map((line) => { + const [key, ...rest] = line.split('='); + const value = rest + .join('=') + .trim() + .replace(/^['"]|['"]$/g, ''); + return [key.trim(), value]; + }), + ); + config = { + api_key: envVars.XPANDER_API_KEY, + organization_id: envVars.XPANDER_ORGANIZATION_ID, + agent_id: envVars.XPANDER_AGENT_ID, + }; + } else { + throw error; + } + } + + // If .env file has credentials, use them instead of profile credentials + // BUT only if --profile was NOT explicitly specified + let deployClient = client; + if (!useProfileCredentials && config.api_key && config.organization_id) { + deploymentSpinner.info('Using credentials from .env file'); + deployClient = new XpanderClient(config.api_key, config.organization_id); + } else if (useProfileCredentials) { + deploymentSpinner.info( + 'Using credentials from profile (--profile flag set)', + ); + } + + deploymentSpinner.stop(); + + // Get agent ID - command line takes precedence over .env file + // Need to resolve agent name to ID if a name was provided + let agentId: string | undefined; + + if (_agentId) { + // Command-line argument provided - resolve name to ID + const { resolveAgentId } = await import('../../../utils/agent-resolver'); + const resolved = await resolveAgentId(deployClient, _agentId, true); + agentId = resolved || undefined; + } else if (config.agent_id) { + // Use agent ID from .env file + agentId = config.agent_id; + } else { + // No agent ID provided + // In non-interactive mode, we'll use the directory name and let the agent creation logic handle it + // In interactive mode, prompt user to select + if (isNonInteractive) { + // Use directory name as agent name/id - will be created if doesn't exist + agentId = path.basename(currentDirectory); + deploymentSpinner.info( + `No agent ID found, will create/use agent: ${agentId}`, + ); + } else { + // Prompt user to select or create one + const { getAgentIdFromEnvOrSelection } = await import( + '../../../utils/agent-resolver' + ); + const selectedAgentId = await getAgentIdFromEnvOrSelection( + deployClient, + undefined, + false, + currentDirectory, + ); + if (!selectedAgentId) { + return; + } + agentId = selectedAgentId; + } + } + + if (!skipDeploymentConfirmation && !isNonInteractive) { + const { shouldDeploy } = await inquirer.prompt([ + { + type: 'confirm', + name: 'shouldDeploy', + message: 'Are you sure you want to deploy your AI Agent?', + default: true, + }, + ]); + if (!shouldDeploy) { + return; + } + } else if (!skipDeploymentConfirmation && isNonInteractive) { + console.log( + chalk.yellow( + '→ Running in non-interactive mode, proceeding with deployment', + ), + ); + } + + deploymentSpinner.start('Retrieving agent information...'); - const agent = await client.getAgent(config.agent_id); + let agent = await deployClient.getAgent(agentId!); if (!agent) { - deploymentSpinner.fail(`Agent ${config.agent_id} not found!`); - return; + // Agent doesn't exist, create it + deploymentSpinner.info( + `Agent ${agentId} not found, creating new agent...`, + ); + + if (!skipDeploymentConfirmation && !isNonInteractive) { + const { shouldCreate } = await inquirer.prompt([ + { + type: 'confirm', + name: 'shouldCreate', + message: `Agent ${agentId} does not exist. Would you like to create it?`, + default: true, + }, + ]); + if (!shouldCreate) { + deploymentSpinner.fail('Agent creation cancelled'); + return; + } + } else if (skipDeploymentConfirmation || isNonInteractive) { + console.log( + chalk.yellow('→ Agent does not exist, creating automatically...'), + ); + } + + deploymentSpinner.start('Creating new agent...'); + + try { + // Use the directory name as the agent name + const agentName = path.basename(currentDirectory); + + // Create agent with basic configuration + agent = await deployClient.createAgent( + agentName, // Use directory name as agent name + 'container', // Default to container deployment + ); + deploymentSpinner.succeed(`Agent ${agent.name} created successfully`); + + // Update agentId to use the newly created agent's ID + agentId = agent.id; + + // Update .env file with the new agent ID + if (agent) { + const envContent = await fs.readFile(envPath, 'utf-8'); + const updatedEnvContent = envContent + .split('\n') + .map((line) => { + if (line.trim().startsWith('XPANDER_AGENT_ID=')) { + return `XPANDER_AGENT_ID=${agent!.id}`; + } + return line; + }) + .join('\n'); + await fs.writeFile(envPath, updatedEnvContent); + deploymentSpinner.info( + `Updated .env file with new agent ID: ${agent.id}`, + ); + } + } catch (createError: any) { + deploymentSpinner.fail( + `Failed to create agent: ${createError.message}`, + ); + return; + } + } + + // Stop any existing deployment before deploying new version + deploymentSpinner.text = `Stopping existing deployment of ${agent.name} if running...`; + try { + await stopDeployment(deploymentSpinner, deployClient, agent.id); + deploymentSpinner.start(); // Restart spinner after stop operation + } catch (error: any) { + // If stop fails (e.g., no deployment running), continue anyway + deploymentSpinner.info( + `No existing deployment to stop, proceeding with deployment...`, + ); + deploymentSpinner.start(); // Restart spinner after info message } deploymentSpinner.text = `Building agent ${agent.name}`; @@ -78,19 +281,19 @@ export async function deployAgent( const imagePath = await buildAndSaveDockerImage( deploymentSpinner, currentDirectory, - config.agent_id, + agentId!, skipLocalTests, ); if (!imagePath) { - deploymentSpinner.fail(`Agent ${config.agent_id} failed to build!`); + deploymentSpinner.fail(`Agent ${agentId} failed to build!`); return; } // upload and deploy const result = await uploadAndDeploy( deploymentSpinner, - client, + deployClient, agent.id, imagePath, currentDirectory, diff --git a/src/commands/agent/interactive/dev.ts b/src/commands/agent/interactive/dev.ts index 8f440d9..57ec4e6 100644 --- a/src/commands/agent/interactive/dev.ts +++ b/src/commands/agent/interactive/dev.ts @@ -35,13 +35,16 @@ async function findPythonExecutable(): Promise { /** * Start agent locally and keep process running */ -export async function startAgent(providedAgentId?: string) { +export async function startAgent( + providedAgentId?: string, + workingDirectory?: string, +) { console.log('\n'); console.log(chalk.bold.blue('✨ Starting agent in dev mode')); console.log(chalk.dim('─'.repeat(60))); const devModeSpinner = ora('Initializing local dev mode').start(); - const currentDirectory = process.cwd(); + const currentDirectory = workingDirectory || process.cwd(); // If agent ID provided, we assume user wants to run in that directory // Otherwise ensure current directory is initialized diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 4f87762..bfced7e 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,6 +1,5 @@ import { Command } from 'commander'; import { CommandType } from '../types'; -import { getAgentIdFromEnvOrSelection } from '../utils/agent-resolver'; import { createClient } from '../utils/client'; import { deployAgent } from './agent/interactive/deploy'; @@ -13,24 +12,26 @@ export function configureDeployCommand(program: Command): Command { .alias('d') .description('Deploy agent') .option('--profile ', 'Profile to use') + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) .option('--confirm', 'Skip confirmation prompts') .option('--skip-local-tests', 'Skip local Docker container tests') - .action(async (agentId, options) => { - const client = createClient(options.profile); + .action(async (agentId, options, command) => { + // Check if --profile was explicitly provided in command line + const profileExplicitlySet = + command.parent?.rawArgs.includes('--profile'); - const resolvedAgentId = await getAgentIdFromEnvOrSelection( - client, - agentId, - ); - if (!resolvedAgentId) { - return; - } + const client = createClient(options.profile); await deployAgent( client, - resolvedAgentId, + agentId, options.confirm, options.skipLocalTests, + options.path, + profileExplicitlySet || false, // Use profile credentials if --profile was explicitly set ); }); diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 20c4f18..9698c94 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -10,8 +10,12 @@ export function configureDevCommand(program: Command): Command { .command(`${CommandType.Dev} [agent]`) .description('Run agent locally') .option('--profile ', 'Profile to use') - .action(async (agentId, _options) => { - await startAgent(agentId); + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) + .action(async (agentId, options) => { + await startAgent(agentId, options.path); }); return operationsCmd; diff --git a/src/commands/restart.ts b/src/commands/restart.ts index 313c240..d7089a8 100644 --- a/src/commands/restart.ts +++ b/src/commands/restart.ts @@ -9,6 +9,7 @@ import { restartDeployment } from '../utils/custom_agents_utils/deploymentManage export async function restartAgent( client: XpanderClient, providedAgentId?: string, + workingDirectory?: string, ) { console.log('\n'); console.log(chalk.bold.blue('🔄 Agent restart')); @@ -18,7 +19,18 @@ export async function restartAgent( const restartSpinner = ora(`Initializing restart...`).start(); try { + // Change to working directory if provided + const originalCwd = process.cwd(); + if (workingDirectory) { + process.chdir(workingDirectory); + } + const agentId = await getAgentIdFromEnvOrSelection(client, providedAgentId); + + // Restore original working directory + if (workingDirectory) { + process.chdir(originalCwd); + } if (!agentId) { restartSpinner.fail('No agent selected.'); return; @@ -108,9 +120,13 @@ export function configureRestartCommand(program: Command): Command { .command('restart [agent]') .description('Restart agent deployment') .option('--profile ', 'Profile to use') + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) .action(async (agentId, options) => { const client = createClient(options.profile); - await restartAgent(client, agentId); + await restartAgent(client, agentId, options.path); }); return restartCmd; diff --git a/src/commands/stop.ts b/src/commands/stop.ts index 2f681f9..dfc4881 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -9,6 +9,7 @@ import { stopDeployment } from '../utils/custom_agents_utils/deploymentManagemen export async function stopAgent( client: XpanderClient, providedAgentId?: string, + workingDirectory?: string, ) { console.log('\n'); console.log(chalk.bold.blue('🛑 Agent stop')); @@ -18,7 +19,18 @@ export async function stopAgent( const stopSpinner = ora(`Initializing stop...`).start(); try { + // Change to working directory if provided + const originalCwd = process.cwd(); + if (workingDirectory) { + process.chdir(workingDirectory); + } + const agentId = await getAgentIdFromEnvOrSelection(client, providedAgentId); + + // Restore original working directory + if (workingDirectory) { + process.chdir(originalCwd); + } if (!agentId) { stopSpinner.fail('No agent selected.'); return; @@ -75,9 +87,13 @@ export function configureStopCommand(program: Command): Command { .command('stop [agent]') .description('Stop agent deployment') .option('--profile ', 'Profile to use') + .option( + '--path ', + 'Path to agent directory (defaults to current directory)', + ) .action(async (agentId, options) => { const client = createClient(options.profile); - await stopAgent(client, agentId); + await stopAgent(client, agentId, options.path); }); return stopCmd; diff --git a/src/utils/agent-resolver.ts b/src/utils/agent-resolver.ts index 9889ecc..3f37f9a 100644 --- a/src/utils/agent-resolver.ts +++ b/src/utils/agent-resolver.ts @@ -131,6 +131,7 @@ export async function getAgentIdFromEnvOrSelection( client: XpanderClient, providedAgentId?: string, silent: boolean = false, + workingDirectory?: string, ): Promise { // If agent ID is provided, resolve it (could be name or ID) if (providedAgentId) { @@ -139,7 +140,9 @@ export async function getAgentIdFromEnvOrSelection( // Try to get agent ID from .env file try { - const config = await getXpanderConfigFromEnvFile(process.cwd()); + const config = await getXpanderConfigFromEnvFile( + workingDirectory || process.cwd(), + ); if (config?.agent_id) { return config.agent_id; }