@agenteract/cli
Version:
The unified command-line interface for Agenteract
414 lines (413 loc) • 19.8 kB
JavaScript
// packages/cli/src/commands/dev.ts
import fs from 'node:fs';
import { loadConfig, MissingConfigError } from '../config.js';
import { detectInvoker } from '@agenteract/core';
const { pkgManager, isNpx } = detectInvoker();
import path from 'path';
import pty from 'node-pty';
const spawnBin = pkgManager === 'pnpm' ? 'pnpm' : 'npx';
const agentserverVersion = process.env.AGENTERACT_SERVER_VERSION ? `@${process.env.AGENTERACT_SERVER_VERSION}` : '';
const agenterServePackage = pkgManager === 'pnpm' ? 'agenterserve' : '@agenteract/server' + agentserverVersion;
// If we're in the agenteract monorepo, use the local packages
// get nearest package.json
const nearestPackageJson = findNearestPackageJson(process.cwd());
const isAgenteractPackage = nearestPackageJson?.name?.startsWith('@agenteract/') || nearestPackageJson?.name === 'agenteract';
const typeToCommandMap = {
expo: isAgenteractPackage ? 'agenterexpo' : '@agenteract/expo',
vite: isAgenteractPackage ? 'agentervite' : '@agenteract/vite',
flutter: isAgenteractPackage ? 'agenterflutter' : '@agenteract/flutter-cli',
};
function findNearestPackageJson(startDir) {
let currentDir = startDir;
while (true) {
const packageJsonPath = path.join(currentDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null; // reached root
}
currentDir = parentDir;
}
}
const handleMissingConfig = (_) => {
console.error('agenteract.config.js is missing. Use `npx @agenteract/cli add-config` to create it.');
return;
};
export async function runDevCommand(args) {
const rootDir = path.dirname(path.resolve(args.config));
let config;
try {
config = await loadConfig(rootDir);
}
catch (error) {
if (error instanceof MissingConfigError) {
handleMissingConfig(error);
return;
}
throw error;
}
if (!config || !config.projects) {
console.error('Invalid config: projects array not found.');
return;
}
const commands = [
{
command: `${spawnBin} ${agenterServePackage} --port ${config.port}`,
name: 'agent-server',
cwd: rootDir,
type: 'pty',
},
];
if (config.projects.some((p) => p.type === 'native')) {
commands.push({
command: `${spawnBin} ${agenterServePackage} --log-only`,
name: 'log-server',
cwd: rootDir,
type: 'pty',
});
}
commands.push(...config.projects.map((project) => {
const projectPath = path.resolve(rootDir, project.path);
let command = '';
if (project.type !== 'native') {
const baseCommand = `${spawnBin} ${typeToCommandMap[project.type]} --port ${project.ptyPort}`;
// Flutter needs explicit --cwd because process.cwd() isn't reliable when spawned via shell
if (project.type === 'flutter') {
command = `${baseCommand} --cwd "${projectPath}"`;
}
else {
command = baseCommand;
}
}
return {
command,
name: project.name,
cwd: projectPath,
type: project.type,
// Flutter is hybrid: has PTY but also uses WebSocket logs
isHybrid: project.type === 'flutter',
};
}));
const terminals = [];
let activeIndex = 0;
const MAX_BUFFER_LINES = 1000;
// --- Create all Terminals (PTY or Buffer-only) ---
commands.forEach((cmdInfo, index) => {
const buffer = [];
let ptyProcess;
let errorOutput = []; // Capture error output
if (cmdInfo.type !== 'native' && cmdInfo.command) {
console.log(`Spawning command: ${cmdInfo.command}`);
const shell = process.env.SHELL || '/bin/bash';
ptyProcess = pty.spawn(shell, ['-c', cmdInfo.command], {
name: 'xterm-color',
cols: process.stdout.columns,
rows: process.stdout.rows,
cwd: cmdInfo.cwd,
env: process.env,
});
ptyProcess.onExit((e) => {
const { exitCode } = e;
// Find this terminal and mark it as exited
const terminal = terminals.find(t => t.ptyProcess === ptyProcess);
if (terminal) {
terminal.hasExited = true;
terminal.exitCode = exitCode;
terminal.ptyProcess = undefined; // Clear the process reference
}
const statusMsg = exitCode === 0
? `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n✓ ${cmdInfo.name} exited successfully (code ${exitCode})\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
: `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n✗ ${cmdInfo.name} exited with code ${exitCode}\nCommand: ${cmdInfo.command}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
// Add status to buffer so it persists in the terminal view
buffer.push(statusMsg);
// Write to console immediately so it's visible before screen clears
if (exitCode !== 0) {
console.error(statusMsg);
// Show ALL captured error output immediately
if (errorOutput.length > 0) {
const allErrors = errorOutput.join('\n');
console.error('\n📋 Error output from process:\n');
console.error(allErrors);
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
// Also add to buffer
buffer.push(`\nError output:\n${allErrors}\n`);
}
}
else {
console.log(statusMsg);
}
// Add restart instructions if applicable
if (terminal && terminal.canRestart) {
const restartMsg = '\n💡 Press Enter to restart this process\n';
buffer.push(restartMsg);
if (activeIndex === index) {
process.stdout.write(restartMsg);
}
}
});
ptyProcess.onData((data) => {
// For active terminal, write directly to preserve all ANSI escape codes
// This ensures spinners, progress bars, and other interactive elements work correctly
if (activeIndex === index) {
process.stdout.write(data);
}
// For buffering (inactive terminals), we still need to process the data
const lines = data.split('\n');
for (const line of lines) {
if (!line)
continue;
// Capture potential error lines
const lowerLine = line.toLowerCase();
if (lowerLine.includes('error') ||
lowerLine.includes('failed') ||
lowerLine.includes('not found') ||
lowerLine.includes('command not found')) {
errorOutput.push(line);
if (errorOutput.length > 20)
errorOutput.shift(); // Keep last 20 error lines
}
// Exclusive handling for routed logs from the log-server
if (cmdInfo.name === 'log-server') {
const match = line.match(/^\[([^\]]+)\]/);
const projectName = match ? match[1] : null;
const targetTerminal = projectName ? terminals.find(t => t.name === projectName) : null;
if (targetTerminal && projectName) {
const logMessage = `[${new Date().toLocaleTimeString()}] ${line.substring(projectName.length + 2).trim()}\n`;
targetTerminal.buffer.push(logMessage);
if (targetTerminal.buffer.length > MAX_BUFFER_LINES) {
targetTerminal.buffer.shift();
}
if (terminals[activeIndex].name === targetTerminal.name) {
process.stdout.write(logMessage);
}
continue; // Consume the line
}
}
// Exclusive handling for routed logs from the agent-server
if (cmdInfo.name === 'agent-server' && line.startsWith('AGENT_LOG::')) {
try {
const logData = JSON.parse(line.substring(11));
const targetTerminal = terminals.find(t => t.name === logData.project);
if (targetTerminal) {
const logMessage = `[${new Date().toLocaleTimeString()}] ${logData.log}\n`;
targetTerminal.buffer.push(logMessage);
if (targetTerminal.buffer.length > MAX_BUFFER_LINES) {
targetTerminal.buffer.shift();
}
// If the target terminal is active, write to it directly
if (terminals[activeIndex].name === targetTerminal.name) {
process.stdout.write(logMessage);
}
}
}
catch (e) {
// Failed to parse, so we don't log it anywhere to avoid clutter.
// This line is now considered "consumed".
}
// Crucially, skip to the next line after handling the AGENT_LOG
continue;
}
// todo(mribbons): use proper ansi code parsing for background tabs
// For buffering: skip lines that are likely spinner/progress bar updates
// These contain ANSI escape codes for cursor movement or carriage returns
const hasSpinnerCodes = line.includes('\r') ||
(line.includes('\x1b[') && (line.includes('A') || line.includes('K') || line.includes('J')));
if (!hasSpinnerCodes) {
// Only buffer stable lines (not spinner updates)
buffer.push(line + '\n');
if (buffer.length > MAX_BUFFER_LINES) {
buffer.shift();
}
}
}
});
}
else if (cmdInfo.type === 'native') {
buffer.push(`--- Logs for ${cmdInfo.name} ---\n\n`);
buffer.push('Waiting for application to connect and send logs...\n');
}
terminals.push({
name: cmdInfo.name,
ptyProcess,
buffer,
hasExited: false,
canRestart: cmdInfo.type !== 'native' && !!cmdInfo.command,
restartCommand: cmdInfo.command,
restartCwd: cmdInfo.cwd,
});
});
const switchTo = (index) => {
activeIndex = index;
const activeTerminal = terminals[activeIndex];
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
// Add status indicator to title
const statusIndicator = activeTerminal.hasExited
? activeTerminal.exitCode === 0 ? ' ✓' : ' ✗'
: '';
process.stdout.write(`\x1b[1m--- ${activeTerminal.name}${statusIndicator} ---\x1b[0m (Tab: cycle, Ctrl+C: exit)\r\n\n`);
// Only resize if it's a live PTY process (not exited)
if (activeTerminal.ptyProcess && !activeTerminal.hasExited) {
const { cols, rows } = { cols: process.stdout.columns, rows: process.stdout.rows };
try {
activeTerminal.ptyProcess.resize(cols, rows);
}
catch (err) {
// Process may have exited between the check and resize, ignore
}
}
const linesToShow = Math.min(process.stdout.rows - 3, activeTerminal.buffer.length);
const startIdx = Math.max(0, activeTerminal.buffer.length - linesToShow);
for (let i = startIdx; i < activeTerminal.buffer.length; i++) {
process.stdout.write(activeTerminal.buffer[i]);
}
};
// Function to restart a terminal process
const restartTerminal = (terminal) => {
if (!terminal.canRestart || !terminal.restartCommand || !terminal.restartCwd) {
return;
}
const restartMsg = `\n🔄 Restarting ${terminal.name}...\n`;
terminal.buffer.push(restartMsg);
process.stdout.write(restartMsg);
terminal.hasExited = false;
terminal.exitCode = undefined;
const shell = process.env.SHELL || '/bin/bash';
const newPtyProcess = pty.spawn(shell, ['-c', terminal.restartCommand], {
name: 'xterm-color',
cols: process.stdout.columns,
rows: process.stdout.rows,
cwd: terminal.restartCwd,
env: process.env,
});
terminal.ptyProcess = newPtyProcess;
// Reattach exit handler
const terminalIndex = terminals.indexOf(terminal);
const errorOutput = [];
newPtyProcess.onExit((e) => {
const { exitCode } = e;
terminal.hasExited = true;
terminal.exitCode = exitCode;
terminal.ptyProcess = undefined;
const statusMsg = exitCode === 0
? `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n✓ ${terminal.name} exited successfully (code ${exitCode})\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
: `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n✗ ${terminal.name} exited with code ${exitCode}\nCommand: ${terminal.restartCommand}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
terminal.buffer.push(statusMsg);
if (exitCode !== 0) {
console.error(statusMsg);
if (errorOutput.length > 0) {
const allErrors = errorOutput.join('\n');
console.error('\n📋 Error output from process:\n');
console.error(allErrors);
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
terminal.buffer.push(`\nError output:\n${allErrors}\n`);
}
}
else {
console.log(statusMsg);
}
if (terminal.canRestart) {
const restartMsg = '\n💡 Press Enter to restart this process\n';
terminal.buffer.push(restartMsg);
if (activeIndex === terminalIndex) {
process.stdout.write(restartMsg);
}
}
});
// Reattach data handler
newPtyProcess.onData((data) => {
if (activeIndex === terminalIndex) {
process.stdout.write(data);
}
const lines = data.split('\n');
for (const line of lines) {
if (!line)
continue;
const lowerLine = line.toLowerCase();
if (lowerLine.includes('error') ||
lowerLine.includes('failed') ||
lowerLine.includes('not found') ||
lowerLine.includes('command not found')) {
errorOutput.push(line);
if (errorOutput.length > 20)
errorOutput.shift();
}
const hasSpinnerCodes = line.includes('\r') ||
(line.includes('\x1b[') && (line.includes('A') || line.includes('K') || line.includes('J')));
if (!hasSpinnerCodes) {
terminal.buffer.push(line + '\n');
if (terminal.buffer.length > MAX_BUFFER_LINES) {
terminal.buffer.shift();
}
}
}
});
};
// --- Initial Start ---
process.stdout.write('--- Starting Agenteract Dev Environment ---\r\n');
process.stdout.write('Initializing terminals...\r\n\n');
await new Promise(resolve => setTimeout(resolve, 100));
// --- Raw Input Handler ---
if (process.stdin.isTTY && process.stdout.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
}
process.stdin.on('data', (key) => {
if (key === '\t') {
switchTo((activeIndex + 1) % terminals.length);
}
else if (key === '\u0003') { // Ctrl+C
terminals.forEach(t => t.ptyProcess?.kill());
process.exit(0);
}
else if (key === '\r' || key === '\n') { // Enter key
const activeTerminal = terminals[activeIndex];
// If terminal has exited and can restart, restart it
if (activeTerminal.hasExited && activeTerminal.canRestart) {
restartTerminal(activeTerminal);
}
else if (activeTerminal.ptyProcess) {
// Otherwise pass the Enter key to the PTY
activeTerminal.ptyProcess.write(key);
}
}
else {
// Only write to live PTY processes
if (terminals[activeIndex].ptyProcess && !terminals[activeIndex].hasExited) {
terminals[activeIndex].ptyProcess?.write(key);
}
}
});
process.stdout.on('resize', () => {
terminals.forEach(term => {
// Only resize live PTY processes (not exited)
if (term.ptyProcess && !term.hasExited) {
try {
term.ptyProcess.resize(process.stdout.columns, process.stdout.rows);
}
catch (err) {
// Process may have exited, ignore
}
}
});
});
switchTo(0);
const cleanup = () => {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdout.write('\n--- Shutting down all processes... ---\n');
terminals.forEach(t => t.ptyProcess?.kill());
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
}