@jordancoin/notioncli 1.0.0 → 1.1.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
@@ -6,7 +6,7 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
8
8
 
9
- A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.
9
+ A powerful CLI for the Notion API — aliases, zero UUIDs, relations & rollups, and full block-level CRUD. Built for humans and AI agents.
10
10
 
11
11
  **No more copy-pasting UUIDs.** Set up aliases once, then just type `notion query tasks` or `notion add projects --prop "Name=Ship it"`.
12
12
 
@@ -34,6 +34,39 @@ Zero UUIDs. One command. You're ready to go.
34
34
 
35
35
  ---
36
36
 
37
+ ## What's New (v1.1)
38
+
39
+ notioncli now understands Notion as a **graph**, not just a table.
40
+
41
+ - 🔗 **Relations** — `get` resolves linked page titles automatically. New `notion relations` command for exploring connected pages.
42
+ - 📊 **Rollups** — Numbers, dates, and arrays are parsed into readable values. No more raw JSON blobs.
43
+ - 🧱 **Blocks CRUD** — Edit and delete blocks directly with `block-edit` and `block-delete`. Use `--ids` flag on `blocks` for precise targeting.
44
+
45
+ ---
46
+
47
+ ## Notion as a Graph
48
+
49
+ Notion databases don't live in isolation — they're connected by relations and rollups. notioncli treats your workspace as a **graph of linked pages**:
50
+
51
+ ```
52
+ $ notion get tasks --filter "Name=Ship v1.1"
53
+ Properties:
54
+ Name: Ship v1.1
55
+ Status: Active
56
+ Project: Launch CLI ← relation resolved to title
57
+ Task Count: 3 ← rollup parsed to number
58
+
59
+ $ notion relations tasks --filter "Name=Ship v1.1"
60
+ Project: 1 linked page
61
+ id │ title │ url
62
+ ──────────┼────────────┼──────────────────────
63
+ 302903e2… │ Launch CLI │ https://notion.so/...
64
+ ```
65
+
66
+ Relations resolve to human-readable titles. Rollups return real values. Blocks can be queried, edited, and deleted directly. This makes it possible to explore, automate, and reason about complex workspaces entirely from the CLI.
67
+
68
+ ---
69
+
37
70
  ## Setup
38
71
 
39
72
  ### 1. Create a Notion Integration
@@ -90,7 +123,7 @@ notion alias rename reading-list reads
90
123
 
91
124
  ### `notion query` — Query a Database
92
125
 
93
- The command you'll use most. Filter, sort, and browse your data:
126
+ The command you'll use most. Filter, sort, and browse your data. Rollups are automatically parsed into numbers, dates, or arrays instead of raw JSON.
94
127
 
95
128
  ```
96
129
  $ notion query projects
@@ -107,12 +140,6 @@ With filters and sorting:
107
140
 
108
141
  ```
109
142
  $ notion query projects --filter Status=Active --sort Date:desc --limit 5
110
- Date │ Name │ Status │ Priority
111
- ───────────┼───────────────┼────────┼─────────
112
- 2026-02-09 │ Launch CLI │ Active │ High
113
- 2026-02-08 │ Write Docs │ Active │ Medium
114
-
115
- 2 results
116
143
  ```
117
144
 
118
145
  ### `notion add` — Add a Page
@@ -143,52 +170,70 @@ $ notion update workouts --filter "Name=LEGS #5" --prop "Notes=Great session"
143
170
 
144
171
  ### `notion delete` — Delete (Archive) a Page
145
172
 
146
- By page ID:
147
- ```
148
- $ notion delete a1b2c3d4-5678-90ab-cdef-1234567890ab
149
- 🗑️ Archived page: a1b2c3d4-...
150
- (Restore it from the trash in Notion if needed)
151
- ```
152
-
153
- By alias + filter:
154
173
  ```
155
174
  $ notion delete workouts --filter "Date=2026-02-09"
156
175
  🗑️ Archived page: a1b2c3d4-...
176
+ (Restore it from the trash in Notion if needed)
157
177
  ```
158
178
 
159
179
  ### `notion get` — View Page Details
160
180
 
161
- By page ID:
162
- ```
163
- $ notion get a1b2c3d4-5678-90ab-cdef-1234567890ab
164
- ```
181
+ Relations are **automatically resolved** to linked page titles:
165
182
 
166
- By alias + filter:
167
183
  ```
168
- $ notion get workouts --filter "Name=LEGS #5"
184
+ $ notion get tasks --filter "Name=Implement relations"
169
185
  Page: a1b2c3d4-5678-90ab-cdef-1234567890ab
170
- URL: https://www.notion.so/New-Feature-a1b2c3d4...
186
+ URL: https://www.notion.so/...
171
187
  Created: 2026-02-10T14:30:00.000Z
172
188
  Updated: 2026-02-10T14:30:00.000Z
173
189
 
174
190
  Properties:
175
- Name: New Feature
176
- Status: Todo
177
- Date: 2026-02-10
178
- Priority: High
191
+ Name: Implement relations
192
+ Project: Build CLI ← resolved title, not a UUID
193
+ Done:
194
+ ```
195
+
196
+ ### `notion relations` — Explore Connections
197
+
198
+ See what a page is linked to, with resolved titles and URLs:
199
+
200
+ ```
201
+ $ notion relations tasks --filter "Name=Implement relations"
202
+ Project: 1 linked page
203
+ id │ title │ url
204
+ ──────────┼───────────┼──────────────────────
205
+ 302903e2… │ Build CLI │ https://notion.so/...
179
206
  ```
180
207
 
181
- ### `notion blocks` — View Page Content
208
+ ### `notion blocks` — View & Edit Page Content
209
+
210
+ View blocks with optional IDs for targeting:
182
211
 
183
- By page ID or alias + filter:
184
212
  ```
