meld
Version:
Meld: A template language for LLM prompts
484 lines (426 loc) • 15.5 kB
text/typescript
/**
* CLIService - A thin wrapper around the API
*
* This service provides backward compatibility with code that depends on CLIService
* but delegates the actual processing to the API.
*/
import { main as apiMain } from '@api/index.js';
import { cliLogger as logger } from '@core/utils/logger.js';
import { MeldError, ErrorSeverity } from '@core/errors/MeldError.js';
import { version } from '@core/version.js';
import { createInterface } from 'readline';
import { dirname, basename, extname } from 'path';
import { join } from 'path';
import { IParserService } from '@services/parser/ParserService/IParserService.js';
import { IInterpreterService } from '@services/pipeline/InterpreterService/IInterpreterService.js';
import { IOutputService } from '@services/output/OutputService/IOutputService.js';
import { IFileSystemService } from '@services/fs/FileSystemService/IFileSystemService.js';
import { IPathService } from '@services/fs/PathService/IPathService.js';
import { IStateService } from '@services/state/StateService/IStateService.js';
import { ProcessOptions } from '@api/types.js';
import readline from 'readline';
/**
* Interface for a service that handles user prompts
*/
export interface IPromptService {
/**
* Gets text input from the user
* @param prompt The prompt to display to the user
* @param defaultValue Optional default value to use if the user presses Enter without input
* @returns The user's input
*/
getText(prompt: string, defaultValue?: string): Promise<string>;
}
export interface CLIOptions {
input?: string;
output?: string;
format?: 'xml' | 'llmxml' | 'markdown' | 'md';
strict?: boolean;
stdout?: boolean;
version?: boolean;
verbose?: boolean;
homePath?: string;
debug?: boolean;
help?: boolean;
}
export interface ICLIService {
run(args: string[]): Promise<void>;
}
export class CLIService implements ICLIService {
private parserService: IParserService;
private interpreterService: IInterpreterService;
private outputService: IOutputService;
private fileSystemService: IFileSystemService;
private pathService: IPathService;
private stateService: IStateService;
private promptService: IPromptService;
private flags: Record<string, string | boolean | undefined> = {};
private cmdOptions: ProcessOptions = {
output: ''
};
constructor(
private parserService: IParserService,
private interpreterService: IInterpreterService,
private outputService: IOutputService,
private fileSystemService: IFileSystemService,
private pathService: IPathService,
private stateService: IStateService,
promptService?: IPromptService
) {
this.parserService = parserService;
this.interpreterService = interpreterService;
this.outputService = outputService;
this.fileSystemService = fileSystemService;
this.pathService = pathService;
this.stateService = stateService;
// Use the provided prompt service or create a default one
this.promptService = promptService || {
getText: async (prompt: string, defaultValue?: string): Promise<string> => {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question(prompt, (answer) => {
rl.close();
// If the user just pressed Enter and we have a default value, use that
resolve(answer.trim() || defaultValue || '');
});
});
}
};
}
private normalizeFormat(format: string): 'markdown' | 'xml' {
format = format.toLowerCase();
switch (format) {
case 'markdown':
case 'md':
return 'markdown';
case 'xml':
default:
return 'markdown';
}
}
private getOutputExtension(format: string): string {
switch (format.toLowerCase()) {
case 'markdown':
case 'md':
return '.md';
case 'xml':
return '.xml';
default:
return '.xml'; // Default to XML
}
}
/**
* Process CLI arguments
*/
parseArguments(args: string[]): CLIOptions {
const options: CLIOptions = {
format: 'xml',
strict: false
};
// Skip 'node' and 'meld' executable names if present at the beginning
let startIndex = 0;
if (args.length > 0 && (args[0] === 'node' || args[0] === 'meld')) {
startIndex = 1;
// If second arg is 'meld', skip that too (handles both 'node meld' and just 'meld')
if (args.length > 1 && args[1] === 'meld') {
startIndex = 2;
}
}
// Process all arguments starting from the appropriate index
for (let i = startIndex; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--version':
case '-V':
options.version = true;
break;
case '--output':
case '-o':
options.output = args[++i];
break;
case '--format':
case '-f':
options.format = this.normalizeFormat(args[++i]);
break;
case '--stdout':
options.stdout = true;
break;
case '--verbose':
case '-v':
options.verbose = true;
break;
case '--strict':
options.strict = true;
break;
case '--permissive':
options.strict = false;
break;
case '--home-path':
options.homePath = args[++i];
break;
case '--debug':
case '-d':
options.debug = true;
break;
case '--help':
case '-h':
options.help = true;
break;
default:
if (!arg.startsWith('-') && !options.input) {
options.input = arg;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
}
if (!options.input && !options.version) {
throw new Error('No input file specified');
}
return options;
}
/**
* Convert CLI options to API options
*/
private cliToApiOptions(cliOptions: CLIOptions): ProcessOptions {
return {
format: cliOptions.format,
debug: cliOptions.debug,
strict: cliOptions.strict,
transformation: true, // Enable transformation by default for CLI usage
fs: this.fileSystemService.getFileSystem()
};
}
/**
* Confirms whether a file should be overwritten
*/
async confirmOverwrite(outputPath: string): Promise<{ outputPath: string; shouldOverwrite: boolean }> {
this.debug(`confirmOverwrite: ${outputPath}`);
// Check if file exists
const exists = await this.fileSystemService.exists(outputPath);
if (!exists) {
this.debug(`confirmOverwrite: file does not exist, no need to overwrite`);
return { outputPath, shouldOverwrite: true };
}
// Prompt for overwrite
const response = await this.promptService.getText(
`File ${outputPath} already exists. Overwrite? [Y/n] `,
'y'
);
this.debug(`confirmOverwrite: user response: ${response}`);
if (response.toLowerCase() === 'n') {
this.debug('confirmOverwrite: user declined overwrite');
return this.findAvailableIncrementalFilename(outputPath);
}
return { outputPath, shouldOverwrite: true };
}
/**
* Finds an available incremental filename (file-1.ext, file-2.ext, etc.)
* @param outputPath The original output path
* @returns An object with the available output path and shouldOverwrite=true
*/
private async findAvailableIncrementalFilename(outputPath: string): Promise<{ outputPath: string; shouldOverwrite: boolean }> {
console.log('Finding incremental filename for:', outputPath);
const ext = extname(outputPath); // Use Node.js path module
console.log('Extension:', ext);
const basePath = outputPath.slice(0, -ext.length);
console.log('Base path:', basePath);
let counter = 1;
let newPath = `${basePath}-${counter}${ext}`;
console.log('Trying path:', newPath);
while (await this.fileSystemService.exists(newPath)) {
console.log(`Path ${newPath} exists, incrementing counter`);
counter++;
newPath = `${basePath}-${counter}${ext}`;
console.log('Trying next path:', newPath);
}
console.log('Found available path:', newPath);
logger.info(`Using incremental filename: ${newPath}`);
return { outputPath: newPath, shouldOverwrite: true };
}
/**
* Run the CLI with the given arguments
*/
public async run(args: string[]): Promise<void> {
try {
// Parse command line arguments
const options = this.parseArguments(args);
// Handle special commands
if (options.version) {
console.log(`meld version ${version}`);
return;
}
if (options.help) {
this.showHelp();
return;
}
// Set up environment paths
const state = this.stateService.createChildState();
// Set up project path
const projectPath = await this.pathService.resolveProjectPath();
state.setPathVar('PROJECTPATH', projectPath);
state.setPathVar('.', projectPath);
// Set up home path if specified
if (options.homePath) {
state.setPathVar('HOMEPATH', options.homePath);
state.setPathVar('~', options.homePath);
}
// Configure logging based on options
if (options.verbose) {
logger.level = 'debug';
} else if (options.debug) {
logger.level = 'trace';
} else {
logger.level = 'info';
}
logger.info('Starting Meld CLI', {
version,
options
});
// Remove watch check and directly process the file
await this.processFile(options);
} catch (error) {
// For CLI errors, always log and exit with error code
logger.error('Error running Meld CLI', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
throw error;
}
}
/**
* Process a file using the API
*/
private async processFile(options: CLIOptions): Promise<void> {
// Configure logging based on options
if (options.verbose) {
logger.level = 'debug';
}
logger.info('Starting Meld CLI', {
version,
options
});
// Store the options for later use
this.cmdOptions = {
...this.cmdOptions,
output: options.output
};
try {
// Check if input file exists
const inputPath = await this.pathService.resolvePath(options.input);
if (!(await this.fileSystemService.exists(inputPath))) {
throw new MeldError(`File not found: ${options.input}`, {
severity: ErrorSeverity.Fatal,
code: 'FILE_NOT_FOUND'
});
}
// Read input file
const content = await this.fileSystemService.readFile(inputPath);
// Parse content into AST
const ast = await this.parserService.parse(content);
// Interpret AST
const interpretResult = await this.interpreterService.interpret(ast, {
strict: options.strict
});
// Determine output path
const outputPath = await this.determineOutputPath(options);
// Convert to desired format
const outputContent = await this.outputService.convert(
ast, // Pass the AST as the first parameter
this.stateService, // Pass the state service as the second parameter
options.format || 'md', // Pass the format as the third parameter
{ // Pass the options as the fourth parameter
preserveMarkdown: options.format === 'markdown'
}
);
// Write output or log to stdout
if (options.stdout) {
console.log(outputContent);
logger.info('Successfully wrote output to stdout');
} else {
// Check if output file exists and prompt for overwrite if needed
if (await this.fileSystemService.exists(outputPath) && !options.force) {
const { outputPath: confirmedOutputPath, shouldOverwrite } = await this.confirmOverwrite(outputPath);
if (!shouldOverwrite) {
// Instead of cancelling, use an incremental filename
const alternateOutputPath = await this.findAvailableIncrementalFilename(confirmedOutputPath);
logger.info('Using alternative filename instead of overwriting', { path: alternateOutputPath });
// Write to the alternate file
await this.fileSystemService.writeFile(alternateOutputPath, outputContent);
console.log(`Output written to ${alternateOutputPath}`);
return;
}
}
// Write output file
await this.fileSystemService.writeFile(outputPath, outputContent);
logger.info('Successfully wrote output file', { path: outputPath });
}
} catch (error) {
// Convert errors to MeldError for consistent handling
const meldError = error instanceof MeldError
? error
: new MeldError(error instanceof Error ? error.message : String(error), {
severity: ErrorSeverity.Fatal,
code: 'PROCESSING_ERROR'
});
logger.error('Error processing file', {
error: meldError.message,
code: meldError.code,
severity: meldError.severity
});
throw meldError;
}
}
/**
* Determine the output path based on CLI options
*/
private async determineOutputPath(options: CLIOptions): Promise<string> {
// If output path is explicitly specified, use it
if (options.output) {
return this.pathService.resolvePath(options.output);
}
// If no output path specified, use input path with new extension
if (!options.input || typeof options.input !== 'string') {
throw new MeldError('Input file path is required', {
severity: ErrorSeverity.Fatal,
code: 'INVALID_INPUT'
});
}
const inputPath = options.input;
const inputExt = extname(inputPath); // Use Node.js path module
const outputExt = this.getOutputExtension(options.format || 'md');
// Extract the base filename without extension
const basePath = inputPath.substring(0, inputPath.length - inputExt.length);
// Always append .o.{format} for default behavior
const outputPath = `${basePath}.o${outputExt}`;
return this.pathService.resolvePath(outputPath);
}
private showHelp() {
console.log(`
Usage: meld [options] <input-file>
Options:
-f, --format <format> Output format (md, markdown, llm) [default: llm]
-o, --output <path> Output file path [default: input filename with new extension]
--stdout Print to stdout instead of file
--strict Enable strict mode (fail on all errors)
--permissive Enable permissive mode (ignore recoverable errors) [default]
--home-path <path> Set custom home path for $~/ and $HOMEPATH
-v, --verbose Enable verbose output
-d, --debug Enable debug output
-h, --help Display this help message
-V, --version Display version information
`);
}
/**
* Log debug messages if verbose mode is enabled
* @param message Message to log
*/
private debug(message: string): void {
if (this.cmdOptions.verbose) {
console.log(`DEBUG: ${message}`);
}
}
}