Skip to content

JSON output

--json is global. Every command accepts it, every command emits its payload to stdout, and every failure emits the same envelope — including the commands that have no success payload of their own.

Human output goes to stderr, so a piped --json stream stays parseable even when the command is chatty:

git tpl --json questions ./template | jq '.questions[] | select(.when == null) | .name'

Failure

One shape, from every command:

{
  "ok": false,
  "error": {
    "code": "tpl::render::collision",
    "message": "`a.jinja` and `b.jinja` both render to `x`",
    "help": "two template files cannot produce the same output file",
    "causes": [{ "code": "tpl::eval::expression", "message": "…", "help": "…" }],
    "labels": [{ "offset": 412, "length": 9, "label": "…" }]
  }
}

Branch on error.code, never on message. causes carries the chain: the outer error names the file, the one beneath names the reason, and only the pair is actionable.

The exit code is unchanged — non-zero on failure, 2 from status when a template update is pending. Reading the code to decide what went wrong and the exit status to decide whether it did are different jobs.

Success

Every payload carries "ok": true, so a caller can check one field without first knowing which command it ran.

render

{ "ok": true,
  "template": { "name": "rust", "description": "…" },
  "revision": { "reference": "main", "commit": "a17b0b2…", "dirty": false },
  "output": "/tmp/out",
  "files": [{ "path": "Cargo.toml", "bytes": 412, "executable": false, "templated": true }],
  "ignoredAnswers": [],
  "skippedByGitignore": [] }

templated says whether the file went through MiniJinja or was copied byte-for-byte. It is the only way to tell, from the output, that a workflow full of ${{ }} was copied rather than rendered-and-survived.

lint

{ "ok": true,
  "template": "rust",
  "diagnostics": [{ "severity": "warning", "code": "tpl::lint::undeclared",
                    "message": "…", "help": "…", "path": "Cargo.toml.jinja" }],
  "errors": 0, "warnings": 1 }

ok is about the command, not the template: warnings do not fail it. Check errors, or the exit code.

questions

{ "ok": true,
  "template": { "name": "rust", "description": "…", "root": "template" },
  "questions": [{ "name": "crate", "order": 0, "type": "string", "prompt": "Crate name",
                  "default": null, "defaultIsExpression": false, "when": null,
                  "pattern": "^[a-z0-9-]+$", "message": "…" }],
  "computed": ["lib_name"],
  "data": [{ "name": "targets", "source": "data/targets.toml", "format": "toml" }] }

Questions come in resolution order, which is the order they must be answered in when a when or a default references an earlier answer.

defaultIsExpression distinguishes "{{ crate }}" from a literal. choicesResolved appears when a choices_from points at a data file inside the template, saving the caller from fetching and parsing it.

context

{ "ok": true,
  "answers": {}, "computed": {}, "data": {}, "template": {}, "flat": {} }

flat is what a template body sees: answers and computed values at the top level, data and template namespaced.

With --eval:

{ "ok": true, "expression": "{{ x | length }}", "type": "an integer", "value": 3 }

status

Documented in status. --format json is deprecated in favour of --json; it still works, and warns, for one more minor release.

diff

{ "ok": true,
  "conflicts": ["mise.toml"],
  "changes": [{ "path": "mise.toml", "kind": "modified", "insertions": 9,
                "deletions": 3, "binary": false }],
  "insertions": 9, "deletions": 3 }

conflicts names the paths a merge could not resolve on its own. They are still reported as changes — the preview contains them with conflict markers, which is what a merge would leave in the worktree.