rootr-cli 0.8.0 → 0.9.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.
@@ -13,6 +13,15 @@ import {
13
13
  /** Human text for the `databaseId` param — accepts a path just like the document tools. */
14
14
  const DATABASE_TARGET_DESC = `DATABASE node id${NODE_TARGET_HINT} (e.g. "/제작 공정/칸반")`;
15
15
 
16
+ /**
17
+ * DB-BODY — 행의 상세 내용(본문 메모). 화면에서 칸반 카드를 열면 속성 아래에 붙는 그 글이며,
18
+ * 실체는 표 밑에 달리는 문서 하나다. 예전에는 REST 2단계로만 닿을 수 있어서 CLI·MCP에서는
19
+ * 아예 손댈 수 없었다.
20
+ */
21
+ const BODY_DESC =
22
+ "The row's detail body — the note shown under a kanban card's properties, in markdown " +
23
+ '(행의 상세 내용/본문 메모). Creates the row\'s page on demand; replaces the whole body.';
24
+
16
25
  /**
17
26
  * Resolve a `{propertyNameOrId: value}` map to `{propertyId: value}` using the
18
27
  * database schema, so agents can address columns by their human name OR their
@@ -51,11 +60,17 @@ export function registerDatabaseTools(server) {
51
60
  'board, a task/requirement/risk tracker, anything with trackable rows. Do NOT fake a table with a markdown ' +
52
61
  'document, and do NOT create an empty database and then add columns one by one — `properties` order IS the ' +
53
62
  'column order, so one call keeps the columns in exactly the order you list them. ' +
54
- 'The FIRST property must be type "title" (the row label). Other types: text | number | select | ' +
55
- 'multi_select | date | checkbox | url | person. select/multi_select take options:[{name,color}]. ' +
56
- 'Views default to a single table view; a "board" view needs config.groupBy = a select property id, a ' +
57
- '"timeline" view needs config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
58
- '{ start: "YYYY-MM-DD", end? }, multi_select is string[].',
63
+ 'The FIRST property must be type "title" (the row label). Other types: text | number | select | multi_select | ' +
64
+ 'status | date | checkbox | url | email | phone | person | files | relation | formula | rollup | ' +
65
+ 'created_time | last_edited_time | created_by | last_edited_by | unique_id. ' +
66
+ 'select/multi_select/status take options:[{name,color,group?}] (group ∈ todo|in_progress|done, status only); ' +
67
+ 'relation takes relationDatabaseId; formula takes formula ("{수량} * {단가}"); rollup takes ' +
68
+ 'rollup:{relation,property?,function}; unique_id takes prefix ("TASK" → "TASK-42"). The last seven types are ' +
69
+ 'filled in by the server and take no row value. ' +
70
+ 'Views default to a single table view; view types are table | board | gallery | calendar | timeline | list — ' +
71
+ 'a "board" view needs config.groupBy (a select/status property id), a "timeline"/"calendar" view needs ' +
72
+ 'config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
73
+ '{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids.',
59
74
  annotations: { readOnlyHint: false, destructiveHint: false },
60
75
  inputSchema: {
61
76
  name: z.string().describe('Table name, e.g. "업무 목록"'),
@@ -68,12 +83,54 @@ export function registerDatabaseTools(server) {
68
83
  z.object({
69
84
  name: z.string().describe('Column header, e.g. "담당자"'),
70
85
  type: z
71
- .enum(['title', 'text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
86
+ .enum([
87
+ 'title',
88
+ 'text',
89
+ 'number',
90
+ 'select',
91
+ 'multi_select',
92
+ 'status',
93
+ 'date',
94
+ 'checkbox',
95
+ 'url',
96
+ 'email',
97
+ 'phone',
98
+ 'person',
99
+ 'files',
100
+ 'relation',
101
+ 'formula',
102
+ 'rollup',
103
+ 'created_time',
104
+ 'last_edited_time',
105
+ 'created_by',
106
+ 'last_edited_by',
107
+ 'unique_id',
108
+ ])
72
109
  .describe('Column type; the FIRST property must be "title"'),
73
110
  options: z
74
- .array(z.object({ name: z.string(), color: z.string() }))
111
+ .array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
112
+ .optional()
113
+ .describe('select/multi_select/status only: the choices'),
114
+ relationDatabaseId: z.string().optional().describe('relation only: the table this column points at'),
115
+ formula: z.string().optional().describe('formula only: e.g. "{수량} * {단가}"'),
116
+ rollup: z
117
+ .object({
118
+ relation: z.string(),
119
+ property: z.string().optional(),
120
+ function: z.enum([
121
+ 'count',
122
+ 'count_values',
123
+ 'sum',
124
+ 'average',
125
+ 'min',
126
+ 'max',
127
+ 'show_original',
128
+ 'percent_checked',
129
+ ]),
130
+ })
75
131
  .optional()
76
- .describe('select/multi_select only: the choices'),
132
+ .describe('rollup only: what to gather through which relation'),
133
+ prefix: z.string().optional().describe('unique_id only: e.g. "TASK" → "TASK-42"'),
77
134
  }),
78
135
  )
79
136
  .optional()
@@ -111,6 +168,66 @@ export function registerDatabaseTools(server) {
111
168
  },
112
169
  );
113
170
 
171
+ server.registerTool(
172
+ 'rootr_create_view',
173
+ {
174
+ title: 'Add a view to an existing database',
175
+ description:
176
+ 'Add ONE more view (table / board / gallery / calendar / timeline) to a Rootr (루터) DATABASE that ' +
177
+ 'already exists. Until now views could only be set when the table was CREATED, so a Notion table with ' +
178
+ 'four views could only be migrated with one. Existing views are untouched — the server merges, unlike ' +
179
+ 'a full PATCH which replaces the whole views array and silently drops anything you leave out. ' +
180
+ 'A board needs `groupBy` (a select column); calendar/timeline need `dateProp`. Columns may be given ' +
181
+ 'by NAME or id.',
182
+ annotations: { readOnlyHint: false, destructiveHint: false },
183
+ inputSchema: {
184
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
185
+ name: z.string().describe('View name shown on the tab, e.g. "일정"'),
186
+ type: z
187
+ .enum(['table', 'board', 'gallery', 'calendar', 'timeline'])
188
+ .describe('View layout'),
189
+ groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
190
+ dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
191
+ endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
192
+ },
193
+ },
194
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp }) => {
195
+ try {
196
+ const client = makeClient();
197
+ const id = await resolveNodeId(client, databaseId);
198
+ const view = await client.createDatabaseView(id, { name, type, groupBy, dateProp, endDateProp });
199
+ return jsonResult(view);
200
+ } catch (err) {
201
+ return errorResult(err);
202
+ }
203
+ },
204
+ );
205
+
206
+ server.registerTool(
207
+ 'rootr_delete_view',
208
+ {
209
+ title: 'Delete one view from a database',
210
+ description:
211
+ 'Remove ONE view from a Rootr (루터) DATABASE. Rows and column values are NOT touched — only that ' +
212
+ 'view\'s layout is removed. The last remaining view cannot be deleted (there would be nothing to look at).',
213
+ annotations: { readOnlyHint: false, destructiveHint: true },
214
+ inputSchema: {
215
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
216
+ viewId: z.string().describe('View id or view NAME (from rootr_read_database)'),
217
+ },
218
+ },
219
+ async ({ databaseId, viewId }) => {
220
+ try {
221
+ const client = makeClient();
222
+ const id = await resolveNodeId(client, databaseId);
223
+ await client.deleteDatabaseView(id, viewId);
224
+ return jsonResult({ deleted: viewId });
225
+ } catch (err) {
226
+ return errorResult(err);
227
+ }
228
+ },
229
+ );
230
+
114
231
  server.registerTool(
115
232
  'rootr_update_view',
116
233
  {
@@ -164,15 +281,19 @@ export function registerDatabaseTools(server) {
164
281
  annotations: { readOnlyHint: true, destructiveHint: false },
165
282
  inputSchema: {
166
283
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
284
+ includeBody: z
285
+ .boolean()
286
+ .optional()
287
+ .describe("true → also return each row's detail body (the note under a card). Off by default."),
167
288
  },
168
289
  },
169
- async ({ databaseId }) => {
290
+ async ({ databaseId, includeBody }) => {
170
291
  try {
171
292
  const client = makeClient();
172
293
  const id = await resolveNodeId(client, databaseId);
173
294
  const [database, rows] = await Promise.all([
174
295
  client.getDatabase(id),
175
- client.listRows(id),
296
+ client.listRows(id, { withBody: includeBody === true }),
176
297
  ]);
177
298
  return jsonResult({ database, rows });
178
299
  } catch (err) {
@@ -191,12 +312,18 @@ export function registerDatabaseTools(server) {
191
312
  annotations: { readOnlyHint: true, destructiveHint: false },
192
313
  inputSchema: {
193
314
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
315
+ includeBody: z
316
+ .boolean()
317
+ .optional()
318
+ .describe("true → also return each row's detail body (the note under a card). Off by default."),
194
319
  },
195
320
  },
196
- async ({ databaseId }) => {
321
+ async ({ databaseId, includeBody }) => {
197
322
  try {
198
323
  const client = makeClient();
199
- const rows = await client.listRows(await resolveNodeId(client, databaseId));
324
+ const rows = await client.listRows(await resolveNodeId(client, databaseId), {
325
+ withBody: includeBody === true,
326
+ });
200
327
  return jsonResult({ rows });
201
328
  } catch (err) {
202
329
  return errorResult(err);
@@ -210,23 +337,27 @@ export function registerDatabaseTools(server) {
210
337
  title: 'Add a database row',
211
338
  description:
212
339
  'Append a row to a Rootr (루터) DATABASE. `values` maps property NAME (or id) → value. ' +
213
- 'Value shapes by type: title/text/url/select/person → string; number → number; multi_select → string[]; ' +
340
+ 'Value shapes by type: title/text/url/select → string; number → number; multi_select → string[]; ' +
341
+ 'person → string[] (a LIST of names — a meeting has several attendees; one bare name is also accepted); ' +
214
342
  'checkbox → boolean; date → { start: "YYYY-MM-DD", end? }. A select value is the OPTION NAME. ' +
215
- 'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index.',
343
+ 'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index. ' +
344
+ 'Pass `body` to also write the row\'s DETAIL PAGE (the note shown under a kanban card\'s properties) — ' +
345
+ 'that page is created on demand, so a row with `body` is the one-call equivalent of "메모 추가".',
216
346
  annotations: { readOnlyHint: false, destructiveHint: false },
217
347
  inputSchema: {
218
348
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
219
349
  values: z.record(z.unknown()).describe('{ propertyNameOrId: value } for the new row'),
220
350
  position: z.number().optional().describe('0-based insert index; appended at the end if omitted'),
351
+ body: z.string().optional().describe(BODY_DESC),
221
352
  },
222
353
  },
223
- async ({ databaseId, values, position }) => {
354
+ async ({ databaseId, values, position, body }) => {
224
355
  try {
225
356
  const client = makeClient();
226
357
  const id = await resolveNodeId(client, databaseId);
227
358
  const database = await client.getDatabase(id);
228
359
  const resolved = resolveValueKeys(database.properties || [], values);
229
- const row = await client.createRow(id, { values: resolved, position });
360
+ const row = await client.createRow(id, { values: resolved, position, body });
230
361
  return jsonResult(row);
231
362
  } catch (err) {
232
363
  return errorResult(err);
@@ -249,9 +380,10 @@ export function registerDatabaseTools(server) {
249
380
  rowId: z.string().describe('Row id (must belong to the database)'),
250
381
  values: z.record(z.unknown()).optional().describe('{ propertyNameOrId: newValue | null } partial patch'),
251
382
  position: z.number().optional().describe('New 0-based position for the row'),
383
+ body: z.string().optional().describe(BODY_DESC),
252
384
  },
253
385
  },
254
- async ({ databaseId, rowId, values, position }) => {
386
+ async ({ databaseId, rowId, values, position, body }) => {
255
387
  try {
256
388
  const client = makeClient();
257
389
  const id = await resolveNodeId(client, databaseId);
@@ -260,7 +392,7 @@ export function registerDatabaseTools(server) {
260
392
  const database = await client.getDatabase(id);
261
393
  resolved = resolveValueKeys(database.properties || [], values);
262
394
  }
263
- const row = await client.updateRow(id, rowId, { values: resolved, position });
395
+ const row = await client.updateRow(id, rowId, { values: resolved, position, body });
264
396
  return jsonResult(row);
265
397
  } catch (err) {
266
398
  return errorResult(err);
@@ -305,27 +437,91 @@ export function registerDatabaseTools(server) {
305
437
  description:
306
438
  'Add ONE column to a Rootr (루터) DATABASE. The existing columns and every row are left untouched — ' +
307
439
  'rows simply read empty for the new column until you fill them with rootr_update_row. ' +
308
- 'type ∈ text | number | select | multi_select | date | checkbox | url | person ' +
440
+ 'type ∈ text | number | select | multi_select | status | date | checkbox | url | email | phone | person | files | ' +
441
+ 'relation | formula | rollup | created_time | last_edited_time | created_by | last_edited_by | unique_id ' +
309
442
  '("title" is not allowed: a database already has exactly one title column). ' +
310
- 'For select/multi_select pass options: [{ name, color }]. Returns the created column WITH ITS id.',
443
+ 'For select/multi_select/status pass options: [{ name, color, group? }] (group ∈ todo|in_progress|done, status only). ' +
444
+ 'For relation pass relationDatabaseId (the node id of the table it points at). ' +
445
+ 'For formula pass formula, e.g. "{수량} * {단가}" or "if({금액} >= 5000, \\"큰 건\\", \\"작은 건\\")" — ' +
446
+ 'available functions: if, concat, length, upper, lower, contains, abs, round, floor, ceil, min, max, dateBetween, now, empty. ' +
447
+ 'For rollup pass rollup: { relation, property?, function } (function ∈ count|count_values|sum|average|min|max|show_original|percent_checked). ' +
448
+ 'For unique_id pass prefix to get "TASK-42" style ids. ' +
449
+ 'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
450
+ 'they take no value and rootr_update_row will refuse to set them. ' +
451
+ 'Returns the created column WITH ITS id.',
311
452
  annotations: { readOnlyHint: false, destructiveHint: false },
312
453
  inputSchema: {
313
454
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
314
455
  name: z.string().describe('Column header, e.g. "담당자"'),
315
456
  type: z
316
- .enum(['text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
457
+ .enum([
458
+ 'text',
459
+ 'number',
460
+ 'select',
461
+ 'multi_select',
462
+ 'status',
463
+ 'date',
464
+ 'checkbox',
465
+ 'url',
466
+ 'email',
467
+ 'phone',
468
+ 'person',
469
+ 'files',
470
+ 'relation',
471
+ 'formula',
472
+ 'rollup',
473
+ 'created_time',
474
+ 'last_edited_time',
475
+ 'created_by',
476
+ 'last_edited_by',
477
+ 'unique_id',
478
+ ])
317
479
  .describe('Column type'),
318
480
  options: z
319
- .array(z.object({ name: z.string(), color: z.string() }))
481
+ .array(z.object({ name: z.string(), color: z.string(), group: z.string().optional() }))
482
+ .optional()
483
+ .describe('select/multi_select/status only: the choices, e.g. [{ name: "진행중", color: "blue" }]'),
484
+ relationDatabaseId: z
485
+ .string()
486
+ .optional()
487
+ .describe('relation only: the node id of the database this column points at'),
488
+ formula: z
489
+ .string()
490
+ .optional()
491
+ .describe('formula only: the expression, e.g. "{수량} * {단가}"'),
492
+ rollup: z
493
+ .object({
494
+ relation: z.string().describe('the relation column (id or name) to follow'),
495
+ property: z.string().optional().describe('the column to read in the linked table'),
496
+ function: z.enum([
497
+ 'count',
498
+ 'count_values',
499
+ 'sum',
500
+ 'average',
501
+ 'min',
502
+ 'max',
503
+ 'show_original',
504
+ 'percent_checked',
505
+ ]),
506
+ })
320
507
  .optional()
321
- .describe('select/multi_select only: the choices, e.g. [{ name: "진행중", color: "blue" }]'),
508
+ .describe('rollup only: what to gather through which relation'),
509
+ prefix: z.string().optional().describe('unique_id only: prefix for the number, e.g. "TASK" → "TASK-42"'),
322
510
  },
323
511
  },
324
- async ({ databaseId, name, type, options }) => {
512
+ async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix }) => {
325
513
  try {
326
514
  const client = makeClient();
327
515
  const id = await resolveNodeId(client, databaseId);
328
- const property = await client.addColumn(id, { name, type, options });
516
+ const property = await client.addColumn(id, {
517
+ name,
518
+ type,
519
+ options,
520
+ relationDatabaseId,
521
+ formula,
522
+ rollup,
523
+ prefix,
524
+ });
329
525
  return jsonResult({ property });
330
526
  } catch (err) {
331
527
  return errorResult(err);
@@ -364,6 +560,131 @@ export function registerDatabaseTools(server) {
364
560
  },
365
561
  );
366
562
 
563
+ // ── DB-PERM-001 / DB-WATCH — 노션에 없는 두 가지: 자물쇠와 알림 ────────────
564
+ //
565
+ // 표를 만들 수 있으면 잠글 수도, 누구에게 알릴지 정할 수도 있어야 한다. 이게 없으면
566
+ // 에이전트가 "지원서 표 만들어 줘. 서로 못 보게 하고, 새 지원 들어오면 김승수한테
567
+ // 알려 줘" 같은 부탁의 절반만 해내고 만다.
568
+
569
+ server.registerTool(
570
+ 'rootr_read_database_permissions',
571
+ {
572
+ title: 'Read a database\'s locks',
573
+ description:
574
+ 'Read the three locks on a Rootr (루터) DATABASE — schema lock (only managers change columns/views), ' +
575
+ 'own-rows-only (people see just what they submitted), and manager-only columns. Notion has none of these.',
576
+ annotations: { readOnlyHint: true, destructiveHint: false },
577
+ inputSchema: { databaseId: z.string().describe(DATABASE_TARGET_DESC) },
578
+ },
579
+ async ({ databaseId }) => {
580
+ try {
581
+ const client = makeClient();
582
+ const id = await resolveNodeId(client, databaseId);
583
+ return jsonResult(await client.getDatabasePermissions(id));
584
+ } catch (err) {
585
+ return errorResult(err);
586
+ }
587
+ },
588
+ );
589
+
590
+ server.registerTool(
591
+ 'rootr_set_database_permissions',
592
+ {
593
+ title: 'Lock a database',
594
+ description:
595
+ 'Set the locks on a Rootr (루터) DATABASE. `schemaLock: "managers"` stops editors from changing columns and ' +
596
+ 'views; `rowScope: "own"` hides other people\'s rows (application forms, report boxes); `lockedProps` names ' +
597
+ 'the columns only managers may edit (approval flags, final scores). Omitted fields keep their current value. ' +
598
+ 'Requires MANAGE on the table.',
599
+ annotations: { readOnlyHint: false, destructiveHint: false },
600
+ inputSchema: {
601
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
602
+ schemaLock: z.enum(['open', 'managers']).optional().describe('Who may change columns and views'),
603
+ rowScope: z.enum(['all', 'own']).optional().describe('"own" = each person sees only the rows they created'),
604
+ lockedProps: z
605
+ .array(z.string())
606
+ .optional()
607
+ .describe('Column ids (or names) only managers may edit'),
608
+ },
609
+ },
610
+ async ({ databaseId, schemaLock, rowScope, lockedProps }) => {
611
+ try {
612
+ const client = makeClient();
613
+ const id = await resolveNodeId(client, databaseId);
614
+ const body = {};
615
+ if (schemaLock !== undefined) body.schemaLock = schemaLock;
616
+ if (rowScope !== undefined) body.rowScope = rowScope;
617
+ if (lockedProps !== undefined) body.lockedProps = lockedProps;
618
+ return jsonResult(await client.setDatabasePermissions(id, body));
619
+ } catch (err) {
620
+ return errorResult(err);
621
+ }
622
+ },
623
+ );
624
+
625
+ server.registerTool(
626
+ 'rootr_read_database_notifications',
627
+ {
628
+ title: 'Read a database\'s notification rules',
629
+ description:
630
+ 'Read who gets notified when rows are added, changed or deleted in a Rootr (루터) DATABASE. ' +
631
+ 'Notion cannot do this — there each person must turn on watching for themselves.',
632
+ annotations: { readOnlyHint: true, destructiveHint: false },
633
+ inputSchema: { databaseId: z.string().describe(DATABASE_TARGET_DESC) },
634
+ },
635
+ async ({ databaseId }) => {
636
+ try {
637
+ const client = makeClient();
638
+ const id = await resolveNodeId(client, databaseId);
639
+ return jsonResult(await client.getDatabaseNotifications(id));
640
+ } catch (err) {
641
+ return errorResult(err);
642
+ }
643
+ },
644
+ );
645
+
646
+ server.registerTool(
647
+ 'rootr_set_database_notifications',
648
+ {
649
+ title: 'Set who gets notified about a database',
650
+ description:
651
+ 'Replace the notification rules on a Rootr (루터) DATABASE. Each rule names the people to notify (`userIds`), ' +
652
+ 'when (`events`: created / updated / deleted), and optionally a filter so only some rows ping — ' +
653
+ '`{propId, op: "is"|"contains"|"changed", value}` (e.g. status is 긴급, owner changed). ' +
654
+ 'Rules replace the existing set; pass [] to clear. Requires MANAGE. A person is never notified about their own change.',
655
+ annotations: { readOnlyHint: false, destructiveHint: true },
656
+ inputSchema: {
657
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
658
+ watchers: z
659
+ .array(
660
+ z.object({
661
+ id: z.string().optional(),
662
+ userIds: z.array(z.string()).describe('Workspace member user ids to notify'),
663
+ events: z.array(z.enum(['created', 'updated', 'deleted'])),
664
+ filter: z
665
+ .object({
666
+ propId: z.string(),
667
+ op: z.enum(['is', 'contains', 'changed']),
668
+ value: z.string().optional(),
669
+ })
670
+ .optional(),
671
+ label: z.string().optional(),
672
+ }),
673
+ )
674
+ .describe('The complete rule set (replaces what is stored)'),
675
+ },
676
+ },
677
+ async ({ databaseId, watchers }) => {
678
+ try {
679
+ const client = makeClient();
680
+ const id = await resolveNodeId(client, databaseId);
681
+ return jsonResult(await client.setDatabaseNotifications(id, watchers));
682
+ } catch (err) {
683
+ return errorResult(err);
684
+ }
685
+ },
686
+ );
687
+
367
688
  server.registerTool(
368
689
  'rootr_delete_column',
369
690
  {