#!/usr/bin/env bun /** * procurement-scorecard * Scores vendor bids against weighted criteria using OpenAI. */ import { parseArgs } from "util"; import { existsSync, mkdirSync, appendFileSync, readFileSync } from "fs"; import { join, dirname, resolve } from "path"; type OutputFormat = "markdown" | "json"; type ScorecardTemplate = { vendors?: Array>; criteria?: Array>; weights?: Record; notes?: string; }; interface SkillOptions { dataset: string; audience?: string; weights?: Record; threshold: number; format: OutputFormat; model: string; output?: string; template?: ScorecardTemplate; } interface OpenAIChatResponse { choices?: Array<{ message?: { content?: string | null; }; }>; error?: { message?: string; }; } const SKILL_SLUG = "procurement-scorecard"; function ensureDir(path: string) { if (!existsSync(path)) { mkdirSync(path, { recursive: true }); } } function getPaths() { const sessionStamp = new Date().toISOString().replace(/[:.]/g, "_").replace(/-/g, "_"); const exportsRoot = process.env.SKILLS_EXPORTS_DIR || join(process.cwd(), ".skills", "exports"); const logsRoot = process.env.SKILLS_LOGS_DIR || join(process.cwd(), ".skills", "logs"); const skillExportsDir = join(exportsRoot, SKILL_SLUG); const skillLogsDir = join(logsRoot, SKILL_SLUG); ensureDir(skillExportsDir); ensureDir(skillLogsDir); return { sessionStamp, skillExportsDir, skillLogsDir, }; } function createLogger(logDir: string, sessionStamp: string) { const logFile = join(logDir, `log_${sessionStamp}.txt`); function write(level: "info" | "success" | "error", message: string) { const timestamp = new Date().toISOString(); const entry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; appendFileSync(logFile, entry); const prefix = level === "success" ? "✅" : level === "error" ? "❌" : "â„šī¸"; console.log(`${prefix} ${message}`); } return { info: (message: string) => write("info", message), success: (message: string) => write("success", message), error: (message: string) => write("error", message), logFile, }; } function slugify(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40) || "scorecard"; } function parseJsonTemplate(content: string): ScorecardTemplate | undefined { try { const data = JSON.parse(content); if (typeof data === "object" && data !== null) { return data as ScorecardTemplate; } } catch (_error) { // treat as plain text when parsing fails } return undefined; } function parseWeights(value?: string): Record | undefined { if (!value) return undefined; const weights: Record = {}; value.split(",").forEach(entry => { const [key, raw] = entry.split("=").map(part => part.trim()); if (key && raw) { const num = Number.parseFloat(raw); if (!Number.isNaN(num)) { weights[key] = num; } } }); return Object.keys(weights).length ? weights : undefined; } function normalizeWeights(weights?: Record): Record | undefined { if (!weights) return undefined; const total = Object.values(weights).reduce((sum, val) => sum + val, 0); if (total <= 0) return undefined; const normalized: Record = {}; Object.entries(weights).forEach(([key, val]) => { normalized[key] = Number((val / total * 100).toFixed(2)); }); return normalized; } function showHelp(): void { console.log(` procurement-scorecard - Score vendor bids against weighted criteria using AI Usage: skills run procurement-scorecard -- [options] skills run procurement-scorecard -- --text "" [options] Options: -h, --help Show this help message --text Inline vendor data --audience Target audience (default: Procurement) --weights Comma-separated weights (e.g., "price=40,quality=30,support=30") --threshold Minimum score threshold (default: 75) --format Output format: markdown | json (default: markdown) --model OpenAI model (default: gpt-4o-mini) --output Custom output file path Output includes: - Executive summary - Vendor ranking with scores - Strengths and risks per vendor - Score rationale - Follow-up actions - Recommendation Examples: skills run procurement-scorecard -- ./bids.json --weights "price=50,quality=30,timeline=20" skills run procurement-scorecard -- --text "Vendor A: $50k, 6 weeks..." --threshold 80 Requirements: OPENAI_API_KEY environment variable must be set. `); } function parseOptions(): SkillOptions { const { values, positionals } = parseArgs({ args: Bun.argv.slice(2), options: { help: { type: "boolean", short: "h" }, text: { type: "string" }, audience: { type: "string" }, weights: { type: "string" }, threshold: { type: "string", default: "75" }, format: { type: "string", default: "markdown" }, model: { type: "string", default: "gpt-4o-mini" }, output: { type: "string" }, }, allowPositionals: true, }); if (values.help) { showHelp(); process.exit(0); } let dataset = values.text || ""; let template: ScorecardTemplate | undefined; if (!dataset && positionals[0]) { const filePath = resolve(positionals[0]); const content = readFileSync(filePath, "utf-8"); template = parseJsonTemplate(content); dataset = template ? "" : content; } if (!dataset.trim() && !template) { throw new Error("Provide vendor data via file path, JSON template, or --text."); } const format: OutputFormat = values.format === "json" ? "json" : values.format === "markdown" ? "markdown" : "markdown"; const threshold = (() => { const parsed = Number.parseFloat(values.threshold); return Number.isNaN(parsed) ? 75 : Math.min(Math.max(parsed, 0), 100); })(); const weights = normalizeWeights(parseWeights(values.weights)); return { dataset, audience: values.audience, weights, threshold, format, model: values.model, output: values.output, template, }; } function buildPrompt(options: SkillOptions) { const system = `You are a procurement analyst scoring vendor proposals. Apply the weighting scheme, evaluate qualitative responses, and deliver a recommendation. Explain rationale for scores and highlight risks plus follow-up actions.`; const instructions = options.format === "json" ? "Respond in JSON with keys: summary, ranking, scores, rationale, follow_up. Each ranking entry should include vendor, total_score, strengths, risks, recommendation." : "Respond in polished Markdown. Start with an executive summary, present a table of vendor scores, explain strengths/weaknesses per vendor, note risks, and provide recommendation with follow-up actions."; const payload = { audience: options.audience || "Procurement", threshold: options.threshold, weights: options.weights, dataset_text: options.dataset.substring(0, 6000), structured_template: options.template, }; const user = `${instructions}\n\n${JSON.stringify(payload, null, 2)}`; return { system, user }; } async function callOpenAI(options: SkillOptions, system: string, user: string): Promise { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error("OPENAI_API_KEY environment variable is required."); } const body = { model: options.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, ], temperature: 0.33, max_tokens: options.format === "json" ? 2200 : 2000, }; const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); const data: OpenAIChatResponse = await response.json(); if (!response.ok) { throw new Error(data.error?.message || `OpenAI API error (${response.status})`); } const content = data.choices?.[0]?.message?.content; if (!content) { throw new Error("OpenAI response did not include content."); } return content.trim(); } async function writeExport(path: string, content: string) { ensureDir(dirname(path)); await Bun.write(path, content); } function buildExportPath(skillExportsDir: string, sessionStamp: string, options: SkillOptions) { if (options.output) { return resolve(options.output); } const descriptorParts = [options.audience || "audience", `${options.threshold}`]; const base = slugify(descriptorParts.filter(Boolean).join("-")); const extension = options.format === "json" ? "json" : "md"; return join(skillExportsDir, `${base}_${sessionStamp}.${extension}`); } function preview(content: string) { const lines = content.split(/\r?\n/).slice(0, 8); lines.forEach(line => console.log(` ${line}`)); if (content.split(/\r?\n/).length > 8) { console.log(" ..."); } } async function main() { const { sessionStamp, skillExportsDir, skillLogsDir } = getPaths(); const logger = createLogger(skillLogsDir, sessionStamp); try { const options = parseOptions(); logger.info("Parsed procurement inputs and options."); const { system, user } = buildPrompt(options); logger.info("Constructed procurement scorecard prompt."); const content = await callOpenAI(options, system, user); logger.success("Received procurement scorecard from OpenAI."); const exportPath = buildExportPath(skillExportsDir, sessionStamp, options); await writeExport(exportPath, content); logger.success(`Saved procurement scorecard to ${exportPath}`); console.log("\nPreview:"); preview(content); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(message); process.exitCode = 1; } } main();