JSON Output
Scripts and coding agents run a CLI and read what it prints. With --json, a command of Gunshi prints its result as one JSON document to stdout, and a failure as one line of JSON to stderr, so a program can read them without parsing text for people.
--json is designed for AI agents first. An agent that runs your CLI reads the result as data, not as text for people. When the command fails, the error tells the agent whether it can fix the input (status), why the command failed (reason), which argument is wrong (field), and which command to run next (next), so the agent can correct the command by itself. See Errors.
Supporting --json
A command supports --json when it spreads jsonArgs into its arguments. It returns its result from run, as an object keyed by a noun, and prints the output for people with ctx.log:
import { cli, define, jsonArgs } from 'gunshi'
const projects = [
{ name: 'acme-web', url: 'https://acme-web.example.com' },
{ name: 'acme-api', url: 'https://acme-api.example.com' }
]
const list = define({
name: 'list',
description: 'List the projects',
args: {
...jsonArgs,
limit: { type: 'number', default: 10, description: 'How many projects to list' }
},
run: ctx => {
const result = projects.slice(0, ctx.values.limit)
// The output for people, printed only without `--json`
for (const project of result) {
ctx.log(`${project.name}\t${project.url}`)
}
// The result, printed as one JSON document with `--json`
return { projects: result }
}
})
await cli(process.argv.slice(2), list, { name: 'projects' })Without --json, the command prints its lines for people:
$ node cli.js
acme-web https://acme-web.example.com
acme-api https://acme-api.example.comWith --json, Gunshi silences ctx.log, and prints the result instead:
$ node cli.js --json | cat
{"projects":[{"name":"acme-web","url":"https://acme-web.example.com"},{"name":"acme-api","url":"https://acme-api.example.com"}]}TIP
The example fully code is here.
--json shows up in the help of the command, as --json Output the result as JSON. To give it a short name, spread its schema: json: { ...jsonArgs.json, short: 'j' }.
To know whether the command is asked for JSON, read ctx.values.json, for example to skip a prompt or a spinner.
The Result
Gunshi prints the result as one JSON document to stdout: indented with two spaces on a terminal, and on one line when stdout is a pipe or a file.
ctx.logprints nothing with--json, in the command, in the hooks, and in the plugins, so stdout has the JSON only.console.logis not silenced: do not use it for the lines for people.- Gunshi waits until stdout has written the document before
cli()returns, so a large document is not cut when the app callsprocess.exit()aftercli(). - Gunshi does not touch the exit code on success. To tell something with it, such as that a check found problems, set
process.exitCodeand return the result. - The
cli()promise resolves withundefined, andonAfterCommandreceivesundefined: the result is printed, not returned.
What to Return
Return an object, and key the data by a noun, so that fields can be added later without breaking the programs that read it:
// a project
return { project }
// a list
return { projects }
// a command that changes something, and has nothing else to return
return { outcome: 'deleted', project: { name } }Do not return the data as it is, such as return project or return projects.
Gunshi converts the result with JSON.stringify, so convert what JSON cannot hold yourself before you return it: a Map or a Set becomes {}, and a BigInt fails.
The command must return an object. A command written in JavaScript that returns nothing, a string, an array, or a value that JSON.stringify does not turn into an object, such as a Date, fails with invalid_result. A result that JSON.stringify cannot convert, such as a BigInt, fails with unserializable_result. The command has finished then, and the message says so.
Type Errors
The types check what run returns: a command that spreads jsonArgs returns an object, and any other command returns a string or nothing. The types check that the result is an object, but not that it is keyed by a noun.
The last line of a type error tells what went wrong:
| What you wrote | The last line of the type error | How to fix it |
|---|---|---|
run returns nothing | Type 'void' is not assignable to type 'JsonResult', or 'Awaitable<JsonResult>' when run is not async | Return the result |
run returns a value of an interface as it is (return project) | Index signature for type 'string' is missing in type 'Project' | Key it by a noun: return { project } |
run returns an array | Index signature for type 'string' is missing in type 'Project[]' | Key it by a noun: return { projects }, or print it with a json of your own |
A lazy command spreads jsonArgs into the command that it loads, but not into its definition | No overload matches this call, and '{ project: Project; }' is not assignable to type 'string | void', or 'Awaitable<string | void>' when run is not async | Spread jsonArgs into the definition too (see Lazy Commands) |
Errors
With --json, stdout is empty when the command fails, and stderr has one line of JSON:
$ node cli.js show nope --json
{"status":"error","reason":"not_found","message":"Project nope not found"}
$ echo $?
1| Field | Description |
|---|---|
status | action_required when the command stopped before it ran, and the input can be fixed. error when the command failed while it ran, or after: do not run it again without checking |
reason | The reason of the failure, for programs to read, in snake_case |
message | The message for people, as Gunshi prints it without --json, translated by the i18n plugin. Read reason, not message |
next | The commands to run next: the help of the nearest command. Only for action_required |
data | issues: the argument errors, each with field, reason and message. causes: the messages of the causes of the error, from the outermost |
An argument error looks like this:
{
"status": "action_required",
"reason": "required_option",
"message": "Option '--name' is required",
"next": [
{
"command": "demo deploy --help",
"argv": ["demo", "deploy", "--help"],
"when": "See the arguments of the command"
}
],
"data": {
"issues": [
{ "field": "--name", "reason": "required_option", "message": "Option '--name' is required" }
]
}
}The field of an issue is written as on the command line: the long form of an option (--dry-run with toKebab), the name of a positional argument, and an unknown option or an unknown command as it was typed.
next starts with the name of the CLI, given to cli() as name. Without it, the command line starts with <COMMAND>, and when says to replace it with how the CLI was run. Give your CLI a name, so that next is a command line to run as it is.
Reasons
| Reason | Status | When |
|---|---|---|
required_option, required_positional, missing_value, invalid_type, invalid_choice, invalid_default, custom_parse, unexpected_value, conflicting_options, unknown_option | action_required | An argument is wrong |
invalid_arguments | action_required | Two or more arguments are wrong: each is in data.issues |
unknown_command, ambiguous_command, inconsistent_options, lazy_schema_mismatch | action_required | The command cannot be resolved |
json_not_supported | action_required | --json is given to a command that does not spread jsonArgs |
The code of a CommandError | error | The command throws a CommandError with a code |
command_failed | error | The command throws any other error, or a CommandError without a code |
invalid_result, unserializable_result | error | The result cannot be printed as JSON |
The exit code is 1, except for a CommandError, which keeps its own exitCode.
Failures of the Command
To report a failure that the command expects, throw a CommandError, as without --json. Its code is the reason, as it is, so write it in snake_case, such as not_found or network_error. The messages of its causes are in data.causes:
throw new CommandError(`Project ${name} not found`, { code: 'not_found', cause: error })Any other error that the command throws is command_failed, even when it has a code of its own, such as ENOENT. With --json, Gunshi prints it as JSON too, and the cli() promise resolves. For more about the errors, see Errors and Exit Codes.
jsonArgs or a json of Your Own
Spread jsonArgs when the command can follow the contract of Gunshi: an object keyed by a noun for the result, and one line of JSON for a failure.
When the output is already fixed, such as an array that the readers expect, an existing format, or NDJSON for a stream, declare a json argument of your own, and print what you need. Gunshi does not touch a json argument without the mark of jsonArgs, and the command handles its failures as well.
Commands Without --json
When --json is given to a command that does not spread jsonArgs, Gunshi stops it before it runs, with json_not_supported, so that a program does not take its text for JSON. --help and --version still print as before.
With strict, --json is not reported as an unknown option, since Gunshi knows what it means: a command without jsonArgs gets json_not_supported, and a command with it runs. --json=false asks for no JSON, and stays an unknown option with strict.
Lazy Commands
A lazy command spreads jsonArgs into its definition too, not only into the command that it loads:
// show.js: the command that the loader returns
export default define({
name: 'show',
args: { ...jsonArgs, name: { type: 'positional' } },
run: async ctx => ({ project: await fetchProject(ctx.values.name) })
})
// cli.js: the definition spreads jsonArgs too
const show = lazy(() => import('./show.js').then(m => m.default), {
name: 'show',
description: 'Show a project',
args: { ...jsonArgs, name: { type: 'positional' } }
})Gunshi resolves which command runs from the definitions, before it loads the command. Without jsonArgs in the definition, --json before the name of the command (projects --json show) takes the name for its value. In TypeScript, such a definition is a type error: No overload matches this call.
Collecting Commands
To collect commands into an object, a Map or an array, type it with SubCommandable, the type that the subCommands of cli() takes. Command without type arguments does not take a command that spreads jsonArgs:
import type { SubCommandable } from 'gunshi'
const commands: Record<string, SubCommandable> = { list, show }To type one command, use define, or Command<{ args: typeof args }>.
Hooks and Plugins
With --json, the hooks, the command decorators and the extensions of plugins must not print to stdout or stderr. Print with ctx.log, which is silenced then. A command decorator returns what the command returns, as it is, or the result does not reach Gunshi, and the command fails with invalid_result.
To tell an error of the JSON output, such as the json_not_supported in onErrorCommand, use isJsonOutputError().
Reporting Errors Yourself
With renderValidationErrors: null or usageSilent: true, Gunshi prints no JSON for the argument errors and for json_not_supported, and the cli() promise rejects with the AggregateError that holds them, as without --json. A renderer of the validation errors is not called with --json: to report them yourself, set it to null.
The errors of the command and of the result are printed as JSON with these options too, since they are not texts for people.
