Scripting
With --json, --yes and five exit codes, every CLI command can run unattended in a script, a CI job or a tool that drives the CLI for you.
Why a program reads the CLI differently from a person
People use the command line at a terminal. So do programs, and a program cannot read prose or answer a question. A pipeline that pushes content on merge has to know whether the push worked, whether a person has to look at a conflict, or whether the key was refused. It has to learn that without parsing sentences written for a person. A script that publishes a batch needs the same thing, and so does a language model working through a translation queue.
The CLI therefore keeps the two audiences apart. What a person reads goes where a person reads it. What a program reads is one JSON document, and the outcome is in the exit code.
What a script gets back from every command
Each command splits its output by reader. A program gets one JSON document on stdout and a number as the exit code, while a person gets prose on stderr. No command stops to ask a question that a flag has already answered.
--json
With --json, a command prints exactly one JSON document on stdout and nothing else. What the document holds depends on the command:
- for a command that makes one call, the API's own answer, such as the document view that
content putsaved; - for a command that does several things, its own result: for
diffandstatusthe verdicts and their counts, forpushitsresultsand what itpublished, forbackupthe archive's manifest and itspath; - for
content get, the body itself, since the body is what the command is for.--metagives you the view instead.
Progress, warnings and the summary line go to stderr, so a pipe into jq never sees them. An error arrives as a JSON envelope on stderr:
{
"error": "unauthorized",
"message": "This needs the owner role, which an API key never has.",
"fields": {},
"hint": "Sign in with a personal token to do this."
}
error is a code: either the API's own, such as not_found, in_use or base_mismatch, or one of the CLI's, which are usage, unauthorized, decision and network. fields holds a 422's messages by field path. hint appears only when the CLI has a sentence to add.
Exit codes
| Code | Meaning | A pipeline should |
|---|---|---|
| 0 | done | carry on |
| 1 | the API refused, or something failed | fail the job and show stderr |
| 2 | usage: a bad flag, a missing setting, a confirmation with no --yes |
fix the job, not the content |
| 3 | a decision the data forces, such as a conflict | stop and ask a person |
| 4 | no credential, or the credential was refused | check the secret and the key's scopes |
The command reference lists, command by command, which outcome exits with which code.
No questions off a terminal
Nothing prompts when a flag or a variable already gives the answer. Off a terminal, a command that would ask, such as unpublish, content delete or restore, refuses with exit code 2 unless you pass --yes. At a terminal it waits for y. A line pasted after the command is read as that answer, so pass --yes at a terminal too when you paste a block. publish asks only at a terminal. Off one it goes ahead, because publishing destroys nothing. --yes never overrides a refusal from the CMS: a document that is in use stays in use.
--quiet drops the summary line. --verbose logs each request's method, path and status on stderr, and never a header or a body. Colour is off when stdout is not a terminal or when NO_COLOR is set.
The surface, as data
quillet help --json describes the command tree itself. It lists every command with its arguments and options, and gives the global flags once, beside the version. A script or a model can learn what the CLI can do from the CLI itself, rather than from this page. quillet help content put --json narrows the description to one command.
How to
List every command the installed CLI has:
npx quillet help --json | jq -r '.commands[].name'
List the keys under one folder, one per line:
npx quillet content list <folder>/ --json | jq -r '.[].key'
Read an error as data. The envelope goes to stderr, so send stderr to a file and read the code from that file:
npx quillet content get <folder>/missing --json 2> error.json || jq -r '.error' error.json
It prints not_found.
In CI, give the job a CLI key with content:read, content:write and content:publish, pass it in through the job's secret store, and expose it as a variable. That is enough for the whole sync workflow:
export QUILLET_MANAGEMENT_KEY=<keyId>:<secret>
npx quillet whoami --json | jq -r '.scopes | join(", ")'
Then push, and branch on the exit code. A conflict is the one outcome that a person has to settle, so it gets a message of its own:
npx quillet push --publish --json > push.json
case $? in
0) jq -r '"published \(.published | length)"' push.json ;;
3) echo "Conflict: pull, resolve and push again." ;;
4) echo "The key was refused: check QUILLET_MANAGEMENT_KEY and its scopes." ;;
*) echo "The push failed." ;;
esac
Take a document out of the live site without a prompt, and log what was sent:
npx quillet unpublish <key> --yes --verbose
POST /api/v1/projects/handbook/envs/main/unpublish 200
Unpublished docs/hello (en-US).
This site, as a worked example
This site's own repository drives the CLI from Node. Its scripts/lib/cli.mjs runs quillet <args> --json with the locally installed binary, collects stdout and stderr, and treats any non-zero exit as a failure that carries the exit code and stderr, with secrets blanked out. npm run setup builds on it: whoami checks the key's scopes, push --dry-run prints the plan, and push --publish sends it. Reduced to its core, a call looks like this:
import { spawn } from 'node:child_process'
export function cli(args) {
return new Promise((resolve, reject) => {
const child = spawn('node_modules/.bin/quillet', [...args, '--json'])
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => (stdout += chunk))
child.stderr.on('data', (chunk) => (stderr += chunk))
child.on('close', (code) =>
code === 0
? resolve(stdout.trim() ? JSON.parse(stdout) : null)
: reject(Object.assign(new Error(stderr.trim()), { code })),
)
})
}
const plan = await cli(['push', '--dry-run'])
console.log(`${plan.upserts.length} to write, ${plan.conflicts.length} to resolve`)
Limits
--jsondescribes one run. There is no streaming output and no event for each document as it is written; progress is prose on stderr.- For a single-call command the JSON is the API's view, and a later CMS can add fields to it. Read the fields you need and ignore the rest.
- Exit code 1 covers every refusal that is not a credential, usage or decision problem, so to tell a 404 from a 422 you read the envelope's
error, not the exit code. - The CLI retries a 429 or a 503 only for reads,
GETandHEAD, and twice at most; a refused write fails the command. A long batch job should leave room for the rate limits in Limits and quotas. - No command waits for a webhook or a build. Publishing returns once the CMS has published, and what your site does next is up to the site.
Related
- Command reference: every command, flag and exit code.
- Install and sign in:
QUILLET_MANAGEMENT_KEY, and the other ways to pass a credential. - Sync a repository: what
push,pullanddiffdecide. - Integrating a site: how a site learns that something was published.
- Management API: the routes behind every command.