Skip to main content

CLI and Python client for the Supernote viewer.supernote.com cloud API

Project description

supernote-cli

CLI and Python client for the Supernote cloud API (viewer.supernote.com).

Install

supernote-cli depends on supernotelib, which pulls in pycairo for note rendering. On macOS, that means you need the native Cairo toolchain installed externally before uv can build the Python package:

brew install pkg-config cairo

Then install the project:

uv tool install supernote-cli
# or:
pip install supernote-cli
# or from a local checkout:
cd supernote-cli && uv sync

Credentials

Put your account credentials in a .env file. supernote-cli never stores your password — only the session token it receives from the API.

SUPERNOTE_USER=you@example.com
SUPERNOTE_PASSWORD=your-password
# Optional:
# SUPERNOTE_EQUIPMENT_NO=MACOS_<uuid>

.env is discovered from the current directory walking upwards (standard python-dotenv behavior). The session token is cached at $XDG_CONFIG_HOME/supernote-cli/token.json (fallback ~/.config/supernote-cli/token.json), chmod 0600.

CLI

supernote login | logout | whoami

supernote ls [PATH] [--json]                          # list folder contents
supernote download <path> [--by-id ID] [-o PATH]      # download by remote path (or id)
supernote upload <local> <remote-dir> [--overwrite]   # upload a local file
supernote delete <path>... [--by-id ID]               # delete remote file(s)
supernote sync <path> -o DIR \
         [--days-ago N] [--dry-run] [--recursive]

supernote source ls [--days-ago N] [--limit N] [--json]

supernote annotation ls [--limit N] [--days-ago N] [--json]   # alias: an
supernote annotation <id> \                                   # blockquote to stdout; nothing written
         [-o PATH] [--ocr {none,ollama}] [--model M] [--force] [--json] [--prompt TEXT]

supernote notebook ls [--days-ago N] [--limit N] [--json]     # alias: nb
supernote notebook <id|name|path> \                           # device transcript to stdout; nothing written
         [-o DIR] [--ocr {supernote,ollama}] [--model M] [--force] [--json] [--prompt TEXT]

Global flags: --no-cache, --verbose, --equipment-no.

Addressing

Most commands take a remote path (Note/Inbox/foo.note). download and delete also accept --by-id <ID> as an escape hatch. upload expects the destination folder to already exist — it won't create missing folders. delete removes the remote file immediately with no confirmation prompt; upload --overwrite uses it internally and waits for the deletion to propagate server-side before re-uploading.

annotation <id> (alias an) — blockquote to stdout; -o for the PNG, --ocr for LLM transcription

By default, annotation <id> prints just the highlighted passage. Nothing is written to disk and no Ollama call is made:

$ supernote annotation 832783777540341760
> I've decided to throw my chemoreceptors into the ring...
  • The > block is the highlighted passage Supernote already transcribed (digest.content).
  • If the annotation also has handwriting, a hint is printed to stderr: Note: annotation <id> has untranscribed digest; pass --ocr to transcribe (or -o PATH to save the PNG). Stdout stays clean for piping.

annotation ls groups annotations by their source document. Each source's filename prints once as a header; annotation rows under it are indented with {id} {mtime} {fragment}. Sources are sorted by most-recent activity oldest-first, annotations within a source by mtime ascending — so the latest entry lands right above your prompt (same convention as nb ls). Timestamps follow macOS ls -l style: Mon DD HH:MM for recent (<6mo), Mon DD YYYY for older. Fragment text is truncated to fit the terminal width minus a 1-char right margin. Rows whose underlying digest has a handwritten annotation on top get a trailing (A) marker (always at the same column, so it's easy to scan).

