feat(cli): support stdin for input handling (fixes #71)

This commit is contained in:
Johann Schopplich
2025-11-03 11:39:10 +01:00
parent f08376ca04
commit a3b1a01a8b
5 changed files with 204 additions and 118 deletions

View File

@@ -473,10 +473,12 @@ Command-line tool for converting between JSON and TOON formats.
### Usage ### Usage
```bash ```bash
npx @toon-format/cli <input> [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 ```bash
# Encode JSON to TOON (auto-detected) # Encode JSON to TOON (auto-detected)
@@ -487,6 +489,16 @@ npx @toon-format/cli data.toon -o output.json
# Output to stdout # Output to stdout
npx @toon-format/cli input.json 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 ### Options
@@ -516,6 +528,10 @@ npx @toon-format/cli data.json --delimiter "|" --length-marker -o output.toon
# Lenient decoding (skip validation) # Lenient decoding (skip validation)
npx @toon-format/cli data.toon --no-strict -o output.json 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 ## Format Overview

View File

@@ -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<EncodeOptions['lengthMarker']>
printStats: boolean
}): Promise<void> {
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<void> {
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)
}
}

View File

@@ -1,12 +1,13 @@
import type { DecodeOptions, Delimiter, EncodeOptions } from '../../toon/src' import type { Delimiter } from '../../toon/src'
import * as fsp from 'node:fs/promises' import type { InputSource } from './types'
import * as path from 'node:path' import * as path from 'node:path'
import process from 'node:process' import process from 'node:process'
import { defineCommand, runMain } from 'citty' import { defineCommand, runMain } from 'citty'
import { consola } from 'consola' import { consola } from 'consola'
import { estimateTokenCount } from 'tokenx'
import { name, version } from '../../toon/package.json' with { type: 'json' } 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({ const main = defineCommand({
meta: { meta: {
@@ -17,8 +18,8 @@ const main = defineCommand({
args: { args: {
input: { input: {
type: 'positional', type: 'positional',
description: 'Input file path', description: 'Input file path (omit or use "-" to read from stdin)',
required: true, required: false,
}, },
output: { output: {
type: 'string', type: 'string',
@@ -62,12 +63,11 @@ const main = defineCommand({
}, },
}, },
async run({ args }) { async run({ args }) {
const input = args.input || args._[0] const input = args.input
if (!input) {
throw new Error('Input file path is required')
}
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 const outputPath = args.output ? path.resolve(args.output) : undefined
// Parse and validate indent // Parse and validate indent
@@ -82,12 +82,12 @@ const main = defineCommand({
throw new Error(`Invalid delimiter "${delimiter}". Valid delimiters are: comma (,), tab (\\t), pipe (|)`) 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 { try {
if (mode === 'encode') { if (mode === 'encode') {
await encodeToToon({ await encodeToToon({
input: inputPath, input: inputSource,
output: outputPath, output: outputPath,
delimiter: delimiter as Delimiter, delimiter: delimiter as Delimiter,
indent, indent,
@@ -97,7 +97,7 @@ const main = defineCommand({
} }
else { else {
await decodeToJson({ await decodeToJson({
input: inputPath, input: inputSource,
output: outputPath, output: outputPath,
indent, indent,
strict: args.strict !== false, 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<EncodeOptions['lengthMarker']>
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) runMain(main)

View File

@@ -0,0 +1,3 @@
export type InputSource
= | { type: 'stdin' }
| { type: 'file', path: string }

79
packages/cli/src/utils.ts Normal file
View File

@@ -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<string> {
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<string> {
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()
})
}