ntion 0.1.3 → 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")
@@ -9,13 +9,142 @@ function chunkText(content) {
9
9
  }
10
10
  return chunks;
11
11
  }
12
+ const DEFAULT_ANNOTATIONS = {
13
+ bold: false,
14
+ italic: false,
15
+ strikethrough: false,
16
+ code: false,
17
+ };
18
+ function findClosingStar(text, start) {
19
+ for (let i = start; i < text.length; i++) {
20
+ if (text[i] === "*" && text[i + 1] !== "*" && text[i - 1] !== "*") {
21
+ return i;
22
+ }
23
+ }
24
+ return -1;
25
+ }
26
+ function parseInlineSegments(text, inherited, linkUrl) {
27
+ const segments = [];
28
+ let i = 0;
29
+ let plain = "";
30
+ const flush = () => {
31
+ if (plain) {
32
+ segments.push({ content: plain, annotations: { ...inherited }, link: linkUrl });
33
+ plain = "";
34
+ }
35
+ };
36
+ while (i < text.length) {
37
+ // Inline code (no nesting inside)
38
+ if (text[i] === "`") {
39
+ const close = text.indexOf("`", i + 1);
40
+ if (close !== -1) {
41
+ flush();
42
+ segments.push({
43
+ content: text.slice(i + 1, close),
44
+ annotations: { ...inherited, code: true },
45
+ link: linkUrl,
46
+ });
47
+ i = close + 1;
48
+ continue;
49
+ }
50
+ }
51
+ // Link [text](url)
52
+ if (text[i] === "[") {
53
+ const bracketClose = text.indexOf("]", i + 1);
54
+ if (bracketClose !== -1 && text[bracketClose + 1] === "(") {
55
+ const parenClose = text.indexOf(")", bracketClose + 2);
56
+ if (parenClose !== -1) {
57
+ flush();
58
+ const linkText = text.slice(i + 1, bracketClose);
59
+ const url = text.slice(bracketClose + 2, parenClose);
60
+ segments.push(...parseInlineSegments(linkText, inherited, url));
61
+ i = parenClose + 1;
62
+ continue;
63
+ }
64
+ }
65
+ }
66
+ // *** bold+italic
67
+ if (text[i] === "*" && text[i + 1] === "*" && text[i + 2] === "*") {
68
+ const close = text.indexOf("***", i + 3);
69
+ if (close !== -1) {
70
+ flush();
71
+ segments.push(...parseInlineSegments(text.slice(i + 3, close), { ...inherited, bold: true, italic: true }, linkUrl));
72
+ i = close + 3;
73
+ continue;
74
+ }
75
+ }
76
+ // ** bold
77
+ if (text[i] === "*" && text[i + 1] === "*") {
78
+ const close = text.indexOf("**", i + 2);
79
+ if (close !== -1) {
80
+ flush();
81
+ segments.push(...parseInlineSegments(text.slice(i + 2, close), { ...inherited, bold: true }, linkUrl));
82
+ i = close + 2;
83
+ continue;
84
+ }
85
+ }
86
+ // * italic
87
+ if (text[i] === "*") {
88
+ const close = findClosingStar(text, i + 1);
89
+ if (close !== -1) {
90
+ flush();
91
+ segments.push(...parseInlineSegments(text.slice(i + 1, close), { ...inherited, italic: true }, linkUrl));
92
+ i = close + 1;
93
+ continue;
94
+ }
95
+ }
96
+ // ~~ strikethrough
97
+ if (text[i] === "~" && text[i + 1] === "~") {
98
+ const close = text.indexOf("~~", i + 2);
99
+ if (close !== -1) {
100
+ flush();
101
+ segments.push(...parseInlineSegments(text.slice(i + 2, close), { ...inherited, strikethrough: true }, linkUrl));
102
+ i = close + 2;
103
+ continue;
104
+ }
105
+ }
106
+ plain += text[i];
107
+ i++;
108
+ }
109
+ flush();
110
+ return segments;
111
+ }
112
+ function segmentToRichText(segment) {
113
+ return chunkText(segment.content).map((chunk) => {
114
+ const textObj = { content: chunk };
115
+ if (segment.link) {
116
+ textObj.link = { url: segment.link };
117
+ }
118
+ const obj = { type: "text", text: textObj };
119
+ const { bold, italic, strikethrough, code } = segment.annotations;
120
+ if (bold || italic || strikethrough || code) {
121
+ const annotations = {};
122
+ if (bold)
123
+ annotations.bold = true;
124
+ if (italic)
125
+ annotations.italic = true;
126
+ if (strikethrough)
127
+ annotations.strikethrough = true;
128
+ if (code)
129
+ annotations.code = true;
130
+ obj.annotations = annotations;
131
+ }
132
+ return obj;
133
+ });
134
+ }
12
135
  function toRichText(content) {
136
+ const normalized = content.length > 0 ? content : " ";
137
+ const segments = parseInlineSegments(normalized, DEFAULT_ANNOTATIONS);
138
+ if (segments.length === 0) {
139
+ return [{ type: "text", text: { content: " " } }];
140
+ }
141
+ return segments.flatMap(segmentToRichText);
142
+ }
143
+ function toPlainRichText(content) {
13
144
  const normalized = content.length > 0 ? content : " ";
14
145
  return chunkText(normalized).map((chunk) => ({
15
146
  type: "text",
16
- text: {
17
- content: chunk,
18
- },
147
+ text: { content: chunk },
19
148
  }));
20
149
  }
21
150
  function paragraphBlock(text) {
@@ -131,11 +260,71 @@ function codeBlock(text, language) {
131
260
  object: "block",
132
261
  type: "code",
133
262
  code: {
134
- rich_text: toRichText(text),
263
+ rich_text: toPlainRichText(text),
135
264
  language,
136
265
  },
137
266
  };
138
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
+ }
139
328
  function isDivider(trimmed) {
140
329
  return trimmed === "---" || trimmed === "***" || trimmed === "___";
141
330
  }
@@ -183,6 +372,20 @@ export function markdownToBlocks(markdown) {
183
372
  index += 1;
184
373
  continue;
185
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
+ }
186
389
  const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/);
187
390
  if (headingMatch) {
188
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.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Token-efficient CLI for personal Notion workspaces",
5
5
  "type": "module",
6
6
  "license": "MIT",