@sakasegawa/ncli
Advanced tools
+400
-3
@@ -45,2 +45,41 @@ #!/usr/bin/env node | ||
| } | ||
| function restErrorToCliError(status, apiError) { | ||
| const message = apiError.message || "Unknown REST API error"; | ||
| if (status === 401) { | ||
| return new CliError( | ||
| "REST API authentication failed", | ||
| message, | ||
| 'Check your integration token. Set NOTION_API_KEY env var, or run "ncli rest login"' | ||
| ); | ||
| } | ||
| if (status === 403) { | ||
| return new CliError( | ||
| "REST API access denied", | ||
| message, | ||
| "The integration may not have access to this resource. Check connection settings at notion.so/profile/integrations" | ||
| ); | ||
| } | ||
| if (status === 404) { | ||
| return new CliError( | ||
| "REST API resource not found", | ||
| message, | ||
| "The integration may not have access to this page. Go to https://www.notion.so/profile/integrations/internal \u2192 select your integration \u2192 Content access tab \u2192 edit access to add pages." | ||
| ); | ||
| } | ||
| if (status === 429) { | ||
| return new CliError( | ||
| "REST API rate limited", | ||
| message, | ||
| "Wait a moment and retry. The CLI retries automatically up to 3 times" | ||
| ); | ||
| } | ||
| if (apiError.code === "validation_error") { | ||
| return new CliError( | ||
| "REST API validation error", | ||
| message, | ||
| 'Check required fields. Run "ncli rest --help" for usage' | ||
| ); | ||
| } | ||
| return new CliError(`REST API error (${status})`, message); | ||
| } | ||
| async function withRetry(fn, opts = {}) { | ||
@@ -85,2 +124,5 @@ const { maxRetries = 3, baseDelayMs = 1e3 } = opts; | ||
| var AUTH_TIMEOUT_MS = 12e4; | ||
| var NOTION_API_BASE_URL = "https://api.notion.com/v1"; | ||
| var NOTION_API_VERSION = "2026-03-11"; | ||
| var REST_TOKEN_ENV_VAR = "NOTION_API_KEY"; | ||
@@ -275,2 +317,12 @@ // src/auth/callback-server.ts | ||
| } | ||
| readRestToken() { | ||
| const data = this.readJson("rest-token.json"); | ||
| return data?.token; | ||
| } | ||
| saveRestToken(token) { | ||
| this.writeJson("rest-token.json", { token }); | ||
| } | ||
| deleteRestToken() { | ||
| this.deleteFile("rest-token.json"); | ||
| } | ||
| deleteAll() { | ||
@@ -280,2 +332,3 @@ this.deleteTokens(); | ||
| this.deleteCodeVerifier(); | ||
| this.deleteRestToken(); | ||
| } | ||
@@ -463,2 +516,22 @@ }; | ||
| } | ||
| function formatRestOutput(result, opts) { | ||
| if (opts.json) { | ||
| return JSON.stringify(result, null, 2); | ||
| } | ||
| if (opts.raw) { | ||
| return JSON.stringify(result); | ||
| } | ||
| return JSON.stringify(result, null, 2); | ||
| } | ||
| function isEmptyListResponse(result) { | ||
| return result.object === "list" && Array.isArray(result.results) && result.results.length === 0; | ||
| } | ||
| function printRestOutput(result, opts) { | ||
| console.log(formatRestOutput(result, opts)); | ||
| if (isEmptyListResponse(result)) { | ||
| console.error( | ||
| "\nNote: No results found. If you expected results, ensure your integration has access to pages.\n Go to https://www.notion.so/profile/integrations/internal \u2192 select your integration \u2192 Content access tab \u2192 edit access to add pages." | ||
| ); | ||
| } | ||
| } | ||
@@ -693,2 +766,184 @@ // src/util/stdin.ts | ||
| // src/commands/file.ts | ||
| import path4 from "path"; | ||
| // src/rest/client.ts | ||
| import fs2 from "fs"; | ||
| import path3 from "path"; | ||
| var MIME_TYPES = { | ||
| ".png": "image/png", | ||
| ".jpg": "image/jpeg", | ||
| ".jpeg": "image/jpeg", | ||
| ".gif": "image/gif", | ||
| ".webp": "image/webp", | ||
| ".svg": "image/svg+xml", | ||
| ".pdf": "application/pdf", | ||
| ".txt": "text/plain", | ||
| ".csv": "text/csv", | ||
| ".json": "application/json", | ||
| ".mp3": "audio/mpeg", | ||
| ".mp4": "video/mp4", | ||
| ".zip": "application/zip" | ||
| }; | ||
| function guessMimeType(fileName) { | ||
| const ext = path3.extname(fileName).toLowerCase(); | ||
| return MIME_TYPES[ext] || "application/octet-stream"; | ||
| } | ||
| function buildRestUrl(basePath) { | ||
| const normalized = basePath.startsWith("/") ? basePath : `/${basePath}`; | ||
| return `${NOTION_API_BASE_URL}${normalized}`; | ||
| } | ||
| function buildRestHeaders(token, contentType) { | ||
| const headers = { | ||
| Authorization: `Bearer ${token}`, | ||
| "Notion-Version": NOTION_API_VERSION | ||
| }; | ||
| if (contentType) { | ||
| headers["Content-Type"] = contentType; | ||
| } | ||
| return headers; | ||
| } | ||
| var NotionRestClient = class { | ||
| constructor(token) { | ||
| this.token = token; | ||
| } | ||
| async request(method, apiPath, body) { | ||
| const url = buildRestUrl(apiPath); | ||
| const headers = buildRestHeaders(this.token, body ? "application/json" : void 0); | ||
| const response = await fetch(url, { | ||
| method, | ||
| headers, | ||
| body: body ? JSON.stringify(body) : void 0 | ||
| }); | ||
| const json = await response.json(); | ||
| if (!response.ok) { | ||
| throw restErrorToCliError(response.status, json); | ||
| } | ||
| return json; | ||
| } | ||
| async uploadFile(filePath, displayName) { | ||
| const absolutePath = path3.resolve(filePath); | ||
| if (!fs2.existsSync(absolutePath)) { | ||
| throw new CliError( | ||
| "File not found", | ||
| `The file "${filePath}" does not exist`, | ||
| "Check the file path and try again" | ||
| ); | ||
| } | ||
| const fileBuffer = fs2.readFileSync(absolutePath); | ||
| const fileName = displayName || path3.basename(absolutePath); | ||
| const mimeType = guessMimeType(fileName); | ||
| const createResult = await this.request("POST", "/file_uploads", { | ||
| mode: "single_part", | ||
| filename: fileName, | ||
| content_type: mimeType | ||
| }); | ||
| const fileUploadId = createResult.id; | ||
| const formData = new FormData(); | ||
| formData.append("file", new Blob([fileBuffer], { type: mimeType }), fileName); | ||
| const sendHeaders = buildRestHeaders(this.token); | ||
| const sendResponse = await fetch(`${NOTION_API_BASE_URL}/file_uploads/${fileUploadId}/send`, { | ||
| method: "POST", | ||
| headers: sendHeaders, | ||
| body: formData | ||
| }); | ||
| const sendResult = await sendResponse.json(); | ||
| if (!sendResponse.ok) { | ||
| throw restErrorToCliError(sendResponse.status, sendResult); | ||
| } | ||
| return sendResult; | ||
| } | ||
| }; | ||
| // src/rest/with-rest-client.ts | ||
| function resolveRestToken() { | ||
| const envToken = process.env[REST_TOKEN_ENV_VAR]; | ||
| if (envToken) { | ||
| return envToken; | ||
| } | ||
| const store = new TokenStore(CONFIG_DIR); | ||
| const storedToken = store.readRestToken(); | ||
| if (storedToken) { | ||
| return storedToken; | ||
| } | ||
| throw new CliError( | ||
| "No REST API token configured", | ||
| "Notion REST API requires an integration token (Bearer token)", | ||
| `Set ${REST_TOKEN_ENV_VAR} env var, or run "ncli rest login" to save a token` | ||
| ); | ||
| } | ||
| async function withRestClient(fn) { | ||
| const token = resolveRestToken(); | ||
| const client = new NotionRestClient(token); | ||
| try { | ||
| return await withRetry(() => fn(client)); | ||
| } catch (error) { | ||
| process.exitCode = 1; | ||
| throw error; | ||
| } | ||
| } | ||
| // src/commands/file.ts | ||
| function buildFileUploadArgs(filePath, opts) { | ||
| if (!filePath) { | ||
| throw new CliError( | ||
| "Missing file path", | ||
| "A local file path is required", | ||
| "Usage: ncli file upload <file-path>" | ||
| ); | ||
| } | ||
| return { filePath, displayName: opts.name }; | ||
| } | ||
| function buildAttachHint(fileUploadId, fileName) { | ||
| const block = JSON.stringify({ | ||
| children: [ | ||
| { | ||
| type: "file", | ||
| file: { | ||
| type: "file_upload", | ||
| file_upload: { id: fileUploadId }, | ||
| name: fileName | ||
| } | ||
| } | ||
| ] | ||
| }); | ||
| return ` | ||
| Attach to a page (appends to end): | ||
| ncli rest PATCH /blocks/<page-id>/children '${block}' | ||
| Insert after a specific block: | ||
| ncli rest PATCH /blocks/<page-id>/children '{"position":{"type":"after_block","after_block":{"id":"<block-id>"}},"children":[...]}' | ||
| Use "ncli fetch <page-id>" or "ncli rest GET /blocks/<page-id>/children" to find block IDs.`; | ||
| } | ||
| function registerFileCommands(program2) { | ||
| const file = program2.command("file").description("Upload files to Notion (REST API)"); | ||
| file.command("upload").description("Upload a file to Notion (returns file_upload_id for attaching to pages)").argument("<file-path>", "Local file path to upload").option("--name <display-name>", "Display name for the file in Notion").addHelpText( | ||
| "after", | ||
| ` | ||
| Examples: | ||
| ncli file upload ./screenshot.png | ||
| ncli file upload ./report.pdf --name "Q1 Report" | ||
| Requires REST API authentication: | ||
| ncli rest login # Save integration token (one-time) | ||
| NOTION_API_KEY=ntn_... # Or set env var | ||
| After uploading, attach the file to a page: | ||
| ncli rest PATCH /blocks/<page-id>/children '{"children":[{"type":"file","file":{"type":"file_upload","file_upload":{"id":"<file_upload_id>"},"name":"file.png"}}]}' | ||
| Insert after a specific block: | ||
| ncli rest PATCH /blocks/<page-id>/children '{"position":{"type":"after_block","after_block":{"id":"<block-id>"}},"children":[...]}' | ||
| Use "ncli fetch <page-id>" or "ncli rest GET /blocks/<page-id>/children" to find block IDs.` | ||
| ).action(async (filePath, opts, cmd) => { | ||
| const args = buildFileUploadArgs(filePath, opts); | ||
| await withRestClient(async (client) => { | ||
| const result = await client.uploadFile(args.filePath, args.displayName); | ||
| printRestOutput(result, cmd.optsWithGlobals()); | ||
| const fileUploadId = result.id; | ||
| const fileName = args.displayName || path4.basename(args.filePath); | ||
| console.error(buildAttachHint(fileUploadId, fileName)); | ||
| }); | ||
| }); | ||
| } | ||
| // src/commands/login.ts | ||
@@ -909,2 +1164,136 @@ function registerLoginCommands(program2) { | ||
| // src/commands/rest.ts | ||
| var VALID_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PATCH", "DELETE"]); | ||
| function buildRestCall(method, apiPath, jsonStr) { | ||
| const upperMethod = method.toUpperCase(); | ||
| if (!VALID_METHODS.has(upperMethod)) { | ||
| throw new CliError( | ||
| `Invalid HTTP method: ${method}`, | ||
| "Supported methods are GET, POST, PATCH, DELETE", | ||
| "Example: ncli rest GET /pages/<id>" | ||
| ); | ||
| } | ||
| const normalizedPath = apiPath.startsWith("/") ? apiPath : `/${apiPath}`; | ||
| if (!jsonStr || jsonStr.trim() === "") { | ||
| return { method: upperMethod, path: normalizedPath }; | ||
| } | ||
| const body = parseJsonData(jsonStr); | ||
| return { method: upperMethod, path: normalizedPath, body }; | ||
| } | ||
| function registerRestCommands(program2) { | ||
| const rest = program2.command("rest").description("Call Notion REST API directly \u2014 requires integration token").addHelpText( | ||
| "after", | ||
| ` | ||
| Setup: | ||
| ncli rest login # Save integration token (one-time) | ||
| Usage: | ||
| ncli rest GET /users/me # Verify auth | ||
| ncli rest GET /pages/<page-id> # Get a page | ||
| ncli rest POST /search '{"query":"test"}' # Search | ||
| ncli file upload <page-id> ./image.png # Upload a file | ||
| The integration must have access to target pages. | ||
| Go to https://www.notion.so/profile/integrations/internal | ||
| \u2192 select your integration \u2192 Content access \u2192 add pages.` | ||
| ); | ||
| rest.command("login").description("Save a Notion integration token for REST API access").addHelpText( | ||
| "after", | ||
| ` | ||
| Examples: | ||
| ncli rest login # Enter token interactively | ||
| echo "ntn_..." | ncli rest login # Pipe token from stdin | ||
| Get your integration token at: | ||
| https://www.notion.so/profile/integrations` | ||
| ).action(async () => { | ||
| let token; | ||
| if (!process.stdin.isTTY) { | ||
| token = (await readStdin()).trim(); | ||
| } else { | ||
| console.log(`Get your integration token at: https://www.notion.so/profile/integrations | ||
| Create a new integration or copy the token from an existing one. | ||
| `); | ||
| process.stdout.write("Enter your Notion integration token: "); | ||
| token = await new Promise((resolve) => { | ||
| const chunks = []; | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| process.stdin.setEncoding("utf-8"); | ||
| const onData = (data) => { | ||
| for (const ch of data) { | ||
| if (ch === "\r" || ch === "\n") { | ||
| process.stdin.setRawMode(false); | ||
| process.stdin.pause(); | ||
| process.stdin.removeListener("data", onData); | ||
| process.stdout.write("\n"); | ||
| resolve(Buffer.concat(chunks).toString("utf-8").trim()); | ||
| return; | ||
| } | ||
| if (ch === "") { | ||
| process.stdin.setRawMode(false); | ||
| process.exit(1); | ||
| } | ||
| if (ch === "\x7F" || ch === "\b") { | ||
| if (chunks.length > 0) { | ||
| chunks.pop(); | ||
| process.stdout.write("\b \b"); | ||
| } | ||
| } else { | ||
| chunks.push(Buffer.from(ch)); | ||
| process.stdout.write("*"); | ||
| } | ||
| } | ||
| }; | ||
| process.stdin.on("data", onData); | ||
| }); | ||
| } | ||
| if (!token) { | ||
| throw new CliError( | ||
| "No token provided", | ||
| "An integration token is required", | ||
| "Get your token at https://www.notion.so/profile/integrations" | ||
| ); | ||
| } | ||
| const store = new TokenStore(CONFIG_DIR); | ||
| store.saveRestToken(token); | ||
| console.log(`Token saved. | ||
| Next steps: | ||
| 1. Open the Notion page you want to access | ||
| 2. Click "..." (top right) \u2192 "Connections" \u2192 Add your integration | ||
| 3. Verify: ncli rest GET /users/me`); | ||
| }); | ||
| rest.command("logout").description("Remove saved REST API integration token").action(() => { | ||
| const store = new TokenStore(CONFIG_DIR); | ||
| store.deleteRestToken(); | ||
| console.log("REST API token removed."); | ||
| }); | ||
| rest.command("call", { isDefault: true }).description("Make a REST API request").argument("<method>", "HTTP method (GET, POST, PATCH, DELETE)").argument("<path>", "API path (e.g. /pages/<id>, /databases/<id>)").argument("[json]", "JSON request body").addHelpText( | ||
| "after", | ||
| ` | ||
| Examples: | ||
| ncli rest GET /users/me | ||
| ncli rest GET /pages/<page-id> | ||
| ncli rest POST /pages '{"parent":{"page_id":"..."},"properties":{"title":[{"text":{"content":"New Page"}}]}}' | ||
| ncli rest PATCH /blocks/<block-id>/children '{"children":[...]}' | ||
| ncli rest DELETE /blocks/<block-id> | ||
| echo '{"query":"test"}' | ncli rest POST /search | ||
| REST API docs: https://developers.notion.com/reference` | ||
| ).action( | ||
| async (method, apiPath, json, _opts, cmd) => { | ||
| let jsonStr = json; | ||
| if (!jsonStr && !process.stdin.isTTY) { | ||
| jsonStr = (await readStdin()).trim(); | ||
| } | ||
| const call = buildRestCall(method, apiPath, jsonStr); | ||
| await withRestClient(async (client) => { | ||
| const result = await client.request(call.method, call.path, call.body); | ||
| printRestOutput(result, cmd.optsWithGlobals()); | ||
| }); | ||
| } | ||
| ); | ||
| } | ||
| // src/commands/search.ts | ||
@@ -1005,4 +1394,4 @@ function buildSearchCall(query) { | ||
| var program = new Command().name("ncli").version("0.2.0").description( | ||
| "ncli \u2014 read and write Notion from the terminal.\nAll commands support --json and --raw for structured output." | ||
| ).option("--json", "Output as JSON (structured, parseable)").option("--raw", "Output raw MCP response (full server payload)").option("--verbose", "Verbose output").option("--no-color", "Disable colors").configureOutput({ | ||
| "ncli \u2014 read and write Notion from the terminal.\nMCP commands use OAuth. REST API commands (rest, file) use integration token." | ||
| ).option("--json", "Output as JSON (structured, parseable)").option("--raw", "Output raw response (full server payload)").option("--verbose", "Verbose output").option("--no-color", "Disable colors").configureOutput({ | ||
| writeErr: (str) => { | ||
@@ -1019,3 +1408,3 @@ process.stderr.write(str); | ||
| ` | ||
| Quick start: | ||
| Quick start (MCP \u2014 OAuth auth): | ||
| ncli search "keyword" # Find pages/databases | ||
@@ -1026,2 +1415,8 @@ ncli fetch <id> # Get content (use ID from search results) | ||
| Quick start (REST API \u2014 integration token): | ||
| ncli rest login # Save integration token (one-time) | ||
| ncli rest GET /users/me # Verify auth | ||
| ncli rest GET /pages/<id> # Get page via REST API | ||
| ncli file upload <page-id> ./image.png # Upload a file | ||
| Workflow: search \u2192 fetch (get IDs/schema) \u2192 create/update/query | ||
@@ -1043,2 +1438,4 @@ For databases: always "ncli fetch <db-id>" first to get data_source_id. | ||
| registerApiCommand(program); | ||
| registerRestCommands(program); | ||
| registerFileCommands(program); | ||
@@ -1045,0 +1442,0 @@ // src/index.ts |
+2
-2
| { | ||
| "name": "@sakasegawa/ncli", | ||
| "version": "0.2.0", | ||
| "description": "CLI wrapper for Notion's Remote MCP server", | ||
| "version": "0.3.0", | ||
| "description": "CLI for Notion — MCP + REST API support", | ||
| "type": "module", | ||
@@ -6,0 +6,0 @@ "bin": { |
+23
-1
@@ -11,3 +11,3 @@ # ncli | ||
| [Remote Notion MCP](https://mcp.notion.com/mcp) の CLI ラッパー — ターミナルから Notion を読み書きできます。 | ||
| Notion 用 CLI — MCP + REST API 対応。ターミナルから Notion を読み書きできます。 | ||
@@ -19,2 +19,5 @@ 人間とコーディングエージェント(Claude Code, Codex 等)の両方に最適化。出力はすべて構造化 JSON、エラーにはリカバリーヒント付き。 | ||
| - Notion ワークスペースへのフルアクセス: 検索、ページ、データベース、ビュー、コメント、ユーザー、チーム、ミーティングノート | ||
| - REST API 直接アクセス: `ncli rest` で任意の Notion API を呼び出し | ||
| - ファイルアップロード: `ncli file upload` でページにファイルを添付 | ||
| - デュアル認証: OAuth(MCP コマンド)+ インテグレーショントークン(`NOTION_API_KEY` 環境変数 または `ncli rest login`) | ||
| - OAuth 2.0 + PKCE 認証(ブラウザベース、設定不要) | ||
@@ -52,2 +55,16 @@ - Agent-first 設計: `--json` 出力、構造化エラーヒント(What + Why + Hint) | ||
| ### REST API | ||
| ```bash | ||
| # REST API セットアップ(インテグレーショントークン) | ||
| ncli rest login | ||
| # REST API を直接呼び出し | ||
| ncli rest GET /users/me | ||
| ncli rest GET /pages/<page-id> | ||
| # ファイルをアップロード | ||
| ncli file upload ./image.png | ||
| ``` | ||
| ## コマンド一覧 | ||
@@ -76,2 +93,6 @@ | ||
| | `ncli meeting-notes query` | ミーティングノートをフィルタ付きでクエリ | | ||
| | `ncli rest login` | REST API インテグレーショントークンを保存 | | ||
| | `ncli rest logout` | 保存済み REST API トークンを削除 | | ||
| | `ncli rest <METHOD> <path> [json]` | 任意の Notion REST API エンドポイントを呼び出し | | ||
| | `ncli file upload <file-path>` | ファイルをアップロード(file_upload_id を返し、ページへの添付方法を案内) | | ||
| | `ncli api <tool> [json]` | MCP ツールを直接呼び出し(エスケープハッチ) | | ||
@@ -153,2 +174,3 @@ | ||
| - Notion アカウント(ブラウザ経由の OAuth 認証) | ||
| - Notion インテグレーショントークン(REST API コマンド用 — https://www.notion.so/profile/integrations で取得) | ||
@@ -155,0 +177,0 @@ ## 法的情報 |
+23
-1
@@ -11,3 +11,3 @@ # ncli | ||
| CLI wrapper for [Remote Notion MCP](https://mcp.notion.com/mcp) — read and write Notion from the terminal. | ||
| CLI for Notion — MCP + REST API support. Read and write Notion from the terminal. | ||
@@ -19,2 +19,5 @@ Designed for both humans and coding agents (Claude Code, Codex, etc.). All output is structured JSON with recovery hints on errors. | ||
| - Full Notion workspace access: search, pages, databases, views, comments, users, teams, meeting notes | ||
| - REST API direct access: `ncli rest` for arbitrary Notion API calls | ||
| - File upload: `ncli file upload` for attaching files to pages | ||
| - Dual auth: OAuth (MCP commands) + integration token (`NOTION_API_KEY` env var or `ncli rest login`) | ||
| - OAuth 2.0 + PKCE authentication (browser-based, zero-config) | ||
@@ -52,2 +55,16 @@ - Agent-first design: `--json` output, structured error hints (What + Why + Hint) | ||
| ### REST API | ||
| ```bash | ||
| # REST API setup (integration token) | ||
| ncli rest login | ||
| # Call REST API directly | ||
| ncli rest GET /users/me | ||
| ncli rest GET /pages/<page-id> | ||
| # Upload a file | ||
| ncli file upload ./image.png | ||
| ``` | ||
| ## Commands | ||
@@ -76,2 +93,6 @@ | ||
| | `ncli meeting-notes query` | Query meeting notes with filters | | ||
| | `ncli rest login` | Save REST API integration token | | ||
| | `ncli rest logout` | Remove saved REST API token | | ||
| | `ncli rest <METHOD> <path> [json]` | Call any Notion REST API endpoint directly | | ||
| | `ncli file upload <file-path>` | Upload a file (returns file_upload_id for attaching to pages) | | ||
| | `ncli api <tool> [json]` | Call any MCP tool directly (escape hatch) | | ||
@@ -153,2 +174,3 @@ | ||
| - A Notion account (OAuth authentication via browser) | ||
| - Notion integration token (for REST API commands — get at https://www.notion.so/profile/integrations) | ||
@@ -155,0 +177,0 @@ ## Legal |
Network access
Supply chain riskThis module accesses the network.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
66379
31.51%1405
37.34%180
13.92%4
100%3
200%