diff --git a/README.md b/README.md
index f9d4d47..db6cef3 100644
--- a/README.md
+++ b/README.md
@@ -411,6 +411,54 @@ pnpm add @byjohann/toon
yarn add @byjohann/toon
```
+## CLI
+
+Command-line tool for converting between JSON and TOON formats.
+
+### Usage
+
+```bash
+npx @byjohann/toon [options]
+```
+
+**Auto-detection:** The CLI automatically detects the operation based on file extension (`.json` → encode, `.toon` → decode).
+
+```bash
+# Encode JSON to TOON (auto-detected)
+toon input.json -o output.toon
+
+# Decode TOON to JSON (auto-detected)
+toon data.toon -o output.json
+
+# Output to stdout
+toon input.json
+```
+
+### Options
+
+| Option | Description |
+| ------ | ----------- |
+| `-o, --output ` | Output file path (prints to stdout if omitted) |
+| `-e, --encode` | Force encode mode (overrides auto-detection) |
+| `-d, --decode` | Force decode mode (overrides auto-detection) |
+| `--delimiter ` | Array delimiter: `,` (comma), `\t` (tab), `\|` (pipe) |
+| `--indent ` | Indentation size (default: `2`) |
+| `--length-marker` | Add `#` prefix to array lengths (e.g., `items[#3]`) |
+| `--no-strict` | Disable strict validation when decoding |
+
+### Examples
+
+```bash
+# Tab-separated output (often more token-efficient)
+toon data.json --delimiter "\t" -o output.toon
+
+# Pipe-separated with length markers
+toon data.json --delimiter "|" --length-marker -o output.toon
+
+# Lenient decoding (skip validation)
+toon data.toon --no-strict -o output.json
+```
+
## Quick Start
```ts
diff --git a/bin/toon.mjs b/bin/toon.mjs
new file mode 100644
index 0000000..37a89e8
--- /dev/null
+++ b/bin/toon.mjs
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+'use strict'
+import('../dist/cli/index.js')
diff --git a/cli/package.json b/cli/package.json
new file mode 100644
index 0000000..04a9499
--- /dev/null
+++ b/cli/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@toon/cli",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "tsx ./src/index.ts"
+ },
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.2"
+ }
+}
diff --git a/cli/src/index.ts b/cli/src/index.ts
new file mode 100644
index 0000000..b33a6f3
--- /dev/null
+++ b/cli/src/index.ts
@@ -0,0 +1,197 @@
+import type { DecodeOptions, EncodeOptions } from '../../src'
+import * as fsp from 'node:fs/promises'
+import * as path from 'node:path'
+import process from 'node:process'
+import { defineCommand, runMain } from 'citty'
+import { consola } from 'consola'
+import { version } from '../../package.json' with { type: 'json' }
+import { decode, DELIMITERS, encode } from '../../src'
+
+const main = defineCommand({
+ meta: {
+ name: 'toon',
+ description: 'TOON CLI — Convert between JSON and TOON formats',
+ version,
+ },
+ args: {
+ input: {
+ type: 'positional',
+ description: 'Input file path',
+ required: true,
+ },
+ output: {
+ type: 'string',
+ description: 'Output file path',
+ alias: 'o',
+ },
+ encode: {
+ type: 'boolean',
+ description: 'Encode JSON to TOON (auto-detected by default)',
+ alias: 'e',
+ },
+ decode: {
+ type: 'boolean',
+ description: 'Decode TOON to JSON (auto-detected by default)',
+ alias: 'd',
+ },
+ delimiter: {
+ type: 'string',
+ description: 'Delimiter for arrays: comma (,), tab (\\t), or pipe (|)',
+ default: ',',
+ },
+ indent: {
+ type: 'string',
+ description: 'Indentation size',
+ default: '2',
+ },
+ lengthMarker: {
+ type: 'boolean',
+ description: 'Use length marker (#) for arrays',
+ default: false,
+ },
+ strict: {
+ type: 'boolean',
+ description: 'Enable strict mode for decoding',
+ default: true,
+ },
+ },
+ async run({ args }) {
+ const input = args.input || args._[0]
+ if (!input) {
+ throw new Error('Input file path is required')
+ }
+
+ const inputPath = path.resolve(input)
+ const outputPath = args.output ? path.resolve(args.output) : undefined
+
+ // Parse and validate indent
+ const indent = Number.parseInt(args.indent || '2', 10)
+ if (Number.isNaN(indent) || indent < 0) {
+ throw new Error(`Invalid indent value: ${args.indent}`)
+ }
+
+ // Validate delimiter
+ const delimiter = args.delimiter || ','
+ if (!Object.values(DELIMITERS).includes(delimiter as any)) {
+ throw new Error(`Invalid delimiter "${delimiter}". Valid delimiters are: comma (,), tab (\\t), pipe (|)`)
+ }
+
+ const mode = detectMode(inputPath, args.encode, args.decode)
+
+ try {
+ if (mode === 'encode') {
+ await encodeToToon({
+ input: inputPath,
+ output: outputPath,
+ delimiter: delimiter as ',' | '\t' | '|',
+ indent,
+ lengthMarker: args.lengthMarker === true ? '#' : false,
+ })
+ }
+ else {
+ await decodeToJson({
+ input: inputPath,
+ output: outputPath,
+ indent,
+ strict: args.strict !== false,
+ })
+ }
+ }
+ catch (error) {
+ consola.error(error)
+ process.exit(1)
+ }
+ },
+})
+
+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: ',' | '\t' | '|'
+ indent: number
+ lengthMarker: '#' | false
+}) {
+ 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)
+ }
+}
+
+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/package.json b/package.json
index 1ec1639..7e09a54 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
},
"types": "./dist/index.d.ts",
"files": [
+ "bin",
"dist"
],
"scripts": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 520ab31..23e8a37 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -93,6 +93,15 @@ importers:
specifier: ^2.8.1
version: 2.8.1
+ cli:
+ dependencies:
+ citty:
+ specifier: ^0.1.6
+ version: 0.1.6
+ consola:
+ specifier: ^3.4.2
+ version: 3.4.2
+
packages:
'@ai-sdk/anthropic@2.0.37':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 76137e2..4a20a92 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,3 @@
packages:
- benchmarks
+ - cli
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 0dfb197..f661973 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -2,7 +2,10 @@ import type { UserConfig, UserConfigFn } from 'tsdown/config'
import { defineConfig } from 'tsdown/config'
const config: UserConfig | UserConfigFn = defineConfig({
- entry: ['src/index.ts'],
+ entry: {
+ 'index': 'src/index.ts',
+ 'cli/index': 'cli/src/index.ts',
+ },
dts: true,
})