import { createInterface } from "node:readline"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ONLINE_PROVIDERS, LOCAL_PROVIDER, type OnlineProvider, } from "../config/providers.js"; import type { LlmProvider } from "../config/llm.js"; // ── Types ────────────────────────────────────────────────────────────────── export interface PipelineConfig { provider: LlmProvider; url: string; model: string | undefined; batchSize: number; maxRetries: number; } interface SavedConfig { provider: PipelineConfig["provider"]; model: string; batchSize: number; maxRetries: number; } // ── Helpers ──────────────────────────────────────────────────────────────── function getConfigPath(): string { return join(import.meta.dirname, "..", ".pipeline-config.json"); } function loadLastConfig(): SavedConfig | null { const path = getConfigPath(); if (!existsSync(path)) return null; try { const raw = readFileSync(path, "utf-8"); return JSON.parse(raw) as SavedConfig; } catch { return null; } } function saveConfig(config: SavedConfig): void { writeFileSync(getConfigPath(), JSON.stringify(config, null, 2)); } function ask( rl: ReturnType, prompt: string, ): Promise { return new Promise((resolve) => { rl.question(prompt, resolve); }); } function printLine(char = "─", length = 50): void { console.log(char.repeat(length)); } function formatProviderLabel(p: LlmProvider): string { const meta = p === "local" ? LOCAL_PROVIDER : ONLINE_PROVIDERS[p]; return meta ? meta.name : p; } // ── Validation ───────────────────────────────────────────────────────────── function validateBatchSize(input: string): number { const n = parseInt(input.trim(), 10); if (Number.isNaN(n) || n < 1 || n > 20) { throw new Error("Batch size must be an integer between 1 and 20"); } return n; } function checkApiKey(provider: OnlineProvider): void { const meta = ONLINE_PROVIDERS[provider]; if (!meta) return; const key = process.env[meta.envVar]; if (!key) { console.error(`\n ❌ Missing API key: ${meta.envVar} is not set.`); console.error(`Export it before running the pipeline:`); console.error(`export ${meta.envVar}=your_key_here\n`); process.exit(1); } } // ── Prompt flows ──────────────────────────────────────────────────────────── async function promptProviderType( rl: ReturnType, ): Promise<"local" | "online"> { console.log("\nSelect provider type:"); console.log(" [1] Local (llama.cpp, ollama, lm-studio, etc.)"); console.log(" [2] Online API (Gemini, DeepSeek, OpenRouter, Groq)"); while (true) { const choice = (await ask(rl, "Choice [1/2]: ")).trim(); if (choice === "1") return "local"; if (choice === "2") return "online"; console.log(" Invalid choice. Enter 1 or 2."); } } async function promptOnlineProvider( rl: ReturnType, ): Promise { console.log("\nSelect online provider:"); const entries = Object.entries(ONLINE_PROVIDERS); entries.forEach(([_key, meta], i) => { const hasKey = process.env[meta.envVar] ? "✓" : "✗"; console.log(` [${i + 1}] ${meta.name} (${hasKey} ${meta.envVar})`); }); while (true) { const choice = (await ask(rl, `Choice [1-${entries.length}]: `)).trim(); const idx = parseInt(choice, 10) - 1; if (idx >= 0 && idx < entries.length) { const entry = entries[idx]!; // Object.entries returns string keys, so we must cast it const provider = entry[0] as OnlineProvider; checkApiKey(provider); return provider; } console.log(` Invalid choice. Enter 1-${entries.length}.`); } } async function promptModel( rl: ReturnType, provider: LlmProvider, ): Promise { if (provider === "local") { console.log("\nLocal provider selected."); console.log(" Using: http://127.0.0.1:8080/v1/chat/completions"); const model = ( await ask(rl, "Model name (optional, press Enter to skip): ") ).trim(); return model || "local-model"; } // TypeScript automatically narrows `provider` to `OnlineProvider` here const meta = ONLINE_PROVIDERS[provider]; if (!meta) { throw new Error(`Unknown provider: ${provider}`); } console.log(`\nSelect model for ${meta.name}:`); meta.models.forEach((m, i) => console.log(` [${i + 1}] ${m}`)); console.log(` [${meta.models.length + 1}] Other (type manually)`); while (true) { const choice = ( await ask(rl, `Choice [1-${meta.models.length + 1}]: `) ).trim(); const idx = parseInt(choice, 10) - 1; if (idx >= 0 && idx < meta.models.length) { return meta.models[idx]!; } if (idx === meta.models.length) { const custom = (await ask(rl, "Enter model name: ")).trim(); if (custom) return custom; console.log(" Model name cannot be empty."); continue; } console.log(` Invalid choice. Enter 1-${meta.models.length + 1}.`); } } async function promptBatchSize( rl: ReturnType, ): Promise { console.log("\nBatch size: how many words to enrich per LLM call."); console.log(" Recommended: 2–6 for complex languages, 4–8 for simple."); while (true) { const input = (await ask(rl, "Batch size [1-20, default 4]: ")).trim(); if (!input) return 4; try { return validateBatchSize(input); } catch (err) { console.log(` ${(err as Error).message}`); } } } async function promptConfirm( rl: ReturnType, config: PipelineConfig, ): Promise { console.log("\n"); printLine(); console.log(" CONFIGURATION SUMMARY"); printLine(); console.log(` Provider: ${formatProviderLabel(config.provider)}`); console.log(` URL: ${config.url}`); console.log(` Model: ${config.model ?? "(none)"}`); console.log(` Batch: ${config.batchSize} words/call`); console.log(` Retries: ${config.maxRetries}`); printLine(); const answer = (await ask(rl, "\nProceed with this configuration? [Y/n]: ")) .trim() .toLowerCase(); return answer === "" || answer === "y" || answer === "yes"; } // ── Main export ──────────────────────────────────────────────────────────── export async function runCli(): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const lastConfig = loadLastConfig(); // ── Startup menu ───────────────────────────────────────────────────────── console.log("\n"); printLine("═", 50); console.log(" PIPELINE CONFIGURATION"); printLine("═", 50); if (lastConfig) { console.log("\nLast used configuration:"); console.log(` Provider: ${formatProviderLabel(lastConfig.provider)}`); console.log(` Model: ${lastConfig.model}`); console.log(` Batch: ${lastConfig.batchSize}`); } else { console.log("\nNo previous configuration found."); } console.log( "\n[1] Use last config" + (lastConfig ? "" : " (not available)") + "\n[2] Configure new run", ); let useLast = false; if (lastConfig) { while (true) { const choice = (await ask(rl, "Choice [1/2]: ")).trim(); if (choice === "1") { useLast = true; break; } if (choice === "2") break; console.log(" Invalid choice. Enter 1 or 2."); } } else { // No last config, auto-select new run console.log("Auto-selecting: Configure new run"); await ask(rl, "Press Enter to continue..."); } // ── Build config ──────────────────────────────────────────────────────── let config: PipelineConfig; if (useLast && lastConfig) { // Re-validate API key before reusing if (lastConfig.provider !== "local") { checkApiKey(lastConfig.provider); } const meta = lastConfig.provider === "local" ? LOCAL_PROVIDER : ONLINE_PROVIDERS[lastConfig.provider]; config = { provider: lastConfig.provider, url: meta?.url ?? LOCAL_PROVIDER.url, model: lastConfig.model, batchSize: lastConfig.batchSize, maxRetries: lastConfig.maxRetries, }; } else { // New run flow const providerType = await promptProviderType(rl); let provider: LlmProvider; let url: string; if (providerType === "local") { provider = "local"; url = LOCAL_PROVIDER.url; } else { provider = await promptOnlineProvider(rl); url = ONLINE_PROVIDERS[provider].url; } const model = await promptModel(rl, provider); const batchSize = await promptBatchSize(rl); config = { provider: provider, url, model: model || undefined, batchSize, maxRetries: 3, }; // Confirm before saving const confirmed = await promptConfirm(rl, config); if (!confirmed) { console.log("\n ❌ Configuration cancelled. Exiting.\n"); process.exit(0); } // Save for next time saveConfig({ provider: config.provider, model: config.model ?? "", batchSize: config.batchSize, maxRetries: config.maxRetries, }); console.log("\n ✓ Configuration saved to .pipeline-config.json"); } console.log("\n"); return config; } finally { rl.close(); } }