diff --git a/README.md b/README.md index efc381a..b5035bf 100644 --- a/README.md +++ b/README.md @@ -473,10 +473,12 @@ Command-line tool for converting between JSON and TOON formats. ### Usage ```bash -npx @toon-format/cli [options] +npx @toon-format/cli [options] [input] ``` -**Auto-detection:** The CLI automatically detects the operation based on file extension (`.json` → encode, `.toon` → decode). +**Standard input:** Omit the input argument or use `-` to read from stdin. This enables piping data directly from other commands. + +**Auto-detection:** The CLI automatically detects the operation based on file extension (`.json` → encode, `.toon` → decode). When reading from stdin, use `--encode` or `--decode` flags to specify the operation (defaults to encode). ```bash # Encode JSON to TOON (auto-detected) @@ -487,6 +489,16 @@ npx @toon-format/cli data.toon -o output.json # Output to stdout npx @toon-format/cli input.json + +# Pipe from stdin (no argument needed) +cat data.json | npx @toon-format/cli +echo '{"name": "Ada"}' | npx @toon-format/cli + +# Explicit stdin with hyphen (equivalent to above) +cat data.json | npx @toon-format/cli - + +# Decode from stdin +cat data.toon | npx @toon-format/cli --decode ``` ### Options @@ -516,6 +528,10 @@ npx @toon-format/cli data.json --delimiter "|" --length-marker -o output.toon # Lenient decoding (skip validation) npx @toon-format/cli data.toon --no-strict -o output.json + +# Stdin workflows +echo '{"name": "Ada", "age": 30}' | npx @toon-format/cli --stats +cat large-dataset.json | npx @toon-format/cli --delimiter "\t" > output.toon ``` ## Format Overview diff --git a/packages/cli/src/conversion.ts b/packages/cli/src/conversion.ts new file mode 100644 index 0000000..228ab33 --- /dev/null +++ b/packages/cli/src/conversion.ts @@ -0,0 +1,90 @@ +import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src' +import type { InputSource } from './types' +import * as fsp from 'node:fs/promises' +import * as path from 'node:path' +import process from 'node:process' +import { consola } from 'consola' +import { estimateTokenCount } from 'tokenx' +import { decode, encode } from '../../toon/src' +import { formatInputLabel, readInput } from './utils' + +export async function encodeToToon(config: { + input: InputSource + output?: string + delimiter: Delimiter + indent: number + lengthMarker: NonNullable + printStats: boolean +}): Promise { + const jsonContent = await readInput(config.input) + + let data: unknown + try { + data = JSON.parse(jsonContent) + } + catch (error) { + throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`) + } + + const encodeOptions: EncodeOptions = { + delimiter: config.delimiter, + indent: config.indent, + lengthMarker: config.lengthMarker, + } + + const toonOutput = encode(data, encodeOptions) + + if (config.output) { + await fsp.writeFile(config.output, toonOutput, 'utf-8') + const relativeInputPath = formatInputLabel(config.input) + const relativeOutputPath = path.relative(process.cwd(), config.output) + consola.success(`Encoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``) + } + else { + console.log(toonOutput) + } + + if (config.printStats) { + const jsonTokens = estimateTokenCount(jsonContent) + const toonTokens = estimateTokenCount(toonOutput) + const diff = jsonTokens - toonTokens + const percent = ((diff / jsonTokens) * 100).toFixed(1) + + console.log() + consola.info(`Token estimates: ~${jsonTokens} (JSON) → ~${toonTokens} (TOON)`) + consola.success(`Saved ~${diff} tokens (-${percent}%)`) + } +} + +export async function decodeToJson(config: { + input: InputSource + output?: string + indent: number + strict: boolean +}): Promise { + const toonContent = await readInput(config.input) + + let data: unknown + try { + const decodeOptions: DecodeOptions = { + indent: config.indent, + strict: config.strict, + } + data = decode(toonContent, decodeOptions) + } + catch (error) { + throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`) + } + + const jsonOutput = JSON.stringify(data, undefined, config.indent) + + if (config.output) { + await fsp.writeFile(config.output, jsonOutput, 'utf-8') + const relativeInputPath = formatInputLabel(config.input) + const relativeOutputPath = path.relative(process.cwd(), config.output) + consola.success(`Decoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``) + } + else { + console.log(jsonOutput) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c791d7d..f7a25df 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,12 +1,13 @@ -import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src' -import * as fsp from 'node:fs/promises' +import type { Delimiter } from '../../toon/src' +import type { InputSource } from './types' import * as path from 'node:path' import process from 'node:process' import { defineCommand, runMain } from 'citty' import { consola } from 'consola' -import { estimateTokenCount } from 'tokenx' import { name, version } from '../../toon/package.json' with { type: 'json' } -import { decode, DEFAULT_DELIMITER, DELIMITERS, encode } from '../../toon/src' +import { DEFAULT_DELIMITER, DELIMITERS } from '../../toon/src' +import { decodeToJson, encodeToToon } from './conversion' +import { detectMode } from './utils' const main = defineCommand({ meta: { @@ -17,8 +18,8 @@ const main = defineCommand({ args: { input: { type: 'positional', - description: 'Input file path', - required: true, + description: 'Input file path (omit or use "-" to read from stdin)', + required: false, }, output: { type: 'string', @@ -62,12 +63,11 @@ const main = defineCommand({ }, }, async run({ args }) { - const input = args.input || args._[0] - if (!input) { - throw new Error('Input file path is required') - } + const input = args.input - const inputPath = path.resolve(input) + const inputSource: InputSource = !input || input === '-' + ? { type: 'stdin' } + : { type: 'file', path: path.resolve(input) } const outputPath = args.output ? path.resolve(args.output) : undefined // Parse and validate indent @@ -82,12 +82,12 @@ const main = defineCommand({ throw new Error(`Invalid delimiter "${delimiter}". Valid delimiters are: comma (,), tab (\\t), pipe (|)`) } - const mode = detectMode(inputPath, args.encode, args.decode) + const mode = detectMode(inputSource, args.encode, args.decode) try { if (mode === 'encode') { await encodeToToon({ - input: inputPath, + input: inputSource, output: outputPath, delimiter: delimiter as Delimiter, indent, @@ -97,7 +97,7 @@ const main = defineCommand({ } else { await decodeToJson({ - input: inputPath, + input: inputSource, output: outputPath, indent, strict: args.strict !== false, @@ -111,106 +111,4 @@ const main = defineCommand({ }, }) -function detectMode( - inputFile: string, - encodeFlag?: boolean, - decodeFlag?: boolean, -): 'encode' | 'decode' { - // Explicit flags take precedence - if (encodeFlag) - return 'encode' - if (decodeFlag) - return 'decode' - - // Auto-detect based on file extension - if (inputFile.endsWith('.json')) - return 'encode' - if (inputFile.endsWith('.toon')) - return 'decode' - - // Default to encode - return 'encode' -} - -async function encodeToToon(config: { - input: string - output?: string - delimiter: Delimiter - indent: number - lengthMarker: NonNullable - printStats: boolean -}) { - const jsonContent = await fsp.readFile(config.input, 'utf-8') - - let data: unknown - try { - data = JSON.parse(jsonContent) - } - catch (error) { - throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`) - } - - const encodeOptions: EncodeOptions = { - delimiter: config.delimiter, - indent: config.indent, - lengthMarker: config.lengthMarker, - } - - const toonOutput = encode(data, encodeOptions) - - if (config.output) { - await fsp.writeFile(config.output, toonOutput, 'utf-8') - const relativeInputPath = path.relative(process.cwd(), config.input) - const relativeOutputPath = path.relative(process.cwd(), config.output) - consola.success(`Encoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``) - } - else { - console.log(toonOutput) - } - - if (config.printStats) { - const jsonTokens = estimateTokenCount(jsonContent) - const toonTokens = estimateTokenCount(toonOutput) - const diff = jsonTokens - toonTokens - const percent = ((diff / jsonTokens) * 100).toFixed(1) - - console.log() - consola.info(`Token estimates: ~${jsonTokens} (JSON) → ~${toonTokens} (TOON)`) - consola.success(`Saved ~${diff} tokens (-${percent}%)`) - } -} - -async function decodeToJson(config: { - input: string - output?: string - indent: number - strict: boolean -}) { - const toonContent = await fsp.readFile(config.input, 'utf-8') - - let data: unknown - try { - const decodeOptions: DecodeOptions = { - indent: config.indent, - strict: config.strict, - } - data = decode(toonContent, decodeOptions) - } - catch (error) { - throw new Error(`Failed to decode TOON: ${error instanceof Error ? error.message : String(error)}`) - } - - const jsonOutput = JSON.stringify(data, undefined, config.indent) - - if (config.output) { - await fsp.writeFile(config.output, jsonOutput, 'utf-8') - const relativeInputPath = path.relative(process.cwd(), config.input) - const relativeOutputPath = path.relative(process.cwd(), config.output) - consola.success(`Decoded \`${relativeInputPath}\` → \`${relativeOutputPath}\``) - } - else { - console.log(jsonOutput) - } -} - runMain(main) diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 0000000..2dd5c9e --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,3 @@ +export type InputSource + = | { type: 'stdin' } + | { type: 'file', path: string } diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts new file mode 100644 index 0000000..9605d90 --- /dev/null +++ b/packages/cli/src/utils.ts @@ -0,0 +1,79 @@ +import type { InputSource } from './types' +import * as fsp from 'node:fs/promises' +import * as path from 'node:path' +import process from 'node:process' + +export function detectMode( + input: InputSource, + encodeFlag?: boolean, + decodeFlag?: boolean, +): 'encode' | 'decode' { + // Explicit flags take precedence + if (encodeFlag) + return 'encode' + if (decodeFlag) + return 'decode' + + // Auto-detect based on file extension + if (input.type === 'file') { + if (input.path.endsWith('.json')) + return 'encode' + if (input.path.endsWith('.toon')) + return 'decode' + } + + // Default to encode + return 'encode' +} + +export async function readInput(source: InputSource): Promise { + if (source.type === 'stdin') + return readFromStdin() + + return fsp.readFile(source.path, 'utf-8') +} + +export function formatInputLabel(source: InputSource): string { + if (source.type === 'stdin') + return 'stdin' + + const relativePath = path.relative(process.cwd(), source.path) + return relativePath || path.basename(source.path) +} + +function readFromStdin(): Promise { + const { stdin } = process + + if (stdin.readableEnded) + return Promise.resolve('') + + return new Promise((resolve, reject) => { + let data = '' + + const onData = (chunk: string) => { + data += chunk + } + + function cleanup() { + stdin.off('data', onData) + stdin.off('error', onError) + stdin.off('end', onEnd) + } + + function onError(error: Error) { + cleanup() + reject(error) + } + + function onEnd() { + cleanup() + resolve(data) + } + + stdin.setEncoding('utf-8') + stdin.on('data', onData) + stdin.once('error', onError) + stdin.once('end', onEnd) + stdin.resume() + }) +}