ntion 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,16 @@ A single "get all my tasks" workflow tells the whole story:
35
35
  2. **No schema bloat** — MCP's database fetch includes ~2 KB of SQLite DDL, ~800 B of XML boilerplate, and ~1.4 KB of base64 `collectionPropertyOption://` URLs that are never used for reads. ntion returns only actionable data.
36
36
  3. **Markdown-first** — Page content defaults to markdown, matching what agents actually consume. No manual format negotiation needed.
37
37
 
38
+ ### Operations the official MCP can't do
39
+
40
+ The official Notion MCP server has no block delete tool. ntion does:
41
+
42
+ ```bash
43
+ ntion blocks delete --block-ids <block_id>
44
+ ```
45
+
46
+ Delete one or many blocks in a single call — useful for cleaning up content, removing broken blocks, or precise page editing.
47
+
38
48
  ## Agent skill
39
49
 
40
50
  ntion ships with an [agent skill](https://docs.anthropic.com/en/docs/claude-code/skills) that teaches AI agents how to use the CLI. Install it with:
@@ -157,6 +167,10 @@ ntion blocks replace-range \
157
167
  --start-selector-json '{"where":{"text_contains":"Start"}}' \
158
168
  --end-selector-json '{"where":{"text_contains":"End"}}' \
159
169
  --markdown "Replacement content"
170
+
171
+ # Delete blocks (not available in the official Notion MCP)
172
+ ntion blocks delete --block-ids <block_id>
173
+ ntion blocks delete --block-ids <id1> <id2> <id3>
160
174
  ```
161
175
 
162
176
  ### Health check
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { getConfigPath } from "./config/paths.js";
10
10
  import { buildInitialAuthConfig, loadConfigOrNull, saveConfig } from "./config/store.js";
11
11
  import { CliError } from "./errors/cli-error.js";
12
12
  import { markdownToBlocks } from "./notion/markdown.js";
13
- import { appendBlocks, archivePage, createPage, createPagesBulk, getBlocks, getDataSource, getDataSourceSchema, getPage, insertBlocks, listDataSources, queryDataSourcePages, replaceBlockRange, searchWorkspace, selectBlocks, setRelation, unarchivePage, updatePage, } from "./notion/repository.js";
13
+ import { appendBlocks, archivePage, createPage, createPagesBulk, deleteBlocks, getBlocks, getDataSource, getDataSourceSchema, getPage, insertBlocks, listDataSources, queryDataSourcePages, replaceBlockRange, searchWorkspace, selectBlocks, setRelation, unarchivePage, updatePage, } from "./notion/repository.js";
14
14
  import { parseJsonOption } from "./utils/json.js";
15
15
  const ROOT_HELP_EPILOG = [
16
16
  "",
@@ -218,6 +218,7 @@ async function runInteractiveAuthSetup() {
218
218
  }
219
219
  const rl = createInterface({ input, output });
220
220
  try {
221
+ console.log("Create a token at https://www.notion.so/profile/integrations\n");
221
222
  const response = await rl.question("Paste your Notion integration token: ");
222
223
  const trimmed = response.trim();
223
224
  if (!trimmed) {
@@ -254,9 +255,11 @@ async function saveAuthConfigEnv(tokenEnv) {
254
255
  config_path: getConfigPath(),
255
256
  };
256
257
  }
258
+ const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8"));
257
259
  const program = new Command();
258
260
  program
259
261
  .name("ntion")
262
+ .version(packageJson.version, "-V, --version", "print version number")
260
263
  .description("Token-efficient, workspace-agnostic Notion CLI")
261
264
  .showHelpAfterError()
262
265
  .addHelpText("after", ROOT_HELP_EPILOG);
@@ -918,6 +921,27 @@ const blocksReplaceRangeCommand = blocksCommand
918
921
  });
919
922
  });
920
923
  blocksReplaceRangeCommand.addHelpText("after", BLOCKS_REPLACE_RANGE_HELP_EPILOG);
924
+ blocksCommand
925
+ .command("delete")
926
+ .description("Delete one or more blocks by ID")
927
+ .requiredOption("--block-ids <ids...>", "Block IDs to delete")
928
+ .option("--pretty", "pretty-print JSON output")
929
+ .option("--timeout-ms <n>", "request timeout in milliseconds")
930
+ .action(async (options) => {
931
+ await runAction(Boolean(options.pretty), async (requestId) => {
932
+ const { notion } = await loadRuntime({ timeoutMs: options.timeoutMs });
933
+ const result = await executeMutationWithIdempotency({
934
+ commandName: "blocks.delete",
935
+ requestId,
936
+ requestShape: { block_ids: options.blockIds },
937
+ targetIds: options.blockIds,
938
+ run: () => deleteBlocks(notion, { blockIds: options.blockIds }),
939
+ });
940
+ return {
941
+ data: result,
942
+ };
943
+ });
944
+ });
921
945
  program
922
946
  .command("doctor")
923
947
  .description("Validate config and auth quickly")
@@ -265,6 +265,66 @@ function codeBlock(text, language) {
265
265
  },
266
266
  };
267
267
  }
268
+ function imageBlock(url, caption) {
269
+ return {
270
+ object: "block",
271
+ type: "image",
272
+ image: {
273
+ type: "external",
274
+ external: { url },
275
+ caption: toRichText(caption),
276
+ },
277
+ };
278
+ }
279
+ function parseMarkdownTable(lines, startIndex) {
280
+ const tableLines = [];
281
+ let i = startIndex;
282
+ while (i < lines.length && lines[i].trim().includes("|")) {
283
+ tableLines.push(lines[i].trim());
284
+ i++;
285
+ }
286
+ const linesConsumed = tableLines.length;
287
+ const parseCells = (line) => {
288
+ let stripped = line;
289
+ if (stripped.startsWith("|"))
290
+ stripped = stripped.slice(1);
291
+ if (stripped.endsWith("|"))
292
+ stripped = stripped.slice(0, -1);
293
+ return stripped.split("|").map((c) => c.trim());
294
+ };
295
+ const isSeparator = (line) => {
296
+ const cells = parseCells(line);
297
+ return cells.every((c) => /^[-:]+$/.test(c));
298
+ };
299
+ const hasColumnHeader = tableLines.length >= 2 && isSeparator(tableLines[1]);
300
+ const dataLines = tableLines.filter((_, idx) => !(hasColumnHeader && idx === 1));
301
+ const tableWidth = dataLines.length > 0 ? parseCells(dataLines[0]).length : 0;
302
+ const children = dataLines.map((line) => {
303
+ const cells = parseCells(line);
304
+ while (cells.length < tableWidth)
305
+ cells.push("");
306
+ return {
307
+ object: "block",
308
+ type: "table_row",
309
+ table_row: {
310
+ cells: cells.slice(0, tableWidth).map((c) => toRichText(c)),
311
+ },
312
+ };
313
+ });
314
+ return {
315
+ block: {
316
+ object: "block",
317
+ type: "table",
318
+ table: {
319
+ table_width: tableWidth,
320
+ has_column_header: hasColumnHeader,
321
+ has_row_header: false,
322
+ children,
323
+ },
324
+ },
325
+ linesConsumed,
326
+ };
327
+ }
268
328
  function isDivider(trimmed) {
269
329
  return trimmed === "---" || trimmed === "***" || trimmed === "___";
270
330
  }
@@ -312,6 +372,20 @@ export function markdownToBlocks(markdown) {
312
372
  index += 1;
313
373
  continue;
314
374
  }
375
+ const imageMatch = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
376
+ if (imageMatch) {
377
+ flushParagraph();
378
+ blocks.push(imageBlock(imageMatch[2], imageMatch[1]));
379
+ index += 1;
380
+ continue;
381
+ }
382
+ if (trimmed.startsWith("|")) {
383
+ flushParagraph();
384
+ const result = parseMarkdownTable(lines, index);
385
+ blocks.push(result.block);
386
+ index += result.linesConsumed;
387
+ continue;
388
+ }
315
389
  const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/);
316
390
  if (headingMatch) {
317
391
  flushParagraph();
@@ -140,3 +140,6 @@ export declare function replaceBlockRange(notion: NotionClientAdapter, args: {
140
140
  dryRun: boolean;
141
141
  maxBlocks: number;
142
142
  }): Promise<Record<string, unknown>>;
143
+ export declare function deleteBlocks(notion: NotionClientAdapter, args: {
144
+ blockIds: string[];
145
+ }): Promise<Record<string, unknown>>;
@@ -455,6 +455,54 @@ function extractBlockText(block) {
455
455
  })
456
456
  .join("");
457
457
  }
458
+ function richTextToMarkdown(richTextArray) {
459
+ return richTextArray
460
+ .map((item) => {
461
+ if (!item || typeof item !== "object")
462
+ return "";
463
+ const plain = item.plain_text;
464
+ if (typeof plain !== "string")
465
+ return "";
466
+ const annotations = item.annotations;
467
+ const href = item.href ??
468
+ (item.text?.link?.url ?? null);
469
+ let result = plain;
470
+ if (annotations?.code) {
471
+ result = `\`${result}\``;
472
+ }
473
+ else {
474
+ if (annotations?.bold && annotations?.italic) {
475
+ result = `***${result}***`;
476
+ }
477
+ else if (annotations?.bold) {
478
+ result = `**${result}**`;
479
+ }
480
+ else if (annotations?.italic) {
481
+ result = `*${result}*`;
482
+ }
483
+ if (annotations?.strikethrough) {
484
+ result = `~~${result}~~`;
485
+ }
486
+ }
487
+ if (typeof href === "string") {
488
+ result = `[${result}](${href})`;
489
+ }
490
+ return result;
491
+ })
492
+ .join("");
493
+ }
494
+ function extractBlockMarkdown(block) {
495
+ const type = block.type;
496
+ if (typeof type !== "string")
497
+ return null;
498
+ const typedData = block[type];
499
+ if (!typedData || typeof typedData !== "object")
500
+ return null;
501
+ const richText = typedData.rich_text;
502
+ if (!Array.isArray(richText))
503
+ return null;
504
+ return richTextToMarkdown(richText);
505
+ }
458
506
  function toCompactBlock(block) {
459
507
  return {
460
508
  id: block.id ?? null,
@@ -508,7 +556,7 @@ function renderQuoteMarkdown(text, indent) {
508
556
  }
509
557
  function renderBlockToMarkdown(block, depth) {
510
558
  const type = typeof block.type === "string" ? block.type : "unsupported";
511
- const text = extractBlockText(block) ?? "";
559
+ const text = extractBlockMarkdown(block) ?? "";
512
560
  const indent = " ".repeat(depth);
513
561
  const children = collectRenderableChildren(block);
514
562
  const childMarkdown = children.length > 0 ? renderBlocksToMarkdown(children, depth + 1) : "";
@@ -557,6 +605,41 @@ function renderBlockToMarkdown(block, depth) {
557
605
  }
558
606
  case "toggle":
559
607
  return withChildren(`${indent}- ${text}`, true);
608
+ case "image": {
609
+ const img = block.image;
610
+ let imgUrl = "";
611
+ if (img) {
612
+ if (img.type === "external") {
613
+ const ext = img.external;
614
+ imgUrl = ext?.url ?? "";
615
+ }
616
+ else {
617
+ const file = img.file;
618
+ imgUrl = file?.url ?? "";
619
+ }
620
+ }
621
+ const caption = img && Array.isArray(img.caption) ? richTextToMarkdown(img.caption) : "";
622
+ return withChildren(`${indent}![${caption}](${imgUrl})`);
623
+ }
624
+ case "table": {
625
+ const tbl = block.table;
626
+ const rows = children;
627
+ const rowLines = [];
628
+ for (let ri = 0; ri < rows.length; ri++) {
629
+ const row = rows[ri];
630
+ const tr = row.table_row;
631
+ if (!tr?.cells)
632
+ continue;
633
+ const cellTexts = tr.cells.map((cell) => richTextToMarkdown(cell).replace(/\|/g, "\\|"));
634
+ rowLines.push(`${indent}| ${cellTexts.join(" | ")} |`);
635
+ if (ri === 0 && tbl?.has_column_header) {
636
+ rowLines.push(`${indent}| ${cellTexts.map(() => "---").join(" | ")} |`);
637
+ }
638
+ }
639
+ return rowLines.join("\n");
640
+ }
641
+ case "table_row":
642
+ return "";
560
643
  default: {
561
644
  const fallback = text.length > 0 ? text : `[${type}]`;
562
645
  return withChildren(`${indent}${fallback}`);
@@ -996,3 +1079,14 @@ export async function replaceBlockRange(notion, args) {
996
1079
  deleted_count: blocksToDelete.length,
997
1080
  };
998
1081
  }
1082
+ export async function deleteBlocks(notion, args) {
1083
+ const deletedIds = [];
1084
+ for (const blockId of args.blockIds) {
1085
+ await notion.deleteBlock({ block_id: blockId });
1086
+ deletedIds.push(blockId);
1087
+ }
1088
+ return {
1089
+ deleted_count: deletedIds.length,
1090
+ deleted_ids: deletedIds,
1091
+ };
1092
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntion",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Token-efficient CLI for personal Notion workspaces",
5
5
  "type": "module",
6
6
  "license": "MIT",