From fc5a96c0f00894c0b38fb60ea8fdbdfbc181b954 Mon Sep 17 00:00:00 2001
From: Josh Dzielak <174777+joshed-io@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:16:26 +0100
Subject: [PATCH 1/2] Expand block, page, search, and datasource commands;
remove database list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Major feature additions across the CLI:
- block append: support 13 block types (headings, callout, quote, divider,
code, bookmark, to_do, lists, table_of_contents) with --type, --color,
--icon, --language, --checked flags, plus --json for raw block JSON and
stdin piping
- block update: add --color support with text-preserving color-only updates
- block children: show block IDs in formatted output for easier targeting
- page update: add --set for typed property values (rich_text, number,
select, multi_select, date, checkbox, url, email, phone_number), --icon
for emoji, --cover for external images, with "none" removal support
- page get --raw: return { page, blocks } instead of just blocks
- search: add --direction sort, --sort-by field, --raw output, and handle
data_source objects in results
- datasource update: add --add-property and --remove-property for schema
management with select/multi_select option pre-population
Remove database list command — the 2025-09-03 API moves entries to data
sources, so users are directed to datasource query instead.
Update docs command, README, and types. Add comprehensive tests for all
new features including error validation.
---
notion-cli/README.md | 241 +++++++++-----
notion-cli/commands/block.ts | 259 +++++++++++++--
notion-cli/commands/database.ts | 130 ++------
notion-cli/commands/datasource.ts | 109 +++++-
notion-cli/commands/docs.ts | 100 ++++--
notion-cli/commands/integration.ts | 2 +-
notion-cli/commands/page.ts | 210 +++++++++---
notion-cli/commands/search.ts | 103 +++++-
.../src/postman/notion-api/shared/types.ts | 5 +-
notion-cli/test/block.test.ts | 309 ++++++++++++++++++
notion-cli/test/database.test.ts | 22 +-
notion-cli/test/datasource.test.ts | 51 +++
notion-cli/test/page.test.ts | 110 +++++++
notion-cli/test/search.test.ts | 31 ++
14 files changed, 1352 insertions(+), 330 deletions(-)
diff --git a/notion-cli/README.md b/notion-cli/README.md
index 1bd3102..866473e 100644
--- a/notion-cli/README.md
+++ b/notion-cli/README.md
@@ -6,7 +6,7 @@ A command-line tool for reading and writing to Notion workspaces — built for h
### What you can do
-- **Search** across your workspace by keyword, filtered to pages, databases, or both
+- **Search** across your workspace by keyword, filtered to pages, databases, or both, with sort direction control
- **Read pages** with full content — blocks are fetched recursively and rendered with structure intact (headings, lists, toggles, callouts, code blocks, and more)
- **Navigate databases** — view the schema, data sources, and entries with all their property values
- **Work with data sources directly** — retrieve, query, update, and list templates for the data source layer underneath databases
@@ -46,6 +46,7 @@ const notion = createNotionClient(token);
const results = await notion.search({
query: "meeting notes",
filter: { value: "page", property: "object" },
+ sort: { direction: "descending", timestamp: "last_edited_time" },
});
// Retrieve a database schema and list its entries via data sources
@@ -81,7 +82,6 @@ Each command uses one or more Postman Collection requests — the same requests
| `page property` | [Retrieve a page property item](https://go.postman.co/request/52041987-e3c019ad-9c8b-4975-a142-5549ef771028) | [retrieve-page-property](src/postman/notion-api/pages/retrieve-page-property/client.ts) |
| `page move` | [Move page](https://go.postman.co/request/52041987-9686de7b-77c0-4d53-b800-bf6c748bc668) | [move-page](src/postman/notion-api/pages/move-page/client.ts) |
| `database get` | [Retrieve a database](https://go.postman.co/request/52041987-73359528-2278-415f-98f2-4d20274cc69e) | [retrieve-database](src/postman/notion-api/databases/retrieve-database/client.ts) |
-| `database list` | [Retrieve a database](https://go.postman.co/request/52041987-73359528-2278-415f-98f2-4d20274cc69e)
[Query a data source](https://go.postman.co/request/52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d) | [retrieve-database](src/postman/notion-api/databases/retrieve-database/client.ts)
[query-data-source](src/postman/notion-api/data-sources/query-data-source/client.ts) |
| `database create` | [Create a database](https://go.postman.co/request/52041987-85ab373b-2fc1-4b9a-a8a6-ee6b9e72728c) | [create-database](src/postman/notion-api/databases/create-database/client.ts) |
| `database update` | [Update a database](https://go.postman.co/request/52041987-5febd2f6-c9ff-486d-8a4b-71e5c58f82ef) | [update-database](src/postman/notion-api/databases/update-database/client.ts) |
| `datasource get` | [Retrieve a data source](https://go.postman.co/request/52041987-dfeeac14-f85e-4527-ad2e-d85f79284dd9) | [retrieve-data-source](src/postman/notion-api/data-sources/retrieve-data-source/client.ts) |
@@ -232,9 +232,13 @@ notion-cli search --filter all
# Limit results
notion-cli search -n 5
+
+# Sort by last edited time (ascending = oldest first, descending = newest first)
+notion-cli search --direction descending
+notion-cli search -d asc
```
-By default, search returns pages only. Use `--filter database` to find databases, or `--filter all` to show both. Results are paginated — use `--cursor` with the cursor from the previous response to fetch the next page.
+By default, search returns pages only. Use `--filter database` to find databases, or `--filter all` to show both. Use `--direction` to sort results by last edited time (`ascending` or `descending`; shorthand `asc`/`desc` also works). The sort field defaults to `last_edited_time` — the only value currently documented by Notion — but `--sort-by` can override it if new options become available. Results are paginated — use `--cursor` with the cursor from the previous response to fetch the next page.
Example:
@@ -360,22 +364,48 @@ Page created.
#### page update
-Update a page's properties:
+Update a page's title, property values, icon, or cover image:
```bash
notion-cli page update --title "New Title"
```
-Currently supports setting the title via `--title`. For more complex property updates, use the generated client directly.
+Set typed property values with `--set` (`-s`). Format: `"Name:type:value"`:
+
+```bash
+# Set text, number, and select properties
+notion-cli page update -s "Blamed On:rich_text:Chatbot" -s "Severity:select:High"
+
+# Multi-select (comma-separated values)
+notion-cli page update -s "Tags:multi_select:Hallucination,Customer-Facing,Urgent"
+```
+
+Supported types: `rich_text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `email`, `phone_number`.
+
+Set the page icon (emoji) or cover image (external URL):
+
+```bash
+# Set icon and cover
+notion-cli page update --icon 🎸
+notion-cli page update --cover "https://images.unsplash.com/photo-..."
+
+# Combine with other updates
+notion-cli page update --title "New Title" --icon 📚 --cover "https://..."
+
+# Remove icon or cover
+notion-cli page update --icon none
+notion-cli page update --cover none
+```
Example:
```
-$ notion-cli page update d4e5f6a7-b8c9-0123-defa-234567890123 --title "AI Pilot Retrospective"
+$ notion-cli page update d4e5f6a7-... -s "Status:select:Resolved" --icon ✅
Page updated.
- Title: AI Pilot Retrospective
- ID: d4e5f6a7-b8c9-0123-defa-234567890123
- URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123
+ Title: Mars Shipping Promise Incident
+ ID: d4e5f6a7-...
+ Icon: ✅
+ URL: https://www.notion.so/Mars-Shipping-Promise-Incident-d4e5f6a7...
```
#### page archive
@@ -452,7 +482,7 @@ View a database's metadata and schema:
notion-cli database get
```
-Shows the database title, ID, parent, dates, URL, data sources, and schema (property names and types). Use `database list` to see the entries.
+Shows the database title, ID, parent, dates, URL, data sources, and schema (property names and types). Use the data source ID from the output to query entries with `datasource query`.
Example:
@@ -478,53 +508,8 @@ Schema (6 properties):
Resolution: rich_text
Name: title
-To list entries: notion-cli database list a6b7c8d9-e0f1-2345-abcd-567890123456
-```
-
-#### database list
-
-List entries in a database:
-
-```bash
-notion-cli database list
-
-# Limit results
-notion-cli database list --limit 50
-
-# Paginate through entries
-notion-cli database list --cursor
-```
-
-Retrieves the database to find its data source ID, then queries that data source. Shows entries with titles, IDs, property values, and URLs. Each entry is a Notion page — use `page get ` to read its full content. Falls back to the legacy `databases.query()` if no data sources are found.
-
-Example:
-
-```
-$ notion-cli database list a6b7c8d9-e0f1-2345-abcd-567890123456 --limit 2
-🗃️ Listing database entries...
-
-Entries (2+):
-
-────────────────────────────────────────────────────────────
- 📄 Chatbot Told Customer We Sell Jetpacks
- ID: b7c8d9e0-f1a2-3456-bcde-678901234567
- Last edited: 1/20/2026
- URL: https://www.notion.so/Chatbot-Told-Customer-We-Sell-Jetpacks-b7c8d9e0f1a23456bcde678901234567
- Date: 2026-01-20
- Severity: High
- Status: Resolved
- Blamed On: temperature set to 1.0
- Resolution: Added product catalog to system prompt
-
- 📄 Auto-Reply Sent CEO's Email to Spam
- ID: c8d9e0f1-a2b3-4567-cdef-789012345678
- Last edited: 2/3/2026
- URL: https://www.notion.so/Auto-Reply-Sent-CEOs-Email-to-Spam-c8d9e0f1a2b34567cdef789012345678
- Date: 2026-02-03
- Severity: Critical
- Status: Investigating
- Blamed On: overzealous email filter agent
- Resolution: (empty)
+To query entries: notion-cli datasource query b7c8d9e0-f1a2-3456-bcde-aaaaaaaaaaaa
+To view schema: notion-cli datasource get b7c8d9e0-f1a2-3456-bcde-aaaaaaaaaaaa
```
#### database create
@@ -603,7 +588,7 @@ notion-cli datasource query --limit 50
notion-cli datasource query --cursor
```
-Returns entries with titles, IDs, property values, and URLs — the same output format as `database list`, but operating directly on a data source ID.
+Returns entries with titles, IDs, property values, and URLs.
#### datasource create
@@ -615,12 +600,48 @@ notion-cli datasource create --title "Q1 Data"
#### datasource update
-Update a data source's title:
+Update a data source's title and/or schema properties:
```bash
notion-cli datasource update --title "New Title"
```
+Add schema properties (columns) with `--add-property` (`-p`):
+
+```bash
+# Add typed columns
+notion-cli datasource update -p "Blamed On:rich_text" -p "Cost:number"
+
+# For select/multi_select, pre-populate options after a second colon
+notion-cli datasource update -p "Severity:select:Low,Medium,High,Critical"
+```
+
+Supported property types: `rich_text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `email`, `phone_number`, `status`, `people`, `files`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by`.
+
+Remove properties with `--remove-property`:
+
+```bash
+notion-cli datasource update --remove-property "Old Column"
+```
+
+Example:
+
+```
+$ notion-cli datasource update b7c8d9e0-... -p "Blamed On:rich_text" -p "Cost:number" -p "Severity:select:Low,Medium,High,Critical"
+Data source updated.
+ Title: AI Incident Log
+ ID: b7c8d9e0-...
+ Schema (6 properties):
+ Date: date
+ Severity: select
+ Cost: number
+ Blamed On: rich_text
+ Resolution: rich_text
+ Name: title
+```
+
+> **Note:** In the Notion API (2025-09-03), schema properties live on data sources, not databases. Use `datasource update` to manage columns — `database update` only handles title and description.
+
#### datasource templates
List available page templates for a data source:
@@ -689,38 +710,106 @@ Found 2 block(s):
#### block append
-Append a paragraph block to a page or block:
+Append blocks to a page or block:
```bash
+# Default: append a paragraph
notion-cli block append "Hello, world!"
+
+# Specify block type
+notion-cli block append "Section Title" --type heading_2 --color purple
+
+# Callout with emoji icon and background color
+notion-cli block append "Important note!" --type callout --icon 🔥 --color red_background
+
+# Code block with language
+notion-cli block append "console.log('hi')" --type code --language javascript
+
+# Blocks that need no text
+notion-cli block append --type divider
+notion-cli block append --type table_of_contents --color gray_background
+
+# Bookmark (text is the URL)
+notion-cli block append "https://example.com" --type bookmark
+
+# To-do items
+notion-cli block append "Buy milk" --type to_do
+notion-cli block append "Already done" --type to_do --checked
+
+# List items
+notion-cli block append "First point" --type bulleted_list_item
+notion-cli block append "Step one" --type numbered_list_item
```
-Appends a paragraph block containing the given text. For more complex block structures, use the generated client directly.
+Supported block types: `paragraph` (default), `heading_1`, `heading_2`, `heading_3`, `callout`, `quote`, `divider`, `code`, `bookmark`, `to_do`, `bulleted_list_item`, `numbered_list_item`, `table_of_contents`.
-Example:
+Type-specific options:
+
+| Option | Applies to | Example |
+|--------|-----------|---------|
+| `--color ` | All types with text | `--color blue`, `--color yellow_background` |
+| `--icon ` | `callout` | `--icon 🎸` |
+| `--language ` | `code` | `--language python` |
+| `--checked` | `to_do` | `--checked` |
+
+For complex structures (tables, toggles with children, columns, multi-block appends), use `--json` to pass raw Notion block JSON:
+
+```bash
+# Single block as JSON
+notion-cli block append --json '{"type":"callout","callout":{"rich_text":[{"type":"text","text":{"content":"Rich!"}}],"icon":{"emoji":"🎯"},"color":"green_background"}}'
+
+# Multiple blocks in one request (up to 100)
+notion-cli block append --json '[{"type":"divider","divider":{}},{"type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Bold!"},"annotations":{"bold":true}}]}}]'
+
+# Read from stdin
+cat blocks.json | notion-cli block append --json -
+```
+
+Examples:
```
-$ notion-cli block append d4e5f6a7-b8c9-0123-defa-234567890123 "Update the FAQ before the chatbot invents new company policies."
+$ notion-cli block append d4e5f6a7-... "Section Title" --type heading_2 --color blue
+Appended 1 block(s).
+ ## Section Title
+
+$ notion-cli block append d4e5f6a7-... "Warning!" --type callout --icon ⚠️ --color yellow_background
+Appended 1 block(s).
+ ⚠️ Warning!
+
+$ notion-cli block append d4e5f6a7-... --type divider
Appended 1 block(s).
- Update the FAQ before the chatbot invents new company policies.
+ ---
```
#### block update
-Update a block's text content:
+Update a block's text content and/or color:
```bash
+# Update text
notion-cli block update "Updated text"
+
+# Update color only (preserves existing text)
+notion-cli block update --color red_background
+
+# Update both
+notion-cli block update "Updated text" --color purple
```
-Works with paragraph, heading, bulleted list item, numbered list item, to-do, toggle, callout, and quote blocks. The command retrieves the block first to determine its type, then sends the update with the correct type key.
+Works with paragraph, heading, bulleted list item, numbered list item, to-do, toggle, callout, and quote blocks. The command retrieves the block first to determine its type, then sends the update with the correct type key. Color-only updates preserve the existing text content.
Example:
```
-$ notion-cli block update a2b3c4d5-e6f7-8901-abcd-123456789012 "The chatbot no longer makes promises about interplanetary logistics."
+$ notion-cli block update a2b3c4d5-... "The chatbot no longer makes promises about interplanetary logistics."
Block updated.
- ID: a2b3c4d5-e6f7-8901-abcd-123456789012
+ ID: a2b3c4d5-...
+ Type: paragraph
+ Content: The chatbot no longer makes promises about interplanetary logistics.
+
+$ notion-cli block update a2b3c4d5-... --color blue_background
+Block updated.
+ ID: a2b3c4d5-...
Type: paragraph
Content: The chatbot no longer makes promises about interplanetary logistics.
```
@@ -1042,10 +1131,7 @@ notion-cli page get
# 3. View a database's schema
notion-cli database get
-# 4. List entries in a database
-notion-cli database list
-
-# 5. Read an entry or child page
+# 4. Read an entry or child page
notion-cli page get
```
@@ -1058,7 +1144,6 @@ If you want to modify the CLI, use `npm run cli` during development to run from
```bash
npm run cli -- integration pages
npm run cli -- page get
-npm run cli -- database list
```
After making changes, rebuild with `npm run build` to update the linked `notion-cli` command.
@@ -1097,12 +1182,12 @@ Tests are split into independent files — each suite creates its own test resou
|-------|----------------|------------|
| `test:docs` | `docs` | prints guide content |
| `test:user` | `user me`, `user list`, `user get` | `--raw`, ID chaining |
-| `test:search` | `search` | default pages, `--filter database`, `--filter all`, with query, `--limit` |
-| `test:page` | `page get`, `page property`, `page update`, `page create`, `page archive`, `page move` | formatted + `--raw`, create/archive lifecycle, move between parents |
-| `test:block` | `block get`, `block children`, `block append`, `block update`, `block delete` | formatted + `--raw`, full CRUD lifecycle |
+| `test:search` | `search` | default pages, `--filter database`, `--filter all`, with query, `--limit`, `--direction` |
+| `test:page` | `page get`, `page property`, `page update`, `page create`, `page archive`, `page move` | formatted + `--raw`, create/archive lifecycle, move between parents, `--set` typed properties, `--icon` emoji, `--cover` external URL, icon/cover removal |
+| `test:block` | `block get`, `block children`, `block append`, `block update`, `block delete` | formatted + `--raw`, full CRUD lifecycle, `--type` (13 block types), `--color`, `--icon`, `--language`, `--checked`, `--json` (single, array, stdin), `block update --color`, error validation |
| `test:comment` | `comment add`, `comment list`, `comment get`, `comment reply` | formatted + `--raw`, thread verification |
-| `test:database` | `database create`, `database update`, `database get`, `database list` | `--raw`, data sources, full CRUD lifecycle |
-| `test:datasource` | `datasource get`, `datasource query`, `datasource update`, `datasource templates` | formatted + `--raw`, pagination |
+| `test:database` | `database create`, `database update`, `database get` | `--raw`, data sources, full CRUD lifecycle |
+| `test:datasource` | `datasource get`, `datasource query`, `datasource update`, `datasource templates` | formatted + `--raw`, pagination, `--add-property`, `--remove-property` |
| `test:file` | `file upload`, `file list`, `file get` | formatted + `--raw`, full upload lifecycle |
| `test:integration` | `integration pages` | formatted output |
| `test:auth` | `auth-internal` set/status/clear, `auth-public` status/login/introspect/revoke | config management (isolated HOME), OAuth API tests (env var gated) |
diff --git a/notion-cli/commands/block.ts b/notion-cli/commands/block.ts
index 1e29879..2959055 100644
--- a/notion-cli/commands/block.ts
+++ b/notion-cli/commands/block.ts
@@ -120,8 +120,11 @@ Examples:
for (const block of response.results) {
const formatted = formatBlock(block);
const children = block.has_children ? " [has children]" : "";
+ // Child pages and databases already include their ID in the formatted output
+ const isChildRef = block.type === "child_page" || block.type === "child_database";
+ const idSuffix = isChildRef ? "" : ` (ID: ${block.id})`;
if (formatted) {
- console.log(` ${formatted}${children}`);
+ console.log(` ${formatted}${idSuffix}${children}`);
} else {
console.log(` [${block.type}] (ID: ${block.id})${children}`);
}
@@ -139,38 +142,205 @@ Examples:
// -- block append -------------------------------------------------------------
+const APPEND_BLOCK_TYPES = [
+ "paragraph", "heading_1", "heading_2", "heading_3",
+ "callout", "quote", "divider", "code", "bookmark",
+ "to_do", "bulleted_list_item", "numbered_list_item",
+ "table_of_contents",
+] as const;
+
+type AppendBlockType = typeof APPEND_BLOCK_TYPES[number];
+
+const NOTION_COLORS = [
+ "default", "gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red",
+ "gray_background", "brown_background", "orange_background", "yellow_background",
+ "green_background", "blue_background", "purple_background", "pink_background", "red_background",
+] as const;
+
+/**
+ * Build a Notion block object from CLI flags.
+ */
+function buildBlock(
+ type: AppendBlockType,
+ text: string | undefined,
+ opts: { color?: string; icon?: string; language?: string; checked?: boolean },
+): Record {
+ const richText = text
+ ? [{ type: "text", text: { content: text } }]
+ : [];
+
+ const color = opts.color || "default";
+
+ switch (type) {
+ case "paragraph":
+ return { object: "block", type, paragraph: { rich_text: richText, color } };
+ case "heading_1":
+ return { object: "block", type, heading_1: { rich_text: richText, color } };
+ case "heading_2":
+ return { object: "block", type, heading_2: { rich_text: richText, color } };
+ case "heading_3":
+ return { object: "block", type, heading_3: { rich_text: richText, color } };
+ case "callout":
+ return {
+ object: "block", type,
+ callout: {
+ rich_text: richText,
+ color,
+ ...(opts.icon ? { icon: { type: "emoji", emoji: opts.icon } } : {}),
+ },
+ };
+ case "quote":
+ return { object: "block", type, quote: { rich_text: richText, color } };
+ case "divider":
+ return { object: "block", type, divider: {} };
+ case "code":
+ return {
+ object: "block", type,
+ code: { rich_text: richText, language: opts.language || "plain text" },
+ };
+ case "bookmark":
+ return {
+ object: "block", type,
+ bookmark: { url: text || "", caption: [] },
+ };
+ case "to_do":
+ return {
+ object: "block", type,
+ to_do: { rich_text: richText, checked: opts.checked ?? false, color },
+ };
+ case "bulleted_list_item":
+ return { object: "block", type, bulleted_list_item: { rich_text: richText, color } };
+ case "numbered_list_item":
+ return { object: "block", type, numbered_list_item: { rich_text: richText, color } };
+ case "table_of_contents":
+ return { object: "block", type, table_of_contents: { color } };
+ default:
+ return { object: "block", type: "paragraph", paragraph: { rich_text: richText, color } };
+ }
+}
+
+interface BlockAppendOptions {
+ raw?: boolean;
+ type?: string;
+ color?: string;
+ icon?: string;
+ language?: string;
+ checked?: boolean;
+ json?: string;
+}
+
const blockAppendCommand = new Command("append")
- .description("Append child blocks to a page or block")
+ .description("Append blocks to a page or block")
.argument("", "the ID of the parent page or block")
- .argument("", "text content to append as a paragraph block")
+ .argument("[text]", "text content for the block")
.option("-r, --raw", "output raw JSON instead of formatted text")
+ .option(
+ "-T, --type ",
+ `block type: ${APPEND_BLOCK_TYPES.join(", ")}`,
+ "paragraph",
+ )
+ .option(
+ "--color ",
+ `block color: ${NOTION_COLORS.join(", ")}`,
+ )
+ .option("--icon ", "emoji icon (callout blocks)")
+ .option("--language ", "code language (code blocks)", "plain text")
+ .option("--checked", "mark as checked (to_do blocks)")
+ .option(
+ "--json ",
+ "raw JSON block(s) to append — overrides text and --type; use - for stdin",
+ )
.addHelpText(
"after",
`
Details:
- Appends a paragraph block containing the given text to the parent.
- The parent can be a page ID or a block ID.
+ Appends one or more blocks to the parent. Default type is paragraph.
+
+ Use --type for headings, callouts, dividers, code, quotes, bookmarks,
+ lists, to-dos, and table of contents. Use --json for complex structures
+ (tables, toggles with children, columns, multi-block appends).
+
+ Types that need no text: divider, table_of_contents
+ Types where text is a URL: bookmark
- For more complex block structures, use the generated client directly.
+Block types:
+ paragraph, heading_1, heading_2, heading_3, callout, quote, divider,
+ code, bookmark, to_do, bulleted_list_item, numbered_list_item,
+ table_of_contents
+
+Colors:
+ Text: default, gray, brown, orange, yellow, green, blue, purple, pink, red
+ Background: gray_background, brown_background, orange_background, ...
Examples:
- $ notion-cli block append "Hello, world!"
- $ notion-cli block append "Nested content" --raw
+ $ notion-cli block append "Hello, world!"
+ $ notion-cli block append "Important heading" --type heading_1 --color purple
+ $ notion-cli block append "Note this!" --type callout --icon 🎸 --color blue_background
+ $ notion-cli block append --type divider
+ $ notion-cli block append "console.log('hi')" --type code --language javascript
+ $ notion-cli block append "https://example.com" --type bookmark
+ $ notion-cli block append "Buy milk" --type to_do
+ $ notion-cli block append "Done already" --type to_do --checked
+ $ notion-cli block append --type table_of_contents --color gray_background
+ $ notion-cli block append --json '[{"type":"divider","divider":{}}]'
+ $ cat blocks.json | notion-cli block append --json -
`,
)
- .action(async (parentId: string, text: string, options: { raw?: boolean }) => {
+ .action(async (parentId: string, text: string | undefined, options: BlockAppendOptions) => {
const bearerToken = getBearerToken();
const notion = createNotionClient(bearerToken);
- const children = [
- {
- object: "block",
- type: "paragraph",
- paragraph: {
- rich_text: [{ type: "text", text: { content: text } }],
- },
- },
- ];
+ let children: unknown[];
+
+ if (options.json) {
+ // JSON mode: parse from argument or stdin
+ let jsonStr = options.json;
+ if (jsonStr === "-") {
+ // Read from stdin
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(chunk as Buffer);
+ }
+ jsonStr = Buffer.concat(chunks).toString("utf-8");
+ }
+
+ try {
+ const parsed = JSON.parse(jsonStr);
+ children = Array.isArray(parsed) ? parsed : [parsed];
+ } catch {
+ console.error("Error: --json value is not valid JSON.");
+ process.exit(1);
+ }
+ } else {
+ // Flag-based mode
+ const blockType = (options.type || "paragraph") as AppendBlockType;
+
+ if (!APPEND_BLOCK_TYPES.includes(blockType)) {
+ console.error(`Error: unsupported block type "${options.type}".`);
+ console.error(`Supported: ${APPEND_BLOCK_TYPES.join(", ")}`);
+ process.exit(1);
+ }
+
+ if (options.color && !NOTION_COLORS.includes(options.color as typeof NOTION_COLORS[number])) {
+ console.error(`Error: unsupported color "${options.color}".`);
+ console.error(`Supported: ${NOTION_COLORS.join(", ")}`);
+ process.exit(1);
+ }
+
+ // Validate text presence
+ const noTextTypes: AppendBlockType[] = ["divider", "table_of_contents"];
+ if (!text && !noTextTypes.includes(blockType)) {
+ console.error(`Error: text argument is required for block type "${blockType}".`);
+ process.exit(1);
+ }
+
+ children = [buildBlock(blockType, text, {
+ color: options.color,
+ icon: options.icon,
+ language: options.language,
+ checked: options.checked,
+ })];
+ }
try {
const response = await notion.blocks.appendChildren(parentId, children);
@@ -198,29 +368,52 @@ Examples:
// -- block update -------------------------------------------------------------
const blockUpdateCommand = new Command("update")
- .description("Update a block's text content")
+ .description("Update a block's text content and/or color")
.argument("", "the ID of the block to update")
- .argument("", "new text content for the block")
+ .argument("[text]", "new text content for the block")
.option("-r, --raw", "output raw JSON instead of formatted text")
+ .option(
+ "--color ",
+ `block color: ${NOTION_COLORS.join(", ")}`,
+ )
.addHelpText(
"after",
`
Details:
Updates a paragraph, heading, bulleted list item, numbered list item,
- to-do, toggle, callout, or quote block with new text content.
+ to-do, toggle, callout, or quote block with new text and/or color.
First retrieves the block to determine its type, then sends the
update with the correct type key.
+ You can update text only, color only, or both at once.
+
+Colors:
+ Text: default, gray, brown, orange, yellow, green, blue, purple, pink, red
+ Background: gray_background, brown_background, orange_background, ...
+
Examples:
$ notion-cli block update "Updated text"
+ $ notion-cli block update --color red_background
+ $ notion-cli block update "Updated text" --color purple
$ notion-cli block update "Updated text" --raw
`,
)
- .action(async (blockId: string, text: string, options: { raw?: boolean }) => {
+ .action(async (blockId: string, text: string | undefined, options: { raw?: boolean; color?: string }) => {
const bearerToken = getBearerToken();
const notion = createNotionClient(bearerToken);
+ if (!text && !options.color) {
+ console.error("Error: provide text, --color, or both.");
+ process.exit(1);
+ }
+
+ if (options.color && !NOTION_COLORS.includes(options.color as typeof NOTION_COLORS[number])) {
+ console.error(`Error: unsupported color "${options.color}".`);
+ console.error(`Supported: ${NOTION_COLORS.join(", ")}`);
+ process.exit(1);
+ }
+
try {
// First, retrieve the block to get its type
const existing = await notion.blocks.retrieve(blockId);
@@ -234,14 +427,26 @@ Examples:
];
if (!richTextTypes.includes(blockType)) {
- console.error(`Error: block type "${blockType}" does not support text updates via this command.`);
+ console.error(`Error: block type "${blockType}" does not support text/color updates via this command.`);
process.exit(1);
}
+ const updatePayload: Record = {};
+ if (text !== undefined) {
+ updatePayload.rich_text = [{ type: "text", text: { content: text } }];
+ } else {
+ // Preserve existing rich_text — the API requires it even for color-only updates
+ const existingData = (existing as Record)[blockType];
+ if (existingData?.rich_text) {
+ updatePayload.rich_text = existingData.rich_text;
+ }
+ }
+ if (options.color) {
+ updatePayload.color = options.color;
+ }
+
const params: Record = {
- [blockType]: {
- rich_text: [{ type: "text", text: { content: text } }],
- },
+ [blockType]: updatePayload,
};
const block = await notion.blocks.update(blockId, params);
@@ -308,7 +513,7 @@ Examples:
// -- block command group ------------------------------------------------------
export const blockCommand = new Command("block")
- .description("Read and inspect Notion blocks")
+ .description("Read and manage Notion blocks")
.addCommand(blockGetCommand)
.addCommand(blockChildrenCommand)
.addCommand(blockAppendCommand)
diff --git a/notion-cli/commands/database.ts b/notion-cli/commands/database.ts
index 5379411..9769e29 100644
--- a/notion-cli/commands/database.ts
+++ b/notion-cli/commands/database.ts
@@ -1,12 +1,13 @@
/**
* database command group
- * database get — metadata and schema
- * database list — paginated entries
+ * database get — metadata and schema
+ * database create — create a database
+ * database update — update title/description
*/
import { Command } from "commander";
import { createNotionClient, type DatabasePropertySchema } from "../src/postman/notion-api/index.js";
-import { getBearerToken, getPageTitle, formatDate, formatPropertyValue } from "../helpers.js";
+import { getBearerToken, formatDate } from "../helpers.js";
// -- database get -------------------------------------------------------------
@@ -20,10 +21,11 @@ const databaseGetCommand = new Command("get")
Details:
Fetches a single database and displays:
• Metadata – title, ID, parent, created/edited dates, URL
- • Schema – property names and their types
+ • Data sources – IDs needed to query entries
+ • Schema – property names and types (when available on the database object)
- To list the entries in this database, use:
- $ notion-cli database list
+ To query entries, use the data source ID from the output:
+ $ notion-cli datasource query
Examples:
$ notion-cli database get 725a78f3-00bf-4dde-b207-d04530545c45
@@ -80,6 +82,9 @@ Examples:
}
}
+ // Show schema if present on the database object
+ // Note: the 2025-09-03 API moves schema to data sources — use
+ // "datasource get " to see the full schema when this section is empty.
const propEntries = Object.entries(database.properties || {});
if (propEntries.length > 0) {
console.log(`\nSchema (${propEntries.length} properties):`);
@@ -89,111 +94,12 @@ Examples:
}
}
- // Suggest using data source ID for queries if available
+ // Point users to datasource commands for querying entries
if (database.data_sources && database.data_sources.length > 0) {
- console.log(`\nTo list entries: notion-cli database list ${databaseId}`);
- console.log(` (uses data source ID: ${database.data_sources[0].id})`);
- } else {
- console.log(`\nTo list entries: notion-cli database list ${databaseId}`);
- }
- } catch (error) {
- console.error(`Error: ${error instanceof Error ? error.message : error}`);
- process.exit(1);
- }
- });
-
-// -- database list ------------------------------------------------------------
-
-const databaseListCommand = new Command("list")
- .description("List entries in a database")
- .argument("", "Notion database ID")
- .option("-r, --raw", "output raw JSON instead of formatted text")
- .option("-n, --limit ", "max entries to return, 1-100", "20")
- .option("-c, --cursor ", "pagination cursor from a previous query")
- .addHelpText(
- "after",
- `
-Details:
- Lists entries (rows) in a Notion database, with pagination.
-
- Each entry is a Notion page. To read an entry's full content
- and properties, use:
- $ notion-cli page get
-
- To view the database schema, use:
- $ notion-cli database get
-
-Examples:
- $ notion-cli database list 725a78f3-00bf-4dde-b207-d04530545c45
- $ notion-cli database list 725a78f3-00bf-4dde-b207-d04530545c45 --limit 50
- $ notion-cli database list 725a78f3-00bf-4dde-b207-d04530545c45 --raw
-`,
- )
- .action(async (databaseId: string, options: { raw?: boolean; limit: string; cursor?: string }) => {
- const bearerToken = getBearerToken();
- const notion = createNotionClient(bearerToken);
- const pageSize = Math.min(parseInt(options.limit, 10) || 20, 100);
-
- console.log(`🗃️ Listing database entries...\n`);
-
- try {
- // Two-step flow: retrieve database to get data source ID, then query the data source
- const database = await notion.databases.retrieve(databaseId);
- const dataSourceId = database.data_sources?.[0]?.id;
-
- let queryResponse;
- if (dataSourceId) {
- queryResponse = await notion.dataSources.query(dataSourceId, {
- page_size: pageSize,
- start_cursor: options.cursor,
- });
- } else {
- // Fallback to legacy database query if no data sources available
- queryResponse = await notion.databases.query(databaseId, {
- page_size: pageSize,
- start_cursor: options.cursor,
- });
- }
-
- if (options.raw) {
- console.log(JSON.stringify(queryResponse, null, 2));
- return;
- }
-
- if (queryResponse.results.length === 0) {
- console.log("No entries found.");
- return;
+ const dsId = database.data_sources[0].id;
+ console.log(`\nTo query entries: notion-cli datasource query ${dsId}`);
+ console.log(`To view schema: notion-cli datasource get ${dsId}`);
}
-
- console.log(`Entries (${queryResponse.results.length}${queryResponse.has_more ? "+" : ""}):\n`);
- console.log("─".repeat(60));
-
- for (const page of queryResponse.results) {
- const pageTitle = getPageTitle(page);
- const lastEdited = formatDate(page.last_edited_time);
-
- console.log(` 📄 ${pageTitle}`);
- console.log(` ID: ${page.id}`);
- console.log(` Last edited: ${lastEdited}`);
- console.log(` URL: ${page.url}`);
-
- for (const [name, prop] of Object.entries(page.properties)) {
- if (prop.type === "title") continue;
- const value = formatPropertyValue(prop);
- console.log(` ${name}: ${value}`);
- }
-
- console.log();
- }
-
- console.log("─".repeat(60));
-
- if (queryResponse.has_more && queryResponse.next_cursor) {
- console.log(`\n📑 More entries available. Next page:`);
- console.log(` notion-cli database list ${databaseId} --cursor ${queryResponse.next_cursor}`);
- }
-
- console.log(`\nTo read an entry: notion-cli page get `);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
process.exit(1);
@@ -264,6 +170,9 @@ Details:
Updates a database's title and/or description.
Only the specified fields are updated; others remain unchanged.
+ To add or remove schema properties, use datasource update:
+ $ notion-cli datasource update --add-property "Name:type"
+
Examples:
$ notion-cli database update --title "New Title"
$ notion-cli database update --description "Updated description"
@@ -309,8 +218,7 @@ Examples:
// -- database command group ---------------------------------------------------
export const databaseCommand = new Command("database")
- .description("View and query Notion databases")
+ .description("View and manage Notion databases")
.addCommand(databaseGetCommand)
- .addCommand(databaseListCommand)
.addCommand(databaseCreateCommand)
.addCommand(databaseUpdateCommand);
diff --git a/notion-cli/commands/datasource.ts b/notion-cli/commands/datasource.ts
index 6d64315..efc3647 100644
--- a/notion-cli/commands/datasource.ts
+++ b/notion-cli/commands/datasource.ts
@@ -4,7 +4,7 @@
* datasource query — query entries from a data source
* datasource templates — list available templates
* datasource create — create a data source in a database
- * datasource update — update a data source
+ * datasource update — update title, schema, or properties
*/
import { Command } from "commander";
@@ -273,24 +273,90 @@ Examples:
// -- datasource update --------------------------------------------------------
+/** Property types that can be added via --add-property */
+const VALID_PROPERTY_TYPES = [
+ "rich_text", "number", "select", "multi_select", "date",
+ "checkbox", "url", "email", "phone_number", "status",
+ "people", "files", "created_time", "created_by",
+ "last_edited_time", "last_edited_by",
+];
+
+/**
+ * Parse a "Name:type" string into a Notion property schema object.
+ * For select/multi_select, accepts "Name:select:Option1,Option2,..." to pre-populate options.
+ */
+function parsePropertySpec(spec: string): { name: string; schema: Record } {
+ const parts = spec.split(":");
+ if (parts.length < 2) {
+ console.error(`Error: invalid property format "${spec}". Expected "Name:type" (e.g. "Artist:rich_text").`);
+ process.exit(1);
+ }
+
+ const name = parts[0].trim();
+ const type = parts[1].trim().toLowerCase();
+
+ if (!name) {
+ console.error(`Error: property name cannot be empty in "${spec}".`);
+ process.exit(1);
+ }
+
+ if (!VALID_PROPERTY_TYPES.includes(type)) {
+ console.error(`Error: unknown property type "${type}" in "${spec}".`);
+ console.error(`Valid types: ${VALID_PROPERTY_TYPES.join(", ")}`);
+ process.exit(1);
+ }
+
+ // For select/multi_select, support optional options after a third colon
+ if ((type === "select" || type === "multi_select") && parts.length >= 3) {
+ const optionNames = parts.slice(2).join(":").split(",").map((o) => o.trim()).filter(Boolean);
+ if (optionNames.length > 0) {
+ return {
+ name,
+ schema: { [type]: { options: optionNames.map((o) => ({ name: o })) } },
+ };
+ }
+ }
+
+ return { name, schema: { [type]: {} } };
+}
+
const datasourceUpdateCommand = new Command("update")
- .description("Update a data source")
+ .description("Update a data source's title, schema, or properties")
.argument("", "the ID of the data source to update")
.option("-t, --title ", "set a new title")
+ .option("-p, --add-property ", 'add properties — format: "Name:type" (repeatable)')
+ .option("--remove-property ", "remove properties by name (repeatable)")
.option("-r, --raw", "output raw JSON instead of formatted text")
.addHelpText(
"after",
`
Details:
- Updates a data source's title. Only the specified fields are
- updated; others remain unchanged.
+ Updates a data source's title and/or schema properties.
+ Only the specified fields are updated; others remain unchanged.
+
+ --add-property accepts "Name:type" where type is one of:
+ rich_text, number, select, multi_select, date, checkbox,
+ url, email, phone_number, status, people, files,
+ created_time, created_by, last_edited_time, last_edited_by
+
+ For select/multi_select, pre-populate options with "Name:select:Opt1,Opt2":
+ --add-property "Genre:select:Lo-fi,Shoegaze,Post-rock"
+
+ --remove-property removes a column by name (sets it to null).
Examples:
- $ notion-cli datasource update --title "New Title"
- $ notion-cli datasource update --title "New Title" --raw
+ $ notion-cli datasource update --title "New Title"
+ $ notion-cli datasource update -p "Artist:rich_text" -p "Year:number"
+ $ notion-cli datasource update -p "Genre:select:Rock,Pop,Jazz"
+ $ notion-cli datasource update --remove-property "Old Column"
`,
)
- .action(async (datasourceId: string, options: { title?: string; raw?: boolean }) => {
+ .action(async (datasourceId: string, options: {
+ title?: string;
+ addProperty?: string[];
+ removeProperty?: string[];
+ raw?: boolean;
+ }) => {
const bearerToken = getBearerToken();
const notion = createNotionClient(bearerToken);
@@ -299,8 +365,25 @@ Examples:
params.title = [{ text: { content: options.title } }];
}
+ // Build properties object from --add-property and --remove-property
+ const properties: Record = {};
+ if (options.addProperty) {
+ for (const spec of options.addProperty) {
+ const { name, schema } = parsePropertySpec(spec);
+ properties[name] = schema;
+ }
+ }
+ if (options.removeProperty) {
+ for (const name of options.removeProperty) {
+ properties[name] = null;
+ }
+ }
+ if (Object.keys(properties).length > 0) {
+ params.properties = properties;
+ }
+
if (Object.keys(params).length === 0) {
- console.error("Error: nothing to update. Use --title to set a new title.");
+ console.error("Error: nothing to update. Use --title, --add-property, or --remove-property.");
process.exit(1);
}
@@ -316,6 +399,16 @@ Examples:
console.log(`Data source updated.`);
console.log(` Title: ${title}`);
console.log(` ID: ${ds.id}`);
+
+ // Show updated schema summary
+ const propEntries = Object.entries(ds.properties || {});
+ if (propEntries.length > 0) {
+ console.log(` Schema (${propEntries.length} properties):`);
+ for (const [pName, prop] of propEntries) {
+ const schema = prop as DatabasePropertySchema;
+ console.log(` ${pName}: ${schema.type}`);
+ }
+ }
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
process.exit(1);
diff --git a/notion-cli/commands/docs.ts b/notion-cli/commands/docs.ts
index 048311b..7ed0e1f 100644
--- a/notion-cli/commands/docs.ts
+++ b/notion-cli/commands/docs.ts
@@ -8,24 +8,35 @@ const DOCS = `
NOTION CLI — DOCUMENTATION
===========================
-SETUP
------
+AUTHENTICATION
+--------------
-1. Create a Notion integration at https://www.notion.so/my-integrations
-2. Save your token: notion-cli set-token
-3. Share pages/databases with your integration in Notion
+Two auth methods are supported:
-Your integration can only access pages that have been explicitly shared
-with it. Share a top-level page to give access to all its children.
+ Internal integration (recommended for personal use):
+ 1. Create an integration at https://www.notion.so/my-integrations
+ 2. Choose "Internal", enable "Read content" capability
+ 3. Copy the "Internal Integration Secret"
+ 4. Run: notion-cli auth-internal set
-The token is stored in ~/.notion-cli/config.json. You can also set the
-NOTION_TOKEN environment variable, which takes precedence.
+ Public integration (OAuth — for multi-user apps):
+ 1. Create a "Public" integration at https://www.notion.so/my-integrations
+ 2. Set redirect URI to http://localhost:8787/callback
+ 3. Run: notion-cli auth-public setup (saves client ID + secret)
+ 4. Run: notion-cli auth-public login (opens browser for authorization)
+
+ After authenticating, share pages with your integration in Notion:
+ Open a page → ••• menu → Add connections → select your integration.
+ Share a top-level page to give access to all its children.
+
+ Token is stored in ~/.notion-cli/config.json. The NOTION_TOKEN
+ environment variable takes precedence over the stored token.
MAPPING A WORKSPACE
-------------------
-The Notion API doesn't have a "list all pages" endpoint. To build a
-complete picture of a workspace, start from the roots and traverse down:
+The Notion API doesn't have a "list all pages" endpoint. To map a
+workspace, start from the roots and traverse down:
Step 1 — Find root pages:
$ notion-cli integration pages
@@ -34,9 +45,10 @@ complete picture of a workspace, start from the roots and traverse down:
$ notion-cli page get
Look for child pages (📄) and child databases (🗃️) in the output.
- Step 3 — For each child database, view its schema then list entries:
+ Step 3 — For each child database, view its schema then query entries:
$ notion-cli database get
- $ notion-cli database list
+ Use the data source ID from the output:
+ $ notion-cli datasource query
Step 4 — Read child pages and database entries:
$ notion-cli page get
@@ -46,30 +58,62 @@ complete picture of a workspace, start from the roots and traverse down:
Key points:
• Start from roots — integration pages finds all entry points
• One page at a time — page get shows children but doesn't recurse into them
- • Databases have two views — schema (get) and entries (list)
+ • Databases have two layers — database (metadata) and data source (schema + entries)
• IDs are in the output — every child shows its ID for navigation
+WRITING CONTENT
+---------------
+
+ Create pages: page create --title "My Page"
+ Update pages: page update --title "New" -s "Status:select:Done"
+ Append blocks: block append "text" --type heading_2
+ Update blocks: block update "new text" --color blue
+ Delete blocks: block delete
+ Add comments: comment add "comment text"
+ Reply to threads: comment reply "reply text"
+ Upload files: file upload ./file.pdf
+ Move pages: page move --parent
+ Archive pages: page archive
+ Create databases: database create --title "Tracker"
+ Manage schema: datasource update -p "Column:type"
+
+ page update --set accepts "Name:type:value" properties:
+ rich_text, number, select, multi_select, date, checkbox,
+ url, email, phone_number
+
+ block append --type supports:
+ paragraph, heading_1, heading_2, heading_3, callout, quote, divider,
+ code, bookmark, to_do, bulleted_list_item, numbered_list_item,
+ table_of_contents
+
+ Use --json for complex structures (tables, toggles with children, columns).
+
+COMMENTS — LIMITATIONS
+----------------------
+
+ • The Notion API only returns unresolved comments
+ • comment add creates top-level page comments only
+ • comment reply can respond to existing threads (including inline)
+ • "Read comments" and "Insert comments" capabilities must be enabled
+ in the integration dashboard — they are off by default
+
OUTPUT & PERFORMANCE
--------------------
-All commands output human-readable text by default. Use --raw on most
-commands for JSON output (useful for piping or programmatic access).
+All commands output human-readable, token-efficient text by default.
+Use --raw (-r) on most commands for full JSON output — only use this
+when you need fields not shown in the default view.
+
+When more results are available, pagination hints appear in the output
+with the --cursor value for the next page.
+
+Run "notion-cli --help" for detailed usage and examples on
+any subcommand.
• Block fetching uses parallel API calls for speed
• Large pages (100+ blocks) may take 5–15 seconds
- • Database list queries are fast (single API call)
+ • Data source queries are fast (single API call)
• integration pages paginates internally (may take a few seconds)
-
-AGENT USAGE
------------
-
-This CLI is designed to work well with AI coding agents. An agent can:
-
- 1. Run "notion-cli docs" to understand available workflows
- 2. Use "notion-cli integration pages" to discover entry points
- 3. Navigate the workspace tree using page get and database list
- 4. Use search to find specific content by keyword
- 5. Use --raw for structured JSON when parsing output programmatically
`.trimStart();
export const docsCommand = new Command("docs")
diff --git a/notion-cli/commands/integration.ts b/notion-cli/commands/integration.ts
index 74b0be0..2be61a9 100644
--- a/notion-cli/commands/integration.ts
+++ b/notion-cli/commands/integration.ts
@@ -33,7 +33,7 @@ Details:
This is the recommended starting point for mapping content. From
these roots, use "page get" to discover child pages and databases,
- then traverse the tree with page get / database get / database list.
+ then traverse the tree with page get / database get / datasource query.
Examples:
$ notion-cli integration pages
diff --git a/notion-cli/commands/page.ts b/notion-cli/commands/page.ts
index 88f9690..eea5016 100644
--- a/notion-cli/commands/page.ts
+++ b/notion-cli/commands/page.ts
@@ -27,8 +27,8 @@ Details:
the single page. Use the listed IDs to fetch children separately.
For child databases, use:
- $ notion-cli database get (view schema)
- $ notion-cli database list (list entries)
+ $ notion-cli database get (view metadata + data source IDs)
+ $ notion-cli datasource query (list entries)
Examples:
$ notion-cli page get 35754014-c743-4bb5-aa0a-721f51256861
@@ -39,47 +39,10 @@ Examples:
const bearerToken = getBearerToken();
const notion = createNotionClient(bearerToken);
- console.log(`📄 Fetching page...\n`);
-
try {
// First fetch page metadata
const page = await notion.pages.retrieve(pageId);
- // Show page info
- const title = getPageTitle(page);
- const parent = page.parent;
- let parentInfo: string;
- if (parent.type === "database_id") {
- parentInfo = `database (ID: ${parent.database_id})`;
- } else if (parent.type === "data_source_id") {
- parentInfo = `data source (ID: ${parent.data_source_id})`;
- } else if (parent.type === "page_id") {
- parentInfo = `page (ID: ${parent.page_id})`;
- } else {
- parentInfo = "workspace (top-level)";
- }
-
- console.log(`Title: ${title}`);
- console.log(`ID: ${page.id}`);
- console.log(`Parent: ${parentInfo}`);
- console.log(`Created: ${formatDate(page.created_time)}`);
- console.log(`Last edited: ${formatDate(page.last_edited_time)}`);
- console.log(`Archived: ${page.archived}`);
- if (page.in_trash) {
- console.log(`In trash: ${page.in_trash}`);
- }
- console.log(`URL: ${page.url}`);
-
- // Show properties (useful for database items)
- const propEntries = Object.entries(page.properties);
- if (propEntries.length > 0) {
- console.log(`\nProperties (${propEntries.length}):`);
- for (const [name, prop] of propEntries) {
- const value = formatPropertyValue(prop);
- console.log(` ${name}: ${value}`);
- }
- }
-
// Fetch blocks with parallel recursion for speed
const allBlocks: NotionBlock[] = [];
@@ -122,13 +85,51 @@ Examples:
await fetchBlocksRecursive(pageId);
- if (allBlocks.length === 0) {
- console.log("\nContent: (no blocks)");
+ // Raw mode: output page + blocks as a single JSON object
+ if (options.raw) {
+ console.log(JSON.stringify({ page, blocks: allBlocks }, null, 2));
return;
}
- if (options.raw) {
- console.log(JSON.stringify(allBlocks, null, 2));
+ // Formatted output
+ console.log(`📄 Fetching page...\n`);
+
+ const title = getPageTitle(page);
+ const parent = page.parent;
+ let parentInfo: string;
+ if (parent.type === "database_id") {
+ parentInfo = `database (ID: ${parent.database_id})`;
+ } else if (parent.type === "data_source_id") {
+ parentInfo = `data source (ID: ${parent.data_source_id})`;
+ } else if (parent.type === "page_id") {
+ parentInfo = `page (ID: ${parent.page_id})`;
+ } else {
+ parentInfo = "workspace (top-level)";
+ }
+
+ console.log(`Title: ${title}`);
+ console.log(`ID: ${page.id}`);
+ console.log(`Parent: ${parentInfo}`);
+ console.log(`Created: ${formatDate(page.created_time)}`);
+ console.log(`Last edited: ${formatDate(page.last_edited_time)}`);
+ console.log(`Archived: ${page.archived}`);
+ if (page.in_trash) {
+ console.log(`In trash: ${page.in_trash}`);
+ }
+ console.log(`URL: ${page.url}`);
+
+ // Show properties (useful for database items)
+ const propEntries = Object.entries(page.properties);
+ if (propEntries.length > 0) {
+ console.log(`\nProperties (${propEntries.length}):`);
+ for (const [name, prop] of propEntries) {
+ const value = formatPropertyValue(prop);
+ console.log(` ${name}: ${value}`);
+ }
+ }
+
+ if (allBlocks.length === 0) {
+ console.log("\nContent: (no blocks)");
return;
}
@@ -323,25 +324,91 @@ Examples:
// -- page update --------------------------------------------------------------
+/**
+ * Parse a "Name:type:value" property-set spec into a Notion property value object.
+ * Supported types: rich_text, number, select, multi_select, date, checkbox, url, email, phone_number
+ */
+function parsePropertyValue(spec: string): { name: string; value: unknown } {
+ const colonIdx = spec.indexOf(":");
+ if (colonIdx === -1) {
+ console.error(`Error: invalid property format "${spec}". Expected "Name:type:value".`);
+ process.exit(1);
+ }
+
+ const name = spec.slice(0, colonIdx).trim();
+ const rest = spec.slice(colonIdx + 1);
+ const secondColon = rest.indexOf(":");
+ if (secondColon === -1) {
+ console.error(`Error: invalid property format "${spec}". Expected "Name:type:value" (e.g. "Artist:rich_text:Radiohead").`);
+ process.exit(1);
+ }
+
+ const type = rest.slice(0, secondColon).trim().toLowerCase();
+ const rawValue = rest.slice(secondColon + 1);
+
+ switch (type) {
+ case "rich_text":
+ return { name, value: { rich_text: [{ text: { content: rawValue } }] } };
+ case "number":
+ return { name, value: { number: Number(rawValue) } };
+ case "select":
+ return { name, value: { select: { name: rawValue } } };
+ case "multi_select":
+ return { name, value: { multi_select: rawValue.split(",").map((v) => ({ name: v.trim() })) } };
+ case "date":
+ return { name, value: { date: { start: rawValue } } };
+ case "checkbox":
+ return { name, value: { checkbox: rawValue.toLowerCase() === "true" } };
+ case "url":
+ return { name, value: { url: rawValue } };
+ case "email":
+ return { name, value: { email: rawValue } };
+ case "phone_number":
+ return { name, value: { phone_number: rawValue } };
+ default:
+ console.error(`Error: unsupported property type "${type}" in "${spec}".`);
+ console.error(`Supported: rich_text, number, select, multi_select, date, checkbox, url, email, phone_number`);
+ process.exit(1);
+ }
+}
+
const pageUpdateCommand = new Command("update")
- .description("Update a page's properties")
+ .description("Update a page's properties, icon, or cover")
.argument("", "the ID of the page to update")
.option("-t, --title ", "set a new title")
+ .option("-s, --set ", 'set property values — format: "Name:type:value" (repeatable)')
+ .option("--icon ", "set page icon (emoji)")
+ .option("--cover ", "set page cover image (external URL)")
.option("-r, --raw", "output raw JSON instead of formatted text")
.addHelpText(
"after",
`
Details:
- Updates properties on a page. Currently supports setting the title
- via --title. For more complex property updates, use the generated
- client directly.
+ Updates properties, icon, or cover on a page. Use --title for the
+ title property, --set for other properties, --icon for the page emoji,
+ and --cover for the cover image.
+
+ --set accepts "Name:type:value" where type is one of:
+ rich_text, number, select, multi_select, date,
+ checkbox, url, email, phone_number
+
+ For multi_select, separate values with commas:
+ --set "Tags:multi_select:Rock,Indie,90s"
+
+ --icon accepts a single emoji character. To remove, pass "none".
+ --cover accepts an external image URL. To remove, pass "none".
Examples:
$ notion-cli page update --title "New Title"
- $ notion-cli page update --title "New Title" --raw
+ $ notion-cli page update --set "Artist:rich_text:Radiohead"
+ $ notion-cli page update -s "Artist:rich_text:Radiohead" -s "Year:number:1997"
+ $ notion-cli page update --icon 🎸
+ $ notion-cli page update --cover "https://images.unsplash.com/photo-123"
+ $ notion-cli page update --icon 📚 --cover "https://images.unsplash.com/photo-456"
+ $ notion-cli page update --icon none # remove icon
`,
)
- .action(async (pageId: string, options: { title?: string; raw?: boolean }) => {
+ .action(async (pageId: string, options: { title?: string; set?: string[]; icon?: string; cover?: string; raw?: boolean }) => {
const bearerToken = getBearerToken();
const notion = createNotionClient(bearerToken);
@@ -349,14 +416,45 @@ Examples:
if (options.title) {
properties.title = [{ text: { content: options.title } }];
}
+ if (options.set) {
+ for (const spec of options.set) {
+ const { name, value } = parsePropertyValue(spec);
+ properties[name] = value;
+ }
+ }
+
+ // Build the update params
+ const params: Record = {};
+
+ if (Object.keys(properties).length > 0) {
+ params.properties = properties;
+ }
+
+ // Icon: emoji or null (to remove)
+ if (options.icon) {
+ if (options.icon.toLowerCase() === "none") {
+ params.icon = null;
+ } else {
+ params.icon = { type: "emoji", emoji: options.icon };
+ }
+ }
- if (Object.keys(properties).length === 0) {
- console.error("Error: nothing to update. Use --title to set a new title.");
+ // Cover: external URL or null (to remove)
+ if (options.cover) {
+ if (options.cover.toLowerCase() === "none") {
+ params.cover = null;
+ } else {
+ params.cover = { type: "external", external: { url: options.cover } };
+ }
+ }
+
+ if (Object.keys(params).length === 0) {
+ console.error("Error: nothing to update. Use --title, --set, --icon, or --cover.");
process.exit(1);
}
try {
- const page = await notion.pages.update(pageId, { properties });
+ const page = await notion.pages.update(pageId, params);
if (options.raw) {
console.log(JSON.stringify(page, null, 2));
@@ -367,6 +465,14 @@ Examples:
console.log(`Page updated.`);
console.log(` Title: ${title}`);
console.log(` ID: ${page.id}`);
+ if (options.icon) {
+ const iconDisplay = options.icon.toLowerCase() === "none" ? "(removed)" : options.icon;
+ console.log(` Icon: ${iconDisplay}`);
+ }
+ if (options.cover) {
+ const coverDisplay = options.cover.toLowerCase() === "none" ? "(removed)" : options.cover;
+ console.log(` Cover: ${coverDisplay}`);
+ }
console.log(` URL: ${page.url}`);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
diff --git a/notion-cli/commands/search.ts b/notion-cli/commands/search.ts
index 4e3f422..d7f06b7 100644
--- a/notion-cli/commands/search.ts
+++ b/notion-cli/commands/search.ts
@@ -22,6 +22,15 @@ function normalizeFilterOption(input: string | undefined): SearchFilterOption {
process.exit(1);
}
+function normalizeDirectionOption(input: string | undefined): "ascending" | "descending" | undefined {
+ if (!input) return undefined;
+ const v = input.trim().toLowerCase();
+ if (v === "ascending" || v === "asc") return "ascending";
+ if (v === "descending" || v === "desc") return "descending";
+ console.error(`Error: invalid --direction value "${input}". Expected: ascending (asc) or descending (desc).`);
+ process.exit(1);
+}
+
function isNotionPage(result: NotionPage | NotionDatabase): result is NotionPage {
return result.object === "page";
}
@@ -30,12 +39,33 @@ function isNotionDatabase(result: NotionPage | NotionDatabase): result is Notion
return result.object === "database";
}
+/** Data source objects are returned when searching with filter: data_source (2025-09-03 API) */
+interface DataSourceResult {
+ object: "data_source";
+ id: string;
+ title?: Array<{ plain_text: string }>;
+ parent?: { type: string; database_id?: string };
+ database_parent?: { type: string; database_id?: string };
+ created_time: string;
+ last_edited_time: string;
+ archived?: boolean;
+ url?: string;
+ properties?: Record;
+}
+
+function isDataSource(result: unknown): result is DataSourceResult {
+ return (result as Record)?.object === "data_source";
+}
+
export const searchCommand = new Command("search")
.description("Search for pages and databases")
.argument("[query]", "text to search for (omit to list all results)")
.option("-c, --cursor ", "pagination cursor from a previous search")
.option("-n, --limit ", "max results per page, 1-100", "20")
.option("-f, --filter