UNPKG

ploon-cli

Version:

CLI tool for PLOON (Path-Level Object Oriented Notation) - Convert JSON/XML/YAML to the most token-efficient format

215 lines (210 loc) 8.03 kB
#!/usr/bin/env node import { Command } from "commander"; import { consola } from "consola"; import { readFile, writeFile } from "node:fs/promises"; import { stdin, stdout } from "node:process"; import { fromJSON, fromXML, fromYAML, isValid, minify, parse, prettify, stringify, toJSON, toXML, toYAML } from "ploon"; import { encoding_for_model } from "tiktoken"; //#region src/utils.ts /** * Read input from file or stdin */ async function readInput(filePath) { if (!filePath || filePath === "-") return readStdin(); try { return await readFile(filePath, "utf-8"); } catch (error) { throw new Error(`Failed to read file: ${filePath}`); } } /** * Write output to file or stdout */ async function writeOutput(content, filePath) { if (!filePath) { stdout.write(content + "\n"); return; } try { await writeFile(filePath, content, "utf-8"); } catch (error) { throw new Error(`Failed to write file: ${filePath}`); } } /** * Read from stdin */ function readStdin() { return new Promise((resolve, reject) => { let data = ""; stdin.setEncoding("utf-8"); stdin.on("data", (chunk) => { data += chunk; }); stdin.on("end", () => { resolve(data); }); stdin.on("error", (error) => { reject(error); }); }); } /** * Detect input format from file extension or content */ function detectFormat(filePath, content) { if (filePath && filePath !== "-") { const ext = filePath.split(".").pop()?.toLowerCase(); if (ext === "json") return "json"; if (ext === "xml") return "xml"; if (ext === "yaml" || ext === "yml") return "yaml"; if (ext === "ploon") return "ploon"; } const trimmed = content.trim(); if (trimmed.match(/^\[[\w]+#\d+\]/)) return "ploon"; if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; if (trimmed.startsWith("<")) return "xml"; if (trimmed.match(/^[\w-]+:/) || trimmed.startsWith("-")) return "yaml"; return "json"; } //#endregion //#region src/convert.ts /** * Main conversion function */ async function convert(input, options) { const { inputFormat = "auto", outputFormat, minify: shouldMinify, prettify: shouldPrettify, validate: shouldValidate } = options; if (shouldValidate) { if (!isValid(input)) throw new Error("Invalid PLOON format"); return input; } if (outputFormat) return convertFromPloon(input, outputFormat, options); return convertToPloon(input, inputFormat, shouldMinify, shouldPrettify, options); } /** * Convert to PLOON from various formats */ function convertToPloon(input, format, shouldMinify, shouldPrettify, options) { let data; if (format === "json" || format === "auto") try { data = fromJSON(input); } catch (error) { throw new Error(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`); } else if (format === "xml") try { data = fromXML(input); } catch (error) { throw new Error(`Invalid XML: ${error instanceof Error ? error.message : String(error)}`); } else if (format === "yaml") try { data = fromYAML(input); } catch (error) { throw new Error(`Invalid YAML: ${error instanceof Error ? error.message : String(error)}`); } else if (format === "ploon") { if (shouldMinify) return minify(input); if (shouldPrettify) return prettify(input); return input; } else throw new Error(`Unsupported input format: ${format}`); const ploonFormat = shouldMinify ? "compact" : "standard"; const ploon = stringify(data, { format: ploonFormat, config: options?.config }); return ploon; } /** * Convert from PLOON to various formats */ function convertFromPloon(input, format, options) { let data; try { data = parse(input, { config: options?.config }); } catch (error) { throw new Error(`Failed to parse PLOON: ${error instanceof Error ? error.message : String(error)}`); } if (format === "json") return toJSON(data, true); else if (format === "xml") return toXML(data, true); else if (format === "yaml") return toYAML(data, true); else throw new Error(`Unsupported output format: ${format}`); } //#endregion //#region src/stats.ts /** * Count tokens using tiktoken (accurate for GPT models) */ function countTokens(text) { try { const encoding = encoding_for_model("gpt-4"); const tokens = encoding.encode(text); const count = tokens.length; encoding.free(); return count; } catch (error) { consola.warn("Tiktoken failed, using estimation"); return Math.ceil(text.length / 4); } } /** * Show statistics comparing formats */ function showStats(input, output, options) { const inputTokens = countTokens(input); const outputTokens = countTokens(output); const inputChars = input.length; const outputChars = output.length; const tokenDiff = inputTokens - outputTokens; const tokenPercent = inputTokens > 0 ? (tokenDiff / inputTokens * 100).toFixed(1) : "0.0"; const charDiff = inputChars - outputChars; const charPercent = inputChars > 0 ? (charDiff / inputChars * 100).toFixed(1) : "0.0"; console.log(""); consola.box({ title: "📊 Token Statistics (tiktoken/GPT-4)", message: [ `Input: ${inputTokens} tokens (${inputChars} chars)`, `Output: ${outputTokens} tokens (${outputChars} chars)`, "", tokenDiff >= 0 ? `✅ Saved ${tokenDiff} tokens (-${tokenPercent}%)` : `⚠️ Added ${Math.abs(tokenDiff)} tokens (+${Math.abs(parseFloat(tokenPercent))}%)`, charDiff >= 0 ? `✅ Saved ${charDiff} chars (-${charPercent}%)` : `⚠️ Added ${Math.abs(charDiff)} chars (+${Math.abs(parseFloat(charPercent))}%)` ].join("\n"), style: { padding: 1, borderColor: tokenDiff >= 0 ? "green" : "yellow", borderStyle: "round" } }); } //#endregion //#region src/index.ts const program = new Command(); program.name("ploon").description("PLOON - Path-Level Object Oriented Notation CLI").version("1.0.3"); program.argument("[input]", "Input file (or stdin if not provided)").option("-o, --output <file>", "Output file (default: stdout)").option("--from <format>", "Input format: json|xml|yaml (default: auto-detect)").option("--to <format>", "Output format: json|xml|yaml (converts from PLOON)").option("--minify", "Output compact format (semicolon-separated)").option("--prettify", "Output standard format (newline-separated)").option("--validate", "Validate PLOON format only").option("--stats", "Show token count comparison").option("-c, --config <file>", "Custom configuration file").option("--field-delimiter <char>", "Field delimiter (default: |)").option("--path-separator <char>", "Path separator (default: :)").option("--array-marker <char>", "Array size marker (default: #)").option("--escape-char <char>", "Escape character (default: \\)").option("--no-preserve-empty-fields", "Remove null/empty values from arrays (cleaner output)").action(async (input, options) => { try { const inputData = await readInput(input); const config = {}; if (options.fieldDelimiter !== void 0) config.fieldDelimiter = options.fieldDelimiter; if (options.pathSeparator !== void 0) config.pathSeparator = options.pathSeparator; if (options.arrayMarker !== void 0) config.arraySizeMarker = options.arrayMarker; if (options.escapeChar !== void 0) config.escapeChar = options.escapeChar; if (options.preserveEmptyFields !== void 0) config.preserveEmptyFields = options.preserveEmptyFields; const convertOptions = { inputFormat: options.from, outputFormat: options.to, minify: options.minify, prettify: options.prettify, validate: options.validate, showStats: options.stats, config: Object.keys(config).length > 0 ? config : void 0 }; if (!convertOptions.inputFormat && !convertOptions.outputFormat) convertOptions.inputFormat = detectFormat(input, inputData); const result = await convert(inputData, convertOptions); if (options.stats) showStats(inputData, result, convertOptions); if (!options.validate) await writeOutput(result, options.output); if (options.validate) consola.success("PLOON format is valid"); } catch (error) { consola.error(error instanceof Error ? error.message : String(error)); process.exit(1); } }); program.parse(); //#endregion