From 80acc9d4fedca64967104a0fc6f65308ab17b670 Mon Sep 17 00:00:00 2001 From: Andreas Partsch Date: Thu, 30 Oct 2025 08:08:08 +0100 Subject: [PATCH] feat: add cli (#34) * feat: add cli for toon * docs: use npx in the readme * feat: overhaul and refactor --------- Co-authored-by: Johann Schopplich --- README.md | 48 +++++++++++ bin/toon.mjs | 3 + cli/package.json | 12 +++ cli/src/index.ts | 197 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 9 ++ pnpm-workspace.yaml | 1 + tsdown.config.ts | 5 +- 8 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 bin/toon.mjs create mode 100644 cli/package.json create mode 100644 cli/src/index.ts 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, })