feat: add cli (#34)

* feat: add cli for toon

* docs: use npx in the readme

* feat: overhaul and refactor

---------

Co-authored-by: Johann Schopplich <mail@johannschopplich.com>
This commit is contained in:
Andreas Partsch
2025-10-30 08:08:08 +01:00
committed by GitHub
parent 28896e19e8
commit 80acc9d4fe
8 changed files with 275 additions and 1 deletions

View File

@@ -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 <input> [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 <file>` | 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 <char>` | Array delimiter: `,` (comma), `\t` (tab), `\|` (pipe) |
| `--indent <number>` | 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

3
bin/toon.mjs Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
'use strict'
import('../dist/cli/index.js')

12
cli/package.json Normal file
View File

@@ -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"
}
}

197
cli/src/index.ts Normal file
View File

@@ -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)

View File

@@ -23,6 +23,7 @@
},
"types": "./dist/index.d.ts",
"files": [
"bin",
"dist"
],
"scripts": {

9
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -1,2 +1,3 @@
packages:
- benchmarks
- cli

View File

@@ -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,
})