$ supernote an ls --limit 6
Breath_The_New_Science_of_a_Lost_Art_James_Nestor.pdf
 833859949221117952  Apr 18 17:29  BREATHING COORDINATION This technique he
 833859951360212992  Apr 18 17:30  RESONANT (COHERENT) BREATHING A calming  (A)
 833859952597532672  Apr 18 17:34  It's important that the first breath in  (A)
 833859955948781568  Apr 18 17:45  TUMMO There are two forms of Tummo—one t (A)
 833859956464680960  Apr 18 17:49  Breathhold Walking Anders Olsson uses th
 833859957852995584  Apr 18 18:00  Close the mouth and inhale quietly throu (A)

The lines without (A) are highlight-only — the user marked the passage but didn't scrawl a note on top. Pass annotation <id> --ocr on the (A) ones to get the handwriting transcribed.

Pass -o PATH to persist the rendered handwriting PNG. PATH ending in .png is file mode; otherwise it's a directory:

$ supernote annotation 832783777540341760 -o ann.png
> I've decided to throw my chemoreceptors into the ring...

_(no transcript)_

![](ann.png)
$ supernote annotation 832783777540341760 -o annotations/
> I've decided to throw my chemoreceptors into the ring...

_(no transcript)_

![](annotations/832783777540341760.png)

In dir mode the markdown cache is keyed by id too — {output}/{annotation_id}.md — so separate single-id runs into the same dir don't overwrite each other.

Pass --ocr to transcribe the handwriting via local Ollama vision OCR. With -o, the OCR text replaces the placeholder and a sibling {annotation_id}.md (file mode: {stem}.md) is written as a cache marker:

$ supernote annotation 832783777540341760 --ocr -o ann.png
> I've decided to throw my chemoreceptors into the ring...

could this be something for dad to look into?

![](ann.png)

Without -o, --ocr still works — the PNG renders to a tempdir, gets OCR'd, and is discarded. Stdout is just blockquote + ocr text.

Pass --json for the structured shape:

{
  "id": "832783687476051968",
  "digest": "just as we've become a culture of overeaters...",
  "annotation": "completely correlated",
  "handwritten_image": "./832783687476051968.png",
  "source_path": "/Document/Breath.epub",
  "last_modified": "2026-04-18T11:42:00"
}

