Documentation Menu

Scripting

7 min read

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 put saved;
  • for a command that does several things, its own result: for diff and status the verdicts and their counts, for push its results and what it published, for backup the archive's manifest and its path;
  • for content get, the body itself, since the body is what the command is for. --meta gives 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

  • --json describes 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, GET and HEAD, 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.

Last updated