185
- $ notion blocks projects --filter "Name=Project Overview"
186
- # Project Overview
187
- This is the main project page.
188
- • First task
189
- Second task
190
- ☑ Completed item
191
- ☐ Pending item
213
+ $ notion blocks tasks --filter "Name=Ship v1.1" --ids
214
+ [a1b2c3d4] # Project Overview
215
+ [e5f67890] This is the main project page.
216
+ [abcd1234] • First task
217
+ [efgh5678] Completed item
218
+ ```
219
+
220
+ In v1.1+, blocks are fully editable and deletable — not just readable:
221
+
222
+ ```
223
+ $ notion block-edit a1b2c3d4-5678-90ab-cdef-1234567890ab "Updated heading text"
224
+ ✅ Updated heading_1 block: a1b2c3d4…
225
+
226
+ $ notion block-delete a1b2c3d4-5678-90ab-cdef-1234567890ab
227
+ 🗑️ Deleted block: a1b2c3d4…
228
+ ```
229
+
230
+ Use `notion blocks --ids` to list block IDs for precise edits.
231
+
232
+ ### `notion append` — Add Content to a Page
233
+
234
+ ```
235
+ $ notion append tasks "Status update: phase 1 complete" --filter "Name=Ship feature"
236
+ ✅ Appended text block to page a1b2c3d4-...
192
237
  ```
193
238
 
194
239
  ### `notion dbs` — List All Databases
@@ -207,12 +252,19 @@ f9e8d7c6-b5a4-3210-fedc-ba0987654321 │ Reading List │ https://...
207
252
 
208
253
  ```
209
254
  $ notion search "meeting"
210
- id │ type │ title │ url
211
- ─────────────────────────────────────┼─────────────┼────────────────┼──────────────
212
- a1b2c3d4-e5f6-7890-abcd-ef1234567890 │ page │ Meeting Notes │ https://...
213
- f9e8d7c6-b5a4-3210-fedc-ba0987654321 │ data_source │ Meetings DB │ https://...
255
+ ```
214
256
 
215
- 2 results
257
+ ### `notion users` / `notion user <id>` — Workspace Users
258
+
259
+ ```
260
+ $ notion users
261
+ ```
262
+
263
+ ### `notion comments` / `notion comment` — Page Comments
264
+
265
+ ```
266
+ $ notion comments tasks --filter "Name=Ship feature"
267
+ $ notion comment tasks "Shipped! 🚀" --filter "Name=Ship feature"
216
268
  ```
217
269
 
218
270
  ### `notion alias` — Manage Aliases
@@ -220,17 +272,10 @@ f9e8d7c6-b5a4-3210-fedc-ba0987654321 │ data_source │ Meetings DB │ http
220
272
  Aliases are created automatically by `notion init`, but you can manage them:
221
273
 
222
274
  ```bash
223
- # See your aliases
224
- notion alias list
225
-
226
- # Rename one
227
- notion alias rename project-tracker projects
228
-
229
- # Add one manually (auto-discovers the right IDs)
230
- notion alias add tasks a1b2c3d4-e5f6-7890-abcd-ef1234567890
231
-
232
- # Remove one
233
- notion alias remove tasks
275
+ notion alias list # See all aliases
276
+ notion alias rename project-tracker projects # Rename one
277
+ notion alias add tasks <database-id> # Add manually
278
+ notion alias remove tasks # Remove one
234
279
  ```
235
280
 
236
281
  ### `--json` — Raw JSON Output
@@ -239,12 +284,20 @@ Add `--json` to any command for the raw Notion API response:
239
284
 
240
285
  ```bash
241
286
  notion --json query projects --limit 1
242
- notion --json dbs
243
- notion --json get a1b2c3d4-...
287
+ notion --json get tasks --filter "Name=Ship it"
244
288
  ```
245
289
 
246
290
  Great for piping into `jq` or other tools.
247
291
 
292
+ ### `--output` — Output Formats
293
+
294
+ ```bash
295
+ notion query tasks --output csv # CSV
296
+ notion query tasks --output yaml # YAML
297
+ notion query tasks --output json # JSON
298
+ notion query tasks --output table # Default
299
+ ```
300
+
248
301
  ---
249
302
 
250
303
  ## Use It With AI Agents
@@ -263,17 +316,20 @@ notion query tasks --filter Status=Todo --sort Priority:desc
263
316
  notion add tasks --prop "Name=Fix bug #42" --prop "Status=In Progress"
264
317
  notion update tasks --filter "Name=Fix bug #42" --prop "Status=Done"
265
318
 
266
- # View, comment, and append — all by alias + filter
267
- notion get tasks --filter "Name=Fix bug #42"
268
- notion comment tasks "Shipped! 🚀" --filter "Name=Fix bug #42"
319
+ # Explore relations between databases
320
+ notion relations tasks --filter "Name=Fix bug #42"
321
+
322
+ # View and edit page content
323
+ notion blocks tasks --filter "Name=Fix bug #42" --ids
324
+ notion block-edit <block-id> "Updated content"
269
325
  notion append tasks "Deployed to production" --filter "Name=Fix bug #42"
270
326
 
327
+ # Comment and collaborate
328
+ notion comment tasks "Shipped! 🚀" --filter "Name=Fix bug #42"
329
+
271
330
  # Delete by alias + filter
272
331
  notion delete tasks --filter "Name=Fix bug #42"
273
332
 
274
- # Or use raw page IDs if you already have them
275
- notion update <page-id> --prop "Status=Done"
276
-
277
333
  # Get raw JSON for parsing
278
334
  notion --json query projects --limit 10
279
335
  ```
@@ -282,7 +338,7 @@ No API key management, no curl commands, no JSON formatting — just simple shel
282
338
 
283
339
  ### Zero-UUID Workflow
