diff --git a/.github/workflows/notion-cli.yml b/.github/workflows/notion-cli.yml
new file mode 100644
index 0000000..aa0a243
--- /dev/null
+++ b/.github/workflows/notion-cli.yml
@@ -0,0 +1,37 @@
+name: notion-cli
+
+on:
+ push:
+ paths:
+ - "notion-cli/**"
+ pull_request:
+ paths:
+ - "notion-cli/**"
+ workflow_dispatch: # allow manual runs
+
+defaults:
+ run:
+ working-directory: notion-cli
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: notion-cli/package-lock.json
+
+ - run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ - name: Integration tests
+ env:
+ NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
+ run: npm test
diff --git a/notion-cli/AGENTS.md b/notion-cli/AGENTS.md
new file mode 100644
index 0000000..fc8fde2
--- /dev/null
+++ b/notion-cli/AGENTS.md
@@ -0,0 +1,7 @@
+# Agent Guidelines for notion-cli
+
+## Notion API Version
+
+Always use `2025-09-03` when making direct Notion API calls. This is the version the CLI uses (defined in `src/postman/notion-api/shared/variables.ts`). When writing scripts, tests, or debugging with `fetch()`, use `Notion-Version: 2025-09-03` — not the older `2022-06-28`.
+
+Before making changes, verify the version in `src/postman/notion-api/shared/variables.ts` matches `2025-09-03`.
diff --git a/notion-cli/KNOWN-ISSUES.md b/notion-cli/KNOWN-ISSUES.md
new file mode 100644
index 0000000..7d9b406
--- /dev/null
+++ b/notion-cli/KNOWN-ISSUES.md
@@ -0,0 +1,44 @@
+# Known Issues
+
+## OAuth tokens cannot read comments
+
+**Status:** Unresolved — reported 2026-02-13
+
+**Summary:** Comment read operations fail when using an OAuth access token, despite the token having `read_comment` scope and the integration having "Read comments" capability enabled. The same operations work correctly with an internal integration token on the same pages.
+
+### Symptoms
+
+| Endpoint | OAuth token | Internal token |
+|----------|------------|----------------|
+| `POST /v1/comments` (create) | 200, but partial response: `{ object, id, request_id }` | 200, full response with `discussion_id`, `rich_text`, etc. |
+| `GET /v1/comments/:id` (retrieve) | 200, but partial response: `{ object, id, request_id }` | 200, full response |
+| `GET /v1/comments?block_id=:id` (list) | 404 "Could not find block" | 200, full list |
+
+The partial response matches Notion's documented [Option 1 / `partialCommentObjectResponse`](https://developers.notion.com/reference/retrieve-comment) — the shape returned when the token lacks read comment permissions.
+
+### What we verified
+
+- **Token scope:** `read_comment` is present (confirmed via `POST /v1/oauth/introspect`)
+- **Integration capabilities:** "Read comments" and "Insert comments" both enabled in the [integration dashboard](https://www.notion.so/profile/integrations)
+- **Page-level permissions:** Page shows "Can read comments" for the OAuth integration in the Notion UI (Connections panel)
+- **Page accessibility:** The same page returns 200 for `GET /v1/pages/:id`, `GET /v1/blocks/:id`, and `GET /v1/blocks/:id/children` with the OAuth token
+- **Comment creation works:** `POST /v1/comments` succeeds (comments appear in the Notion UI), but the response is partial
+- **API version:** Same behavior with both `2022-06-28` and `2025-09-03`
+- **Fresh token:** Revoked and re-authorized — same behavior with a new token
+- **Internal token on same page:** All comment operations work correctly with an internal integration token (`owner.type: "workspace"`) on the exact same page
+
+### Workaround
+
+Use an internal integration token for comment operations. The CLI supports both auth methods:
+
+```bash
+# Switch to internal auth
+notion-cli auth-internal set
+
+# Switch back to OAuth
+notion-cli auth-public login
+```
+
+### Impact on tests
+
+The `comment` test suite requires an internal integration token to pass. When authenticated via OAuth, all 5 comment tests that read comment data will fail. Other test suites (block, page, database, search, user, etc.) are unaffected.
diff --git a/notion-cli/LICENSE b/notion-cli/LICENSE
new file mode 100644
index 0000000..a4e2c4f
--- /dev/null
+++ b/notion-cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Postman, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/notion-cli/README.md b/notion-cli/README.md
index b6528da..1bd3102 100644
--- a/notion-cli/README.md
+++ b/notion-cli/README.md
@@ -1,21 +1,41 @@
# Notion CLI
-A human and agent-friendly CLI for exploring Notion workspaces. Search for pages, read their content, and query databases.
+[](https://github.com/postmanlabs/postman-code-examples/actions/workflows/notion-cli.yml)
-### What you can do with it
+A command-line tool for reading and writing to Notion workspaces — built for humans and AI agents alike. Built on the latest Notion API (`2025-09-03`), including data sources, file uploads, page moves, and OAuth. Search pages, navigate database schemas, read and update content, upload files, and manage comments, all from the terminal.
-- **Search** — Find pages by keyword across everything the integration can access
-- **Read pages** — Fetch page metadata, properties, and content blocks (including nested blocks)
-- **Query databases** — View a database's schema and page through its entries
-- **Map a workspace** — Find root pages and traverse the tree to build a map of all workspace content
-- **Feed an AI agent** — Give an agent the CLI and let it autonomously explore a workspace to answer questions, summarize content, or extract data
-- **Audit or export** — Walk every page to check for stale content, missing properties, or generate a plaintext export
+### What you can do
+
+- **Search** across your workspace by keyword, filtered to pages, databases, or both
+- **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
+- **Write back** — create pages, update titles, move pages between parents, append content blocks, edit block text, archive what you don't need
+- **Manage comments** — list threads, retrieve individual comments, add comments, reply to discussions
+- **Upload files** — upload local files to Notion, list uploads, and retrieve upload status
+- **Two auth methods** — internal integration tokens for simplicity, or full OAuth browser flow for public integrations, with token introspection and revocation
+- **Map a workspace** — `integration pages` finds the roots, `page get` reveals children — compose them to walk the full content tree
+- **Great for humans and agents** — default output is human-readable and token-efficient; `--raw` gives agents the full JSON when they need it. A built-in `docs` command and navigation hints between related commands let an AI agent explore a workspace without prior knowledge
+
+## How it was made
+
+This project was built by a human and an AI coding agent working together 🤝. The agent used [Postman Code](https://www.postman.com/explore/code)'s MCP tools to search for the Notion API on Postman's API Network, pull request definitions from the [Notion API Collection](https://www.postman.com/notionhq/notion-s-api-workspace/collection/a1b2c3d/notion-api), and generate a typed TypeScript function for each endpoint. Every API call is a direct API integration — no HTTP code was written by hand and no third-party dependencies were introduced.
+
+The **command layer** was designed collaboratively. The human defined the command hierarchy, output format, and the behaviors that make the CLI useful beyond raw API access — the agent implemented them. Commands aren't thin wrappers around the generated clients; they add real workflow logic:
+
+- `page get` recursively fetches nested blocks in parallel and renders 20+ block types with depth-aware indentation
+- `integration pages` derives the concept of "root pages" (which doesn't exist in the Notion API) by paginating all visible pages and filtering by parent visibility
+- `block update` fetches the block first to determine its type, validates that it supports text updates, then sends the correctly-keyed payload
+- Property formatting handles 15+ Notion property types (select, multi_select, date, formula, rollup, relation, etc.)
+- Every command includes detailed `--help` text with usage examples and navigation hints between related commands
+
+The generated clients handle all the HTTP details. The commands handle workflow — composing API calls, adding derived concepts, and formatting output for readability.
## How it works
-The API clients under `src/postman/notion-api/` were generated by [Postman Code](https://www.postman.com/explore/code). The [Notion API Collection](https://www.postman.com/notionhq/notion-s-api-workspace/collection/a1b2c3d/notion-api) is the source of truth — Postman Code reads a request from the collection and produces an on-demand, type-safe TypeScript function with typed parameters, response types, and error handling. Each request becomes its own `client.ts` file.
+The API clients under `src/postman/notion-api/` were generated by Postman Code. The [Notion API (2025-09-03)](https://www.postman.com/notionhq/notion-s-api-workspace/collection/a1b2c3d/notion-api) collection is the source of truth — Postman Code reads a request from the collection and produces a type-safe TypeScript function. Each request becomes its own `client.ts` file.
-Those generated clients are re-exported through an `index.ts` file that namespaces them by resource. You create a client with your token, then call methods with just resource IDs and parameters:
+Those generated clients are re-exported through an `index.ts` file that namespaces them by resource.
```typescript
import { createNotionClient } from "./src/postman/notion-api/index.js";
@@ -28,9 +48,10 @@ const results = await notion.search({
filter: { value: "page", property: "object" },
});
-// Retrieve a database schema and query its entries
+// Retrieve a database schema and list its entries via data sources
const schema = await notion.databases.retrieve(databaseId);
-const entries = await notion.databases.query(databaseId, {
+const dataSourceId = schema.data_sources?.[0]?.id;
+const entries = await notion.dataSources.query(dataSourceId, {
page_size: 50,
});
@@ -45,7 +66,64 @@ All types are also exported from the same entry point:
import { createNotionClient, type NotionPage, type NotionDatabase } from "./src/postman/notion-api/index.js";
```
-The CLI commands in `commands/` are thin wrappers around these clients — they handle argument parsing and output formatting, while the generated code handles all the HTTP details.
+The CLI commands in `commands/` use these clients and add the workflow logic described above — recursive traversal, derived concepts, multi-step orchestration, and rich output formatting.
+
+Each command uses one or more Postman Collection requests — the same requests Postman Code used to generate the client code:
+
+| Command | Collection Requests Used | Generated Clients |
+|---------|------------------------|-----------------|
+| `search` | [Search](https://go.postman.co/request/52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a) | [search](src/postman/notion-api/search/search/client.ts) |
+| `integration pages` | [Search](https://go.postman.co/request/52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a) (paginated) | [search](src/postman/notion-api/search/search/client.ts) |
+| `page get` | [Retrieve a page](https://go.postman.co/request/52041987-d7e520f6-0c75-4fe0-9b23-990f742d496e) [Retrieve block children](https://go.postman.co/request/52041987-039ea5be-709a-4539-b021-170a63eba771) | [retrieve-page](src/postman/notion-api/pages/retrieve-page/client.ts) [retrieve-block-children](src/postman/notion-api/blocks/retrieve-block-children/client.ts) |
+| `page create` | [Create a page](https://go.postman.co/request/52041987-a2ef9963-62e0-4e87-a12b-f899f695280c) | [create-page](src/postman/notion-api/pages/create-page/client.ts) |
+| `page update` | [Update page properties](https://go.postman.co/request/52041987-de2726f0-1465-4fdc-81d5-bd35415848b4) | [update-page-properties](src/postman/notion-api/pages/update-page-properties/client.ts) |
+| `page archive` | [Update page properties](https://go.postman.co/request/52041987-de2726f0-1465-4fdc-81d5-bd35415848b4) | [archive-page](src/postman/notion-api/pages/archive-page/client.ts) |
+| `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) |
+| `datasource query` | [Query a data source](https://go.postman.co/request/52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d) | [query-data-source](src/postman/notion-api/data-sources/query-data-source/client.ts) |
+| `datasource create` | [Create a data source](https://go.postman.co/request/52041987-9c41977a-1606-4c76-a4e0-d094a3d0b4c7) | [create-data-source](src/postman/notion-api/data-sources/create-data-source/client.ts) |
+| `datasource update` | [Update a data source](https://go.postman.co/request/52041987-29f06253-bd7e-4c3c-b0d8-a36b285c4e0e) | [update-data-source](src/postman/notion-api/data-sources/update-data-source/client.ts) |
+| `datasource templates` | [List data source templates](https://go.postman.co/request/52041987-f38c907f-36d5-40c7-b057-e4811b4b5cde) | [list-data-source-templates](src/postman/notion-api/data-sources/list-data-source-templates/client.ts) |
+| `block get` | [Retrieve a block](https://go.postman.co/request/52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66) | [retrieve-block](src/postman/notion-api/blocks/retrieve-block/client.ts) |
+| `block children` | [Retrieve block children](https://go.postman.co/request/52041987-039ea5be-709a-4539-b021-170a63eba771) | [retrieve-block-children](src/postman/notion-api/blocks/retrieve-block-children/client.ts) |
+| `block append` | [Append block children](https://go.postman.co/request/52041987-a9376866-eb97-4cfa-b08d-5fc49f09ef26) | [append-block-children](src/postman/notion-api/blocks/append-block-children/client.ts) |
+| `block update` | [Retrieve a block](https://go.postman.co/request/52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66) [Update a block](https://go.postman.co/request/52041987-1a96de40-de2c-49c8-9697-fc91b077d06d) | [retrieve-block](src/postman/notion-api/blocks/retrieve-block/client.ts) [update-block](src/postman/notion-api/blocks/update-block/client.ts) |
+| `block delete` | [Delete a block](https://go.postman.co/request/52041987-95e3f732-e993-42b4-8451-70178b3d2ac9) | [delete-block](src/postman/notion-api/blocks/delete-block/client.ts) |
+| `comment list` | [Retrieve comments](https://go.postman.co/request/52041987-4def4425-319e-418c-9d96-56a4807a8ce7) | [retrieve-comments](src/postman/notion-api/comments/retrieve-comments/client.ts) |
+| `comment get` | [Retrieve a comment](https://go.postman.co/request/52041987-2f312153-d16c-459b-9d51-88358a96fe03) | [retrieve-comment](src/postman/notion-api/comments/retrieve-comment/client.ts) |
+| `comment add` | [Create a comment](https://go.postman.co/request/52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7) | [add-comment-to-page](src/postman/notion-api/comments/add-comment-to-page/client.ts) |
+| `comment reply` | [Create a comment](https://go.postman.co/request/52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7) | [add-comment-to-discussion](src/postman/notion-api/comments/add-comment-to-discussion/client.ts) |
+| `file upload` | [Create a file upload](https://go.postman.co/request/52041987-1548ae35-ac12-4fc3-a337-416ed2a92088) [Send file upload](https://go.postman.co/request/52041987-aaf29c9a-7236-432d-97e8-beebee88b3cd) [Complete file upload](https://go.postman.co/request/52041987-80549896-30b1-43a4-add0-a73958b602e1) | [create-file-upload](src/postman/notion-api/file-uploads/create-file-upload/client.ts) [send-file-upload](src/postman/notion-api/file-uploads/send-file-upload/client.ts) [complete-file-upload](src/postman/notion-api/file-uploads/complete-file-upload/client.ts) |
+| `file list` | [List file uploads](https://go.postman.co/request/52041987-d6b82f81-aaf2-4cc0-b92c-c8cdf709e67d) | [list-file-uploads](src/postman/notion-api/file-uploads/list-file-uploads/client.ts) |
+| `file get` | [Retrieve a file upload](https://go.postman.co/request/52041987-2f30fd9c-c12b-40fc-bb98-03d153c3e353) | [retrieve-file-upload](src/postman/notion-api/file-uploads/retrieve-file-upload/client.ts) |
+| `user me` | [Retrieve your token's bot user](https://go.postman.co/request/52041987-30ad8b7b-5eb0-4bbe-bfbf-509d7f961cae) | [retrieve-bot-user](src/postman/notion-api/users/retrieve-bot-user/client.ts) |
+| `user get` | [Retrieve a user](https://go.postman.co/request/52041987-e23bf76f-cb3a-4bbc-9e5a-0a39cd59e909) | [retrieve-user](src/postman/notion-api/users/retrieve-user/client.ts) |
+| `user list` | [List all users](https://go.postman.co/request/52041987-1f6b9bec-dc7d-4412-9e80-46a33b8b11c6) | [list-users](src/postman/notion-api/users/list-users/client.ts) |
+| `auth-public login` | [Token](https://go.postman.co/request/52041987-ced3cc2e-170e-40fa-8f39-fee0f6a464d7) | [token](src/postman/notion-api/oauth/token/client.ts) |
+| `auth-public introspect` | [Introspect](https://go.postman.co/request/52041987-3070c020-0b6d-402f-bd18-88e9e1348521) | [introspect](src/postman/notion-api/oauth/introspect/client.ts) |
+| `auth-public revoke` | [Revoke](https://go.postman.co/request/52041987-2e4a8940-5ef0-42b2-9c3c-d56c1752e9ac) | [revoke](src/postman/notion-api/oauth/revoke/client.ts) |
+
+Each generated client file embeds the collection and request IDs in its header, along with the last-modified timestamp:
+
+```typescript
+/**
+ * Generated by Postman Code
+ *
+ * Collection: Notion API (2025-09-03)
+ * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9
+ *
+ * Request: Search > Search
+ * Request UID: 52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a
+ * Request modified at: 2022-02-24T23:01:58.000Z
+ */
+```
+
+This means that if the Postman Collection request changes, the change can be detected by Postman Code and client code re-regenerated.
## Getting Started
@@ -67,18 +145,23 @@ npm link
This builds the project and creates a symlink so you can run `notion-cli` from anywhere. To remove it later, run `npm unlink -g notion-cli`.
-### 3. Create a Notion Integration
+### 3. Set Up a Notion Integration
+
+Notion supports two types of integrations. Choose one:
+
+- **Internal** (recommended) — simpler setup, uses a static token. Good for personal use or accessing your own workspace.
+- **Public** (OAuth) — uses a browser-based authorization flow. Needed when other Notion users need to install your integration into their own workspaces.
+
+#### Internal integration
1. Go to [notion.so/my-integrations](https://www.notion.so/my-integrations)
-2. Click "New integration"
+2. Click "New integration" and choose **Internal**
3. Give it a name (e.g., "notion-cli")
4. Select the workspace you want to access
5. Under "Capabilities", enable "Read content"
6. Click "Submit" and copy the "Internal Integration Secret"
-### 4. Share Pages with Your Integration
-
-Your integration can only access pages that have been explicitly shared with it:
+Share pages with your integration — internal integrations can only access pages that have been explicitly shared with them:
1. Open a page or database in Notion
2. Click the `•••` menu in the top right
@@ -87,21 +170,52 @@ Your integration can only access pages that have been explicitly shared with it:
> **Tip**: Share a top-level page to give access to all its children.
-### 5. Set Your API Key
+Save your integration token:
+
+```bash
+notion-cli auth-internal set
+```
+
+This prompts for your token with masked input and stores it in `~/.notion-cli/config.json`. You can also pass the token directly (`notion-cli auth-internal set `) or set the `NOTION_TOKEN` environment variable, which takes precedence over the stored token.
-Save your Notion integration token:
+Run `notion-cli auth-internal status` to check your current authentication state.
+
+#### Public integration (OAuth)
+
+1. Go to [notion.so/my-integrations](https://www.notion.so/my-integrations)
+2. Click "New integration" and choose **Public**
+3. Give it a name and configure the required fields
+4. Set the redirect URI to `http://localhost:8787/callback`
+5. Copy the **OAuth client ID** and **OAuth client secret** from the integration settings
+
+Save your OAuth client credentials:
```bash
-notion-cli set-token
+notion-cli auth-public setup
```
-This prompts for your token with masked input and stores it in `~/.notion-cli/config.json` so it's available from any directory. You can also pass the token directly (`notion-cli set-token `) or set the `NOTION_API_KEY` environment variable, which takes precedence over the stored token.
+This prompts for your client ID and client secret (masked) and stores them in `~/.notion-cli/config.json`.
+
+Then run the OAuth login flow:
+
+```bash
+notion-cli auth-public login
+```
+
+This opens your browser for Notion authorization. During authorization, the user selects which pages to share with the integration — no manual sharing step is needed. The CLI runs a local server to handle the callback, exchanges the authorization code for an access token, and stores it automatically.
+
+Run `notion-cli auth-public status` to check your current authentication state.
## Usage
+Every command can produce two kinds of output:
+
+- **Default (formatted)** — a human-readable, token-efficient summary. Property values are labeled, blocks are indented by depth, and only the most useful fields are shown. This is what you see in the examples below.
+- **Raw (`--raw` / `-r`)** — the full API response as JSON, exactly as Notion returned it. Use this when piping into other tools, when an AI agent needs complete data, or when the formatted view doesn't include a field you need.
+
### search
-Search for pages or find workspace roots:
+Search for pages and databases:
```bash
# List pages the integration can access (via the Search API)
@@ -113,39 +227,261 @@ notion-cli search "meeting notes"
# Search for databases instead of pages
notion-cli search --filter database
-# Find only top-level workspace pages (roots)
-notion-cli search --workspace
+# Show both pages and databases
+notion-cli search --filter all
+
+# Limit results
+notion-cli search -n 5
+```
+
+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.
+
+Example:
+
+```
+$ notion-cli search "AI Pilot" -n 2
+🔍 Searching pages for "AI Pilot"...
+
+Found 2 result(s):
+
+ 📄 AI Pilot Retrospective
+ ID: d4e5f6a7-b8c9-0123-defa-234567890123
+ Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890)
+ Created: 2/12/2026
+ Last edited: 2/12/2026
+ Archived: false
+ URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123
+
+ 📄 Emergency Rollback Procedures
+ ID: e4f5a6b7-c8d9-0123-efab-345678901234
+ Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890)
+ Created: 1/15/2026
+ Last edited: 2/10/2026
+ Archived: false
+ URL: https://www.notion.so/Emergency-Rollback-Procedures-e4f5a6b7c8d90123efab345678901234
+```
+
+### integration pages
+
+List the root pages the integration can access:
+
+```bash
+notion-cli integration pages
+```
+
+Returns the entry points for navigating workspace content. A page is considered a root if its parent is the workspace itself, or if its parent page isn't visible to the integration (meaning the integration was shared with a nested page directly). This is the recommended starting point for mapping a workspace — the command paginates through all pages internally and does the filtering, so agents don't need to figure out which pages are roots themselves.
+
+Example:
+
+```
+$ notion-cli integration pages
+🔍 Finding root pages...
+
+Found 3 root page(s) (scanned 42 total):
+
+ 📄 AI Adoption Plan
+ ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890
+ Created: 6/15/2025
+ Last edited: 2/12/2026
+ URL: https://www.notion.so/AI-Adoption-Plan-a1b2c3d45e6f7890abcdef1234567890
+
+ 📄 Prompt Engineering for Managers
+ ID: b2c3d4e5-f6a7-8901-bcde-f12345678901
+ Created: 8/1/2025
+ Last edited: 2/10/2026
+ URL: https://www.notion.so/Prompt-Engineering-for-Managers-b2c3d4e5f6a78901bcdef12345678901
+
+ 📄 Things the AI Broke
+ ID: c3d4e5f6-a7b8-9012-cdef-123456789012
+ Created: 3/20/2025
+ Last edited: 1/28/2026
+ URL: https://www.notion.so/Things-the-AI-Broke-c3d4e5f6a7b89012cdef123456789012
```
-By default, search returns pages only. Use `--filter database` to find databases, or `--filter all` to show both. The `--workspace` flag paginates through results internally and returns only pages whose parent is the workspace — these are your starting points for building a sitemap.
+### page
-### page get
+Read and manage Notion pages.
+
+#### page get
Read a page's content:
```bash
notion-cli page get
-
-# Output raw JSON
-notion-cli page get --raw
```
Shows page metadata (title, ID, parent, dates, URL), all properties, and content blocks with formatting. Child pages and databases are listed with their IDs for further navigation. The command recursively fetches nested blocks (toggles, callouts, etc.) but stops at child pages and databases.
-### database get
+Example:
+
+```
+$ notion-cli page get d4e5f6a7-b8c9-0123-defa-234567890123
+📄 Fetching page...
+
+Title: AI Pilot Retrospective
+ID: d4e5f6a7-b8c9-0123-defa-234567890123
+Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890)
+Created: 2/12/2026
+Last edited: 2/12/2026
+Archived: false
+URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123
+
+Properties (1):
+ title: AI Pilot Retrospective
+
+Content (2 blocks):
+
+────────────────────────────────────────────────────────────
+The AI pilot went mostly well. Marketing loved the generated content. Legal has questions. The chatbot told three customers we offer free shipping to Mars.
+Action item: add guardrails before the next demo with the board. Seriously.
+────────────────────────────────────────────────────────────
+```
+
+#### page create
+
+Create a new page under a parent page:
+
+```bash
+notion-cli page create --title "My New Page"
+
+# Create under a database parent
+notion-cli page create --title "New Entry" --database
+```
+
+Example:
+
+```
+$ notion-cli page create d4e5f6a7-b8c9-0123-defa-234567890123 --title "Lessons Learned"
+Page created.
+ Title: Lessons Learned
+ ID: d9e0f1a2-b3c4-5678-defa-890123456789
+ URL: https://www.notion.so/Lessons-Learned-d9e0f1a2b3c45678defa890123456789
+```
+
+#### page update
+
+Update a page's properties:
+
+```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.
+
+Example:
+
+```
+$ notion-cli page update d4e5f6a7-b8c9-0123-defa-234567890123 --title "AI Pilot Retrospective"
+Page updated.
+ Title: AI Pilot Retrospective
+ ID: d4e5f6a7-b8c9-0123-defa-234567890123
+ URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123
+```
+
+#### page archive
+
+Archive (soft-delete) a page:
+
+```bash
+notion-cli page archive
+```
+
+The page can be restored later from the Notion UI.
+
+Example:
+
+```
+$ notion-cli page archive d9e0f1a2-b3c4-5678-defa-890123456789
+Page archived.
+ ID: d9e0f1a2-b3c4-5678-defa-890123456789
+ Archived: true
+```
+
+#### page property
+
+Retrieve a single property value from a page:
+
+```bash
+notion-cli page property
+
+# Use the property name "title" as a shorthand
+notion-cli page property title
+```
+
+Useful for paginated properties (rollups, relations with many entries, long rich text) where the full value isn't returned by `page get`. Property IDs can be found in `page get --raw` or `database get --raw` output.
+
+Example:
+
+```
+$ notion-cli page property d4e5f6a7-b8c9-0123-defa-234567890123 title
+Type: property_item
+Items: 1
+ AI Pilot Retrospective
+```
+
+#### page move
+
+Move a page to a new parent:
+
+```bash
+notion-cli page move --parent
+```
+
+The page keeps its content and properties — only the parent changes.
+
+Example:
+
+```
+$ notion-cli page move d9e0f1a2-b3c4-5678-defa-890123456789 --parent a1b2c3d4-5e6f-7890-abcd-ef1234567890
+Page moved.
+ Title: Lessons Learned
+ ID: d9e0f1a2-b3c4-5678-defa-890123456789
+ New parent: a1b2c3d4-5e6f-7890-abcd-ef1234567890
+ URL: https://www.notion.so/Lessons-Learned-d9e0f1a2b3c45678defa890123456789
+```
+
+### database
+
+View and query Notion databases.
+
+#### database get
View a database's metadata and schema:
```bash
notion-cli database get
-
-# Output raw JSON
-notion-cli database get --raw
```
-Shows the database title, ID, parent, dates, URL, 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 `database list` to see the entries.
+
+Example:
+
+```
+$ notion-cli database get a6b7c8d9-e0f1-2345-abcd-567890123456
+🗃️ Fetching database...
+
+Title: AI Incident Log
+ID: a6b7c8d9-e0f1-2345-abcd-567890123456
+Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890)
+Created: 9/5/2025
+Last edited: 2/10/2026
+URL: https://www.notion.so/a6b7c8d9e0f12345abcd567890123456
+
+Data sources (1):
+ AI Incident Log — ID: b7c8d9e0-f1a2-3456-bcde-aaaaaaaaaaaa
+
+Schema (6 properties):
+ Date: date
+ Severity: select
+ Status: select
+ Blamed On: rich_text
+ Resolution: rich_text
+ Name: title
+
+To list entries: notion-cli database list a6b7c8d9-e0f1-2345-abcd-567890123456
+```
-### database list
+#### database list
List entries in a database:
@@ -159,17 +495,546 @@ notion-cli database list --limit 50
notion-cli database list --cursor
```
-Shows entries with titles, IDs, property values, and URLs. Each entry is a Notion page — use `page get ` to read its full content.
+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 workflow: mapping a workspace
+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)
+```
+
+#### database create
+
+Create a new inline database as a child of a page:
+
+```bash
+notion-cli database create --title "Task Tracker"
+```
+
+The database is created with a single "Name" title property. Use `database update` to modify properties after creation.
+
+Example:
+
+```
+$ notion-cli database create d4e5f6a7-b8c9-0123-defa-234567890123 --title "AI Approved Use Cases"
+Database created.
+ Title: AI Approved Use Cases
+ ID: e0f1a2b3-c4d5-6789-efab-901234567890
+ URL: https://www.notion.so/e0f1a2b3c4d56789efab901234567890
+```
+
+#### database update
+
+Update a database's title or description:
+
+```bash
+notion-cli database update --title "New Title"
+
+# Update description
+notion-cli database update --description "Updated description"
+
+# Update both
+notion-cli database update --title "New" --description "Desc"
+```
+
+Only the specified fields are updated; others remain unchanged.
+
+Example:
+
+```
+$ notion-cli database update e0f1a2b3-c4d5-6789-efab-901234567890 --title "AI Approved Use Cases — Revised"
+Database updated.
+ Title: AI Approved Use Cases — Revised
+ ID: e0f1a2b3-c4d5-6789-efab-901234567890
+ URL: https://www.notion.so/e0f1a2b3c4d56789efab901234567890
+```
+
+### datasource
+
+Work with data sources directly. In Notion's data model, databases contain one or more data sources, and data sources contain pages (entries). The `database` commands work at the database level; `datasource` commands give direct access to the data source layer underneath.
+
+Data source IDs are shown in the output of `database get`.
+
+#### datasource get
+
+Retrieve a data source by ID:
+
+```bash
+notion-cli datasource get
+```
+
+Shows the data source title, ID, parent database, schema, and a hint to query its entries.
+
+#### datasource query
+
+Query entries from a data source:
+
+```bash
+notion-cli datasource query
+
+# Limit results
+notion-cli datasource query --limit 50
+
+# Paginate
+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.
+
+#### datasource create
+
+Create a new data source in a database:
+
+```bash
+notion-cli datasource create --title "Q1 Data"
+```
+
+#### datasource update
+
+Update a data source's title:
+
+```bash
+notion-cli datasource update --title "New Title"
+```
+
+#### datasource templates
+
+List available page templates for a data source:
+
+```bash
+notion-cli datasource templates
+```
+
+Templates are pages that serve as blueprints for new entries in the data source.
+
+### block
+
+Read and manage individual content blocks.
+
+#### block get
+
+Retrieve a single block by ID:
+
+```bash
+notion-cli block get
+```
+
+Shows the block type, content, parent, dates, and whether it has children. Pages are also blocks, so you can pass a page ID.
+
+Example:
+
+```
+$ notion-cli block get f1a2b3c4-d5e6-7890-fabc-012345678901
+ID: f1a2b3c4-d5e6-7890-fabc-012345678901
+Type: paragraph
+Parent: page (ID: d4e5f6a7-b8c9-0123-defa-234567890123)
+Has children: false
+Archived: false
+Created: 2/12/2026
+Last edited: 2/12/2026
+
+Content:
+ The AI pilot went mostly well. Marketing loved the generated content. Legal has questions. The chatbot told three customers we offer free shipping to Mars.
+```
+
+#### block children
+
+List the immediate children of a block or page:
+
+```bash
+notion-cli block children
+
+# Also works with page IDs
+notion-cli block children
+
+# Limit results
+notion-cli block children -n 10
+```
+
+Unlike `page get`, this does not recurse into nested blocks — it returns only the direct children.
+
+Example:
+
+```
+$ notion-cli block children d4e5f6a7-b8c9-0123-defa-234567890123
+Found 2 block(s):
+
+ The AI pilot went mostly well. Marketing loved the generated content. Legal has questions. The chatbot told three customers we offer free shipping to Mars.
+ Action item: add guardrails before the next demo with the board. Seriously.
+```
+
+#### block append
+
+Append a paragraph block to a page or block:
+
+```bash
+notion-cli block append "Hello, world!"
+```
+
+Appends a paragraph block containing the given text. For more complex block structures, use the generated client directly.
+
+Example:
+
+```
+$ notion-cli block append d4e5f6a7-b8c9-0123-defa-234567890123 "Update the FAQ before the chatbot invents new company policies."
+Appended 1 block(s).
+ Update the FAQ before the chatbot invents new company policies.
+```
+
+#### block update
+
+Update a block's text content:
+
+```bash
+notion-cli block update "Updated text"
+```
+
+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.
+
+Example:
+
+```
+$ notion-cli block update a2b3c4d5-e6f7-8901-abcd-123456789012 "The chatbot no longer makes promises about interplanetary logistics."
+Block updated.
+ ID: a2b3c4d5-e6f7-8901-abcd-123456789012
+ Type: paragraph
+ Content: The chatbot no longer makes promises about interplanetary logistics.
+```
+
+#### block delete
+
+Delete (archive) a block:
+
+```bash
+notion-cli block delete
+```
+
+The block can be restored via the Notion UI or by updating it with `archived: false`.
+
+Example:
+
+```
+$ notion-cli block delete a2b3c4d5-e6f7-8901-abcd-123456789012
+Block deleted.
+ ID: a2b3c4d5-e6f7-8901-abcd-123456789012
+ Type: paragraph
+ Archived: true
+```
+
+### comment
+
+Read and manage comments on pages and blocks.
+
+> **Limitations:** The Notion API only returns **unresolved** comments — if a comment is resolved in the Notion UI, it won't appear in `comment list`. The API also cannot start new inline discussion threads on blocks; `comment add` creates top-level page comments only, and `comment reply` can respond to existing inline threads.
+>
+> **Requirements:** Both "Read comments" and "Insert comments" capabilities must be enabled in the [Notion integration dashboard](https://www.notion.so/profile/integrations) — they are off by default.
+
+#### comment list
+
+List comments on a page or block:
+
+```bash
+notion-cli comment list
+```
+
+Comments show their text, ID, discussion thread ID, author, and creation date. Comments with the same discussion ID are part of the same thread.
+
+Example:
+
+```
+$ notion-cli comment list d4e5f6a7-b8c9-0123-defa-234567890123
+Found 1 comment(s):
+
+ 💬 Can we not demo this to the board until the Mars shipping issue is fixed?
+ ID: b3c4d5e6-f7a8-9012-bcde-234567890123
+ Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234
+ Author: a8b9c0d1-e2f3-4567-abcd-789012345678
+ Created: 2/12/2026
+```
+
+#### comment get
+
+Retrieve a single comment by ID:
+
+```bash
+notion-cli comment get
+```
+
+Shows the comment text, ID, discussion thread ID, author, and creation date. Comment IDs can be found in the output of `comment list`.
+
+Example:
+
+```
+$ notion-cli comment get b3c4d5e6-f7a8-9012-bcde-234567890123
+💬 Can we not demo this to the board until the Mars shipping issue is fixed?
+ ID: b3c4d5e6-f7a8-9012-bcde-234567890123
+ Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234
+ Author: a8b9c0d1-e2f3-4567-abcd-789012345678
+ Created: 2/12/2026
+```
+
+#### comment add
+
+Add a comment to a page:
+
+```bash
+notion-cli comment add "This looks great!"
+```
+
+Creates a new top-level comment thread on the page.
+
+Example:
+
+```
+$ notion-cli comment add d4e5f6a7-b8c9-0123-defa-234567890123 "Fixed. It now only promises free shipping to the Moon."
+Comment added.
+ ID: e6f7a8b9-c0d1-2345-efab-567890123456
+ Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234
+ Created: 2/12/2026
+```
+
+#### comment reply
+
+Reply to an existing comment thread:
+
+```bash
+notion-cli comment reply "I agree!"
+```
+
+The discussion ID can be found in the output of `comment list`, or from the Notion UI by clicking "Copy link to discussion" on a comment thread — the `d` query parameter in the copied URL is the discussion ID.
+
+Example:
+
+```
+$ notion-cli comment reply c4d5e6f7-a8b9-0123-cdef-345678901234 "Good enough. Ship it."
+Reply added.
+ ID: d5e6f7a8-b9c0-1234-defa-456789012345
+ Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234
+ Created: 2/12/2026
+```
+
+### file
+
+Upload and manage files.
+
+#### file upload
+
+Upload a local file to Notion:
+
+```bash
+notion-cli file upload ./report.pdf
+```
+
+Handles the full upload workflow in one command: creates an upload slot, sends the file data as multipart/form-data, and reports the result. The resulting file upload ID can be used to attach the file to a page or block via the API.
+
+Example:
-The Notion API doesn't have a "list all pages" endpoint, so mapping a workspace typically starts by finding pages whose parent is the workspace, then recursively reading child pages.
+```
+$ notion-cli file upload ./report.pdf
+📎 Uploading report.pdf (2048576 bytes)...
+
+File uploaded.
+ Filename: report.pdf
+ ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ Content type: application/pdf
+ Size: 2048576 bytes
+ Status: uploaded
+ Created: 2/13/2026
+```
+
+#### file list
+
+List file uploads:
+
+```bash
+notion-cli file list
+
+# Limit results
+notion-cli file list --limit 50
+```
+
+Shows each file's name, ID, content type, size, status, and creation date.
+
+#### file get
+
+Retrieve a file upload by ID:
+
+```bash
+notion-cli file get
+```
+
+### auth-internal
+
+Manage internal integration tokens.
+
+```bash
+# Save your integration token (prompts with masked input)
+notion-cli auth-internal set
+
+# Pass the token directly
+notion-cli auth-internal set
+
+# Check if a token is configured
+notion-cli auth-internal status
+
+# Remove the stored token
+notion-cli auth-internal clear
+```
+
+### auth-public
-If you want a workspace sitemap, the high-level loop looks like this:
+Authenticate with a public integration using OAuth.
+
+```bash
+# Save your OAuth client ID and secret (prompted, masked)
+notion-cli auth-public setup
+
+# Run the OAuth browser flow
+notion-cli auth-public login
+
+# Print the authorization URL instead of opening the browser
+notion-cli auth-public login --no-browser
+
+# Check OAuth authentication state
+notion-cli auth-public status
+```
+
+#### auth-public introspect
+
+Inspect a token's metadata:
+
+```bash
+notion-cli auth-public introspect
+```
+
+Shows whether the token is active, along with bot ID, workspace ID, and expiry information.
+
+#### auth-public revoke
+
+Revoke an access token:
+
+```bash
+notion-cli auth-public revoke
+```
+
+### user
+
+View Notion users and bot info.
+
+#### user me
+
+Show the bot user for your integration token:
+
+```bash
+notion-cli user me
+```
+
+Example:
+
+```
+$ notion-cli user me
+Name: my-notion-bot
+ID: a8b9c0d1-e2f3-4567-abcd-789012345678
+Type: bot
+Workspace: Acme Corp
+Owner: workspace
+```
+
+#### user get
+
+Retrieve a user by ID:
+
+```bash
+notion-cli user get
+```
+
+Shows the user's name, ID, type (person or bot), and type-specific details (email for people, workspace and owner info for bots).
+
+Example:
+
+```
+$ notion-cli user get f7a8b9c0-d1e2-3456-fabc-678901234567
+Name: Alice Chen
+ID: f7a8b9c0-d1e2-3456-fabc-678901234567
+Type: person
+Email: alice@example.com
+```
+
+#### user list
+
+List all users in the workspace:
+
+```bash
+notion-cli user list
+
+# Limit results
+notion-cli user list -n 10
+
+# Paginate
+notion-cli user list --cursor
+```
+
+Returns all people and bots in the workspace with their names, IDs, types, emails, and workspace/owner details.
+
+Example:
+
+```
+$ notion-cli user list
+Found 2 user(s):
+
+ 👤 Alice Chen
+ ID: f7a8b9c0-d1e2-3456-fabc-678901234567
+ Type: person
+ Email: alice@example.com
+
+ 🤖 my-notion-bot
+ ID: a8b9c0d1-e2f3-4567-abcd-789012345678
+ Type: bot
+ Workspace: Acme Corp
+ Owner: workspace
+```
+
+### docs
+
+Show guides and workflows for using the CLI:
+
+```bash
+notion-cli docs
+```
+
+Covers setup, workspace mapping, output/performance notes, and agent usage tips. This is the content that would clutter `--help` — run it once to understand the high-level flows.
+
+## Example workflow: mapping a workspace
+
+The Notion API doesn't have a "list all pages" endpoint, so mapping a workspace starts by finding the root pages the integration can access, then recursively reading child pages.
```bash
# 1. Find root pages
-notion-cli search --workspace
+notion-cli integration pages
# 2. Read a page to discover its children
notion-cli page get
@@ -184,44 +1049,77 @@ notion-cli database list
notion-cli page get
```
-For AI agents: run `notion-cli --help` — it includes the full workspace mapping workflow, command details, and examples. Each subcommand also has its own `--help` with detailed usage.
+For AI agents: run `notion-cli docs` for the full workspace mapping workflow and usage guides. Each subcommand also has its own `--help` with detailed usage.
## Development
If you want to modify the CLI, use `npm run cli` during development to run from source without rebuilding:
```bash
-npm run cli -- search --workspace
+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.
-## Project Structure
+## Testing
+Integration tests call each CLI command against a real Notion workspace to verify end-to-end behavior: argument parsing, API calls, and output formatting.
+
+```bash
+# Run all test suites (concurrent — ~20s)
+npm test
+
+# Run a single suite
+npm run test:page
+npm run test:database
+npm run test:datasource
+npm run test:block
+npm run test:comment
+npm run test:file
+npm run test:search
+npm run test:user
+npm run test:docs
+npm run test:integration
+
+# Auth tests (config management + OAuth API tests when credentials are set)
+npm run test:auth
```
-notion-cli/
-├── cli.ts # Main CLI application
-├── commands/ # CLI command handlers
-├── helpers.ts # Shared formatting utilities
-├── src/postman/notion-api/ # Generated API clients
-│ ├── index.ts # notion object with client functions
-│ ├── blocks/retrieve-block-children/client.ts
-│ ├── databases/
-│ │ ├── retrieve-database/client.ts
-│ │ └── query-database/client.ts
-│ ├── pages/retrieve-page/client.ts
-│ ├── search/search/client.ts
-│ └── shared/types.ts
-└── package.json
-```
+
+The tests pick up the token stored by `notion-cli auth-internal set` or `notion-cli auth-public login` (in `~/.notion-cli/config.json`), so if you've already authenticated there's nothing extra to configure. You can also set the `NOTION_TOKEN` environment variable, which takes precedence. If no token is found, each suite skips cleanly.
+
+Tests are split into independent files — each suite creates its own test resources and cleans up afterward. Suites run concurrently when using `npm test`, or individually for faster iteration during development.
+
+**What's covered:**
+
+| Suite | Commands tested | Variations |
+|-------|----------------|------------|
+| `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: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: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) |
+
+**Requirements:**
+- A valid Notion integration token (via `auth-internal set`, `auth-public login`, or env var)
+- The integration must have access to at least one workspace page
+- The integration needs "Read content" and "Insert content" capabilities
+- For OAuth tests: `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars
+
+Each suite is self-contained — it creates a test page during setup (if needed), runs tests against it, and archives it during teardown. No pre-existing workspace content is required beyond the integration being connected to at least one page.
## Troubleshooting
### "No Notion API key found"
-Run `notion-cli set-token ` or set the `NOTION_API_KEY` environment variable.
+Run `notion-cli auth-internal set` or `notion-cli auth-public login` to authenticate. You can also set the `NOTION_TOKEN` environment variable.
### "Unauthorized" or "Forbidden" errors
@@ -234,4 +1132,4 @@ Your integration can only see pages explicitly shared with it. Share a top-level
## License
-MIT
+[MIT](LICENSE)
diff --git a/notion-cli/TASKS.md b/notion-cli/TASKS.md
new file mode 100644
index 0000000..5444bfc
--- /dev/null
+++ b/notion-cli/TASKS.md
@@ -0,0 +1,71 @@
+# Notion CLI — Task Tracker
+
+Worklist for expanding the CLI to cover the full Notion API collection.
+
+Collection: **Notion API** (`15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2`)
+
+## What's done
+
+| Command | Client | Request ID |
+|---------|--------|------------|
+| `search` | `search/search/client.ts` | `15568543-816435ec-1d78-4c55-a85e-a1a4a8a24564` |
+| `page get` | `pages/retrieve-page/client.ts` | `15568543-8a70cbd1-7dbe-4699-a04e-108084a9c31b` |
+| `page create` | `pages/create-page/client.ts` | `15568543-d23f8f9c-e220-46d8-9ac2-aab80b909a42` |
+| `page update` | `pages/update-page-properties/client.ts` | `15568543-3f95deb8-805c-4c1c-a487-a090f06da32f` |
+| `page archive` | `pages/archive-page/client.ts` | `15568543-5911c414-b183-49d7-8c22-72f60a2d0a98` |
+| `page property` | `pages/retrieve-page-property/client.ts` | `15568543-d1bc0b09-7043-4c5d-823f-186fa9110c0f` |
+| `database get` | `databases/retrieve-database/client.ts` | `15568543-095c448d-2373-4aee-9b9e-ed3cf58afbe3` |
+| `database list` | `databases/query-database/client.ts` | `15568543-cddcc0aa-d534-4744-b37a-ddf36dee7d8f` |
+| `database create` | `databases/create-database/client.ts` | `15568543-a027e2d4-11f2-4068-a82c-cb9fb8562036` |
+| `database update` | `databases/update-database/client.ts` | `15568543-4ed21a83-c1aa-4e3b-b35e-2133faf2ea51` |
+| `block get` | `blocks/retrieve-block/client.ts` | `15568543-9142406e-2a57-4861-9f3e-86fd5c847813` |
+| `block children` | `blocks/retrieve-block-children/client.ts` | `15568543-228caaa0-074b-4487-a515-efcea60e5906` |
+| `block append` | `blocks/append-block-children/client.ts` | `15568543-dcab1db7-5157-406b-b4ad-65699dc32900` |
+| `block update` | `blocks/update-block/client.ts` | `15568543-730dedda-9c55-41e8-9a06-7393f7c8b1c4` |
+| `block delete` | `blocks/delete-block/client.ts` | `15568543-b13eff7b-ac0a-4cd8-bce2-080d4211be86` |
+| `comment list` | `comments/retrieve-comments/client.ts` | `15568543-a578d9ef-3e9e-4303-b83e-a1ce660ec699` |
+| `comment add` | `comments/add-comment-to-page/client.ts` | `15568543-262fbf44-6031-44b5-b91a-a919fea9b311` |
+| `comment reply` | `comments/add-comment-to-discussion/client.ts` | `15568543-d78e06cd-6fd2-45b1-af60-1e0b703455df` |
+| `user me` | `users/retrieve-bot-user/client.ts` | `15568543-e3aad8e1-2357-4174-92c2-d8ae56637d60` |
+| `user get` | `users/retrieve-user/client.ts` | `15568543-c8944457-5f3a-4eb0-add2-8aeb15d9765b` |
+| `user list` | `users/list-users/client.ts` | `15568543-f7bf2b64-2739-4539-aee0-326f61b9bfbf` |
+
+## What's left
+
+All Notion API collection requests are covered. No remaining tasks.
+
+> "Sort a database" (`15568543-a3840c38-d152-494c-b62c-cfee89347524`) and "Filter a database" (`15568543-ce121c40-153d-4a00-aade-25eb6cf5245a`) are variants of Query — already covered by `query-database/client.ts`.
+
+> "Update database properties" (`15568543-9d6773b2-72f5-4e81-929c-94498848cc4e`) is the same endpoint as "Update a database" — covered by `update-database/client.ts`.
+
+## Summary
+
+| Resource | Done | Remaining |
+|----------|------|-----------|
+| Users | 3 | 0 |
+| Pages | 5 | 0 |
+| Databases | 4 | 0 |
+| Blocks | 5 | 0 |
+| Comments | 3 | 0 |
+| Search | 1 | 0 |
+| **Total** | **21** | **0** |
+
+## Utility commands (not collection-backed)
+
+These commands exist in the CLI but are not direct mappings to a Notion API collection request, so they are not counted above:
+
+- `set-token` — stores the Notion token locally
+- `integration pages` — derives root pages by paginating the Search API
+
+## Workflow per task
+
+1. `postman-code client install -c 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 -r -l typescript`
+2. Create `client.ts` in the path listed above (follow the pattern in existing clients)
+3. Add any new types to `shared/types.ts` (reuse existing types like `NotionUser` when they already cover the response shape)
+4. Wire into `index.ts` (import function, import types, add to client object, add to type re-exports)
+5. Add CLI command in `commands/.ts`
+6. Register in `cli.ts` if new command group
+7. Add integration test(s) in `test/integration.test.ts` — follow the existing pattern: use `--raw` for commands that need to capture IDs, formatted output for others. If the new command needs an ID from a prior command, add it in the right position in the test sequence.
+8. Build and run `npm run test:integration` to verify
+9. Update this file: move task to "What's done" table, remove from "What's left", update summary counts
+10. Update `README.md`: add usage docs for the new command, update the testing table, update project structure if new directories were created
diff --git a/notion-cli/UPGRADE.md b/notion-cli/UPGRADE.md
new file mode 100644
index 0000000..4eeb9ff
--- /dev/null
+++ b/notion-cli/UPGRADE.md
@@ -0,0 +1,450 @@
+# Notion API Upgrade: 2022-02-22 → 2025-09-03
+
+Tracking the upgrade of the Notion CLI from the old Notion API collection to the **Notion API (2025-09-03)** collection.
+
+- **Old collection UID:** `15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2`
+- **New collection UID:** `52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9`
+- **Notion-Version header:** `2022-02-22` → `2025-09-03`
+
+---
+
+## Summary of API changes
+
+### Breaking: Database query → Data source query
+
+The biggest change. Databases now act as containers for **data sources**, and querying moves from `/v1/databases/{database_id}/query` to `/v1/data_sources/{data_source_id}/query`. Database IDs and data source IDs are not interchangeable. To get a data source ID, first retrieve the database and extract it from the `data_sources` array.
+
+This breaks our `database list` command and the `databases.query()` client.
+
+### New resource: Data sources (5 endpoints)
+
+| Endpoint | Method | URL |
+|----------|--------|-----|
+| Retrieve a data source | GET | `/v1/data_sources/{data_source_id}` |
+| Update a data source | PATCH | `/v1/data_sources/{data_source_id}` |
+| Query a data source | POST | `/v1/data_sources/{data_source_id}/query` |
+| List data source templates | GET | `/v1/data_sources/{data_source_id}/templates` |
+| Create a data source | POST | `/v1/data_sources` |
+
+### New: Move page
+
+`POST /v1/pages/{page_id}/move` — moves a page to a new parent page or database.
+
+### New: Retrieve a comment
+
+`GET /v1/comments/{comment_id}` — retrieves a single comment by ID.
+
+### New resource: File uploads (5 endpoints)
+
+Multi-part file upload support. Lifecycle: create → send (chunks) → complete.
+
+| Endpoint | Method | URL |
+|----------|--------|-----|
+| Create a file upload | POST | `/v1/file_uploads` |
+| Send file upload | POST | `/v1/file_uploads/{file_upload_id}/send` |
+| Complete file upload | POST | `/v1/file_uploads/{file_upload_id}/complete` |
+| Retrieve a file upload | GET | `/v1/file_uploads/{file_upload_id}` |
+| List file uploads | GET | `/v1/file_uploads` |
+
+### New resource: OAuth (3 endpoints)
+
+Token management for OAuth integrations. These use **Basic auth** (`base64(client_id:client_secret)`), not bearer token auth.
+
+| Endpoint | Method | URL | Purpose |
+|----------|--------|-----|---------|
+| Token | POST | `/v1/oauth/token` | Exchange authorization code for access token |
+| Revoke | POST | `/v1/oauth/revoke` | Revoke an access/refresh token |
+| Introspect | POST | `/v1/oauth/introspect` | Inspect a token's metadata |
+
+### Response shape changes in existing endpoints
+
+Comparing the new collection's example responses against the old, two changes affect our code:
+
+**1. Page parent type: `database_id` → `data_source_id`**
+
+Pages that live inside databases now report their parent as `data_source_id` instead of `database_id`:
+
+```json
+// Old
+"parent": { "type": "database_id", "database_id": "668d797c-..." }
+
+// New (2025-09-03)
+"parent": { "type": "data_source_id", "data_source_id": "668d797c-..." }
+```
+
+This affects any code that checks `parent.type`. In our CLI:
+- `page get` — displays parent info
+- `integration pages` — filters pages by parent type to find roots
+- `search` — displays parent type in results
+- The `formatParent()` helper or equivalent logic
+
+**2. Database response includes `data_sources` array**
+
+`GET /v1/databases/{database_id}` now returns a `data_sources` array:
+
+```json
+"data_sources": [
+ { "id": "abc-123-data-source-id", "name": "Main Data Source" }
+]
+```
+
+Plus new fields: `is_locked`, `in_trash`. Our `database get` command should display the data source IDs (users will need them for the new query flow).
+
+**3. Page response includes `in_trash` field**
+
+Minor addition. The `in_trash` boolean is separate from `archived`. Not critical but worth surfacing in output.
+
+**Request shapes unchanged** — All existing endpoint URLs, HTTP methods, and request body structures are identical. The only request-level change is the `Notion-Version` header value (`2022-02-22` → `2025-09-03`), which is set at runtime via `shared/variables.ts`.
+
+### Comments consolidated
+
+The old collection had two separate requests: "Add comment to page" and "Add comment to discussion". The new collection has a single "Create a comment" — the body determines which behavior (include `parent.page_id` for a page comment, or `discussion_id` for a reply). Our two existing clients still work fine, but we could consolidate.
+
+### Archive page removed from collection
+
+The old collection had a dedicated "Archive a page" request. This was always just `PATCH /v1/pages/{page_id}` with `{ archived: true }` — same as update page properties. Our `archivePage` client is fine; it just won't have a dedicated collection request backing it in the new collection.
+
+---
+
+## Understanding OAuth vs. integration tokens
+
+The CLI currently uses **internal integration tokens** — a user creates an integration at notion.so/my-integrations, copies the secret, and stores it via `set-token`. Simple and direct.
+
+**OAuth** is the mechanism for **public integrations** — apps that other Notion users can install into their workspaces (the "Add to Notion" flow). The flow:
+
+1. User visits an authorization URL in their browser
+2. They approve the app's requested permissions
+3. Notion redirects back with an authorization code
+4. The app exchanges the code for an access token via `POST /v1/oauth/token` (using Basic auth with client_id:client_secret)
+5. The access token is used as a bearer token for subsequent API calls (same as integration tokens)
+
+For the CLI, adding OAuth support would mean:
+- A `login` or `auth` command that opens the browser, runs a local HTTP server for the redirect callback, captures the authorization code, and exchanges it for a token
+- Token storage (and refresh, if applicable)
+- The Token, Revoke, and Introspect endpoints are for managing these OAuth tokens
+
+**Decision:** CLI commands for the OAuth building blocks (`oauth token`, `oauth introspect`, `oauth revoke`) are implemented. They require `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars. The interactive browser-based login flow (`oauth login`) is not yet built — it would open the browser, spin up a local HTTP server at `http://localhost:9876/callback` for the redirect, exchange the code, and store the token. The internal integration token flow (`set-token`) remains the primary auth method.
+
+---
+
+## Upgrade steps
+
+### Phase 1: Foundation
+
+- [x] **1.1 Update `shared/variables.ts`** — Change `NOTION_VERSION` from `2022-02-22` to `2025-09-03`. Add new variables (`data_source_id`, `file_upload_id`, `comment_id`, etc.).
+
+- [x] **1.2 Regenerate all existing clients** — Updated all 21 client headers to reference the new collection UID and request UIDs. Code unchanged since `notionVersion` is passed as a parameter. The existing 21 clients:
+ - `blocks/`: append-block-children, delete-block, retrieve-block, retrieve-block-children, update-block
+ - `comments/`: add-comment-to-page, add-comment-to-discussion, retrieve-comments
+ - `databases/`: create-database, query-database, retrieve-database, update-database
+ - `pages/`: archive-page, create-page, retrieve-page, retrieve-page-property, update-page-properties
+ - `search/`: search
+ - `users/`: list-users, retrieve-bot-user, retrieve-user
+
+- [x] **1.3 Handle the query-database → data source migration** — The `query-database` client currently hits `/v1/databases/{database_id}/query`. In the new API this endpoint doesn't exist. Two options:
+ - (a) Replace `query-database` with the new `query-data-source` client and update `database list` to first retrieve the database, extract the data source ID, then query the data source
+ - (b) Keep a backwards-compatible wrapper
+
+ Option (a) is cleaner. The `database list` command will need a two-step flow.
+
+- [x] **1.4 Update `index.ts`** — Add new namespaces (`dataSources`, `fileUploads`, `oauth`) and re-export new clients.
+
+### Phase 1b: Handle response shape changes
+
+- [x] **1b.1 Update parent type handling** — Everywhere we check `parent.type === "database_id"`, add handling for `"data_source_id"`. This includes the page display logic, the `integration pages` root-detection logic, and search result formatting. Grep for `database_id` in the commands layer to find all affected spots.
+
+- [x] **1b.2 Update `database get` output** — Display the `data_sources` array (ID and name for each) so users can see data source IDs. These IDs are needed for the new query flow.
+
+- [x] **1b.3 Update `database list` to extract data source ID** — Before querying, call `databases.retrieve()` first, extract the data source ID from `data_sources[0].id`, then call `dataSources.query()` with that ID. This is the migration path for the removed `/v1/databases/{id}/query` endpoint.
+
+- [x] **1b.4 Surface `in_trash` in page/database output** — Minor. Show `In trash: true` when present, alongside the existing `Archived` field.
+
+### Phase 2: New data source clients & commands
+
+- [x] **2.1 Generate data source clients** (5 new clients):
+ - `data-sources/retrieve-data-source/client.ts`
+ - `data-sources/update-data-source/client.ts`
+ - `data-sources/query-data-source/client.ts`
+ - `data-sources/list-data-source-templates/client.ts`
+ - `data-sources/create-data-source/client.ts`
+
+- [x] **2.2 Add `dataSources` namespace to `index.ts`** — Methods: `retrieve`, `update`, `query`, `create`, `listTemplates`
+
+- [x] **2.3 Update `database list` command** — Change to a two-step flow: retrieve database → extract data source ID → query data source. The command output should remain the same.
+
+- [x] **2.4 Add `datasource` CLI commands** — New top-level command group:
+ - `datasource get ` — retrieve a data source
+ - `datasource query ` — query entries from a data source
+ - `datasource templates ` — list page templates
+ - `datasource create --title "..."` — create a data source in a database
+ - `datasource update --title "..."` — update a data source
+
+### Phase 3: New page, comment, and file upload clients & commands
+
+- [x] **3.1 Generate `move-page` client** — `pages/move-page/client.ts`
+
+- [x] **3.2 Add `page move` command** — `notion-cli page move --parent `
+
+- [x] **3.3 Generate `retrieve-comment` client** — `comments/retrieve-comment/client.ts`
+
+- [x] **3.4 Add `comment get` command** — `notion-cli comment get `
+
+- [x] **3.5 Generate file upload clients** (5 new clients):
+ - `file-uploads/create-file-upload/client.ts`
+ - `file-uploads/send-file-upload/client.ts`
+ - `file-uploads/complete-file-upload/client.ts`
+ - `file-uploads/retrieve-file-upload/client.ts`
+ - `file-uploads/list-file-uploads/client.ts`
+
+- [x] **3.6 Add `fileUploads` namespace to `index.ts`** — Methods: `create`, `send`, `complete`, `retrieve`, `list`
+
+- [x] **3.7 Add `file` CLI commands**:
+ - `file upload ` — creates upload, sends via multipart/form-data, reports result
+ - `file list` — list file uploads
+ - `file get ` — retrieve a file upload
+
+### Phase 4: OAuth clients
+
+- [x] **4.1 Generate OAuth clients** (3 new clients):
+ - `oauth/token/client.ts`
+ - `oauth/revoke/client.ts`
+ - `oauth/introspect/client.ts`
+
+ Note: these use Basic auth, not Bearer token. The clients will need a different auth mechanism than the rest.
+
+- [x] **4.2 Add `oauth` namespace to `index.ts`** — Methods: `token`, `revoke`, `introspect`
+
+- [x] **4.3 Add OAuth CLI commands** — `oauth token`, `oauth introspect`, `oauth revoke`. All require `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars. The interactive `oauth login` browser flow is not yet built.
+
+### Phase 5: Cleanup & testing
+
+- [ ] **5.1 Consider consolidating comment clients** — The old "add-comment-to-page" and "add-comment-to-discussion" could become a single "create-comment" client. Low priority since existing behavior works.
+
+- [x] **5.2 Update integration tests** — Added tests for `page move`, `comment get` (formatted + raw), `datasource` (get, query, update, templates — all with raw variants), `file` (upload, list, get — all with raw variants). Updated OAuth tests to cover CLI commands alongside client tests. 43 tests across 10 suites, all passing.
+
+- [x] **5.3 Update README.md** — Documented all new commands (datasource, file, oauth, page move, comment get), the data source concept, file uploads, OAuth, and the API version. Updated the intro paragraph to mention 2025-09-03.
+
+- [x] **5.4 Update the command table in README** — Added rows for all 13 new commands with collection request links and generated client paths.
+
+---
+
+## New collection request ID reference
+
+For use with `postman-code client install -c 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 -r `:
+
+### Existing endpoints (new request IDs)
+
+| Endpoint | Request UID |
+|----------|-------------|
+| Retrieve bot user | `52041987-30ad8b7b-5eb0-4bbe-bfbf-509d7f961cae` |
+| Retrieve a user | `52041987-e23bf76f-cb3a-4bbc-9e5a-0a39cd59e909` |
+| List all users | `52041987-1f6b9bec-dc7d-4412-9e80-46a33b8b11c6` |
+| Retrieve a page property item | `52041987-e3c019ad-9c8b-4975-a142-5549ef771028` |
+| Retrieve a page | `52041987-d7e520f6-0c75-4fe0-9b23-990f742d496e` |
+| Create a page | `52041987-a2ef9963-62e0-4e87-a12b-f899f695280c` |
+| Update page properties | `52041987-de2726f0-1465-4fdc-81d5-bd35415848b4` |
+| Retrieve block children | `52041987-039ea5be-709a-4539-b021-170a63eba771` |
+| Append block children | `52041987-a9376866-eb97-4cfa-b08d-5fc49f09ef26` |
+| Retrieve a block | `52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66` |
+| Update a block | `52041987-1a96de40-de2c-49c8-9697-fc91b077d06d` |
+| Delete a block | `52041987-95e3f732-e993-42b4-8451-70178b3d2ac9` |
+| Retrieve a database | `52041987-73359528-2278-415f-98f2-4d20274cc69e` |
+| Create a database | `52041987-85ab373b-2fc1-4b9a-a8a6-ee6b9e72728c` |
+| Update a database | `52041987-5febd2f6-c9ff-486d-8a4b-71e5c58f82ef` |
+| Search | `52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a` |
+| Create a comment | `52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7` |
+| Retrieve comments | `52041987-4def4425-319e-418c-9d96-56a4807a8ce7` |
+
+### New endpoints
+
+| Endpoint | Request UID |
+|----------|-------------|
+| Move page | `52041987-9686de7b-77c0-4d53-b800-bf6c748bc668` |
+| Retrieve a comment | `52041987-2f312153-d16c-459b-9d51-88358a96fe03` |
+| Retrieve a data source | `52041987-dfeeac14-f85e-4527-ad2e-d85f79284dd9` |
+| Update a data source | `52041987-29f06253-bd7e-4c3c-b0d8-a36b285c4e0e` |
+| Query a data source | `52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d` |
+| List data source templates | `52041987-f38c907f-36d5-40c7-b057-e4811b4b5cde` |
+| Create a data source | `52041987-9c41977a-1606-4c76-a4e0-d094a3d0b4c7` |
+| Create a file upload | `52041987-1548ae35-ac12-4fc3-a337-416ed2a92088` |
+| Send file upload | `52041987-aaf29c9a-7236-432d-97e8-beebee88b3cd` |
+| Complete file upload | `52041987-80549896-30b1-43a4-add0-a73958b602e1` |
+| Retrieve a file upload | `52041987-2f30fd9c-c12b-40fc-bb98-03d153c3e353` |
+| List file uploads | `52041987-d6b82f81-aaf2-4cc0-b92c-c8cdf709e67d` |
+| OAuth Token | `52041987-ced3cc2e-170e-40fa-8f39-fee0f6a464d7` |
+| OAuth Revoke | `52041987-2e4a8940-5ef0-42b2-9c3c-d56c1752e9ac` |
+| OAuth Introspect | `52041987-3070c020-0b6d-402f-bd18-88e9e1348521` |
+
+---
+
+## Upgrade log
+
+What actually happened when the agent executed this upgrade, in order.
+
+### 1. Assessed what needed to change vs. what didn't
+
+Before touching code, read the existing `search/search/client.ts` to check whether `notionVersion` was hardcoded or passed as a parameter. It's a parameter — injected from `shared/variables.ts` via `index.ts` at client creation time. This meant the 21 existing client functions didn't need code changes, only header metadata updates (collection UID, request UID, modified timestamp). The actual HTTP code is identical between the old and new collections for every existing endpoint.
+
+This was the key insight that shaped the whole approach: the generated client layer is a thin, parameterized HTTP wrapper. The version upgrade flows through `variables.ts` → `index.ts` → every client call. No per-client code changes needed for existing endpoints.
+
+### 2. Updated `shared/variables.ts`
+
+Changed `NOTION_VERSION` from `2022-02-22` to `2025-09-03`. Added the three new collection variables: `COMMENT_ID`, `DATA_SOURCE_ID`, `FILE_UPLOAD_ID`. Updated the collection UID in the file header.
+
+### 3. Updated all 21 existing client headers
+
+Used the request ID reference table (already in this document) to map each existing client file to its new request UID in the new collection. Updated in a single batch:
+
+- **Collection name**: `Notion API` → `Notion API (2025-09-03)`
+- **Collection UID**: `15568543-d990f9b7-...` → `52041987-03f70d8f-...`
+- **Request UIDs**: each mapped individually to the new collection's request ID
+- **Request paths**: updated where names changed (e.g., "Add comment to page" → "Create a comment")
+
+Special cases:
+- `archive-page` — no dedicated request in the new collection. Mapped to "Update page properties" with a note, since archive is just `PATCH /v1/pages/{id}` with `{ archived: true }`.
+- `query-database` — endpoint removed in the new API. Header updated with deprecation note; the client is kept for fallback but `database list` now uses the data source flow.
+- Both comment clients (`add-comment-to-page`, `add-comment-to-discussion`) — now map to the same "Create a comment" request UID, since the new collection consolidated them.
+
+### 4. Updated `shared/types.ts` for new response shapes
+
+Four changes to existing types:
+- `PageParent.type` — added `"data_source_id"` to the union and `data_source_id?: string` field
+- `NotionPage` — added `in_trash?: boolean`
+- `NotionDatabase` — added `is_locked?: boolean`, `in_trash?: boolean`, `data_sources?: DataSourceReference[]`
+- New `DataSourceReference` interface (`id`, `name`)
+
+New types added:
+- `QueryDataSourceParams` / `QueryDataSourceResponse` — mirrors the old database query types
+- `CreateDataSourceParams` / `UpdateDataSourceParams`
+- `MovePageParams`
+
+### 5. Generated 15 new client files
+
+Used `postman-code client install` to fetch the API context for each new endpoint, then wrote client code matching the existing project conventions (same patterns as the 21 existing clients: explicit function parameters, typed responses, `NotionError` handling, JSDoc with `@see` links).
+
+**Data sources** (5 clients — `data-sources/` directory):
+- `query-data-source` — POST `/v1/data_sources/{id}/query`
+- `retrieve-data-source` — GET `/v1/data_sources/{id}`
+- `update-data-source` — PATCH `/v1/data_sources/{id}`
+- `create-data-source` — POST `/v1/data_sources`
+- `list-data-source-templates` — GET `/v1/data_sources/{id}/templates`
+
+**Pages & comments** (2 clients):
+- `move-page` — POST `/v1/pages/{id}/move`
+- `retrieve-comment` — GET `/v1/comments/{id}`
+
+**File uploads** (5 clients — `file-uploads/` directory):
+- `create-file-upload` — POST `/v1/file_uploads`
+- `send-file-upload` — POST `/v1/file_uploads/{id}/send`
+- `complete-file-upload` — POST `/v1/file_uploads/{id}/complete`
+- `retrieve-file-upload` — GET `/v1/file_uploads/{id}`
+- `list-file-uploads` — GET `/v1/file_uploads`
+
+**OAuth** (3 clients — `oauth/` directory):
+- `token` — POST `/v1/oauth/token` (Basic auth, not Bearer)
+- `revoke` — POST `/v1/oauth/revoke` (Basic auth)
+- `introspect` — POST `/v1/oauth/introspect` (Basic auth)
+
+The OAuth clients are the only ones that don't use the standard Bearer token auth pattern — they accept `clientId` and `clientSecret` parameters and use Basic auth, matching the Notion OAuth spec.
+
+### 6. Rewrote `index.ts` with new namespaces
+
+Added four new namespaces to the `createNotionClient()` return object:
+- `dataSources` — `retrieve`, `query`, `create`, `update`, `listTemplates`
+- `pages.move` — added to existing pages namespace
+- `comments.retrieve` — added to existing comments namespace
+- `fileUploads` — `create`, `send`, `complete`, `retrieve`, `list`
+- `oauth` — `token`, `revoke`, `introspect`
+
+Marked `databases.query()` as `@deprecated` with a pointer to `dataSources.query()`.
+
+Re-exported all new types.
+
+### 7. Updated command layer for response shape changes
+
+**Parent type handling** (`page.ts`, `search.ts`, `integration.ts`):
+- Added `parent.type === "data_source_id"` branches wherever `"database_id"` was checked
+- In `integration.ts`, the root-detection filter now explicitly handles `data_source_id` parents as non-roots (same as `database_id`)
+
+**`database get`** (`database.ts`):
+- Now displays the `data_sources` array with IDs and names
+- Shows `is_locked` and `in_trash` fields when present
+- Hints the data source ID in the "To list entries" footer
+
+**`database list`** (`database.ts`):
+- Changed to a two-step flow: first retrieves the database to extract `data_sources[0].id`, then calls `dataSources.query()` with that ID
+- Falls back to the legacy `databases.query()` if no data sources array is present
+
+**`page get`** (`page.ts`):
+- Now shows `Archived` and `In trash` fields in output
+
+### 8. Build verification
+
+Ran `npm run build` (TypeScript compiler). Clean build, no errors. All 36 client files (21 existing + 15 new), the updated `index.ts`, and the updated command files compile without issues.
+
+### 9. Integration tests and additional breaking changes
+
+Updated the integration test suite to account for API version changes and new response shapes.
+
+**Additional breaking changes discovered by running the tests:**
+
+- **Search filter value**: The 2025-09-03 API changed the search filter value from `"database"` to `"data_source"`. Updated `SearchFilter` type and added a `FILTER_API_VALUE` mapping in `search.ts` so the CLI's `--filter database` flag maps to the new API value transparently.
+- **Database create parent requires explicit `type`**: The new API rejects `{ page_id: id }` and requires `{ type: "page_id", page_id: id }`. Updated `CreateDatabaseParams` type and `database.ts` command.
+- **Database `properties` may be absent**: Freshly created databases no longer include a `properties` field in the response. Added a guard (`database.properties || {}`) in the `database get` display.
+- **`archived` field on databases**: May be absent in the 2025-09-03 response. Now only displayed when present.
+
+**Test changes** (`integration.test.ts`):
+- Added `Archived:` assertion to `page get` test
+- Added new `database get shows data sources when present` test
+- Relaxed `database get` schema assertion (new API may not return properties on fresh databases)
+
+**OAuth test suite** (`oauth.test.ts`):
+- New file with env var gating — skips cleanly when `NOTION_OAUTH_CLIENT_ID` / `NOTION_OAUTH_CLIENT_SECRET` are not set
+- Tests: token exchange error handling, introspect (when token provided), revoke (commented out — destructive)
+- Added `test:oauth` npm script
+- OAuth redirect URI convention: `http://localhost:9876/callback` (CLI will spin up a temp local server)
+
+All 29 integration tests pass. OAuth tests skip cleanly.
+
+### 10. Wired up all new CLI commands and fixed clients
+
+Added 10 new subcommands across 5 areas:
+
+**New command groups** (3 new files in `commands/`):
+- `datasource` — `get`, `query`, `templates`, `create`, `update`. Operates directly on data source IDs. Keeps a clean boundary with `database` commands (no crossing API resources).
+- `file` — `upload`, `list`, `get`. The `upload` command handles the full create → send → complete lifecycle in one step.
+- `oauth` — `token`, `introspect`, `revoke`. All gated on `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars.
+
+**Added to existing command groups:**
+- `page move` — moves a page to a new parent via `--parent `
+- `comment get` — retrieves a single comment by ID
+
+**Client fixes** (3 clients had mismatches between the Postman collection definitions and the real Notion API):
+- `create-file-upload` — collection had `{ name, size }` but API expects `{ mode, filename, content_type }`. Rewrote params and response type.
+- `send-file-upload` — collection had JSON body with base64 data but API expects `multipart/form-data` with raw binary. Rewrote to use FormData/Blob.
+- `list-data-source-templates` — response shape is `{ templates: [...] }` not `{ object: "list", results: [...] }`. Fixed response type, added query params support.
+
+**File upload workflow refinement:**
+- For `single_part` mode, the send step auto-transitions the upload to `uploaded` status, so the complete step is skipped. Complete is only called for `multi_part` uploads.
+
+**Tests:**
+- Added `page move` test (create two pages, move one under the other, verify, move back)
+- Added `comment get` tests (formatted + raw)
+- New `datasource.test.ts` — 6 tests covering get, query, update, templates (all with raw variants)
+- New `file.test.ts` — 6 tests covering upload, list, get (all with raw variants)
+- Updated `oauth.test.ts` to test CLI commands alongside client-level tests
+- Added `test:datasource` and `test:file` npm scripts
+
+All 43 integration tests pass across 10 suites. OAuth tests skip cleanly without credentials.
+
+**README updates:**
+- Intro mentions API version `2025-09-03` and OAuth support
+- "What you can do" section covers data sources, file uploads, OAuth, page move
+- Full usage documentation for all new commands
+- Command table has rows for all 13 new commands
+- Test coverage table updated with new suites
+
+### What's left
+
+- [x] **5.1 Consolidate comment clients** — Replaced `add-comment-to-page` and `add-comment-to-discussion` with a single `create-comment` client. The `CreateCommentParams` interface accepts either `parent.page_id` (new thread) or `discussion_id` (reply). The CLI commands (`comment add`, `comment reply`) now both call `notion.comments.create()`.
+- [ ] **OAuth login flow** — An interactive `oauth login` command that opens the browser, spins up a local HTTP server for the redirect callback, exchanges the code, and stores the token. The building blocks (`oauth token`, `introspect`, `revoke`) are in place.
diff --git a/notion-cli/cli.ts b/notion-cli/cli.ts
index c74d83e..505978d 100644
--- a/notion-cli/cli.ts
+++ b/notion-cli/cli.ts
@@ -7,10 +7,19 @@
*/
import { Command } from "commander";
+import { authInternalCommand } from "./commands/auth-internal.js";
+import { authPublicCommand } from "./commands/auth-public.js";
import { setTokenCommand } from "./commands/set-token.js";
import { searchCommand } from "./commands/search.js";
import { pageCommand } from "./commands/page.js";
import { databaseCommand } from "./commands/database.js";
+import { datasourceCommand } from "./commands/datasource.js";
+import { userCommand } from "./commands/user.js";
+import { blockCommand } from "./commands/block.js";
+import { commentCommand } from "./commands/comment.js";
+import { fileCommand } from "./commands/file.js";
+import { integrationCommand } from "./commands/integration.js";
+import { docsCommand } from "./commands/docs.js";
const program = new Command();
@@ -21,59 +30,23 @@ program
.addHelpText(
"after",
`
-Setup:
- 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
-
-Commands:
- search Search for pages and databases
- page get Read a page's content and properties
- database get View a database's metadata and schema
- database list List entries in a database
-
-Mapping a Workspace:
- To build a complete tree of a Notion workspace, follow this workflow:
-
- Step 1 – Find root pages:
- $ notion-cli search --workspace
-
- Step 2 – Read each root page:
- $ notion-cli page get
- Look for child pages (📄) and child databases (🗃️) in the output.
-
- Step 3 – View a database's schema:
- $ notion-cli database get
-
- Step 4 – List entries in a database:
- $ notion-cli database list
-
- Step 5 – Read an entry or child page:
- $ notion-cli page get
-
- Repeat steps 2-5 to traverse the tree.
-
- Key points:
- • Start from roots: search --workspace finds your starting points
- • One page at a time: page get lists children but doesn't traverse them
- • Databases have two views: schema (get) and entries (list)
- • IDs are in output: every child shows its ID for easy extraction
-
-Output:
- All commands output human-readable text by default.
- Use --raw on page get and database get/list for JSON output.
-
-Performance:
- • 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)
- • search --workspace paginates internally (may take a few seconds)
+Run "notion-cli docs" for setup instructions, workspace mapping workflow, and usage guides.
`,
);
-program.addCommand(setTokenCommand);
+program.addCommand(authInternalCommand);
+program.addCommand(authPublicCommand);
+// Kept for backwards compatibility; prefer "auth-internal set"
+program.addCommand(setTokenCommand, { hidden: true });
program.addCommand(searchCommand);
program.addCommand(pageCommand);
program.addCommand(databaseCommand);
+program.addCommand(datasourceCommand);
+program.addCommand(blockCommand);
+program.addCommand(commentCommand);
+program.addCommand(fileCommand);
+program.addCommand(userCommand);
+program.addCommand(integrationCommand);
+program.addCommand(docsCommand);
program.parse();
diff --git a/notion-cli/commands/auth-internal.ts b/notion-cli/commands/auth-internal.ts
new file mode 100644
index 0000000..69cbd11
--- /dev/null
+++ b/notion-cli/commands/auth-internal.ts
@@ -0,0 +1,171 @@
+/**
+ * auth-internal command group — internal integration authentication
+ *
+ * Internal integrations are created at https://www.notion.so/my-integrations.
+ * Authentication is a single secret token — no OAuth flow needed.
+ *
+ * auth-internal set [token] — save your integration token
+ * auth-internal status — check if a token is configured
+ * auth-internal clear — remove the stored token
+ */
+
+import { createInterface } from "readline";
+import { Command } from "commander";
+import { readConfig, writeConfig, CONFIG_FILE } from "../helpers.js";
+
+// ============================================================================
+// Masked input helper
+// ============================================================================
+
+function promptMasked(message: string): Promise {
+ return new Promise((resolve) => {
+ process.stdout.write(message);
+
+ if (!process.stdin.isTTY) {
+ const rl = createInterface({ input: process.stdin, terminal: false });
+ rl.once("line", (line) => {
+ rl.close();
+ resolve(line.trim());
+ });
+ return;
+ }
+
+ process.stdin.setRawMode(true);
+ process.stdin.resume();
+ let input = "";
+
+ const onData = (key: Buffer) => {
+ const chars = key.toString();
+ for (const ch of chars) {
+ if (ch === "\n" || ch === "\r" || ch === "\u0004") {
+ process.stdin.setRawMode(false);
+ process.stdin.removeListener("data", onData);
+ process.stdin.pause();
+ process.stdout.write("\n");
+ resolve(input);
+ return;
+ } else if (ch === "\u0003") {
+ process.stdin.setRawMode(false);
+ process.stdout.write("\n");
+ process.exit(0);
+ } else if (ch === "\u007F" || ch === "\b") {
+ if (input.length > 0) {
+ input = input.slice(0, -1);
+ process.stdout.write("\b \b");
+ }
+ } else {
+ input += ch;
+ process.stdout.write("*");
+ }
+ }
+ };
+ process.stdin.on("data", onData);
+ });
+}
+
+// ============================================================================
+// auth-internal set
+// ============================================================================
+
+const setCommand = new Command("set")
+ .description("Save your integration token")
+ .argument("[token]", "integration secret (omit to enter interactively)")
+ .action(async (token?: string) => {
+ if (!token) {
+ token = await promptMasked("Integration token: ");
+ }
+
+ if (!token) {
+ console.error("No token provided.");
+ process.exit(1);
+ }
+
+ const config = readConfig();
+ config.NOTION_TOKEN = token;
+ config.auth_method = "internal";
+ // Clear any OAuth metadata if switching methods
+ delete (config as Record).oauth_bot_id;
+ delete (config as Record).oauth_workspace_name;
+ delete (config as Record).oauth_workspace_id;
+ writeConfig(config as Record);
+ console.log(`Token saved to ${CONFIG_FILE}`);
+ process.exit(0);
+ });
+
+// ============================================================================
+// auth-internal status
+// ============================================================================
+
+const statusCommand = new Command("status")
+ .description("Check if an integration token is configured")
+ .action(async () => {
+ const config = readConfig();
+ const envToken = process.env.NOTION_TOKEN;
+ const configToken = config.NOTION_TOKEN;
+ const token = envToken || configToken;
+
+ if (!token) {
+ console.log("No integration token configured.");
+ console.log("");
+ console.log("To set one:");
+ console.log(" notion-cli auth-internal set ");
+ return;
+ }
+
+ console.log("Integration token configured.");
+ if (envToken) {
+ console.log(" Source: NOTION_TOKEN environment variable");
+ } else {
+ console.log(` Source: ${CONFIG_FILE}`);
+ }
+ console.log(` Token: ${token.slice(0, 12)}...`);
+ if (config.auth_method) {
+ console.log(` Auth method: ${config.auth_method}`);
+ }
+ });
+
+// ============================================================================
+// auth-internal clear
+// ============================================================================
+
+const clearCommand = new Command("clear")
+ .description("Remove the stored integration token")
+ .action(async () => {
+ const config = readConfig();
+ if (!config.NOTION_TOKEN) {
+ console.log("No stored token to remove.");
+ return;
+ }
+
+ delete (config as Record).NOTION_TOKEN;
+ delete (config as Record).auth_method;
+ writeConfig(config as Record);
+ console.log("Integration token removed.");
+
+ if (process.env.NOTION_TOKEN) {
+ console.log("Note: NOTION_TOKEN environment variable is still set.");
+ }
+ });
+
+// ============================================================================
+// auth-internal command group
+// ============================================================================
+
+export const authInternalCommand = new Command("auth-internal")
+ .description("Authenticate with an internal integration token")
+ .addHelpText(
+ "after",
+ `
+Internal integrations use a single secret token for authentication.
+No OAuth flow or browser interaction needed.
+
+To get started:
+ 1. Go to https://www.notion.so/my-integrations
+ 2. Create an integration (or select an existing one)
+ 3. Copy the "Internal Integration Secret"
+ 4. Run: notion-cli auth-internal set
+`,
+ )
+ .addCommand(setCommand)
+ .addCommand(statusCommand)
+ .addCommand(clearCommand);
diff --git a/notion-cli/commands/auth-public.ts b/notion-cli/commands/auth-public.ts
new file mode 100644
index 0000000..3eec75e
--- /dev/null
+++ b/notion-cli/commands/auth-public.ts
@@ -0,0 +1,479 @@
+/**
+ * auth-public command group — public integration (OAuth) authentication
+ *
+ * Public integrations use OAuth so that other Notion users can install them
+ * via the "Add to Notion" flow. This requires a client ID and client secret
+ * from the integration's settings page.
+ *
+ * auth-public setup — save your OAuth client ID and secret
+ * auth-public login — authenticate via browser (OAuth flow)
+ * auth-public status — check current OAuth authentication state
+ * auth-public logout — revoke and clear the stored OAuth access token
+ */
+
+import { createInterface } from "readline";
+import { createServer } from "http";
+import { exec } from "child_process";
+import { Command } from "commander";
+import {
+ readConfig,
+ writeConfig,
+ getOAuthCredentials,
+ CONFIG_FILE,
+} from "../helpers.js";
+import { createNotionClient } from "../src/postman/notion-api/index.js";
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+const REDIRECT_URI = "http://localhost:8787/callback";
+const NOTION_AUTH_URL = "https://api.notion.com/v1/oauth/authorize";
+
+function promptMasked(message: string): Promise {
+ return new Promise((resolve) => {
+ process.stdout.write(message);
+
+ if (!process.stdin.isTTY) {
+ const rl = createInterface({ input: process.stdin, terminal: false });
+ rl.once("line", (line) => {
+ rl.close();
+ resolve(line.trim());
+ });
+ return;
+ }
+
+ process.stdin.setRawMode(true);
+ process.stdin.resume();
+ let input = "";
+
+ const onData = (key: Buffer) => {
+ const chars = key.toString();
+ for (const ch of chars) {
+ if (ch === "\n" || ch === "\r" || ch === "\u0004") {
+ process.stdin.setRawMode(false);
+ process.stdin.removeListener("data", onData);
+ process.stdin.pause();
+ process.stdout.write("\n");
+ resolve(input);
+ return;
+ } else if (ch === "\u0003") {
+ process.stdin.setRawMode(false);
+ process.stdout.write("\n");
+ process.exit(0);
+ } else if (ch === "\u007F" || ch === "\b") {
+ if (input.length > 0) {
+ input = input.slice(0, -1);
+ process.stdout.write("\b \b");
+ }
+ } else {
+ input += ch;
+ process.stdout.write("*");
+ }
+ }
+ };
+ process.stdin.on("data", onData);
+ });
+}
+
+function promptPlain(message: string): Promise {
+ return new Promise((resolve) => {
+ process.stdout.write(message);
+ const rl = createInterface({ input: process.stdin, terminal: false });
+ rl.once("line", (line) => {
+ rl.close();
+ process.stdin.pause();
+ resolve(line.trim());
+ });
+ });
+}
+
+function openBrowser(url: string): void {
+ const cmd =
+ process.platform === "darwin"
+ ? "open"
+ : process.platform === "win32"
+ ? "start"
+ : "xdg-open";
+ exec(`${cmd} "${url}"`);
+}
+
+function waitForCallback(port: number): Promise {
+ return new Promise((resolve, reject) => {
+ let timer: ReturnType;
+
+ function done() {
+ clearTimeout(timer);
+ server.close();
+ }
+
+ const server = createServer((req, res) => {
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
+
+ if (url.pathname !== "/callback") {
+ res.writeHead(404);
+ res.end("Not found");
+ return;
+ }
+
+ const code = url.searchParams.get("code");
+ const error = url.searchParams.get("error");
+
+ if (error) {
+ res.writeHead(200, { "Content-Type": "text/html" });
+ res.end(callbackPage("Authorization Failed", `Notion returned an error: ${error}. You can close this tab.`));
+ done();
+ reject(new Error(`OAuth authorization error: ${error}`));
+ return;
+ }
+
+ if (!code) {
+ res.writeHead(400, { "Content-Type": "text/html" });
+ res.end(callbackPage("Missing Code", "No authorization code received. You can close this tab."));
+ done();
+ reject(new Error("No authorization code in callback"));
+ return;
+ }
+
+ res.writeHead(200, { "Content-Type": "text/html" });
+ res.end(callbackPage("Success!", "Authorization complete. You can close this tab and return to the terminal."));
+ done();
+ resolve(code);
+ });
+
+ server.listen(port, () => {
+ // Server is ready
+ });
+
+ timer = setTimeout(() => {
+ server.close();
+ reject(new Error("Timed out waiting for OAuth callback (2 minutes)"));
+ }, 120_000);
+ });
+}
+
+function callbackPage(title: string, message: string): string {
+ return `
+
+notion-cli — ${title}
+
+
+
${title}
${message}
+`;
+}
+
+// ============================================================================
+// auth-public setup — save client credentials
+// ============================================================================
+
+const setupCommand = new Command("setup")
+ .description("Save your OAuth client ID and secret")
+ .action(async () => {
+ const existing = getOAuthCredentials();
+ if (existing) {
+ console.log("OAuth client credentials are already configured.");
+ console.log(` Client ID: ${existing.clientId.slice(0, 12)}...`);
+ console.log("");
+ const answer = await promptPlain("Overwrite? (y/N) ");
+ if (answer.toLowerCase() !== "y") {
+ console.log("Kept existing credentials.");
+ return;
+ }
+ console.log("");
+ }
+
+ console.log("Enter your OAuth client credentials.");
+ console.log("(Find these at https://www.notion.so/my-integrations under your public integration.)");
+ console.log("");
+ const clientId = await promptPlain("Client ID: ");
+ const clientSecret = await promptMasked("Client secret: ");
+
+ if (!clientId || !clientSecret) {
+ console.error("Both client ID and client secret are required.");
+ process.exit(1);
+ }
+
+ const config = readConfig();
+ config.NOTION_OAUTH_CLIENT_ID = clientId;
+ config.NOTION_OAUTH_CLIENT_SECRET = clientSecret;
+ writeConfig(config);
+
+ console.log("");
+ console.log("Client credentials saved.");
+ console.log("Run 'notion-cli auth-public login' to authenticate.");
+ process.exit(0);
+ });
+
+// ============================================================================
+// auth-public login — OAuth browser flow
+// ============================================================================
+
+const loginCommand = new Command("login")
+ .description("Authenticate via browser (OAuth authorization flow)")
+ .option("--no-browser", "print the authorization URL instead of opening it")
+ .action(async (options: { browser: boolean }) => {
+ // Check for client credentials
+ const creds = getOAuthCredentials();
+ if (!creds) {
+ console.error("No OAuth client credentials found.");
+ console.error("");
+ console.error("Run 'notion-cli auth-public setup' first to save your client ID and secret.");
+ process.exit(1);
+ }
+
+ // Build authorization URL
+ const authUrl = `${NOTION_AUTH_URL}?client_id=${encodeURIComponent(creds.clientId)}&response_type=code&owner=user&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`;
+
+ // Start local server
+ console.log("Starting local server for OAuth callback...");
+ const codePromise = waitForCallback(8787);
+
+ // Open browser or print URL
+ if (options.browser) {
+ console.log("Opening browser for authorization...");
+ console.log("");
+ openBrowser(authUrl);
+ } else {
+ console.log("Open this URL in your browser to authorize:");
+ console.log("");
+ console.log(` ${authUrl}`);
+ console.log("");
+ }
+
+ console.log("Waiting for authorization...");
+
+ // Wait for callback
+ let code: string;
+ try {
+ code = await codePromise;
+ } catch (err) {
+ console.error(`\n${err instanceof Error ? err.message : err}`);
+ process.exit(1);
+ }
+
+ console.log("Authorization code received. Exchanging for access token...");
+
+ // Exchange code for token
+ const notion = createNotionClient("");
+
+ try {
+ const response = await notion.oauth.token(
+ {
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: REDIRECT_URI,
+ },
+ creds.clientId,
+ creds.clientSecret,
+ );
+
+ // Store token and metadata
+ const config = readConfig();
+ config.NOTION_TOKEN = response.access_token;
+ config.auth_method = "oauth";
+ config.oauth_bot_id = response.bot_id;
+ config.oauth_workspace_name = response.workspace_name;
+ config.oauth_workspace_id = response.workspace_id;
+ writeConfig(config);
+
+ console.log("");
+ console.log("Authenticated successfully!");
+ console.log(` Workspace: ${response.workspace_name}`);
+ console.log(` Bot ID: ${response.bot_id}`);
+ console.log(` Token saved to ${CONFIG_FILE}`);
+ process.exit(0);
+ } catch (err) {
+ console.error(`\nError exchanging code: ${err instanceof Error ? err.message : err}`);
+ process.exit(1);
+ }
+ });
+
+// ============================================================================
+// auth-public status
+// ============================================================================
+
+const statusCommand = new Command("status")
+ .description("Check current OAuth authentication state")
+ .action(async () => {
+ const config = readConfig();
+ const creds = getOAuthCredentials();
+
+ // Client credentials
+ if (creds) {
+ console.log("Client credentials: configured");
+ console.log(` Client ID: ${creds.clientId.slice(0, 12)}...`);
+ } else {
+ console.log("Client credentials: not configured");
+ console.log(" Run 'notion-cli auth-public setup' to save them.");
+ }
+
+ console.log("");
+
+ // Access token
+ const isOAuth = config.auth_method === "oauth";
+ if (isOAuth && config.NOTION_TOKEN) {
+ console.log("Access token: configured");
+ console.log(` Token: ${config.NOTION_TOKEN.slice(0, 12)}...`);
+ if (config.oauth_workspace_name) console.log(` Workspace: ${config.oauth_workspace_name}`);
+ if (config.oauth_workspace_id) console.log(` Workspace ID: ${config.oauth_workspace_id}`);
+ if (config.oauth_bot_id) console.log(` Bot ID: ${config.oauth_bot_id}`);
+ } else if (config.NOTION_TOKEN && !isOAuth) {
+ console.log("Access token: using an internal integration token (not OAuth)");
+ console.log(" Run 'notion-cli auth-public login' to authenticate via OAuth instead.");
+ } else {
+ console.log("Access token: not authenticated");
+ if (creds) {
+ console.log(" Run 'notion-cli auth-public login' to authenticate.");
+ }
+ }
+ });
+
+// ============================================================================
+// auth-public logout
+// ============================================================================
+
+const logoutCommand = new Command("logout")
+ .description("Revoke and clear the stored OAuth access token")
+ .option("--all", "also remove stored client credentials")
+ .action(async (options: { all?: boolean }) => {
+ const config = readConfig();
+ let removed = false;
+
+ if (config.auth_method === "oauth" && config.NOTION_TOKEN) {
+ // Revoke the token with Notion so re-auth issues a fresh token
+ const creds = getOAuthCredentials();
+ if (creds) {
+ try {
+ const notion = createNotionClient("");
+ await notion.oauth.revoke(config.NOTION_TOKEN, creds.clientId, creds.clientSecret);
+ console.log("Token revoked with Notion.");
+ } catch {
+ console.log("Warning: could not revoke token with Notion (it may already be expired).");
+ }
+ }
+
+ delete (config as Record).NOTION_TOKEN;
+ delete (config as Record).auth_method;
+ delete (config as Record).oauth_bot_id;
+ delete (config as Record).oauth_workspace_name;
+ delete (config as Record).oauth_workspace_id;
+ removed = true;
+ }
+
+ if (options.all) {
+ if (config.NOTION_OAUTH_CLIENT_ID || config.NOTION_OAUTH_CLIENT_SECRET) {
+ delete (config as Record).NOTION_OAUTH_CLIENT_ID;
+ delete (config as Record).NOTION_OAUTH_CLIENT_SECRET;
+ removed = true;
+ }
+ }
+
+ if (!removed) {
+ console.log("No OAuth credentials to remove.");
+ return;
+ }
+
+ writeConfig(config as Record);
+ console.log("OAuth credentials removed.");
+ if (!options.all && (config.NOTION_OAUTH_CLIENT_ID || config.NOTION_OAUTH_CLIENT_SECRET)) {
+ console.log("(Client ID/secret retained — use --all to remove those too)");
+ }
+ });
+
+// ============================================================================
+// auth-public introspect — inspect a token's metadata
+// ============================================================================
+
+const introspectCommand = new Command("introspect")
+ .description("Inspect an OAuth token's metadata")
+ .argument("", "the token to introspect")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .action(async (token: string, options: { raw?: boolean }) => {
+ const creds = getOAuthCredentials();
+ if (!creds) {
+ console.error("No OAuth client credentials found.");
+ console.error("Run 'notion-cli auth-public setup' first.");
+ process.exit(1);
+ }
+
+ const notion = createNotionClient("");
+
+ try {
+ const response = await notion.oauth.introspect(token, creds.clientId, creds.clientSecret);
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ console.log(`Active: ${response.active}`);
+ if (response.bot_id) console.log(`Bot ID: ${response.bot_id}`);
+ if (response.workspace_id) console.log(`Workspace ID: ${response.workspace_id}`);
+ if (response.token_type) console.log(`Token type: ${response.token_type}`);
+ if (response.iat) console.log(`Issued at: ${response.iat}`);
+ if (response.exp) console.log(`Expires: ${response.exp}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// ============================================================================
+// auth-public revoke — revoke a token
+// ============================================================================
+
+const revokeCommand = new Command("revoke")
+ .description("Revoke an OAuth token")
+ .argument("", "the token to revoke")
+ .action(async (token: string) => {
+ const creds = getOAuthCredentials();
+ if (!creds) {
+ console.error("No OAuth client credentials found.");
+ console.error("Run 'notion-cli auth-public setup' first.");
+ process.exit(1);
+ }
+
+ const notion = createNotionClient("");
+
+ try {
+ await notion.oauth.revoke(token, creds.clientId, creds.clientSecret);
+ console.log("Token revoked.");
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// ============================================================================
+// auth-public command group
+// ============================================================================
+
+export const authPublicCommand = new Command("auth-public")
+ .description("Authenticate with a public integration (OAuth)")
+ .addHelpText(
+ "after",
+ `
+Public integrations use OAuth so other Notion users can install them.
+This requires a client ID and client secret from your integration's settings.
+
+To get started:
+ 1. Go to https://www.notion.so/my-integrations
+ 2. Create or select a public integration
+ 3. Copy the OAuth client ID and client secret
+ 4. Run: notion-cli auth-public setup
+ 5. Run: notion-cli auth-public login
+`,
+ )
+ .addCommand(setupCommand)
+ .addCommand(loginCommand)
+ .addCommand(statusCommand)
+ .addCommand(introspectCommand)
+ .addCommand(revokeCommand)
+ .addCommand(logoutCommand);
diff --git a/notion-cli/commands/block.ts b/notion-cli/commands/block.ts
new file mode 100644
index 0000000..1e29879
--- /dev/null
+++ b/notion-cli/commands/block.ts
@@ -0,0 +1,316 @@
+/**
+ * block command group
+ * block get — retrieve a single block
+ * block children — list a block's children
+ */
+
+import { Command } from "commander";
+import { createNotionClient } from "../src/postman/notion-api/index.js";
+import { getBearerToken, formatDate, formatBlock } from "../helpers.js";
+
+// -- block get ----------------------------------------------------------------
+
+const blockGetCommand = new Command("get")
+ .description("Retrieve a single block by ID")
+ .argument("", "the ID of the block to retrieve")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Fetches a single block object. Shows the block type, content,
+ parent, dates, and whether it has children.
+
+ Pages are also blocks — you can pass a page ID here.
+
+Examples:
+ $ notion-cli block get
+ $ notion-cli block get --raw
+`,
+ )
+ .action(async (blockId: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const block = await notion.blocks.retrieve(blockId);
+
+ if (options.raw) {
+ console.log(JSON.stringify(block, null, 2));
+ return;
+ }
+
+ const parent = block.parent;
+ let parentInfo: string;
+ if (parent.type === "page_id") {
+ parentInfo = `page (ID: ${parent.page_id})`;
+ } else if (parent.type === "block_id") {
+ parentInfo = `block (ID: ${parent.block_id})`;
+ } else if (parent.type === "database_id") {
+ parentInfo = `database (ID: ${parent.database_id})`;
+ } else {
+ parentInfo = "workspace";
+ }
+
+ console.log(`ID: ${block.id}`);
+ console.log(`Type: ${block.type}`);
+ console.log(`Parent: ${parentInfo}`);
+ console.log(`Has children: ${block.has_children}`);
+ console.log(`Archived: ${block.archived}`);
+ console.log(`Created: ${formatDate(block.created_time)}`);
+ console.log(`Last edited: ${formatDate(block.last_edited_time)}`);
+
+ const formatted = formatBlock(block);
+ if (formatted) {
+ console.log(`\nContent:\n ${formatted}`);
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- block children -----------------------------------------------------------
+
+const blockChildrenCommand = new Command("children")
+ .description("List a block's child blocks")
+ .argument("", "the ID of the block or page to get children for")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .option("-c, --cursor ", "pagination cursor from a previous request")
+ .option("-n, --limit ", "max results per page, 1-100", "100")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Returns the immediate children of a block. Pages are also blocks,
+ so you can pass a page ID to get the top-level content blocks.
+
+ Unlike "page get", this does NOT recurse into nested blocks.
+
+Examples:
+ $ notion-cli block children
+ $ notion-cli block children
+ $ notion-cli block children --raw
+ $ notion-cli block children -n 10
+`,
+ )
+ .action(async (blockId: string, options: { raw?: boolean; cursor?: string; limit: string }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+ const pageSize = Math.min(parseInt(options.limit, 10) || 100, 100);
+
+ try {
+ const response = await notion.blocks.retrieveChildren(blockId, {
+ start_cursor: options.cursor,
+ page_size: pageSize,
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ if (response.results.length === 0) {
+ console.log("No child blocks found.");
+ return;
+ }
+
+ console.log(`Found ${response.results.length} block(s):\n`);
+
+ for (const block of response.results) {
+ const formatted = formatBlock(block);
+ const children = block.has_children ? " [has children]" : "";
+ if (formatted) {
+ console.log(` ${formatted}${children}`);
+ } else {
+ console.log(` [${block.type}] (ID: ${block.id})${children}`);
+ }
+ }
+
+ if (response.has_more && response.next_cursor) {
+ console.log(`\n📑 More results available. Use --cursor to get next page:`);
+ console.log(` notion-cli block children ${blockId} --cursor ${response.next_cursor}`);
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- block append -------------------------------------------------------------
+
+const blockAppendCommand = new Command("append")
+ .description("Append child blocks to a page or block")
+ .argument("", "the ID of the parent page or block")
+ .argument("", "text content to append as a paragraph block")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .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.
+
+ For more complex block structures, use the generated client directly.
+
+Examples:
+ $ notion-cli block append "Hello, world!"
+ $ notion-cli block append "Nested content" --raw
+`,
+ )
+ .action(async (parentId: string, text: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ const children = [
+ {
+ object: "block",
+ type: "paragraph",
+ paragraph: {
+ rich_text: [{ type: "text", text: { content: text } }],
+ },
+ },
+ ];
+
+ try {
+ const response = await notion.blocks.appendChildren(parentId, children);
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ console.log(`Appended ${response.results.length} block(s).`);
+ for (const block of response.results) {
+ const formatted = formatBlock(block);
+ if (formatted) {
+ console.log(` ${formatted}`);
+ } else {
+ console.log(` [${block.type}] (ID: ${block.id})`);
+ }
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- block update -------------------------------------------------------------
+
+const blockUpdateCommand = new Command("update")
+ .description("Update a block's text content")
+ .argument("", "the ID of the block to update")
+ .argument("", "new text content for the block")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Updates a paragraph, heading, bulleted list item, numbered list item,
+ to-do, toggle, callout, or quote block with new text content.
+
+ First retrieves the block to determine its type, then sends the
+ update with the correct type key.
+
+Examples:
+ $ notion-cli block update "Updated text"
+ $ notion-cli block update "Updated text" --raw
+`,
+ )
+ .action(async (blockId: string, text: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ // First, retrieve the block to get its type
+ const existing = await notion.blocks.retrieve(blockId);
+ const blockType = existing.type;
+
+ // Types that support rich_text updates
+ const richTextTypes = [
+ "paragraph", "heading_1", "heading_2", "heading_3",
+ "bulleted_list_item", "numbered_list_item", "to_do",
+ "toggle", "callout", "quote",
+ ];
+
+ if (!richTextTypes.includes(blockType)) {
+ console.error(`Error: block type "${blockType}" does not support text updates via this command.`);
+ process.exit(1);
+ }
+
+ const params: Record = {
+ [blockType]: {
+ rich_text: [{ type: "text", text: { content: text } }],
+ },
+ };
+
+ const block = await notion.blocks.update(blockId, params);
+
+ if (options.raw) {
+ console.log(JSON.stringify(block, null, 2));
+ return;
+ }
+
+ console.log(`Block updated.`);
+ console.log(` ID: ${block.id}`);
+ console.log(` Type: ${block.type}`);
+ const formatted = formatBlock(block);
+ if (formatted) {
+ console.log(` Content: ${formatted}`);
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- block delete -------------------------------------------------------------
+
+const blockDeleteCommand = new Command("delete")
+ .description("Delete (archive) a block")
+ .argument("", "the ID of the block to delete")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Deletes a block by setting its archived status to true.
+ The block can be restored via the Notion UI or by updating
+ it with archived: false.
+
+Examples:
+ $ notion-cli block delete
+ $ notion-cli block delete --raw
+`,
+ )
+ .action(async (blockId: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const block = await notion.blocks.delete(blockId);
+
+ if (options.raw) {
+ console.log(JSON.stringify(block, null, 2));
+ return;
+ }
+
+ console.log(`Block deleted.`);
+ console.log(` ID: ${block.id}`);
+ console.log(` Type: ${block.type}`);
+ console.log(` Archived: ${block.archived}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- block command group ------------------------------------------------------
+
+export const blockCommand = new Command("block")
+ .description("Read and inspect Notion blocks")
+ .addCommand(blockGetCommand)
+ .addCommand(blockChildrenCommand)
+ .addCommand(blockAppendCommand)
+ .addCommand(blockUpdateCommand)
+ .addCommand(blockDeleteCommand);
diff --git a/notion-cli/commands/comment.ts b/notion-cli/commands/comment.ts
new file mode 100644
index 0000000..ef404bb
--- /dev/null
+++ b/notion-cli/commands/comment.ts
@@ -0,0 +1,220 @@
+/**
+ * comment command group
+ * comment list — list comments
+ * comment add — add a comment to a page
+ * comment reply — reply to a comment thread
+ */
+
+import { Command } from "commander";
+import { createNotionClient } from "../src/postman/notion-api/index.js";
+import { getBearerToken, formatDate } from "../helpers.js";
+
+// -- comment list -------------------------------------------------------------
+
+const commentListCommand = new Command("list")
+ .description("List comments on a page or block")
+ .argument("", "the ID of the page or block to get comments for")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .option("-c, --cursor ", "pagination cursor from a previous request")
+ .option("-n, --limit ", "max results per page, 1-100", "100")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Returns comments on a page or block. Pages are also blocks,
+ so a page ID works here too.
+
+ Comments are grouped by discussion_id — comments with the same
+ discussion_id are part of the same thread.
+
+Examples:
+ $ notion-cli comment list
+ $ notion-cli comment list --raw
+`,
+ )
+ .action(async (blockId: string, options: { raw?: boolean; cursor?: string; limit: string }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+ const pageSize = Math.min(parseInt(options.limit, 10) || 100, 100);
+
+ try {
+ const response = await notion.comments.list(blockId, {
+ start_cursor: options.cursor,
+ page_size: pageSize,
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ if (response.results.length === 0) {
+ console.log("No comments found.");
+ return;
+ }
+
+ console.log(`Found ${response.results.length} comment(s):\n`);
+
+ for (const comment of response.results) {
+ const text = comment.rich_text.map((t) => t.plain_text).join("") || "(empty)";
+ const created = formatDate(comment.created_time);
+ const author = comment.created_by.id;
+
+ console.log(` 💬 ${text}`);
+ console.log(` ID: ${comment.id}`);
+ console.log(` Discussion: ${comment.discussion_id}`);
+ console.log(` Author: ${author}`);
+ console.log(` Created: ${created}`);
+ console.log();
+ }
+
+ if (response.has_more && response.next_cursor) {
+ console.log(`📑 More results available. Use --cursor to get next page:`);
+ console.log(` notion-cli comment list ${blockId} --cursor ${response.next_cursor}`);
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- comment add --------------------------------------------------------------
+
+const commentAddCommand = new Command("add")
+ .description("Add a comment to a page")
+ .argument("", "the ID of the page to comment on")
+ .argument("", "the comment text")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Creates a new top-level comment thread on the specified page.
+ The text is added as a plain text comment.
+
+Examples:
+ $ notion-cli comment add "This looks great!"
+ $ notion-cli comment add "Needs review" --raw
+`,
+ )
+ .action(async (pageId: string, text: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const comment = await notion.comments.create({
+ parent: { page_id: pageId },
+ rich_text: [{ text: { content: text } }],
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(comment, null, 2));
+ return;
+ }
+
+ console.log(`Comment added.`);
+ console.log(` ID: ${comment.id}`);
+ console.log(` Discussion: ${comment.discussion_id}`);
+ console.log(` Created: ${formatDate(comment.created_time)}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- comment reply ------------------------------------------------------------
+
+const commentReplyCommand = new Command("reply")
+ .description("Reply to a comment thread")
+ .argument("", "the discussion ID to reply to")
+ .argument("", "the reply text")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Replies to an existing comment thread. The discussion_id can be
+ found in the output of "comment list".
+
+Examples:
+ $ notion-cli comment reply "I agree!"
+ $ notion-cli comment reply "Done." --raw
+`,
+ )
+ .action(async (discussionId: string, text: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const comment = await notion.comments.create({
+ discussion_id: discussionId,
+ rich_text: [{ text: { content: text } }],
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(comment, null, 2));
+ return;
+ }
+
+ console.log(`Reply added.`);
+ console.log(` ID: ${comment.id}`);
+ console.log(` Discussion: ${comment.discussion_id}`);
+ console.log(` Created: ${formatDate(comment.created_time)}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- comment get --------------------------------------------------------------
+
+const commentGetCommand = new Command("get")
+ .description("Retrieve a single comment by ID")
+ .argument("", "the ID of the comment to retrieve")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Retrieves a single comment by its ID. Shows the comment text,
+ discussion thread ID, author, and creation date.
+
+ Comment IDs can be found in the output of "comment list".
+
+Examples:
+ $ notion-cli comment get
+ $ notion-cli comment get --raw
+`,
+ )
+ .action(async (commentId: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const comment = await notion.comments.retrieve(commentId);
+
+ if (options.raw) {
+ console.log(JSON.stringify(comment, null, 2));
+ return;
+ }
+
+ const text = comment.rich_text.map((t) => t.plain_text).join("") || "(empty)";
+ console.log(`💬 ${text}`);
+ console.log(` ID: ${comment.id}`);
+ console.log(` Discussion: ${comment.discussion_id}`);
+ console.log(` Author: ${comment.created_by.id}`);
+ console.log(` Created: ${formatDate(comment.created_time)}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- comment command group ----------------------------------------------------
+
+export const commentCommand = new Command("comment")
+ .description("Read and manage comments on pages and blocks")
+ .addCommand(commentListCommand)
+ .addCommand(commentGetCommand)
+ .addCommand(commentAddCommand)
+ .addCommand(commentReplyCommand);
diff --git a/notion-cli/commands/database.ts b/notion-cli/commands/database.ts
index 70e9d37..5379411 100644
--- a/notion-cli/commands/database.ts
+++ b/notion-cli/commands/database.ts
@@ -61,9 +61,26 @@ Examples:
console.log(`Parent: ${parentInfo}`);
console.log(`Created: ${formatDate(database.created_time)}`);
console.log(`Last edited: ${formatDate(database.last_edited_time)}`);
+ if (database.archived !== undefined) {
+ console.log(`Archived: ${database.archived}`);
+ }
+ if (database.in_trash) {
+ console.log(`In trash: ${database.in_trash}`);
+ }
+ if (database.is_locked) {
+ console.log(`Locked: ${database.is_locked}`);
+ }
console.log(`URL: ${database.url}`);
- const propEntries = Object.entries(database.properties);
+ // Show data sources (new in 2025-09-03)
+ if (database.data_sources && database.data_sources.length > 0) {
+ console.log(`\nData sources (${database.data_sources.length}):`);
+ for (const ds of database.data_sources) {
+ console.log(` ${ds.name || "(Unnamed)"} — ID: ${ds.id}`);
+ }
+ }
+
+ const propEntries = Object.entries(database.properties || {});
if (propEntries.length > 0) {
console.log(`\nSchema (${propEntries.length} properties):`);
for (const [name, prop] of propEntries) {
@@ -72,7 +89,13 @@ Examples:
}
}
- console.log(`\nTo list entries: notion-cli database list ${databaseId}`);
+ // Suggest using data source ID for queries if available
+ 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);
@@ -114,10 +137,23 @@ Examples:
console.log(`🗃️ Listing database entries...\n`);
try {
- const queryResponse = await notion.databases.query(databaseId, {
- page_size: pageSize,
- start_cursor: options.cursor,
- });
+ // 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));
@@ -164,9 +200,117 @@ Examples:
}
});
+// -- database create ----------------------------------------------------------
+
+const databaseCreateCommand = new Command("create")
+ .description("Create a database as a child of a page")
+ .argument("", "ID of the parent page")
+ .option("-t, --title ", "database title")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Creates a new inline database as a child of the specified page.
+ The database is created with a single "Name" title property.
+ Use "database update" to add more properties after creation.
+
+Examples:
+ $ notion-cli database create --title "Task Tracker"
+ $ notion-cli database create --title "Task Tracker" --raw
+`,
+ )
+ .action(async (parentPageId: string, options: { title?: string; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const db = await notion.databases.create({
+ parent: { type: "page_id", page_id: parentPageId },
+ title: options.title ? [{ text: { content: options.title } }] : undefined,
+ properties: {
+ Name: { title: {} },
+ },
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(db, null, 2));
+ return;
+ }
+
+ const title = db.title.map((t) => t.plain_text).join("") || "(Untitled)";
+ console.log(`Database created.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${db.id}`);
+ console.log(` URL: ${db.url}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- database update ----------------------------------------------------------
+
+const databaseUpdateCommand = new Command("update")
+ .description("Update a database's title or description")
+ .argument("", "ID of the database to update")
+ .option("-t, --title ", "set a new title")
+ .option("-d, --description ", "set a new description")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Updates a database's title and/or description.
+ Only the specified fields are updated; others remain unchanged.
+
+Examples:
+ $ notion-cli database update --title "New Title"
+ $ notion-cli database update --description "Updated description"
+ $ notion-cli database update --title "New" --description "Desc" --raw
+`,
+ )
+ .action(async (databaseId: string, options: { title?: string; description?: string; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ const params: Record = {};
+ if (options.title) {
+ params.title = [{ text: { content: options.title } }];
+ }
+ if (options.description) {
+ params.description = [{ text: { content: options.description } }];
+ }
+
+ if (Object.keys(params).length === 0) {
+ console.error("Error: nothing to update. Use --title or --description.");
+ process.exit(1);
+ }
+
+ try {
+ const db = await notion.databases.update(databaseId, params);
+
+ if (options.raw) {
+ console.log(JSON.stringify(db, null, 2));
+ return;
+ }
+
+ const title = db.title.map((t) => t.plain_text).join("") || "(Untitled)";
+ console.log(`Database updated.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${db.id}`);
+ console.log(` URL: ${db.url}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
// -- database command group ---------------------------------------------------
export const databaseCommand = new Command("database")
.description("View and query Notion databases")
.addCommand(databaseGetCommand)
- .addCommand(databaseListCommand);
+ .addCommand(databaseListCommand)
+ .addCommand(databaseCreateCommand)
+ .addCommand(databaseUpdateCommand);
diff --git a/notion-cli/commands/datasource.ts b/notion-cli/commands/datasource.ts
new file mode 100644
index 0000000..6d64315
--- /dev/null
+++ b/notion-cli/commands/datasource.ts
@@ -0,0 +1,333 @@
+/**
+ * datasource command group
+ * datasource get — retrieve a data source
+ * 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
+ */
+
+import { Command } from "commander";
+import { createNotionClient, type DatabasePropertySchema } from "../src/postman/notion-api/index.js";
+import { getBearerToken, getPageTitle, formatDate, formatPropertyValue } from "../helpers.js";
+
+// -- datasource get -----------------------------------------------------------
+
+const datasourceGetCommand = new Command("get")
+ .description("Retrieve a data source by ID")
+ .argument("", "data source ID")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Retrieves a single data source and displays its metadata, parent
+ database, and property schema.
+
+ Data source IDs can be found in the output of "database get".
+
+Examples:
+ $ notion-cli datasource get
+ $ notion-cli datasource get --raw
+`,
+ )
+ .action(async (datasourceId: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const ds = await notion.dataSources.retrieve(datasourceId);
+
+ if (options.raw) {
+ console.log(JSON.stringify(ds, null, 2));
+ return;
+ }
+
+ const title = ds.title?.map((t) => t.plain_text).join("") || "(Untitled)";
+
+ console.log(`Title: ${title}`);
+ console.log(`ID: ${ds.id}`);
+
+ // Data sources return a parent object — display it generically
+ const parent = ds.parent as unknown as Record;
+ if (parent) {
+ if (parent.type === "database_id") {
+ console.log(`Parent database: ${parent.database_id}`);
+ } else if (parent.type === "page_id") {
+ console.log(`Parent page: ${parent.page_id}`);
+ }
+ }
+
+ console.log(`Created: ${formatDate(ds.created_time)}`);
+ console.log(`Last edited: ${formatDate(ds.last_edited_time)}`);
+ if (ds.archived !== undefined) {
+ console.log(`Archived: ${ds.archived}`);
+ }
+ if (ds.in_trash) {
+ console.log(`In trash: ${ds.in_trash}`);
+ }
+
+ const propEntries = Object.entries(ds.properties || {});
+ if (propEntries.length > 0) {
+ console.log(`\nSchema (${propEntries.length} properties):`);
+ for (const [name, prop] of propEntries) {
+ const schema = prop as DatabasePropertySchema;
+ console.log(` ${name}: ${schema.type}`);
+ }
+ }
+
+ console.log(`\nTo query entries: notion-cli datasource query ${datasourceId}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- datasource query ---------------------------------------------------------
+
+const datasourceQueryCommand = new Command("query")
+ .description("Query entries from a data source")
+ .argument("", "data source 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:
+ Queries entries (pages) from a data source, with pagination.
+
+ Each entry is a Notion page. To read an entry's full content,
+ use "page get ".
+
+ Data source IDs can be found in the output of "database get".
+
+Examples:
+ $ notion-cli datasource query
+ $ notion-cli datasource query --limit 50
+ $ notion-cli datasource query --raw
+`,
+ )
+ .action(async (datasourceId: 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);
+
+ try {
+ const response = await notion.dataSources.query(datasourceId, {
+ page_size: pageSize,
+ start_cursor: options.cursor,
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ if (response.results.length === 0) {
+ console.log("No entries found.");
+ return;
+ }
+
+ console.log(`Entries (${response.results.length}${response.has_more ? "+" : ""}):\n`);
+ console.log("─".repeat(60));
+
+ for (const page of response.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 (response.has_more && response.next_cursor) {
+ console.log(`\n📑 More entries available. Next page:`);
+ console.log(` notion-cli datasource query ${datasourceId} --cursor ${response.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);
+ }
+ });
+
+// -- datasource templates -----------------------------------------------------
+
+const datasourceTemplatesCommand = new Command("templates")
+ .description("List available page templates for a data source")
+ .argument("", "data source ID")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .option("-c, --cursor ", "pagination cursor from a previous request")
+ .option("-n, --limit ", "max results per page, 1-100")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Lists page templates available for the data source. Templates are
+ pages that serve as blueprints for new entries.
+
+Examples:
+ $ notion-cli datasource templates
+ $ notion-cli datasource templates --raw
+`,
+ )
+ .action(async (datasourceId: string, options: { raw?: boolean; cursor?: string; limit?: string }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const response = await notion.dataSources.listTemplates(datasourceId, {
+ start_cursor: options.cursor,
+ page_size: options.limit ? Math.min(parseInt(options.limit, 10) || 100, 100) : undefined,
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ if (response.templates.length === 0) {
+ console.log("No templates found.");
+ return;
+ }
+
+ console.log(`Found ${response.templates.length} template(s):\n`);
+
+ for (const template of response.templates) {
+ console.log(` 📋 ${template.name || "(unnamed)"}`);
+ console.log(` ID: ${template.id}`);
+ if (template.is_default) {
+ console.log(` Default: yes`);
+ }
+ console.log();
+ }
+
+ if (response.has_more && response.next_cursor) {
+ console.log(`📑 More templates available. Use --cursor to get next page:`);
+ console.log(` notion-cli datasource templates ${datasourceId} --cursor ${response.next_cursor}`);
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- datasource create --------------------------------------------------------
+
+const datasourceCreateCommand = new Command("create")
+ .description("Create a data source in a database")
+ .argument("", "the ID of the parent database")
+ .option("-t, --title ", "data source title")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Creates a new data source in the specified database.
+
+ Use "datasource templates" to see available templates first.
+
+Examples:
+ $ notion-cli datasource create --title "Q1 Data"
+ $ notion-cli datasource create --title "Q1 Data" --raw
+`,
+ )
+ .action(async (databaseId: string, options: { title?: string; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const ds = await notion.dataSources.create({
+ parent: { type: "database_id", database_id: databaseId },
+ title: options.title ? [{ text: { content: options.title } }] : undefined,
+ properties: {},
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(ds, null, 2));
+ return;
+ }
+
+ const title = ds.title?.map((t) => t.plain_text).join("") || "(Untitled)";
+ console.log(`Data source created.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${ds.id}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- datasource update --------------------------------------------------------
+
+const datasourceUpdateCommand = new Command("update")
+ .description("Update a data source")
+ .argument("", "the ID of the data source to update")
+ .option("-t, --title ", "set a new title")
+ .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.
+
+Examples:
+ $ notion-cli datasource update --title "New Title"
+ $ notion-cli datasource update --title "New Title" --raw
+`,
+ )
+ .action(async (datasourceId: string, options: { title?: string; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ const params: Record = {};
+ if (options.title) {
+ params.title = [{ text: { content: options.title } }];
+ }
+
+ if (Object.keys(params).length === 0) {
+ console.error("Error: nothing to update. Use --title to set a new title.");
+ process.exit(1);
+ }
+
+ try {
+ const ds = await notion.dataSources.update(datasourceId, params);
+
+ if (options.raw) {
+ console.log(JSON.stringify(ds, null, 2));
+ return;
+ }
+
+ const title = ds.title?.map((t) => t.plain_text).join("") || "(Untitled)";
+ console.log(`Data source updated.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${ds.id}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- datasource command group -------------------------------------------------
+
+export const datasourceCommand = new Command("datasource")
+ .description("Work with data sources directly")
+ .addCommand(datasourceGetCommand)
+ .addCommand(datasourceQueryCommand)
+ .addCommand(datasourceTemplatesCommand)
+ .addCommand(datasourceCreateCommand)
+ .addCommand(datasourceUpdateCommand);
diff --git a/notion-cli/commands/docs.ts b/notion-cli/commands/docs.ts
new file mode 100644
index 0000000..048311b
--- /dev/null
+++ b/notion-cli/commands/docs.ts
@@ -0,0 +1,79 @@
+/**
+ * docs command - display guides and workflows for using the CLI
+ */
+
+import { Command } from "commander";
+
+const DOCS = `
+NOTION CLI — DOCUMENTATION
+===========================
+
+SETUP
+-----
+
+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
+
+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.
+
+The token is stored in ~/.notion-cli/config.json. You can also set the
+NOTION_TOKEN environment variable, which takes precedence.
+
+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:
+
+ Step 1 — Find root pages:
+ $ notion-cli integration pages
+
+ Step 2 — Read each root page:
+ $ 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:
+ $ notion-cli database get
+ $ notion-cli database list
+
+ Step 4 — Read child pages and database entries:
+ $ notion-cli page get
+
+ Repeat steps 2–4 to traverse the full tree.
+
+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)
+ • IDs are in the output — every child shows its ID for navigation
+
+OUTPUT & PERFORMANCE
+--------------------
+
+All commands output human-readable text by default. Use --raw on most
+commands for JSON output (useful for piping or programmatic access).
+
+ • 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)
+ • 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")
+ .description("Show guides and workflows for using the CLI")
+ .action(() => {
+ process.stdout.write(DOCS);
+ });
diff --git a/notion-cli/commands/file.ts b/notion-cli/commands/file.ts
new file mode 100644
index 0000000..8c3f884
--- /dev/null
+++ b/notion-cli/commands/file.ts
@@ -0,0 +1,196 @@
+/**
+ * file command group
+ * file upload — upload a file (create + send + complete)
+ * file list — list file uploads
+ * file get — retrieve a file upload
+ */
+
+import { Command } from "commander";
+import { readFileSync, statSync } from "fs";
+import { basename } from "path";
+import { createNotionClient } from "../src/postman/notion-api/index.js";
+import { getBearerToken, formatDate, lookup as mimeLookup } from "../helpers.js";
+
+// -- file upload --------------------------------------------------------------
+
+const fileUploadCommand = new Command("upload")
+ .description("Upload a file to Notion")
+ .argument("", "path to the file to upload")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Uploads a local file to Notion in three steps:
+ 1. Creates a file upload (reserves an upload slot)
+ 2. Sends the file data (multipart/form-data)
+ 3. Completes the upload
+
+ The resulting file upload ID can be used to attach the file
+ to a page or block via the API.
+
+Examples:
+ $ notion-cli file upload ./report.pdf
+ $ notion-cli file upload ./image.png --raw
+`,
+ )
+ .action(async (filePath: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const stat = statSync(filePath);
+ const fileName = basename(filePath);
+ const fileData = readFileSync(filePath);
+ const contentType = mimeLookup(fileName);
+
+ console.log(`📎 Uploading ${fileName} (${stat.size} bytes)...\n`);
+
+ // Step 1: Create the file upload
+ const upload = await notion.fileUploads.create({
+ mode: "single_part",
+ filename: fileName,
+ content_type: contentType,
+ });
+
+ // Step 2: Send the file data as multipart/form-data
+ const blob = new Blob([fileData], { type: contentType });
+ const sent = await notion.fileUploads.send(upload.id, blob, fileName);
+
+ // Step 3: Complete (only needed for multi_part uploads)
+ // For single_part, the send step marks it as "uploaded" automatically.
+ let completed = sent;
+ if (sent.status === "pending" && sent.number_of_parts) {
+ completed = await notion.fileUploads.complete(upload.id);
+ }
+
+ if (options.raw) {
+ console.log(JSON.stringify(completed, null, 2));
+ return;
+ }
+
+ console.log(`File uploaded.`);
+ console.log(` Filename: ${completed.filename}`);
+ console.log(` ID: ${completed.id}`);
+ console.log(` Content type: ${completed.content_type}`);
+ console.log(` Size: ${completed.content_length ?? "unknown"} bytes`);
+ console.log(` Status: ${completed.status}`);
+ console.log(` Created: ${formatDate(completed.created_time)}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- file list ----------------------------------------------------------------
+
+const fileListCommand = new Command("list")
+ .description("List file uploads")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .option("-n, --limit ", "max results per page, 1-100", "20")
+ .option("-c, --cursor ", "pagination cursor from a previous request")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Lists file uploads for the current integration. Shows each
+ file's name, ID, size, status, and creation date.
+
+Examples:
+ $ notion-cli file list
+ $ notion-cli file list --limit 50
+ $ notion-cli file list --raw
+`,
+ )
+ .action(async (options: { raw?: boolean; limit: string; cursor?: string }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+ const pageSize = Math.min(parseInt(options.limit, 10) || 20, 100);
+
+ try {
+ const response = await notion.fileUploads.list({
+ page_size: pageSize,
+ start_cursor: options.cursor,
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(response, null, 2));
+ return;
+ }
+
+ if (response.results.length === 0) {
+ console.log("No file uploads found.");
+ return;
+ }
+
+ console.log(`Found ${response.results.length} file upload(s):\n`);
+
+ for (const file of response.results) {
+ console.log(` 📎 ${file.filename || "(unnamed)"}`);
+ console.log(` ID: ${file.id}`);
+ console.log(` Content type: ${file.content_type || "unknown"}`);
+ console.log(` Size: ${file.content_length ?? "unknown"} bytes`);
+ console.log(` Status: ${file.status}`);
+ console.log(` Created: ${formatDate(file.created_time)}`);
+ console.log();
+ }
+
+ if (response.has_more && response.next_cursor) {
+ console.log(`📑 More results available. Use --cursor to get next page:`);
+ console.log(` notion-cli file list --cursor ${response.next_cursor}`);
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- file get -----------------------------------------------------------------
+
+const fileGetCommand = new Command("get")
+ .description("Retrieve a file upload by ID")
+ .argument("", "the ID of the file upload to retrieve")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Retrieves a single file upload by its ID. Shows the file's
+ name, size, status, and creation date.
+
+Examples:
+ $ notion-cli file get
+ $ notion-cli file get --raw
+`,
+ )
+ .action(async (fileId: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const file = await notion.fileUploads.retrieve(fileId);
+
+ if (options.raw) {
+ console.log(JSON.stringify(file, null, 2));
+ return;
+ }
+
+ console.log(`📎 ${file.filename || "(unnamed)"}`);
+ console.log(` ID: ${file.id}`);
+ console.log(` Content type: ${file.content_type || "unknown"}`);
+ console.log(` Size: ${file.content_length ?? "unknown"} bytes`);
+ console.log(` Status: ${file.status}`);
+ console.log(` Created: ${formatDate(file.created_time)}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- file command group -------------------------------------------------------
+
+export const fileCommand = new Command("file")
+ .description("Upload and manage files")
+ .addCommand(fileUploadCommand)
+ .addCommand(fileListCommand)
+ .addCommand(fileGetCommand);
diff --git a/notion-cli/commands/integration.ts b/notion-cli/commands/integration.ts
new file mode 100644
index 0000000..74b0be0
--- /dev/null
+++ b/notion-cli/commands/integration.ts
@@ -0,0 +1,113 @@
+/**
+ * integration command group
+ * integration pages — list the root pages the integration can access
+ */
+
+import { Command } from "commander";
+import { createNotionClient, type NotionPage } from "../src/postman/notion-api/index.js";
+import { getBearerToken, getPageTitle, formatDate } from "../helpers.js";
+
+// -- integration pages --------------------------------------------------------
+
+const integrationPagesCommand = new Command("pages")
+ .description("List root pages the integration can access")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Lists the root pages visible to this integration — the entry points
+ for navigating workspace content.
+
+ A page is considered a root if:
+ 1. Its parent is the workspace itself (a true top-level page), OR
+ 2. Its parent page is not accessible to the integration.
+
+ Case 2 matters when an integration is shared with a page that is
+ nested inside the workspace hierarchy. That page has a parent, but
+ the integration can't see the parent, so it's effectively a root
+ from the integration's perspective.
+
+ How it works: paginates through all pages via the Search API,
+ collects every page ID, then returns pages whose parent is either
+ the workspace or a page ID not in the collected set.
+
+ 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.
+
+Examples:
+ $ notion-cli integration pages
+`,
+ )
+ .action(async () => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ console.log("🔍 Finding root pages...\n");
+
+ try {
+ const allPages: NotionPage[] = [];
+ let cursor: string | undefined;
+
+ // Paginate through all pages the integration can see
+ do {
+ const response = await notion.search({
+ filter: { value: "page", property: "object" },
+ start_cursor: cursor,
+ page_size: 100,
+ });
+
+ for (const result of response.results) {
+ if (result.object === "page") {
+ allPages.push(result as NotionPage);
+ }
+ }
+
+ cursor = response.has_more ? (response.next_cursor ?? undefined) : undefined;
+ } while (cursor);
+
+ // Build a set of all visible page IDs
+ const visibleIds = new Set(allPages.map((p) => p.id));
+
+ // A page is a root if its parent is the workspace, or its parent
+ // page isn't in the set of pages the integration can see.
+ const rootPages = allPages.filter((page) => {
+ if (page.parent.type === "workspace") return true;
+ if (page.parent.type === "page_id" && page.parent.page_id) return !visibleIds.has(page.parent.page_id);
+ // database/data source children are not roots — they live inside a visible database
+ if (page.parent.type === "database_id" || page.parent.type === "data_source_id") return false;
+ return false;
+ });
+
+ if (rootPages.length === 0) {
+ console.log("No root pages found.");
+ console.log(`(Scanned ${allPages.length} total pages)`);
+ return;
+ }
+
+ console.log(`Found ${rootPages.length} root page(s) (scanned ${allPages.length} total):\n`);
+
+ for (const page of rootPages) {
+ const title = getPageTitle(page);
+ const lastEdited = formatDate(page.last_edited_time);
+ const created = formatDate(page.created_time);
+
+ console.log(` 📄 ${title}`);
+ console.log(` ID: ${page.id}`);
+ console.log(` Created: ${created}`);
+ console.log(` Last edited: ${lastEdited}`);
+ console.log(` Archived: ${page.archived}`);
+ console.log(` URL: ${page.url}`);
+ console.log();
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- integration command group ------------------------------------------------
+
+export const integrationCommand = new Command("integration")
+ .description("Inspect the integration's access and capabilities")
+ .addCommand(integrationPagesCommand);
diff --git a/notion-cli/commands/page.ts b/notion-cli/commands/page.ts
index 0dbd519..88f9690 100644
--- a/notion-cli/commands/page.ts
+++ b/notion-cli/commands/page.ts
@@ -51,6 +51,8 @@ Examples:
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 {
@@ -62,6 +64,10 @@ Examples:
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)
@@ -145,8 +151,281 @@ Examples:
}
});
+// -- page create --------------------------------------------------------------
+
+const pageCreateCommand = new Command("create")
+ .description("Create a new page under a parent page or database")
+ .argument("", "ID of the parent page or database")
+ .option("-t, --title ", "page title")
+ .option("-d, --database", "parent is a database (default: parent is a page)")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Creates a new page. By default, the parent is treated as a page.
+ Use --database if the parent is a database (properties must match
+ the database schema).
+
+ For simple pages under a parent page, --title is all you need.
+
+Examples:
+ $ notion-cli page create --title "My New Page"
+ $ notion-cli page create --title "My New Page" --raw
+`,
+ )
+ .action(async (parentId: string, options: { title?: string; database?: boolean; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ const parent = options.database
+ ? { database_id: parentId }
+ : { page_id: parentId };
+
+ const properties: Record = {};
+ if (options.title) {
+ properties.title = [{ text: { content: options.title } }];
+ }
+
+ try {
+ const page = await notion.pages.create({ parent, properties });
+
+ if (options.raw) {
+ console.log(JSON.stringify(page, null, 2));
+ return;
+ }
+
+ const title = getPageTitle(page);
+ console.log(`Page created.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${page.id}`);
+ console.log(` URL: ${page.url}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- page archive -------------------------------------------------------------
+
+const pageArchiveCommand = new Command("archive")
+ .description("Archive (soft-delete) a page")
+ .argument("", "the ID of the page to archive")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Archives a page by setting its archived property to true.
+ The page can be restored later from the Notion UI.
+
+Examples:
+ $ notion-cli page archive
+ $ notion-cli page archive --raw
+`,
+ )
+ .action(async (pageId: string, options: { raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const page = await notion.pages.archive(pageId);
+
+ if (options.raw) {
+ console.log(JSON.stringify(page, null, 2));
+ return;
+ }
+
+ console.log(`Page archived.`);
+ console.log(` ID: ${page.id}`);
+ console.log(` Archived: ${page.archived}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- page property ------------------------------------------------------------
+
+const pagePropertyCommand = new Command("property")
+ .description("Retrieve a page property item")
+ .argument("", "the ID of the page")
+ .argument("", "the ID of the property to retrieve")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .option("-c, --cursor ", "pagination cursor (for paginated properties)")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Retrieves a single property value from a page. Useful for paginated
+ properties (rollups, relations with many entries, long rich_text or
+ title values) where the full value isn't returned in page get.
+
+ The property ID can be found in the "page get --raw" output or in
+ the database schema from "database get --raw".
+
+Examples:
+ $ notion-cli page property
+ $ notion-cli page property title
+ $ notion-cli page property --raw
+`,
+ )
+ .action(async (pageId: string, propertyId: string, options: { raw?: boolean; cursor?: string }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const result = await notion.pages.retrieveProperty(pageId, propertyId, {
+ start_cursor: options.cursor,
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(result, null, 2));
+ return;
+ }
+
+ console.log(`Type: ${result.type}`);
+
+ // Paginated response (title, rich_text, relation, rollup)
+ if (result.object === "list" && result.results) {
+ console.log(`Items: ${result.results.length}`);
+ for (const item of result.results) {
+ // Try to extract a readable value
+ const val = item[item.type] as Record | undefined;
+ if (val && "plain_text" in val) {
+ console.log(` ${val.plain_text}`);
+ } else if (val && "id" in val) {
+ console.log(` ID: ${val.id}`);
+ } else {
+ console.log(` ${JSON.stringify(val)}`);
+ }
+ }
+ if (result.has_more && result.next_cursor) {
+ console.log(`\n📑 More items available. Use --cursor to get next page:`);
+ console.log(` notion-cli page property ${pageId} ${propertyId} --cursor ${result.next_cursor}`);
+ }
+ } else {
+ // Single-value response (select, number, checkbox, etc.)
+ const val = result[result.type];
+ if (val === null || val === undefined) {
+ console.log(`Value: (empty)`);
+ } else if (typeof val === "object") {
+ console.log(`Value: ${JSON.stringify(val, null, 2)}`);
+ } else {
+ console.log(`Value: ${val}`);
+ }
+ }
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- page update --------------------------------------------------------------
+
+const pageUpdateCommand = new Command("update")
+ .description("Update a page's properties")
+ .argument("", "the ID of the page to update")
+ .option("-t, --title ", "set a new title")
+ .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.
+
+Examples:
+ $ notion-cli page update --title "New Title"
+ $ notion-cli page update --title "New Title" --raw
+`,
+ )
+ .action(async (pageId: string, options: { title?: string; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ const properties: Record = {};
+ if (options.title) {
+ properties.title = [{ text: { content: options.title } }];
+ }
+
+ if (Object.keys(properties).length === 0) {
+ console.error("Error: nothing to update. Use --title to set a new title.");
+ process.exit(1);
+ }
+
+ try {
+ const page = await notion.pages.update(pageId, { properties });
+
+ if (options.raw) {
+ console.log(JSON.stringify(page, null, 2));
+ return;
+ }
+
+ const title = getPageTitle(page);
+ console.log(`Page updated.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${page.id}`);
+ console.log(` URL: ${page.url}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
+// -- page move ----------------------------------------------------------------
+
+const pageMoveCommand = new Command("move")
+ .description("Move a page to a new parent")
+ .argument("", "the ID of the page to move")
+ .requiredOption("-p, --parent ", "the ID of the new parent page")
+ .option("-r, --raw", "output raw JSON instead of formatted text")
+ .addHelpText(
+ "after",
+ `
+Details:
+ Moves a page to a new parent page. The page keeps its content
+ and properties — only the parent changes.
+
+Examples:
+ $ notion-cli page move --parent
+ $ notion-cli page move --parent --raw
+`,
+ )
+ .action(async (pageId: string, options: { parent: string; raw?: boolean }) => {
+ const bearerToken = getBearerToken();
+ const notion = createNotionClient(bearerToken);
+
+ try {
+ const page = await notion.pages.move(pageId, {
+ parent: { type: "page_id", page_id: options.parent },
+ });
+
+ if (options.raw) {
+ console.log(JSON.stringify(page, null, 2));
+ return;
+ }
+
+ const title = getPageTitle(page);
+ console.log(`Page moved.`);
+ console.log(` Title: ${title}`);
+ console.log(` ID: ${page.id}`);
+ console.log(` New parent: ${options.parent}`);
+ console.log(` URL: ${page.url}`);
+ } catch (error) {
+ console.error(`Error: ${error instanceof Error ? error.message : error}`);
+ process.exit(1);
+ }
+ });
+
// -- page command group -------------------------------------------------------
export const pageCommand = new Command("page")
.description("Read and manage Notion pages")
- .addCommand(pageGetCommand);
+ .addCommand(pageGetCommand)
+ .addCommand(pageCreateCommand)
+ .addCommand(pageUpdateCommand)
+ .addCommand(pageArchiveCommand)
+ .addCommand(pagePropertyCommand)
+ .addCommand(pageMoveCommand);
diff --git a/notion-cli/commands/search.ts b/notion-cli/commands/search.ts
index c1308eb..4e3f422 100644
--- a/notion-cli/commands/search.ts
+++ b/notion-cli/commands/search.ts
@@ -1,5 +1,5 @@
/**
- * search command - search for pages matching a query string
+ * search command — search for pages and databases via the Notion Search API
*/
import { Command } from "commander";
@@ -8,6 +8,12 @@ import { getBearerToken, getPageTitle, formatDate } from "../helpers.js";
type SearchFilterOption = "page" | "database" | "all";
+/** Map CLI filter names to API values (database → data_source in 2025-09-03) */
+const FILTER_API_VALUE: Record = {
+ page: "page",
+ database: "data_source",
+};
+
function normalizeFilterOption(input: string | undefined): SearchFilterOption {
if (!input) return "page";
const v = input.trim().toLowerCase();
@@ -25,11 +31,10 @@ function isNotionDatabase(result: NotionPage | NotionDatabase): result is Notion
}
export const searchCommand = new Command("search")
- .description("Search for pages and databases, or list workspace roots")
+ .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("-w, --workspace", "find only top-level workspace pages (roots)")
.option("-f, --filter