handwritten_image is null unless -o was passed. annotation (the OCR'd handwriting) is null unless --ocr was passed.

Multiple comma-separated IDs print one block per annotation (or a JSON array). -o file.png requires a single id; -o dir/ --ocr is also single-id today.

notebook <id|name|path> (alias nb) — device transcript to stdout; -o for page PNGs, --ocr ollama for LLM transcription

The target is resolved in this order:

  • All-digits → numeric file id (recommended, since notebook ls surfaces them).
  • Contains / → full path under Note/ (or absolute Note/sub/foo.note).
  • Otherwise → basename (with or without .note suffix). If multiple notes share that basename across folders, the resolver errors with the matching paths so you can disambiguate.

notebook ls prints 3 columns — id, last-modified date, name — sorted oldest-first / newest-last:

$ supernote nb ls --limit 5
1254057731111780353  Apr 24 08:11  San Francisco Note, April 20
1254579462477971456  Apr 27 07:36  20260424_081053
1256465358412316672  May  1 07:39  20260429_132435
1258756940570296320  May  5 21:10  Project Notes
1257109318499565568  May  5 21:21  20260501_073927
$ supernote notebook 1257109318499565568
## Page 1

(device-OCR transcript for page 1, written by the tablet)

## Page 2

...
  • Pages where device OCR was off (or produced no recognizable text) render as _(no transcript)_.
  • No PNGs are rendered or persisted in this default path — only the .note file is downloaded.

Pass -o DIR to also render and persist page_N.png into DIR. The markdown then includes per-page ![](DIR/page_N.png) refs.

Pass --ocr ollama to run local Ollama vision OCR per page instead of the device transcript (higher quality, slower, requires Ollama). With -o, content.md is written into the dir as a cache marker; without -o, PNGs render to a tempdir and are discarded after OCR.

Pass --json for the v0.2 per-page structured array:

[
  {
    "page": 1,
    "transcript": "device OCR text from supernotelib",
    "annotation": "Ollama OCR text (null without --ocr ollama)",
    "handwritten_image": "MyNotebook/page_1.png"
  }
]

--ocr flags

The two subcommands have different --ocr shapes — what makes sense for each artifact:

Subcommand Flag shape Default Notes
notebook --ocr {supernote,ollama} supernote supernote = per-page device transcript via extract_note_text; ollama = per-page Ollama vision OCR (replaces device transcript)
annotation --ocr (boolean) off Off: no transcription, body has only the blockquote (with _(no transcript)_ next to the image when -o is set and handwriting exists). On: Ollama vision OCR of the rendered handwriting PNG.

The shapes differ because notebooks have two meaningful engines (device vs Ollama), while annotations only have one (the device doesn't OCR digest handwriting).

Custom OCR prompt

--prompt TEXT (only meaningful when Ollama OCR is engaged: --ocr ollama for notebook, --ocr for annotation) layers project-specific transcription rules on top of the default OCR prompt. Useful for preserving inline markers verbatim:

$ supernote notebook <id> --ocr ollama --prompt "When a line begins with → or ☐, transcribe it verbatim including the leading symbol; preserve multi-line continuation."

The text is appended under an Additional instructions: section after the default OCR prompt. The cache markdown does not track the prompt (notebook: content.md; annotation: {annotation_id}.md). If you change the prompt and want fresh output, pass --force to invalidate.

Ollama

Default model is qwen3-vl:8b; change with --model. If Ollama returns an error mid-run (e.g. model not pulled), the CLI surfaces the error to stderr once and emits annotation: null for remaining items — partial results still print. Use OLLAMA_HOST to point at a non-default daemon.

Library

from supernote_cli import Client, api

c = Client.from_env()             # loads .env + cached token

# List .note files under /Note/ (recursive)
for folder_path, note in api.list_notes(c):
    print(note.id, f"{folder_path}/{note.file_name}")

# Build the same markdown the CLI prints. `output` is optional:
#   None (default) → no PNG persisted; transcripts/blockquote only.
#   PathLike       → PNG(s) written; markdown includes image refs.
#                    For digests, suffix `.png` selects file mode.
md = api.render_digest_markdown(c, digest)                         # just the blockquote
md = api.render_digest_markdown(c, digest, "ann.png", ocr_engine="ollama")  # PNG + Ollama
md = api.render_note_markdown(c, file_id)                          # device transcripts only
md = api.render_note_markdown(c, file_id, "/tmp/mynote", ocr_engine="ollama")  # PNGs + Ollama

# Group digests by source document (PDF/EPUB) and get full Digest records
for src in api.list_digested_sources(c, days_ago=30):
    print(src.source_stem, len(src.digests))
    # Lower-level: render handwriting PNGs only (no OCR)
    for d in src.digests:
        paths = api.render_handwriting(c, d, "/tmp/hw")  # writes {digest_id}.png
        if paths:
            print(d.id, "->", paths)

# Upload a local PDF to an existing remote folder, then delete it
note = api.upload_file(c, "~/book.pdf", "Document/Books/")
print(note.id, note.file_name)
api.delete_file(c, note)

# Download + render + Ollama-OCR a .note by cloud id
pages = api.ocr_note_from_cloud(c, "1138647043762290688", "/tmp/wh")
for p in pages:
    print(p.index, p.ocr_text)

Client handles auth transparently: an expired token triggers a re-login if .env credentials are available. Rendering uses supernotelib + pillow (main deps); OCR talks to a local Ollama daemon.

Status

  • v0.3.0 (first PyPI release) — combines two internal milestones (see notes/20260509-v03-v04-min-by-default-and-rename.md for the design rationale):
    • Minimal-by-default. annotation <id> / notebook <id> print the blockquote (annotation) or device transcript per page (notebook) and quit — no PNGs persisted, no Ollama call. Pass -o PATH to persist PNGs (annotation accepts file.png or a dir; notebook accepts a dir). Pass --ocr (annotation: boolean) or --ocr {supernote,ollama} (notebook, default supernote) to control transcription; --ocr ollama runs vision OCR. When an annotation has untranscribed handwriting and no flags pull it, a one-line stderr hint fires: Note: annotation <id> has untranscribed digest; pass --ocr to transcribe (or -o PATH to save the PNG).
    • Subcommand rename for clarity. note <id>notebook <id> (alias nb); digest <id>annotation <id> (alias an). The Python API keeps the original names (render_digest_markdown, Digest dataclass, list_digested_sources) — see the design note for why.
    • Listing redesign. Both notebook ls and annotation ls lead with the snowflake id. notebook ls is {id} {mtime} {name}, sorted oldest-first. annotation ls groups by source document, with (A) markers on rows that have handwriting on top. macOS ls -l-style timestamps throughout.
    • Addressing. notebook <TARGET> accepts a basename (e.g. 20260501_073927 with or without .note), a full path (Note/sub/foo.note), or a numeric id. New API: api.resolve_note(client, target) returns the matching Note (raises NoteNotFound / NoteAmbiguous).
    • Cache md keying. Annotation dir-mode cache is {annotation_id}.md (was content.md) so separate single-id runs into the same dir don't collide. Notebook stays content.md (single-target by construction).
    • API surface. render_digest_markdown / render_note_markdown take an optional output arg and ocr_engine="supernote"|"ollama". render_handwriting writes {digest_id}.png / {digest_id}_pN.png.
  • v0.2 (pre-PyPI, breaking): standardized -o/--output across commands, path-based download / delete (with --by-id fallback), JSON-always output for digest <id> / note <id> using Supernote terms (digest / annotation / handwritten_image), new upload and delete verbs.
  • .note OCR: list_notes, render_note, extract_note_text, ocr_note (local file), ocr_note_from_cloud (by file id), ocr_image in supernote_cli.api / supernote_cli.ocr.
  • Upload: api.upload_file(client, local_path, remote_dir, overwrite=False) and supernote upload CLI. Implements Supernote's file/upload/apply → signed S3 PUT → file/upload/finish flow; remote_dir must already exist (no auto-mkdir).
  • Release playbook: see docs/publishing.md.

Tests

Unit tests are offline:

uv run pytest tests/test_auth_unit.py

Live smoke tests hit the real API and require .env plus the gate:

SUPERNOTE_LIVE_TEST=1 uv run pytest tests/test_smoke_live.py -v

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

supernote_cli-0.3.1.tar.gz (91.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

supernote_cli-0.3.1-py3-none-any.whl (35.1 kB view details)

Uploaded Python 3

File details

Details for the file supernote_cli-0.3.1.tar.gz.

File metadata

  • Download URL: supernote_cli-0.3.1.tar.gz
  • Upload date:
  • Size: 91.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for supernote_cli-0.3.1.tar.gz
Algorithm Hash digest
SHA256 8f915de704436e1a21701418a58e8ef4549d253c6822818558bd9298d76660c3
MD5 ead6dc8c2590d515fc23d4648676f428
BLAKE2b-256 78d44c9efc3247011238cc19074bdd9e195ef067a471c7b47d7eab00850290c7

See more details on using hashes here.

File details

Details for the file supernote_cli-0.3.1-py3-none-any.whl.

File metadata

  • Download URL: supernote_cli-0.3.1-py3-none-any.whl
  • Upload date:
  • Size: 35.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.12

File hashes

Hashes for supernote_cli-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a1517bbe8bc07d6b17057a771bb554b241773ff96aed6eb07ed2e0fe0bd41e1f
MD5 349cd5e664cb695b870113e868084289
BLAKE2b-256 8cf8cae05d185eb2b35187131c0291ee7f41e21a30b3908d9ea633e7008b5fa9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page