Files
teng-lin--notebooklm-py/docs/cli-exit-codes.md
T
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

434 lines
20 KiB
Markdown

# CLI Exit-Code Convention
**Status:** Active
**Last Updated:** 2026-07-04
This document defines the exit-code policy for the `notebooklm` CLI. Shell
scripts, CI pipelines, and AI-agent automations should rely on these codes for
control flow rather than scraping stdout/stderr text — the text is intended for
humans and may evolve, but the exit-code contract is stable.
The companion architectural decision for the `--json` error contract is
[ADR-0015](adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md);
this document is the surface-level reference for callers, and ADR-0015 is the
rationale for the post-parse `ClickException` rules called out below.
For the canonical implementation, see the `handle_errors` context manager in
[`src/notebooklm/cli/error_handler.py`](../src/notebooklm/cli/error_handler.py)
— the policy table lives in its docstring and the `KeyboardInterrupt` clause
sits immediately below (at the time of writing, around lines 64-67 and :81;
rely on the symbol names rather than the line numbers if they drift).
## Standard exit codes
| Code | Meaning | When you'll see it |
|------|---------|-------------------|
| `0` | Success | The command completed and produced its intended effect. |
| `1` | User / application error | Validation, authentication, rate limiting, network failure, configuration error, or any `NotebookLMError` raised by the library. |
| `2` | System / unexpected error | Unhandled exception (likely a bug). The CLI suggests reporting at the issue tracker. Also used for the `source wait` timeout (see exceptions below). |
| `130`| Cancelled by user | The process received `SIGINT` (Ctrl-C). `130 = 128 + signal 2`, the conventional shell value for SIGINT-terminated processes. |
The policy comment in `error_handler.py` is the source of truth:
```text
Exit codes:
1: User/application error (validation, auth, rate limit, etc.)
2: System/unexpected error (bugs, unhandled exceptions)
130: Keyboard interrupt (128 + signal 2)
```
<a id="exception-exit-code-mapping"></a>
## Exception → exit-code mapping
The `handle_errors` context manager wrapping every CLI command translates
library exceptions into exit codes. The table below summarises the live
mapping in `error_handler.py`:
| Library exception | JSON `code` | Exit |
|---|---|---|
| `RateLimitError` | `RATE_LIMITED` | `1` |
| `AuthError` | `AUTH_ERROR` | `1` |
| `ValidationError` | `VALIDATION_ERROR` | `1` |
| `ConfigurationError` | `CONFIG_ERROR` | `1` |
| `NetworkError` | `NETWORK_ERROR` | `1` |
| `NotebookLimitError` | `NOTEBOOK_LIMIT` | `1` |
| `NotFoundError` (and domain `*NotFoundError`) | `NOT_FOUND` | `1` |
| `ArtifactTimeoutError` (and its subclasses) | `ARTIFACT_TIMEOUT` | `1` |
| `NotebookLMError` (other) | `NOTEBOOKLM_ERROR` | `1` |
| `KeyboardInterrupt` | `CANCELLED` | `130` |
| Anything else (`Exception`) | `UNEXPECTED_ERROR` | `2` |
| Parse-time `click.UsageError` / `click.BadParameter` (Click's parser, before command body runs) | `VALIDATION_ERROR` under `--json`; — in text mode | `2` (under `--json`: typed JSON envelope on stdout, **exit code preserved**; text mode: Click's `Usage:/Error:` on stderr) |
| Parse-time `click.ClickException` (other subclasses raised by Click's parser) | `VALIDATION_ERROR` under `--json`; — in text mode | `1` (same: JSON envelope under `--json` with the exit code preserved; native text otherwise) |
| Post-parse `ClickException` raised from a command body or service module | `VALIDATION_ERROR` (or another standard code, per the raise site) | `1` (typed JSON envelope under `--json`; see ADR-0015) |
`click.ClickException` raised by **Click's own parser** is the *parse-time*
path: argv parsing decides the command body should not run at all (unknown
flag, type-validation failure, missing required argument), so `handle_errors(...)`
— the per-command context manager — is never on the stack. But the **root
group** (`SectionedGroup.main` in
[`src/notebooklm/cli/grouped.py`](../src/notebooklm/cli/grouped.py)) sits
*above* `handle_errors`: it runs Click's superclass in non-standalone mode and
catches the parse-time `ClickException` itself.
- **Under `--json`**, the root group emits the typed JSON error envelope on
stdout (`{ "error": true, "code": "VALIDATION_ERROR", "message": ... }`,
empty stderr), so automation that passed `--json` still gets a parseable
document for argv-level failures. The **exit code is preserved** from Click —
`2` for `UsageError` / `BadParameter`, `1` for the base `ClickException` and
other non-usage subclasses. (Note the envelope `code` is `VALIDATION_ERROR`
regardless of which Click subclass fired; only the exit code carries the
`2`-vs-`1` distinction.)
- **In text mode** (no `--json`), behavior is unchanged: Click renders its own
`Usage: ... / Error: ...` message on stderr and exits with that same
`exc.exit_code` (`2` / `1`).
This supersedes the original ADR-0015 §1 stance that "no JSON envelope is
emitted at parse time"; see the amendment note in
[ADR-0015](adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md).
`ClickException`-subclass failures raised from inside a **command body or
its service-layer code** are *post-parse*: argv parsing succeeded, the
command function entered, and `--json`'s value (if any) is bound on the
Click context. These failures route through `output_error(...)` (the
canonical envelope emitter) and exit `1` with the typed JSON error
envelope under `--json` or a plain stderr message in text mode. The
typical code is `VALIDATION_ERROR`. New command/service code MUST NOT
raise `ClickException` for post-parse validation failures unless the call site
is an intentional CLI input-validation boundary marked inline with
`# cli-input-validation: <reason>`. Raw `SystemExit` sites outside the central
handler are similarly marked with `# cli-raw-exit: <reason>`. The guardrail is
[`tests/_guardrails/test_error_handler_allowlist.py`](../tests/_guardrails/test_error_handler_allowlist.py);
see [ADR-0015](adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md)
for the contract and rationale.
## JSON output mode (`--json`)
When a command supports `--json` and the flag is set,
errors are emitted as a JSON document on stdout *and* the exit code still
applies. The shape is:
```json
{
"error": true,
"code": "RATE_LIMITED",
"message": "Error: Rate limited. Retry after 30s.",
"retry_after": 30
}
```
The `code` field is the stable identifier (see table above); `message` is the
human string and may change. Some errors include extra fields
(`retry_after`, `method_id` when `-v/--verbose` is set, etc.). Automation
should branch on `code` (or, more simply, on the exit code).
**Post-parse `ClickException` is covered.** Validation failures that a
command body or its service-layer code chooses to express by raising
`click.UsageError` / `click.BadParameter` / `click.ClickException` (for
example, a flag-combination conflict detected after argv parsing
succeeds) are routed through this same envelope under `--json` and exit
`1` with `code: "VALIDATION_ERROR"` (or another standard code chosen by
the raise site). The contract decision and its enumerated raise sites
are recorded in
[ADR-0015](adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md).
**Parse-time `ClickException` is also covered under `--json`.**
`ClickException` raised by Click's own parser (before the command body runs)
is JSON-wrapped at the root group (`SectionedGroup.main`), not by the
per-command `handle_errors(...)`: under `--json` it emits the same envelope on
stdout with `code: "VALIDATION_ERROR"` and an **exit code preserved** from
Click (`2` for `UsageError` / `BadParameter`, `1` for the base
`ClickException`). In text mode it is unchanged — Click renders its
`Usage: ... / Error: ...` text on stderr and exits with that class-default
code. This is the more consistent behavior for JSON consumers: argv-level
failures no longer fall back to usage prose just because they fired before the
command body. The behavior is pinned by
[`tests/unit/cli/test_json_validation_contract.py`](../tests/unit/cli/test_json_validation_contract.py)
(`test_json_validation_errors_emit_json`, `test_text_validation_errors_keep_click_usage_output`)
and amended into ADR-0015 (amendment note, 2026-06-02).
## Intentional exceptions to the standard convention
Two commands deliberately extend the standard codes (or expose an opt-in
inversion) because their primary use case is shell control flow. Code
referencing them should comment the inverted/extended semantics. Both
exceptions are stable contracts: `source wait`'s three-way exit (`0`/`1`/`2`)
is by design and will not change; `source stale` follows the standard
convention by default and only inverts when callers explicitly pass
`--exit-on-stale`.
<a id="source-stale-exit-on-stale"></a>
### `notebooklm source stale <SOURCE_ID>` — opt-in inverted predicate
Implemented by `source_stale` + `_render_source_stale_result` in
[`src/notebooklm/cli/source_cmd.py`](../src/notebooklm/cli/source_cmd.py).
Default behavior was previously the inverted predicate (`0=stale, 1=fresh`)
but has been standardised; the inversion is now an explicit opt-in via
`--exit-on-stale`.
Default (no flag) — standard CLI convention:
| Exit | Meaning |
|------|---------|
| `0` | Freshness check succeeded (source may be **stale** or **fresh** — branch on stdout text or, with `--json`, on the `stale`/`fresh` fields) |
| `1` | Error (auth, network, validation, unresolvable source ID, etc. — raised by `handle_errors`) |
Opt-in with `--exit-on-stale` — back-compat inverted predicate:
| Exit | Meaning |
|------|---------|
| `0` | Source is **stale** (needs `source refresh`) |
| `1` | Source is **fresh** **or** an error occurred (ambiguous — see below) |
The inversion preserves the natural shell idiom for callers that depend on it:
```bash
if notebooklm source stale --exit-on-stale "$SRC_ID"; then
notebooklm source refresh "$SRC_ID"
fi
```
A `0` exit (with `--exit-on-stale`) reads as "yes, the predicate (stale)
holds, run the body" — the same convention as `test`, `grep -q`, etc.
> **Important — exit-1 ambiguity (only with `--exit-on-stale`).** The
> command is wrapped by the standard `handle_errors` context, so
> `AuthError`, `NetworkError`, `ValidationError`, an unresolvable source
> ID, etc. *also* exit `1` under `--exit-on-stale` and are indistinguishable
> from "source is fresh" by exit code alone. The naive `if`-chain above
> will silently skip the refresh body on an auth/network outage. For
> unattended scripts, validate the session first (`notebooklm auth check --test`
> for a live RPC probe, or `notebooklm auth check` for local cookie checks),
> wrap with `|| die "..."` on the predicate, or
> branch on the JSON `stale`/`fresh` fields with the default (non-opt-in)
> semantics where success and freshness verdict are decoupled.
Note: under `set -e` the `1` exit (when fresh, with `--exit-on-stale`)
will abort the script. Use the predicate inside an `if`/`elif`/`||` (as
above), which shell's errexit explicitly excludes, or `set +e` around the
call. The default semantics (no flag) do not have this hazard — the
command exits `0` on success regardless of freshness.
<a id="source-wait-three-way"></a>
### `notebooklm source wait <SOURCE_ID>` — three-way
Implemented by `source_wait` in
[`src/notebooklm/cli/source_cmd.py`](../src/notebooklm/cli/source_cmd.py) (the
exit-code table is in the command's docstring, around lines 1080-1084 at
the time of writing).
| Exit | Meaning |
|------|---------|
| `0` | Source is ready |
| `1` | Source not found or processing failed |
| `2` | Timeout reached before the source became ready |
This is the only command whose `2` exit does **not** indicate a bug — it is
a recoverable condition the caller may want to retry with a longer
`--timeout`. Scripts that distinguish "transient" from "fatal" should branch
on the specific code rather than the truthy/falsy value:
```bash
notebooklm source wait "$SRC_ID" --timeout 300
case $? in
0) echo "ready" ;;
1) echo "failed"; exit 1 ;;
2) echo "timed out, retry later"; exit 75 ;; # EX_TEMPFAIL
*) echo "unexpected"; exit 1 ;;
esac
```
## Recipes for callers
### Shell
```bash
# Standard — non-zero is failure
notebooklm ask -n "$NOTEBOOK_ID" "Summarize"
status=$?
if [ "$status" -ne 0 ]; then
echo "ask failed (exit $status)" >&2
exit 1
fi
# Distinguish bug from user error
notebooklm <cmd> --json > out.json
case $? in
0) ;; # success
1) jq -r .code out.json ;; # user/app error — branch on code
2) echo "internal CLI error" >&2 ;; # bug; report it
130) echo "cancelled by user" >&2 ;; # ^C
esac
```
### Python `subprocess`
```python
import json
import subprocess
import time
result = subprocess.run(
["notebooklm", "ask", "-n", nb_id, prompt, "--json"],
capture_output=True, text=True,
)
if result.returncode == 0:
payload = json.loads(result.stdout)
elif result.returncode == 1:
err = json.loads(result.stdout) # JSON error document
if err["code"] == "RATE_LIMITED":
time.sleep(err.get("retry_after", 30))
elif result.returncode == 2:
raise RuntimeError(f"CLI bug: {result.stdout}")
elif result.returncode == 130:
raise KeyboardInterrupt
```
## Migration notes
The following shifts have landed (or are about to land) as part of the CLI
UX overhaul and are documented here for callers preparing for — or recovering
from — the contract change.
<a id="get-on-not-found-exits-1-was-0-landed"></a>
### `get`-on-not-found exits `1` (was `0`) ✅ **Landed**
`notebooklm source get`, `notebooklm artifact get`, and `notebooklm note get`
**now exit `1`** with the typed JSON error envelope (`{error, code:
"NOT_FOUND", message, ...}` under `--json`; plain "X not found" on stderr
otherwise) when the requested ID is missing. Previously they printed a "not
found" message to stdout and exited `0`. The new contract matches the rest of
the CLI's user-error convention and lets scripts branch on the exit code
without parsing output text:
```bash
# Idiomatic
if ! notebooklm source get "$SRC_ID"; then
handle_missing "$SRC_ID"
fi
# JSON form — branch on the typed code
notebooklm source get "$SRC_ID" --json > out.json
case $? in
0) ;; # found; ``out.json`` mirrors the Source dataclass
1) jq -r .code out.json ;; # ``NOT_FOUND`` here, but auth/network errors also land
esac
```
This **breaks** any shell script that relied on exit-`0`-on-not-found (e.g.
`notebooklm source get X | grep -q '<title>' && do_something`). Such scripts
must switch to the new exit-code branch shown above. The message text is also
no longer printed to stdout (it's on stderr now), so `grep`-on-stdout for
"not found" likewise stops working — branch on the exit code instead.
The change covers **both** code paths: input IDs ≥20 chars (which skip the
partial-resolve list round-trip in `_resolve_partial_id`) and the rare
race where partial-resolve succeeds but the subsequent `get` returns
`None` because the row was deleted between the two calls.
The pre-existing "no partial-ID match" branch (raised by `_resolve_partial_id`
as a `ClickException`) was already exit `1` and is unchanged.
### `note`/`artifact delete --json` without `--yes`, `note rename` race exit `1` (was `0`) ✅ **Landed**
These surgical fixes match the broader `--json` exit-code convention pinned
by the prior `get`-on-not-found change. The affected cases previously emitted
a `{"<verb>ed": false, "error": ...}` payload on exit `0`, which passed
silently in `set -e` / `check_call`-style scripts branching on the exit code.
`notebooklm note delete <id> --json` without `--yes` cannot prompt (it would
corrupt the parseable-JSON contract callers depend on), but it must also not
appear to succeed. The command now emits the standard typed envelope:
```json
{
"error": true,
"code": "VALIDATION_ERROR",
"message": "Pass --yes to confirm deletion in --json mode",
"id": "...",
"notebook_id": "..."
}
```
on stdout and exits `1`. The text-mode interactive prompt path (no `--json`)
is unchanged — declining the prompt is still a no-op exit `0`.
`notebooklm artifact delete <id> --json` follows the same destructive-operation
guard: without `--yes`, it now emits `VALIDATION_ERROR`, includes the resolved
artifact and notebook IDs plus `"deleted": false`, exits `1`, and does not
delete the artifact. Passing `--yes` preserves the existing successful JSON
payload (`{"id": "...", "deleted": true}`).
`notebooklm note rename <id> "new title"` resolves the partial ID and then
fetches the current note to preserve its content before issuing the update.
A concurrent `note delete` can win the race between those two calls; the
backend then returns `None` and the rename has nothing to update. The
command now funnels that race into the same typed `NOT_FOUND` envelope used
by `note get`'s Path B:
```json
{
"error": true,
"code": "NOT_FOUND",
"message": "Note not found",
"id": "...",
"notebook_id": "..."
}
```
on stdout (text mode: plain `Note not found` on stderr) and exits `1`. The
update RPC is **never** issued on this path — callers can rely on the
absence of side effects when they see this envelope.
**Migration:** scripts branching on the exit code now correctly catch both
misconfigurations. Scripts parsing the JSON body must switch from
`data["deleted"] == false` / `data["renamed"] == false` to
`data["error"] == true` (or branch on `data["code"]`).
### `download` exception paths route through the typed handler
The `download` command group routes all `download` exception paths through `handle_errors` (`cli/download_cmd.py`) so that:
- `--json` consistently produces the JSON error document on every failure.
- Exit codes match the standard table above (`1` for known library errors,
`2` for unexpected, `130` for `^C`).
## See also
- [CLI Reference](cli-reference.md) — command-by-command documentation
- [Configuration](configuration.md) — `--json` and global options
- [Troubleshooting](troubleshooting.md) — interpreting common errors
- [`src/notebooklm/cli/error_handler.py`](../src/notebooklm/cli/error_handler.py)
— canonical implementation
## Exit code semantics
This is the normative one-line summary of the convention every
`notebooklm` CLI command obeys unless it appears in the
[Intentional exceptions](#intentional-exceptions-to-the-standard-convention)
section above.
| Code | Semantic meaning |
|------|------------------|
| `0` | The command succeeded as documented — the requested effect was carried out and any reported result is authoritative. |
| `1` | The command failed, **or** the queried target was not found. Both share exit `1` because automation typically wants the same control-flow branch (`if !` / `case 1)`); JSON mode (`--json`) distinguishes them via the typed `code` field (`NOT_FOUND` vs. `AUTH_ERROR` vs. `VALIDATION_ERROR`, etc.). |
| `2` | Click parser-time error — argv could not be parsed into a valid command invocation (unknown flag, type-validation failure, missing required argument). Under `--json` the root group still emits the typed JSON envelope on stdout (`code: "VALIDATION_ERROR"`) but **preserves** this exit `2`; in text mode Click renders its `Usage:/Error:` prose on stderr. See the [parser-time row in the Exception → exit-code mapping](#exception-exit-code-mapping) for the full behavior; this entry exists to call out that `2` is **not** a post-parse code in the default case. Post-parse `ClickException` is contracted by [ADR-0015](adr/0015-json-envelope-contract-for-post-parse-click-exceptions.md) to route through the typed JSON envelope and exit `1`, not `2`. The same code is also raised when `handle_errors` catches an unhandled non-`NotebookLMError` exception (likely a bug — see the [Standard exit codes](#standard-exit-codes) table). |
Two commands deliberately deviate from this baseline because their primary
use case is shell control flow:
- `source wait` extends the table with `2` = timeout (a recoverable condition,
not a bug — the only command where `2` is not a parser-time error). See
[`notebooklm source wait`](#source-wait-three-way).
- `source stale` offers an opt-in inverted predicate via `--exit-on-stale`
(`0=stale, 1=fresh`) for back-compat with the `if … ; then refresh; fi`
idiom. The default now follows the standard convention. See
[`notebooklm source stale`](#source-stale-exit-on-stale).
`130` (Ctrl-C / SIGINT) is signal-driven and orthogonal to the
success/failure axis; it is documented in the
[Standard exit codes](#standard-exit-codes) table above.