284
340
 
285
- Every page-targeted command (`update`, `delete`, `get`, `blocks`, `comments`, `comment`, `append`) now accepts a **database alias + `--filter`** as an alternative to a raw page ID:
341
+ Every page-targeted command (`update`, `delete`, `get`, `blocks`, `relations`, `comments`, `comment`, `append`, `block-edit`, `block-delete`) accepts a **database alias + `--filter`** as an alternative to a raw page ID:
286
342
 
287
343
  ```bash
288
344
  # Instead of: notion update a1b2c3d4-5678-90ab-cdef-1234567890ab --prop "Status=Done"
@@ -324,6 +380,12 @@ The latest Notion API introduced a dual-ID system for databases. Each database n
324
380
 
325
381
  You don't need to think about this. It just works.
326
382
 
383
+ ### Reliability
384
+
385
+ - 139 unit tests
386
+ - Tested against live Notion workspaces
387
+ - Designed to fail loudly and safely when filters match zero or multiple pages
388
+
327
389
  ### Built on the Official SDK
328
390
 
329
391
  notioncli uses [`@notionhq/client`](https://github.com/makenotion/notion-sdk-js) v5.x, Notion's official JavaScript SDK.
package/bin/notion.js CHANGED
@@ -203,7 +203,7 @@ async function buildFilter(dbIds, filterStr) {
203
203
  program
204
204
  .name('notion')
205
205
  .description('A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.')
206
- .version('1.0.0')
206
+ .version('1.1.0')
207
207
  .option('--json', 'Output raw JSON instead of formatted tables');
208
208
 
209
209
  // ─── init ──────────────────────────────────────────────────────────────────────
@@ -619,7 +619,43 @@ program
619
619
  console.log('');
620
620
  console.log('Properties:');
621
621
  for (const [name, prop] of Object.entries(page.properties)) {
622
- console.log(` ${name}: ${propValue(prop)}`);
622
+ if (prop.type === 'relation') {
623
+ const rels = prop.relation || [];
624
+ if (rels.length === 0) {
625
+ console.log(` ${name}: (none)`);
626
+ } else {
627
+ // Resolve relation titles
628
+ const titles = [];
629
+ for (const rel of rels) {
630
+ try {
631
+ const linked = await notion.pages.retrieve({ page_id: rel.id });
632
+ let t = '';
633
+ for (const [, p] of Object.entries(linked.properties)) {
634
+ if (p.type === 'title') { t = propValue(p); break; }
635
+ }
636
+ titles.push(t || rel.id.slice(0, 8) + '…');
637
+ } catch {
638
+ titles.push(rel.id.slice(0, 8) + '…');
639
+ }
640
+ }
641
+ console.log(` ${name}: ${titles.join(', ')}`);
642
+ }
643
+ } else if (prop.type === 'rollup') {
644
+ const r = prop.rollup;
645
+ if (!r) {
646
+ console.log(` ${name}: (empty)`);
647
+ } else if (r.type === 'number') {
648
+ console.log(` ${name}: ${r.number != null ? r.number : '(empty)'}`);
649
+ } else if (r.type === 'date') {
650
+ console.log(` ${name}: ${r.date ? r.date.start : '(empty)'}`);
651
+ } else if (r.type === 'array' && r.array) {
652
+ console.log(` ${name}: ${r.array.map(item => propValue(item)).join(', ')}`);
653
+ } else {
654
+ console.log(` ${name}: ${JSON.stringify(r)}`);
655
+ }
656
+ } else {
657
+ console.log(` ${name}: ${propValue(prop)}`);
658
+ }
623
659
  }
624
660
  } catch (err) {
625
661
  console.error('Get failed:', err.message);
@@ -632,6 +668,7 @@ program
632
668
  .command('blocks <page-or-alias>')
633
669
  .description('Get page content as rendered blocks by ID or alias + filter')
634
670
  .option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
671
+ .option('--ids', 'Show block IDs alongside content (for editing/deleting)')
635
672
  .action(async (target, opts, cmd) => {
636
673
  try {
637
674
  const notion = getNotion();
@@ -663,7 +700,8 @@ program
663
700
  : type === 'code' ? '```\n'
664
701
  : '';
665
702
  const suffix = type === 'code' ? '\n```' : '';
666
- console.log(`${prefix}${text}${suffix}`);
703
+ const idTag = opts.ids ? `[${block.id.slice(0, 8)}] ` : '';
704
+ console.log(`${idTag}${prefix}${text}${suffix}`);
667
705
  }
668
706
  } catch (err) {
669
707
  console.error('Blocks failed:', err.message);
@@ -671,6 +709,165 @@ program
671
709
  }
672
710
  });
673
711
 
