@meldscience/meld
Version:
pipeable one-shot prompt scripting toolkit
392 lines (351 loc) • 14.2 kB
text/typescript
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);
});