mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 15:24:10 +08:00
feat(cli): support stdin for input handling (fixes #71)
This commit is contained in:
20
README.md
20
README.md
@@ -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
|
||||||
|
|||||||
90
packages/cli/src/conversion.ts
Normal file
90
packages/cli/src/conversion.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
3
packages/cli/src/types.ts
Normal file
3
packages/cli/src/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type InputSource
|
||||||
|
= | { type: 'stdin' }
|
||||||
|
| { type: 'file', path: string }
|
||||||
79
packages/cli/src/utils.ts
Normal file
79
packages/cli/src/utils.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user