712
+ // ─── block-edit ────────────────────────────────────────────────────────────────
713
+ program
714
+ .command('block-edit <block-id> <text>')
715
+ .description('Update a block\'s text content')
716
+ .action(async (blockId, text, opts, cmd) => {
717
+ try {
718
+ const notion = getNotion();
719
+ // First retrieve the block to know its type
720
+ const block = await notion.blocks.retrieve({ block_id: blockId });
721
+ const type = block.type;
722
+
723
+ // Build the update payload based on block type
724
+ const supportedTextTypes = [
725
+ 'paragraph', 'heading_1', 'heading_2', 'heading_3',
726
+ 'bulleted_list_item', 'numbered_list_item', 'quote', 'callout', 'toggle',
727
+ ];
728
+
729
+ if (type === 'to_do') {
730
+ const res = await notion.blocks.update({
731
+ block_id: blockId,
732
+ to_do: {
733
+ rich_text: [{ text: { content: text } }],
734
+ checked: block.to_do?.checked || false,
735
+ },
736
+ });
737
+ if (getGlobalJson(cmd)) { console.log(JSON.stringify(res, null, 2)); return; }
738
+ console.log(`✅ Updated ${type} block: ${blockId.slice(0, 8)}…`);
739
+ } else if (supportedTextTypes.includes(type)) {
740
+ const res = await notion.blocks.update({
741
+ block_id: blockId,
742
+ [type]: {
743
+ rich_text: [{ text: { content: text } }],
744
+ },
745
+ });
746
+ if (getGlobalJson(cmd)) { console.log(JSON.stringify(res, null, 2)); return; }
747
+ console.log(`✅ Updated ${type} block: ${blockId.slice(0, 8)}…`);
748
+ } else if (type === 'code') {
749
+ const res = await notion.blocks.update({
750
+ block_id: blockId,
751
+ code: {
752
+ rich_text: [{ text: { content: text } }],
753
+ language: block.code?.language || 'plain text',
754
+ },
755
+ });
756
+ if (getGlobalJson(cmd)) { console.log(JSON.stringify(res, null, 2)); return; }
757
+ console.log(`✅ Updated code block: ${blockId.slice(0, 8)}…`);
758
+ } else {
759
+ console.error(`Block type "${type}" doesn't support text editing.`);
760
+ console.error('Supported types: paragraph, headings, lists, to_do, quote, callout, toggle, code');
761
+ process.exit(1);
762
+ }
763
+ } catch (err) {
764
+ console.error('Block edit failed:', err.message);
765
+ process.exit(1);
766
+ }
767
+ });
768
+
769
+ // ─── block-delete ──────────────────────────────────────────────────────────────
770
+ program
771
+ .command('block-delete <block-id>')
772
+ .description('Delete a block from a page')
773
+ .action(async (blockId, opts, cmd) => {
774
+ try {
775
+ const notion = getNotion();
776
+ const res = await notion.blocks.delete({ block_id: blockId });
777
+ if (getGlobalJson(cmd)) {
778
+ console.log(JSON.stringify(res, null, 2));
779
+ return;
780
+ }
781
+ console.log(`🗑️ Deleted block: ${blockId.slice(0, 8)}…`);
782
+ } catch (err) {
783
+ console.error('Block delete failed:', err.message);
784
+ process.exit(1);
785
+ }
786
+ });
787
+
788
+ // ─── relations ─────────────────────────────────────────────────────────────────
789
+ program
790
+ .command('relations <page-or-alias>')
791
+ .description('Show all relation and rollup properties with resolved titles')
792
+ .option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
793
+ .action(async (target, opts, cmd) => {
794
+ try {
795
+ const notion = getNotion();
796
+ const { pageId } = await resolvePageId(target, opts.filter);
797
+ const page = await notion.pages.retrieve({ page_id: pageId });
798
+
799
+ if (getGlobalJson(cmd)) {
800
+ console.log(JSON.stringify(page, null, 2));
801
+ return;
802
+ }
803
+
804
+ let found = false;
805
+
806
+ for (const [name, prop] of Object.entries(page.properties)) {
807
+ if (prop.type === 'relation') {
808
+ const rels = prop.relation || [];
809
+ if (rels.length === 0) {
810
+ console.log(`\n${name}: (no linked pages)`);
811
+ continue;
812
+ }
813
+ found = true;
814
+ console.log(`\n${name}: ${rels.length} linked page${rels.length !== 1 ? 's' : ''}`);
815
+
816
+ // Resolve each related page title
817
+ const rows = [];
818
+ for (const rel of rels) {
819
+ try {
820
+ const linked = await notion.pages.retrieve({ page_id: rel.id });
821
+ let title = '';
822
+ for (const [, p] of Object.entries(linked.properties)) {
823
+ if (p.type === 'title') {
824
+ title = propValue(p);
825
+ break;
826
+ }
827
+ }
828
+ rows.push({
829
+ id: rel.id.slice(0, 8) + '…',
830
+ title: title || '(untitled)',
831
+ url: linked.url || '',
832
+ });
833
+ } catch {
834
+ rows.push({ id: rel.id.slice(0, 8) + '…', title: '(access denied)', url: '' });
835
+ }
836
+ }
837
+ printTable(rows, ['id', 'title', 'url']);
838
+ }
839
+
840
+ if (prop.type === 'rollup') {
841
+ found = true;
842
+ const r = prop.rollup;
843
+ console.log(`\n${name} (rollup):`);
844
+ if (!r) {
845
+ console.log(' (empty)');
846
+ continue;
847
+ }
848
+ if (r.type === 'number') {
849
+ console.log(` ${r.function || 'value'}: ${r.number}`);
850
+ } else if (r.type === 'date') {
851
+ console.log(` ${r.date ? r.date.start : '(empty)'}`);
852
+ } else if (r.type === 'array' && r.array) {
853
+ for (const item of r.array) {
854
+ console.log(` • ${propValue(item)}`);
855
+ }
856
+ } else {
857
+ console.log(` ${JSON.stringify(r)}`);
858
+ }
859
+ }
860
+ }
861
+
862
+ if (!found) {
863
+ console.log('This page has no relation or rollup properties.');
864
+ }
865
+ } catch (err) {
866
+ console.error('Relations failed:', err.message);
867
+ process.exit(1);
868
+ }
869
+ });
870
+
674
871
  // ─── dbs ───────────────────────────────────────────────────────────────────────
675
872
  program
676
873
  .command('dbs')
package/lib/helpers.js CHANGED
@@ -74,10 +74,25 @@ function propValue(prop) {
74
74
  return f.string || f.number?.toString() || f.boolean?.toString() || f.date?.start || '';
75
75
  }
76
76
  return '';
