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

20 KiB

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; 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 — 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:

Exit codes:
    1: User/application error (validation, auth, rate limit, etc.)
    2: System/unexpected error (bugs, unhandled exceptions)
    130: Keyboard interrupt (128 + signal 2)

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) 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.

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; see ADR-0015 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:

{
  "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.

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 (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.

notebooklm source stale <SOURCE_ID> — opt-in inverted predicate

Implemented by source_stale + _render_source_stale_result in 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:

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.

notebooklm source wait <SOURCE_ID> — three-way

Implemented by source_wait in 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:

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

# 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

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.

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:

# 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:

{
  "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:

{
  "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

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 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 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 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 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 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.

130 (Ctrl-C / SIGINT) is signal-driven and orthogonal to the success/failure axis; it is documented in the Standard exit codes table above.