UNPKG

@meldscience/meld

Version:

pipeable one-shot prompt scripting toolkit

392 lines (351 loc) 14.2 kB
#!/usr/bin/env -S node --no-warnings --no-deprecation import { Command } from 'commander'; import { readFileSync, writeFileSync } from 'fs'; import { Meld } from '../src/meld'; import { Oneshot } from '../src/oneshot'; import { AnthropicProvider } from '../src/providers/anthropic'; import { OpenAIProvider } from '../src/providers/openai'; import { loadConfig, checkAndPromptConfig, setupConfig, RC_FILE } from '../src/config'; import { parse } from 'yaml'; import { join } from 'path'; import { existsSync } from 'fs'; import { createInterface } from 'readline'; import { homedir } from 'os'; const program = new Command(); program .name('oneshotcat') .description('Send AIs meld scripts with optional variations') .argument('[file]', 'Path to prompt script file (.meld.md) or quoted text') .option('-m, --model <model>', 'AI model(s) to use (e.g. claude-3 or "gpt-4 claude-3")') .option('-o, --outfile <path>', 'Save final response to file') .option('--meld-outfile <path>', 'Save expanded prompt script to file') .option('--system <prompt>', 'System prompt') .option('--system-file <path>', 'System prompt from file') .option('--variations <json>', 'Variation prompts as JSON array') .option('--variations-file <path>', 'Variation prompts from file (JSON or YAML)') .option('--iterations <number>', 'Number of responses per variation', '1') .option('--default [model]', 'Set default model for future oneshotcat commands') .addHelpText('after', ` Examples: $ oneshotcat "# Hello\\n\\nHow are you?" Process text using default model $ oneshotcat '# Question\\n\\nWhy?' Process text using default model $ oneshotcat script.meld.md Process file using default model $ oneshotcat -m claude-3 script.meld.md Process script and send to Claude $ oneshotcat -m "gpt-4 claude-3" script.meld.md Send to multiple models $ oneshotcat script.meld.md --meld-outfile expanded.md $ oneshotcat script.meld.md -o response.md $ oneshotcat script.meld.md --variations-file roles.yaml $ oneshotcat --default Choose default model interactively $ oneshotcat --default opus Set opus as default model For more examples and documentation: https://github.com/meldscience/oneshot#readme`); program.parse(process.argv); async function main() { const options = program.opts(); // Load config first to check for aliases and default model const config = loadConfig(); // Handle setting default model if (options.default !== undefined) { if (options.default === true) { // Interactive model selection async function selectModel() { const rl = createInterface({ input: process.stdin, output: process.stdout }); console.log('\nAvailable models:'); console.log(' Claude models:'); console.log(' 1. claude-3-5-sonnet-latest'); console.log(' 2. claude-3-opus-latest'); console.log(' 3. claude-3-5-haiku-latest'); console.log('\n GPT models:'); console.log(' 4. chatgpt-4o-latest'); console.log(' 5. o1-preview'); console.log(' 6. o1-mini'); if (config.modelAliases) { console.log('\n Aliases:'); Object.entries(config.modelAliases).forEach(([alias, target]) => { console.log(` ${alias}${target}`); }); } const currentDefault = config.defaultOneshotcatModel; console.log(`\nCurrent default: ${currentDefault || '(none)'}`); const answer = await new Promise<string>(resolve => { rl.question('\nEnter model name or number: ', resolve); }); rl.close(); let selectedModel = answer.trim(); // Map numbers to models const modelMap: {[key: string]: string} = { '1': 'claude-3-5-sonnet-latest', '2': 'claude-3-opus-latest', '3': 'claude-3-5-haiku-latest', '4': 'chatgpt-4o-latest', '5': 'o1-preview', '6': 'o1-mini' }; if (modelMap[selectedModel]) { selectedModel = modelMap[selectedModel]; } setupConfig({ defaultOneshotcatModel: selectedModel }); console.log(`✅ Set default model to: ${selectedModel}`); process.exit(0); } await selectModel().catch(error => { console.error('❌ Error:', error.message); process.exit(1); }); return; } else { setupConfig({ defaultOneshotcatModel: options.default }); console.log(`✅ Set default model to: ${options.default}`); process.exit(0); } } // If no arguments provided and no config exists, run interactive setup const rcPath = join(homedir(), RC_FILE); if (process.argv.length === 2 && !existsSync(rcPath)) { // Since we removed interactiveSetup, just show help program.help(); return; } const [inputFile] = program.args; // Require file argument if not setting default if (!inputFile) { console.error('❌ Error: Missing required argument: file'); console.error('(add --help for additional information)'); process.exit(1); } // Get model from option or default let model = options.model; if (!model) { const defaultModel = config.defaultOneshotcatModel; if (!defaultModel) { console.error('❌ Error: No model specified and no default model configured'); console.error('Either provide a model with -m/--model or set a default with:'); console.error(' oneshotcat --default <model>'); process.exit(1); } model = defaultModel; } const models = model.split(/\s+/); try { // Validate and resolve models for (const model of models) { // Check if it's an alias first const resolvedModel = config.modelAliases?.[model] || model; const isAlias = model in (config.modelAliases || {}); if (!resolvedModel.startsWith('claude') && !resolvedModel.startsWith('gpt') && !resolvedModel.startsWith('chatgpt') && !resolvedModel.startsWith('o1')) { if (!isAlias) { // This is not an alias console.error(`❌ Error: Unsupported model: ${model}`); console.error('Supported models start with: claude-, gpt-, chatgpt-, o1'); console.error('\nOr use these aliases:'); if (config.modelAliases) { Object.entries(config.modelAliases).forEach(([alias, target]) => { console.error(` ${alias}${target}`); }); } } else { // This is an alias that resolved to an invalid model console.error(`❌ Error: Unsupported model: ${resolvedModel} (from alias '${model}')`); console.error('Supported models start with: claude-, gpt-, chatgpt-, o1-'); } process.exit(1); } } // Check for required API keys checkAndPromptConfig(models.map((m: string) => config.modelAliases?.[m] || m)); // Get prompt content and determine file path for meld let promptContent: string; let meldInputFile: string; try { if (existsSync(inputFile)) { promptContent = readFileSync(inputFile, 'utf-8'); meldInputFile = inputFile; } else { promptContent = inputFile; // For direct text input, write to a temporary file for meld processing meldInputFile = '.temp-oneshot-input.meld.md'; writeFileSync(meldInputFile, promptContent); } } catch (error: any) { console.error('❌ Error: Failed to read prompt:', error.message); process.exit(1); } // First, process the prompt script console.log('🔄 Processing prompt script'); const meld = new Meld({ inputPath: meldInputFile, workspacePath: process.cwd() }); let expandedContent: string; try { const result = await meld.process(); expandedContent = result.content; } catch (error: any) { console.error('❌ Error processing prompt script:', error.message); if (error.code === 'COMMAND_FAILED') { console.error('Note: Check that all @cmd[...] commands exist and are executable'); } process.exit(1); } // Save expanded content if requested if (options.meldOutfile) { try { writeFileSync(options.meldOutfile, expandedContent); console.log(`📝 Expanded prompt written to ${options.meldOutfile}`); } catch (error: any) { console.error('❌ Error: Failed to write expanded prompt file:', error.message); process.exit(1); } } // Read system prompt if specified let systemPrompt = options.system; if (options.systemFile) { try { systemPrompt = readFileSync(options.systemFile, 'utf-8'); } catch (error: any) { if (error.code === 'ENOENT') { console.error('❌ Error: System prompt file not found:', options.systemFile); process.exit(1); } throw error; } } // Parse variations if specified let variations: string[] | undefined; if (options.variations) { try { variations = JSON.parse(options.variations); if (!Array.isArray(variations)) { console.error('❌ Error: Variations must be a JSON array'); process.exit(1); } } catch (error) { console.error('❌ Error: Invalid JSON in variations argument'); console.error('Example: --variations \'["Perspective 1", "Perspective 2"]\''); process.exit(1); } } else if (options.variationsFile) { try { const content = readFileSync(options.variationsFile, 'utf-8'); if (options.variationsFile.endsWith('.json')) { variations = JSON.parse(content); if (!Array.isArray(variations)) { console.error('❌ Error: JSON variations file must contain an array'); process.exit(1); } } else if (options.variationsFile.endsWith('.yaml') || options.variationsFile.endsWith('.yml')) { const yamlContent = parse(content); variations = Array.isArray(yamlContent) ? yamlContent : Object.values(yamlContent); } else { console.error('❌ Error: Variations file must be .json, .yaml, or .yml'); process.exit(1); } } catch (error: any) { if (error.code === 'ENOENT') { console.error('❌ Error: Variations file not found:', options.variationsFile); } else { console.error('❌ Error: Failed to parse variations file:', error.message || 'Invalid format'); } process.exit(1); } } // Create a temporary file for the expanded content const tempFile = '.temp-oneshot-expanded.md'; writeFileSync(tempFile, expandedContent); // Process each model const allResponses = []; for (const model of models) { // Get resolved model name const resolvedModel = config.modelAliases?.[model] || model; // Select provider based on resolved model let provider; if (resolvedModel.startsWith('claude')) { if (!config.anthropicApiKey) { console.error('❌ Error: Anthropic API key is required for Claude models'); console.error('Run: meld-config --anthropic-key <your-key>'); process.exit(1); } provider = new AnthropicProvider(config.anthropicApiKey); } else if (resolvedModel.startsWith('gpt') || resolvedModel.startsWith('o1') || resolvedModel.startsWith('chatgpt')) { if (!config.openaiApiKey) { console.error('❌ Error: OpenAI API key is required for GPT models'); console.error('Run: meld-config --openai-key <your-key>'); process.exit(1); } provider = new OpenAIProvider(config.openaiApiKey); } else { console.error(`❌ Error: Unsupported model: ${model}`); console.error('Supported models start with: claude-, gpt-, chatgpt-, o1'); process.exit(1); } // Create and run oneshot console.log(`🤖 Sending prompt to ${model} (${resolvedModel})`); const oneshot = new Oneshot({ model, promptFile: tempFile, system: systemPrompt, variations, iterations: parseInt(options.iterations, 10) }, provider); const responses = await oneshot.process(); allResponses.push(...responses.map(r => ({ ...r, model }))); } // Clean up temp file try { require('fs').unlinkSync(tempFile); } catch (error) { // Ignore cleanup errors } // Format output const output = allResponses.map((r) => { const modelHeader = `# Model: ${r.model}`; let variationHeader = ''; if (r.variation) { if (typeof r.variation === 'string') { variationHeader = `## Variation: ${r.variation}`; } else { const parts = []; if (r.variation.prompt) parts.push(`Prompt: ${r.variation.prompt}`); if (r.variation.system) parts.push(`System: ${r.variation.system}`); if (r.variation.model) parts.push(`Model: ${r.variation.model}`); if (parts.length > 0) { variationHeader = `## Variation:\n${parts.map(p => `- ${p}`).join('\n')}`; } } } const headers = [modelHeader, variationHeader].filter(Boolean).join('\n'); return `${headers}\n\n${r.response}`; }).join('\n\n---\n\n'); // Write output if (options.outfile) { try { writeFileSync(options.outfile, output); console.log(`📝 Response written to ${options.outfile}`); } catch (error: any) { console.error('❌ Error: Failed to write output file:', error.message); process.exit(1); } } else { console.log('\n' + output); } } catch (error: any) { console.error('❌ Error:', error.message); process.exit(1); } } // Start the program main().catch(error => { console.error('❌ Error:', error.message); process.exit(1); });