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()
+ })
+}