mirror of
https://github.com/voson-wang/toon.git
synced 2026-01-29 23:34:10 +08:00
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:
48
README.md
48
README.md
@@ -411,6 +411,54 @@ pnpm add @byjohann/toon
|
|||||||
yarn 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
|
## Quick Start
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
3
bin/toon.mjs
Normal file
3
bin/toon.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict'
|
||||||
|
import('../dist/cli/index.js')
|
||||||
12
cli/package.json
Normal file
12
cli/package.json
Normal 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
197
cli/src/index.ts
Normal 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)
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
|
"bin",
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -93,6 +93,15 @@ importers:
|
|||||||
specifier: ^2.8.1
|
specifier: ^2.8.1
|
||||||
version: 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:
|
packages:
|
||||||
|
|
||||||
'@ai-sdk/anthropic@2.0.37':
|
'@ai-sdk/anthropic@2.0.37':
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- benchmarks
|
- benchmarks
|
||||||
|
- cli
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { UserConfig, UserConfigFn } from 'tsdown/config'
|
|||||||
import { defineConfig } from 'tsdown/config'
|
import { defineConfig } from 'tsdown/config'
|
||||||
|
|
||||||
const config: UserConfig | UserConfigFn = defineConfig({
|
const config: UserConfig | UserConfigFn = defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: {
|
||||||
|
'index': 'src/index.ts',
|
||||||
|
'cli/index': 'cli/src/index.ts',
|
||||||
|
},
|
||||||
dts: true,
|
dts: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user