77
- case 'relation':
78
- return (prop.relation || []).map(r => r.id).join(', ');
79
- case 'rollup':
80
- return JSON.stringify(prop.rollup);
77
+ case 'relation': {
78
+ const rels = prop.relation || [];
79
+ if (rels.length === 0) return '';
80
+ if (rels.length === 1) return `→ ${rels[0].id.slice(0, 8)}…`;
81
+ return `→ ${rels.length} linked`;
82
+ }
83
+ case 'rollup': {
84
+ const r = prop.rollup;
85
+ if (!r) return '';
86
+ switch (r.type) {
87
+ case 'number': return r.number != null ? String(r.number) : '';
88
+ case 'date': return r.date ? (r.date.end ? `${r.date.start} → ${r.date.end}` : r.date.start) : '';
89
+ case 'array': {
90
+ if (!r.array || r.array.length === 0) return '';
91
+ return r.array.map(item => propValue(item)).join(', ');
92
+ }
93
+ default: return JSON.stringify(r);
94
+ }
95
+ }
81
96
  case 'people':
82
97
  return (prop.people || []).map(p => p.name || p.id).join(', ');
83
98
  case 'files':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jordancoin/notioncli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.",
5
5
  "main": "bin/notion.js",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: notion
3
- description: Notion API for creating and managing pages, databases, and blocks via the notioncli CLI tool.
3
+ description: Notion API for creating and managing pages, databases, blocks, relations, and rollups via the notioncli CLI tool.
4
4
  homepage: https://github.com/JordanCoin/notioncli
5
5
  metadata:
6
6
  openclaw:
@@ -8,7 +8,7 @@ metadata:
8
8
  requires:
9
9
  env: ["NOTION_API_KEY"]
10
10
  primaryEnv: NOTION_API_KEY
11
- install: "npm install -g notioncli"
11
+ install: "npm install -g @jordancoin/notioncli"
12
12
  ---
13
13
 
14
14
  # notioncli — Notion API Skill
@@ -120,6 +120,27 @@ notion delete tasks --filter "Name=Old task" # By alias + filter
120
120
  notion delete workouts --filter "Date=2026-02-09" # By alias + filter
