A CLI is already a native agent affordance. exec("notion ...") with
JSON output is more direct than wrapping the same calls in MCP's
protocol layer, tool descriptors, and stdio lifecycle. Adding an MCP
surface would just expand maintenance load without giving agents
anything they can't already do.
Add this to Non-goals so the position is clear in the spec, not just
in a closed tracking issue.
Notion shipped an official CLI ('ntn', https://ntn.dev) in 2026,
focused on Workers deployment plus convenience commands for pages /
files / raw API. The two tools have meaningfully different scopes,
but the names are close enough that users will inevitably ask 'why
not just use ntn?'. This commit answers that up front:
- README: add a 'How does this compare' section between Install and
Quick Start with a side-by-side table and clear pick-this-when guidance.
- DESIGN.md: add a 'Non-goals' section (OAuth, Workers, TUI, schema
migrations) and a 'Relationship to ntn' section that names what each
tool owns. Move the OAuth rationale into the Non-goals list and link
to it from the Authentication section instead of duplicating the prose.
No code changes.
Closes#37. Wraps the two 2025 Notion endpoints that do server-side
markdown I/O on a full page:
GET /v1/pages/:id/markdown → notion page markdown <id>
PATCH /v1/pages/:id/markdown → notion page set-markdown <id>
### page markdown
Prints a page's content as markdown. Strictly better than `block list
--md` for page-level dumps: the server handles toggles, columns, synced
blocks, and databases-as-pages uniformly and always matches what the
Notion UI shows.
notion page markdown <id>
notion page markdown <id> > page.md
notion page markdown <id> --out page.md
notion page markdown <id> --format json # full response incl. truncated flag
Surfaces Notion's 'truncated' flag and 'unknown_block_ids' as stderr
notes in default output so silent partial renders don't go unnoticed.
### page set-markdown
Updates a page from markdown in one API call. Supports all four
mutation modes Notion's PATCH endpoint accepts:
--replace (default) replace_content: overwrite whole page
--append insert_content at end
--after <anchor> insert_content after ellipsis anchor ("start...end")
--range <anchor> replace_content_range bounded by ellipsis anchor
--file <path> (use '-' for stdin)
--text <str> inline markdown
--allow-deleting-content pass allow flag for destructive modes
Because Notion handles the full markdown → blocks conversion server-side,
this is the cleanest way to write long documents — the 100-children
batching (#21) doesn't apply to set-markdown.
### Implementation
Two pure helpers isolate the fiddly bits for unit testing without network:
- buildSetMarkdownBody: produces the correct envelope for each mode;
rejects ambiguous flag combinations up front.
- readMarkdownSource: picks the source (--file / --text / stdin); same
mutual-exclusion rules as the rest of the CLI.
### Tests
- Exhaustive buildSetMarkdownBody coverage across all four modes +
allow_deleting_content + multi-mode rejection.
- readMarkdownSource: required / conflict / file / stdin / file-missing.
### Smoke
Verified end-to-end against a real workspace: replace via --file,
append --text, markdown read with --out, stdin replace, and multi-mode
rejection all behave as documented.
Closes#38. Fixes silent truncation of property values with >25 items.
Problem:
'notion page view' and 'notion page props' both call GET /v1/pages/:id,
which returns every property in a single response but truncates values
at 25 items. A relation pointing to 40 pages silently loses 15.
Fix:
New 'page property' subcommand wraps GET /v1/pages/:id/properties/:id
and walks next_cursor until has_more is false, merging results[] into
a single response.
Interface:
notion page property <page-id> <property-id>
notion page property <page-id> --name "References" # resolve id by name
notion page property <page-id> <property-id> --page-size 50
Implementation:
- fetchPagePropertyAllPages handles both paginated ('list' object) and
non-paginated ('property_item' object) response shapes.
- findPropertyIDByName scans page.properties map for --name lookups;
error message includes the list of available names.
- renderPageProperty prints a human-friendly summary; summarizePropertyItem
covers relation / rich_text / title / people / number types with a
JSON fallback for anything else.
Tests:
- TestFetchPagePropertyAllPages_FollowsCursors spins up a 3-page
httptest server and asserts all 4 items are merged with has_more=false.
- Passthrough test for 'property_item' (non-paginated) shape.
- findPropertyIDByName: found / missing / malformed page.
Smoke-tested against a real database: --name resolves correctly, positional
property-id works, 'title' non-paginated path returns the expected single
item, and both conflict / not-found errors fire cleanly.
Closes#36. Brings 'block update' in line with 'block append' / 'block
insert', which have supported markdown input since earlier releases.
New flags:
--file <path> read markdown from a file; must parse to exactly one
block. Fails fast on type mismatch because Notion's
PATCH /v1/blocks/:id cannot change a block's type.
Code-fence language aliases from #22 apply here, so
--file with a 'ts' / 'sh' / 'yml' fence still works.
--markdown when combined with --text, runs the inline parser
(bold / italic / code / strikethrough / links).
Mutual-exclusion and validation:
- --text and --file are mutually exclusive.
- --markdown is implied for --file; supplying both is rejected.
- At least one of --text / --file is required.
- --file preserves type-specific inner fields (code.language, to_do.
checked, etc.) from the parsed block.
Logic extracted to buildUpdateBlockBody() so it's unit-testable without
network. Tests cover plain text, --markdown annotations, --file parsing
with language normalization, type mismatch, multi-block rejection, and
missing-file errors.
Smoke-tested end-to-end: paragraph → plain update, paragraph → markdown
update with links/bold, code block → sh-alias update normalized to
'shell', and the type-mismatch guard fires correctly.
Wraps two endpoints added in Notion's 2025 API that the CLI didn't yet
expose:
- PATCH /v1/comments/:id → 'notion comment update <id> --text ...'
- DELETE /v1/comments/:id → 'notion comment delete <id> [<id> ...]'
'update' reuses the existing buildCommentRichText helper so the
--mention-user flag works the same way as 'comment add'.
'delete' follows the 'block delete' pattern: variadic args, per-id
error isolation, and a summary line (N comment(s) deleted). When the
target id is the anchor of a discussion, Notion removes the whole
thread; deleting a reply removes just that one comment — this is
documented in the Long help.
Two new client methods (UpdateComment, DeleteComment) keep the API
plumbing in internal/client alongside AddComment.
Smoke-tested against a real workspace: create comment → update text →
verify new text via 'comment get' → delete → raw DELETE on the same id
returns object_not_found as expected (the synchronous GET path is
eventually consistent server-side, which is a Notion behavior, not a
CLI issue).
Closes#33
Three changes to de-scare the soft-delete command:
- 'page archive <id>' is the canonical form. This is the term used in
Notion's UI and in the 2025 API (where the field is renamed in_trash,
kept backwards-compatible with 'archived').
- 'page delete' and 'page trash' are now cobra aliases that resolve to
the same RunE. Existing scripts pinned on 'page delete' keep working
unchanged; the legacy variable pageDeleteCmd also still exists as an
assignment to pageArchiveCmd for any external code path.
- Help text now makes the soft-delete nature explicit and the success
line tells the user how to undo ('run notion page restore').
The page restore Long was updated to say 'reverse of archive / delete /
trash' for symmetry.
Aliases are hidden from 'notion page --help' by cobra, so new users see
one canonical command; they surface on 'notion page <alias> --help'.
Closes#35
Wraps GET /v1/file_uploads/:id — the dedicated endpoint for checking the
status of a file upload (pending / uploaded / expired), recovering its
content_type/size, or grabbing an existing file_upload id for re-use
inside a block.
Previously this endpoint was only reachable via:
notion api GET /v1/file_uploads/<id>
The new command prints a human-friendly summary by default and passes
through the raw JSON with --format json.
Tests cover the full-field render and the minimal 'pending' render,
asserting only on output that goes through fmt.Println — keys rendered
via fatih/color bypass our stdout pipe capture (same pattern as the
existing search table tests).
Closes#34
The Notion API returns a technically-accurate but hard-to-action
validation error when an internal integration tries to create a page at
the workspace root:
validation_error: Provide a parent.page_id or parent.database_id
parameter to create a page, or use a public integration with
insert_content capability. Internal integrations aren't owned by a
single user, so creating workspace-level private pages is not
supported.
Two small improvements so users don't have to parse that paragraph:
1. `notion auth status` and `notion auth doctor` now derive the
integration type from bot.owner.type ("workspace" → internal,
"user" → public) and print it. For internal integrations a follow-up
line reminds the user they must share a parent page first.
2. client.errorHint matches the internal-integration validation_error
signature and prints a concrete workaround with the exact next command:
→ Internal integrations can't create pages at the workspace root.
Workaround: create (or pick) a parent page in the Notion UI,
share it with this integration, then pass its ID as the parent:
notion page create <shared-page-id> --title "..."
To list pages shared with your integration: notion page list
Detection code is small (detectIntegrationType in cmd/auth.go) and
defensive: unknown or missing owner shape returns "" so pre-existing
response fixtures that don't include bot.owner still render fine.
Closes#25
Previously `notion file upload` only worked with a local filesystem
path. To upload something that lived behind a URL (chat attachment, CI
artifact, object-storage direct link) you had to curl to a tmp file
first. To pipe bytes into Notion you couldn't at all.
This change introduces a fileSource abstraction and three loaders:
<path> local file (existing behavior, unchanged)
- read from stdin (requires --name)
http(s)://... HTTP GET, follows redirects, reads Content-Type
and Content-Disposition filename if present
A new --name flag overrides the filename in all three modes. It is
required for stdin (we can't guess) and recommended for URLs whose
path segment is opaque (e.g. /download?id=123).
Deliberately scoped out of this PR (use a curl-pipe for these):
- custom auth headers for the URL loader
- chunked / streaming upload (single-part, same as before)
Tests use httptest for URL loading, os.Pipe to redirect stdin, and
integration tests wire uploadFromAny through the existing fileUploadAPI
mock to prove every source funnels into the same two-step upload flow.
Closes#26
Before this change there was no first-class CLI path to turn an uploaded
file into a block. Users had to run `notion file upload`, capture the
id, then hand-craft a PATCH /v1/blocks/<id>/children JSON body and send
it via `notion api`. The existing --image-url only accepted external
http(s) links.
This PR adds a symmetric triple for every media type Notion supports:
--image-url / --image-file / --image-upload
--file-url / --file-file / --file-upload
--video-url / --video-file / --video-upload
--audio-url / --audio-file / --audio-upload
--pdf-url / --pdf-file / --pdf-upload
Semantics:
--<kind>-url http(s) external URL → block.<kind>.type = external
--<kind>-file local path → upload then embed
--<kind>-upload existing file_upload id → embed directly
Flags are mutually exclusive with each other and with --file/positional
text. --caption works with any of them.
Implementation lives in cmd/media_source.go:
- registerMediaFlags: adds the 15 flags + --caption to a cobra command.
- resolveMediaSource: validates, returns the single active source.
- mediaSource.Build: performs the upload (for --*-file) and assembles
the final block map.
The legacy --image-url flag and its validator/builder are now thin
back-compat wrappers around the new helpers, so existing tests and
usage keep working byte-for-byte.
Closes#23
Notion rejects PATCH /v1/blocks/<id>/children with either:
- body.children.length > 100, or
- body.children[N].<type>.rich_text[0].text.content.length > 2000
Both limits are fixed and known at parse time, so the CLI can absorb
them. Previously even a moderately long markdown file (a postmortem, a
design doc with one ~2KB stack trace, …) required manual splitting.
New helpers in cmd/block_limits.go:
- chunkChildren: slice the children list into groups of ≤100.
- appendChildrenBatched: PATCH each chunk sequentially, preserving
order. The `after` anchor is attached to the FIRST batch only,
because Notion won't let subsequent inserts reference a block from
the same transaction. Progress is printed to stderr (not stdout, so
--format json still pipes cleanly).
- handleOversizedBlocks + parseOversizeMode: split oversize code
blocks on newline boundaries by default, with `--on-oversize=
truncate|fail` escape hatches. Code language and to_do checked
state are preserved across split chunks.
Partial-failure message now tells the user exactly which batch failed
and how many blocks were already written:
batch 2/3 failed after writing 100 block(s): <api error>
Wired into both `block append` and `block insert`.
Tests cover: chunk sizes, oversize split/truncate/fail modes, newline
boundary preference, order preservation across batches, `after` anchor
placement, partial-failure error text, and an end-to-end pipeline with a
150-bullet + 3000-char-code-block document.
Closes#21
Markdown / LLM output routinely uses short language labels (`ts`, `sh`,
`yml`, `py`, …) that the Notion API rejects because code.language is a
fixed enum. Previously every such fence hard-failed the whole PATCH with:
body.children[N].code.language should be "abap", ..., "typescript",
"yaml", or "java/c/c++/c#", instead was "ts".
Introduce `normalizeCodeLanguage`:
- passes through any value that's already in the Notion enum,
- maps ~50 common aliases (ts→typescript, sh→shell, yml→yaml, py→python,
cpp→c++, cs→c#, dockerfile→docker, md→markdown, proto→protobuf, …),
- for unknown values, falls back to 'plain text' and emits a one-line
stderr warning so a single unrecognized fence can't break a long doc.
Applied to both markdown parsing (`notion block append --file`) and the
`--lang` flag on inline append/insert, so CLI-typed aliases like
`--lang ts` also work.
Closes#22
- Auto-prefix /v1 when a path starts with / but not /v1/, with a stderr
note so power users see the rewrite. Previously `notion api GET /users/me`
returned an opaque 'invalid_request_url' error.
- Support `--body @file.json` (curl-style) and `--body -` for explicit
stdin reads. The existing implicit stdin fallback for POST/PATCH/PUT
still works.
- Reject GET requests with a body up-front instead of silently dropping it.
- Route PATCH through c.Patch() unconditionally (previously it hit c.Post
first and was overwritten, which made error paths confusing).
- Drop the misleading positional-arg example from --help; the flag was
the only supported form.
Closes#24
DESIGN.md previously listed 'notion auth login' (browser OAuth) and
'OAuth flow (for public integrations)' under Post-MVP, which misled
readers into expecting support. Per issue #20:
- Remove the interactive 'auth login' line (OAuth flow never shipped)
- Remove 'OAuth flow (for public integrations)' from Post-MVP list
- Add explicit 'not planned' note explaining the Notion-side constraints
(no PKCE, no device flow, client_secret required) that make OAuth
impractical for a publicly-distributed OSS binary
Closes#20
- Pre-flight checks HOMEBREW_TAP_TOKEN and NPM_TOKEN are non-empty
(catches empty-secret mistakes before anything runs)
- npm publish step now runs whoami + publish --dry-run before real publish
(catches auth failures at the same step, not mid-release)
- Verify step polls npm view to confirm the version landed
(fail fast if publish silently didn't work)
Prevents the v0.4.0 ghost release where GitHub had the tag but npm didn't.
Move npm distribution files from notion-cli-npm into npm/ directory.
Release workflow now auto-publishes to npm after goreleaser completes.
- npm/: package.json, install.js, bin/notion (shell wrapper), bin/notion.cmd
- release.yml: added Node.js setup + npm publish step
- Version synced from git tag automatically
Requires NPM_TOKEN secret to be configured in GitHub repo settings.
multipart.FileContentDisposition was introduced in Go 1.25 but go.mod
targets 1.24, causing CI build failures. Replace with manual
Content-Disposition header construction with proper quote escaping.
Replace the pattern of temporarily swapping httpClient.Timeout (not
concurrency-safe) with context.WithTimeout on the request. This is
the idiomatic Go approach and safe for concurrent use.
The default 30s HTTP client timeout is too short for file uploads,
causing "context deadline exceeded" errors. Use a dedicated 5-minute
timeout for the UploadFileContent method.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Notion API validation requires table_row blocks inside table.children
(not at block top-level). Fixes 'table.children should be defined' error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After profile-based login, cfg.Token is empty because MigrateToProfiles
clears it. doctor was only checking the legacy top-level token field,
causing it to always report 'no token found' even after a successful login.
Fixes#5
README.md and DESIGN.md documented `--token` which doesn't exist.
The actual flag is `--with-token` (boolean, reads token from stdin).
Also added missing `switch` subcommand to README command table.