121
121
  ```
122
122
 
123
+ ### Relations & Rollups
124
+
125
+ ```bash
126
+ notion relations tasks --filter "Name=Ship feature" # See linked pages with titles
127
+ notion relations projects --filter "Name=Launch CLI" # Explore connections
128
+ ```
129
+
130
+ Relations are automatically resolved to page titles in `get` output. Rollups are parsed into numbers, dates, or arrays instead of raw JSON.
131
+
132
+ ### Blocks CRUD
133
+
134
+ ```bash
135
+ notion blocks tasks --filter "Name=Ship feature" # View page content
136
+ notion blocks tasks --filter "Name=Ship feature" --ids # View with block IDs
137
+ notion append tasks "New paragraph" --filter "Name=Ship feature" # Append block
138
+ notion block-edit <block-id> "Updated text" # Edit a block
139
+ notion block-delete <block-id> # Delete a block
140
+ ```
141
+
142
+ Use `--ids` to get block IDs, then target specific blocks with `block-edit` or `block-delete`.
143
+
123
144
  ### Appending Content
124
145
 
125
146
  ```bash
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "notioncli",
3
+ "name": "notioncli — Notion CLI",
4
+ "description": "Query databases, manage pages, explore relations, and edit blocks in your Notion workspace from the terminal.",
5
+ "longDescription": "A powerful CLI for the Notion API built for both humans and AI agents. Auto-discovers databases and creates aliases so you never type UUIDs. Supports querying, filtering, sorting, creating, updating, deleting pages, comments, and appending content. Works with Notion's 2025 dual-ID API. Zero-UUID workflow via alias + filter on every command.",
6
+ "category": "Productivity",
7
+ "price": 0,
8
+ "icon": "📝",
9
+ "author": "JordanCoin",
10
+ "authorUrl": "https://github.com/JordanCoin",
11
+ "repository": "https://github.com/JordanCoin/notioncli",
12
+ "version": "1.1.0",
13
+ "tags": ["notion", "cli", "api", "database", "productivity", "workspace", "automation", "ai-agent"],
14
+ "requirements": ["Node.js >= 18", "Notion API key (free)"],
15
+ "optional": []
16
+ }
@@ -0,0 +1,32 @@
1
+ const { Client } = require('@notionhq/client');
2
+ const h = require('../lib/helpers');
3
+ const notion = new Client({ auth: h.loadConfig(h.getConfigPaths().CONFIG_PATH).apiKey });
4
+ const parentId = '302903e2-cff4-8059-b808-e3c953fd7d26';
5
+
6
+ (async () => {
7
+ const db = await notion.databases.create({
8
+ parent: { type: 'page_id', page_id: parentId },
9
+ title: [{ text: { content: 'Quick Test DB' } }],
10
+ properties: { Name: { title: {} }, Priority: { number: {} } }
11
+ });
12
+ console.log('DB created:', db.id.slice(0,8));
13
+ console.log('DB keys with id:', Object.keys(db).filter(k => k.includes('id')));
14
+ console.log('database_id:', db.database_id);
15
+
16
+ await new Promise(r => setTimeout(r, 2000));
17
+
18
+ for (const pt of ['data_source_id', 'database_id']) {
19
+ const pid = pt === 'database_id' && db.database_id ? db.database_id : db.id;
20
+ try {
21
+ const page = await notion.pages.create({
22
+ parent: { type: pt, [pt]: pid },
23
+ properties: { Name: { title: [{ text: { content: 'test ' + pt } }] } }
24
+ });
25
+ console.log(pt, 'WORKS:', page.id.slice(0,8));
26
+ await notion.pages.update({ page_id: page.id, archived: true });
27
+ } catch(e) { console.log(pt, 'FAILED:', e.code, e.message.slice(0,100)); }
28
+ }
29
+
30
+ await notion.blocks.delete({ block_id: db.id });
31
+ console.log('cleaned');
32
+ })();
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ // Live integration test: Relations, Rollups & Blocks CRUD
3
+ // Creates temp databases, links them, tests CLI output, cleans up.
4
+
5
+ const { Client } = require('@notionhq/client');
6
+ const { execSync } = require('child_process');
7
+ const path = require('path');
8
+
9
+ const CLI = path.join(__dirname, '..', 'bin', 'notion.js');
10
+ const run = (cmd) => execSync(`node ${CLI} ${cmd}`, { encoding: 'utf-8' }).trim();
11
+ const runJson = (cmd) => JSON.parse(execSync(`node ${CLI} --json ${cmd}`, { encoding: 'utf-8' }));
12
+
13
+ const notion = new Client({ auth: process.env.NOTION_API_KEY || require('../lib/helpers').loadConfig(
14
+ require('../lib/helpers').getConfigPaths().CONFIG_PATH
15
+ ).apiKey });
16
+
17
+ let parentPageId = null;
18
+ let projectsDbId = null;
19
+ let projectsDsId = null;
20
+ let tasksDbId = null;
21
+ let tasksDsId = null;
22
+ let testPageIds = [];
23
+ let createdDbIds = [];
24
+
25
+ async function setup() {
26
+ console.log('\n🔧 Setting up test databases...\n');
27
+
28
+ // Create a dedicated test page as parent (needs to be under a page the integration can access)
29
+ // First, find an existing page the integration has access to
30
+ const search = await notion.search({ filter: { value: 'page', property: 'object' }, page_size: 20 });
31
+
32
+ // Look for a top-level page (parent is workspace), or any page that's not inside a database
33
+ let rootPageId = null;
34
+ for (const p of search.results) {
35
+ if (p.parent?.type === 'workspace' || p.parent?.type === 'page_id') {
36
+ rootPageId = p.id;
37
+ break;
38
+ }
39
+ }
40
+
41
+ if (!rootPageId) {
42
+ // No standalone page found — create the DBs using the workspace parent directly
43
+ // by creating a page in the integration's space
44
+ console.log('No standalone page found. Will use first available page.');
45
+ rootPageId = search.results[0]?.id;
46
+ if (!rootPageId) throw new Error('No pages accessible by integration');
47
+ }
48
+
49
+ // Create a test container page
50
+ const containerPage = await notion.pages.create({
51
+ parent: { type: 'page_id', page_id: rootPageId },
52
+ properties: {
53
+ title: { title: [{ text: { content: 'CLI Test Container (auto-delete)' } }] },
54
+ },
55
+ });
56
+ parentPageId = containerPage.id;
57
+ testPageIds.push(parentPageId);
58
+ console.log(`📄 Created test container page: ${parentPageId.slice(0, 8)}…`);
59
+
60
+ // 1. Create "CLI Test Projects" database
61
+ console.log('\nCreating Projects DB...');
62
+ const projectsDb = await notion.databases.create({
63
+ parent: { type: 'page_id', page_id: parentPageId },
64
+ title: [{ text: { content: 'CLI Test Projects' } }],
65
+ properties: {
66
+ 'Name': { title: {} },
67
+ 'Status': { select: { options: [
68
+ { name: 'Active', color: 'green' },
69
+ { name: 'Done', color: 'gray' },
70
+ ]}},
71
+ 'Priority': { number: {} },
72
+ },
73
+ });
74
+ // databases.create() returns: .id = data_source_id, .database_id = database_id for page creation
75
+ projectsDsId = projectsDb.id;
76
+ projectsDbId = projectsDb.database_id || projectsDb.id;
77
+ createdDbIds.push(projectsDb.id);
78
+ console.log(`✅ Projects DB (db: ${projectsDbId.slice(0, 8)}…, ds: ${projectsDsId.slice(0, 8)}…)`);
79
+
80
+ // 2. Create "CLI Test Tasks" database with relation to Projects
81
+ console.log('Creating Tasks DB with relation...');
82
+ const tasksDb = await notion.databases.create({
83
+ parent: { type: 'page_id', page_id: parentPageId },
84
+ title: [{ text: { content: 'CLI Test Tasks' } }],
85
+ properties: {
86
+ 'Name': { title: {} },
87
+ 'Done': { checkbox: {} },
88
+ 'Project': { relation: { database_id: projectsDbId } },
89
+ },
90
+ });
91
+ tasksDsId = tasksDb.id;
92
+ tasksDbId = tasksDb.database_id || tasksDb.id;
93
+ createdDbIds.push(tasksDb.id);
94
+ console.log(`✅ Tasks DB (db: ${tasksDbId.slice(0, 8)}…, ds: ${tasksDsId.slice(0, 8)}…)`);
95
+
96
+ // 3. Add project pages
97
+ console.log('\nAdding test data...');
98
+ const proj1 = await notion.pages.create({
99
+ parent: { type: 'database_id', database_id: projectsDbId },
100
+ properties: {
101
+ 'Name': { title: [{ text: { content: 'Build CLI' } }] },
102
+ 'Status': { select: { name: 'Active' } },
103
+ 'Priority': { number: 1 },
104
+ },
105
+ });
106
+ testPageIds.push(proj1.id);
107
+ console.log(` 📌 Project: "Build CLI"`);
108
+
109
+ const proj2 = await notion.pages.create({
110
+ parent: { type: 'database_id', database_id: projectsDbId },
111
+ properties: {
112
+ 'Name': { title: [{ text: { content: 'Write Docs' } }] },
113
+ 'Status': { select: { name: 'Done' } },
114
+ 'Priority': { number: 2 },
115
+ },
116
+ });
117
+ testPageIds.push(proj2.id);
118
+ console.log(` 📌 Project: "Write Docs"`);
119
+
120
+ // 4. Add task pages linked to projects
121
+ const task1 = await notion.pages.create({
122
+ parent: { type: 'database_id', database_id: tasksDbId },
123
+ properties: {
124
+ 'Name': { title: [{ text: { content: 'Implement relations' } }] },
125
+ 'Done': { checkbox: true },
126
+ 'Project': { relation: [{ id: proj1.id }] },
127
+ },
128
+ });
129
+ testPageIds.push(task1.id);
130
+ console.log(` 📋 Task: "Implement relations" → Build CLI`);
131
+
132
+ const task2 = await notion.pages.create({
133
+ parent: { type: 'database_id', database_id: tasksDbId },
134
+ properties: {
135
+ 'Name': { title: [{ text: { content: 'Add tests' } }] },
136
+ 'Done': { checkbox: false },
137
+ 'Project': { relation: [{ id: proj1.id }] },
138
+ },
139
+ });
140
+ testPageIds.push(task2.id);
141
+ console.log(` 📋 Task: "Add tests" → Build CLI`);
142
+
143
+ const task3 = await notion.pages.create({
144
+ parent: { type: 'database_id', database_id: tasksDbId },
145
+ properties: {
146
+ 'Name': { title: [{ text: { content: 'Write README' } }] },
147
+ 'Done': { checkbox: true },
148
+ 'Project': { relation: [{ id: proj2.id }] },
149
+ },
150
+ });
151
+ testPageIds.push(task3.id);
152
+ console.log(` 📋 Task: "Write README" → Write Docs`);
153
+
154
+ // 5. Register CLI aliases
155
+ run(`alias add test-projects ${projectsDsId}`);
156
+ run(`alias add test-tasks ${tasksDsId}`);
157
+ console.log(`\n✅ Aliases registered: test-projects, test-tasks\n`);
158
+ }
159
+
160
+ async function runTests() {
161
+ let passed = 0;
162
+ let failed = 0;
163
+
164
+ function check(name, condition, detail) {
165
+ if (condition) {
166
+ console.log(` ✅ ${name}`);
167
+ passed++;
168
+ } else {
169
+ console.log(` ❌ ${name}${detail ? ' — ' + detail : ''}`);
170
+ failed++;
171
+ }
172
+ }
173
+
174
+ console.log('🧪 Running live tests...\n');
175
+
176
+ // --- Test 1: Query tasks — relation column formatting ---
177
+ console.log('--- 1. query (relation display) ---');
178
+ try {
179
+ const out = run('query test-tasks');
180
+ check('query shows tasks', out.includes('Implement relations'));
181
+ check('relation shows → format', out.includes('→'));
182
+ console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
183
+ } catch (e) { check('query test-tasks', false, e.message); }
184
+
185
+ // --- Test 2: Get task — resolve relation to project title ---
186
+ console.log('--- 2. get (relation resolution) ---');
187
+ try {
188
+ const out = run('get test-tasks --filter "Name=Implement relations"');
189
+ check('get resolves relation to title', out.includes('Build CLI'));
190
+ check('get shows URL', out.includes('notion.so'));
191
+ console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
192
+ } catch (e) { check('get with relation', false, e.message); }
193
+
194
+ // --- Test 3: Relations command — graph explorer ---
195
+ console.log('--- 3. relations (graph explorer) ---');
196
+ try {
197
+ const out = run('relations test-tasks --filter "Name=Implement relations"');
198
+ check('relations shows linked pages', out.includes('linked') || out.includes('Build CLI'));
199
+ console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
200
+ } catch (e) { check('relations command', false, e.message); }
201
+
202
+ // --- Test 4: Reverse relation (project → tasks) ---
203
+ console.log('--- 4. reverse relation ---');
204
+ try {
205
+ const out = run('relations test-projects --filter "Name=Build CLI"');
206
+ const hasLinks = out.includes('Implement') || out.includes('Add tests') || out.includes('linked');
207
+ check('project shows reverse relations', hasLinks);
208
+ console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
209
+ } catch (e) { check('reverse relation', false, e.message); }
210
+
211
+ // --- Test 5: Blocks --ids ---
212
+ console.log('--- 5. blocks --ids ---');
213
+ try {
214
+ run('append test-tasks "Test block for live test" --filter "Name=Implement relations"');
215
+ const out = run('blocks test-tasks --filter "Name=Implement relations" --ids');
216
+ check('blocks --ids shows ID prefix', out.includes('['));
217
+ check('blocks shows appended text', out.includes('Test block'));
218
+ console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
219
+ } catch (e) { check('blocks --ids', false, e.message); }
220
+
221
+ // --- Test 6: Block edit ---
222
+ console.log('--- 6. block-edit ---');
223
+ try {
224
+ const json = runJson('blocks test-tasks --filter "Name=Implement relations"');
225
+ const blockId = json.results[json.results.length - 1].id;
226
+ const out = run(`block-edit ${blockId} "EDITED by CLI test"`);
227
+ check('block-edit succeeds', out.includes('✅'));
228
+ const verify = run('blocks test-tasks --filter "Name=Implement relations"');
229
+ check('edited content visible', verify.includes('EDITED'));
230
+ console.log(`\n${verify.split('\n').map(l => ' ' + l).join('\n')}\n`);
231
+ } catch (e) { check('block-edit', false, e.message); }
232
+
233
+ // --- Test 7: Block delete ---
234
+ console.log('--- 7. block-delete ---');
235
+ try {
236
+ const json = runJson('blocks test-tasks --filter "Name=Implement relations"');
237
+ const blockId = json.results[json.results.length - 1].id;
238
+ const out = run(`block-delete ${blockId}`);
239
+ check('block-delete succeeds', out.includes('🗑'));
240
+ const verify = run('blocks test-tasks --filter "Name=Implement relations"');
241
+ check('deleted content gone', !verify.includes('EDITED'));
242
+ } catch (e) { check('block-delete', false, e.message); }
243
+
244
+ // --- Test 8: JSON output preserves relation data ---
245
+ console.log('--- 8. json output ---');
246
+ try {
247
+ const json = runJson('get test-tasks --filter "Name=Implement relations"');
248
+ check('json has properties', !!json.properties);
249
+ check('json has relation property', !!json.properties.Project);
250
+ check('relation type correct', json.properties.Project.type === 'relation');
251
+ check('relation has linked IDs', json.properties.Project.relation.length > 0);
252
+ } catch (e) { check('json output', false, e.message); }
253
+
254
+ // --- Test 9: Query projects with rollup-like display ---
255
+ console.log('--- 9. query projects ---');
256
+ try {
257
+ const out = run('query test-projects');
258
+ check('query shows projects', out.includes('Build CLI'));
259
+ check('query shows both projects', out.includes('Write Docs'));
260
+ console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
261
+ } catch (e) { check('query projects', false, e.message); }
262
+
263
+ console.log(`\n${'═'.repeat(50)}`);
264
+ console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed}`);
265
+ console.log('═'.repeat(50));
266
+
267
+ return failed;
268
+ }
269
+
270
+ async function cleanup() {
271
+ console.log('\n🧹 Cleaning up...');
272
+
273
+ // Archive pages first (before their parent DBs get deleted)
274
+ for (const id of testPageIds.filter(id => !createdDbIds.includes(id))) {
275
+ try { await notion.pages.update({ page_id: id, archived: true }); } catch {}
276
+ }
277
+
278
+ // Then delete DBs and container page
279
+ for (const id of createdDbIds) {
280
+ try { await notion.blocks.delete({ block_id: id }); } catch {}
281
+ }
282
+
283
+ // Delete container page last
284
+ if (parentPageId) {
285
+ try { await notion.blocks.delete({ block_id: parentPageId }); } catch {}
286
+ }
287
+
288
+ try { run('alias remove test-projects'); } catch {}
289
+ try { run('alias remove test-tasks'); } catch {}
290
+
291
+ console.log('✅ Cleaned up\n');
292
+ }
293
+
294
+ async function main() {
295
+ try {
296
+ await setup();
297
+ const failures = await runTests();
298
+ await cleanup();
299
+ process.exit(failures > 0 ? 1 : 0);
300
+ } catch (err) {
301
+ console.error('\n💥 Test crashed:', err.message);
302
+ if (err.body) console.error('API body:', JSON.stringify(err.body).slice(0, 500));
303
+ if (err.stack) console.error(err.stack);
304
+ await cleanup().catch(() => {});
305
+ process.exit(1);
306
+ }
307
+ }
308
+
309
+ main();
package/test/unit.test.js CHANGED
@@ -151,15 +151,54 @@ describe('propValue', () => {
151
151
  assert.equal(propValue({ type: 'formula', formula: null }), '');
152
152
  });
153
153
 
154
- it('handles relation type', () => {
155
- const prop = { type: 'relation', relation: [{ id: 'abc' }, { id: 'def' }] };
156
- assert.equal(propValue(prop), 'abc, def');
154
+ it('handles relation type — empty', () => {
157
155
  assert.equal(propValue({ type: 'relation', relation: [] }), '');
158
156
  });
159
157
 
160
- it('handles rollup type', () => {
161
- const rollupData = { type: 'number', number: 100 };
162
- assert.equal(propValue({ type: 'rollup', rollup: rollupData }), JSON.stringify(rollupData));
158
+ it('handles relation type — single', () => {
159
+ const prop = { type: 'relation', relation: [{ id: 'abc12345-6789-0000-0000-000000000000' }] };
160
+ assert.equal(propValue(prop), '→ abc12345…');
161
+ });
162
+
163
+ it('handles relation type — multiple', () => {
164
+ const prop = { type: 'relation', relation: [{ id: 'aaa' }, { id: 'bbb' }, { id: 'ccc' }] };
165
+ assert.equal(propValue(prop), '→ 3 linked');
166
+ });
167
+
168
+ it('handles rollup type — number', () => {
169
+ assert.equal(propValue({ type: 'rollup', rollup: { type: 'number', number: 100 } }), '100');
170
+ });
171
+
172
+ it('handles rollup type — null number', () => {
173
+ assert.equal(propValue({ type: 'rollup', rollup: { type: 'number', number: null } }), '');
174
+ });
175
+
176
+ it('handles rollup type — date', () => {
177
+ assert.equal(propValue({ type: 'rollup', rollup: { type: 'date', date: { start: '2026-02-09' } } }), '2026-02-09');
178
+ });
179
+
180
+ it('handles rollup type — date range', () => {
181
+ assert.equal(propValue({ type: 'rollup', rollup: { type: 'date', date: { start: '2026-02-01', end: '2026-02-28' } } }), '2026-02-01 → 2026-02-28');
182
+ });
183
+
184
+ it('handles rollup type — array', () => {
185
+ const rollup = {
186
+ type: 'array',
187
+ array: [
188
+ { type: 'number', number: 1 },
189
+ { type: 'number', number: 2 },
190
+ { type: 'number', number: 3 },
191
+ ],
192
+ };
193
+ assert.equal(propValue({ type: 'rollup', rollup }), '1, 2, 3');
194
+ });
195
+
196
+ it('handles rollup type — empty array', () => {
197
+ assert.equal(propValue({ type: 'rollup', rollup: { type: 'array', array: [] } }), '');
198
+ });
199
+
200
+ it('handles rollup type — null', () => {
201
+ assert.equal(propValue({ type: 'rollup', rollup: null }), '');
163
202
  });
164
203
 
165
204
  it('handles people type', () => {