rootr-cli 0.9.0 → 0.13.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,71 @@ 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
+ * ROW-PAGE-PICK — 행을 열면 나오는 것.
18
+ * 표(DATABASE)와 폴더는 뺀다 — 행 하나가 또 다른 트리가 되면 "행을 연다"가
19
+ * 무엇인지 사람이 알 수 없다.
20
+ */
21
+ const ROW_PAGE_TYPE_SCHEMA = z.enum([
22
+ 'DOCUMENT', 'SIGNED', 'SPREADSHEET', 'WHITEBOARD', 'PRESENTATION',
23
+ 'RECORDING', 'FORM', 'ISSUE_TRACKER',
24
+ ]);
25
+
26
+ /**
27
+ * VIEW-FILTER — 사람이 화면에서 거는 "이 조건만 보이게"를 에이전트도 걸 수 있게 한다.
28
+ * 조건은 전부 만족해야 하고(AND), 한 조건 안의 `values`는 그중 하나만 맞아도 된다(OR).
29
+ */
30
+ const FILTER_SHAPE = z.array(
31
+ z.object({
32
+ propId: z.string().describe('Column NAME or id the condition applies to'),
33
+ op: z
34
+ .enum([
35
+ 'is',
36
+ 'is_not',
37
+ 'contains',
38
+ 'is_empty',
39
+ 'is_not_empty',
40
+ 'today',
41
+ 'this_week',
42
+ 'this_month',
43
+ 'past',
44
+ 'upcoming',
45
+ 'before',
46
+ 'after',
47
+ 'on',
48
+ 'between',
49
+ 'eq',
50
+ 'gt',
51
+ 'lt',
52
+ 'checked',
53
+ 'unchecked',
54
+ ])
55
+ .describe('Comparison to apply'),
56
+ values: z
57
+ .array(z.string())
58
+ .optional()
59
+ .describe(
60
+ 'Values the op needs. is/is_not/contains → one or more (OR between them). ' +
61
+ 'before/after/on → one date "YYYY-MM-DD". between → two dates. eq/gt/lt → one number. ' +
62
+ 'is_empty/is_not_empty/today/this_week/this_month/past/upcoming/checked/unchecked take none.',
63
+ ),
64
+ }),
65
+ );
66
+
67
+ const FILTER_DESC =
68
+ 'Only rows matching EVERY condition are shown in this view (AND between conditions, OR inside one ' +
69
+ 'condition\'s values). This is the same filter a person sets on screen, so "검토 대기인 것만 보이게 해줘" ' +
70
+ 'is expressible: [{"propId":"구분","op":"is","values":["검토 대기"]}]. Rows are NOT deleted — other views ' +
71
+ 'still show them. A new row added while a filter is on is seeded with the filtered values so it does not ' +
72
+ 'vanish the moment it is created.';
73
+
74
+ const SORT_SHAPE = z.object({
75
+ propId: z.string().describe('Column NAME or id to sort by'),
76
+ dir: z.enum(['asc', 'desc']).describe('asc = 오름차순, desc = 내림차순'),
77
+ });
78
+
79
+ const SORT_DESC = 'Row order in this view.';
80
+
16
81
  /**
17
82
  * DB-BODY — 행의 상세 내용(본문 메모). 화면에서 칸반 카드를 열면 속성 아래에 붙는 그 글이며,
18
83
  * 실체는 표 밑에 달리는 문서 하나다. 예전에는 REST 2단계로만 닿을 수 있어서 CLI·MCP에서는
@@ -143,10 +208,17 @@ export function registerDatabaseTools(server) {
143
208
  .array(z.object({ values: z.record(z.unknown()) }))
144
209
  .optional()
145
210
  .describe('Initial rows, e.g. [{ values: { "이름": "킥오프", "기한": { "start": "2026-08-01" } } }]'),
211
+ rowPageType: ROW_PAGE_TYPE_SCHEMA.optional().describe(
212
+ 'ROW-PAGE-TYPE — the DEFAULT kind of detail page a new row opens as. "SIGNED" makes rows 서명문서/연구노트 ' +
213
+ '(freezes on submit, ordered signatures + RFC 3161 timestamps, undeletable) — 실험 기록 · 점검 대장 · ' +
214
+ '결재 대장. "RECORDING" makes each row a meeting with audio + transcript, "SPREADSHEET" a per-row ' +
215
+ 'calculation sheet, "FORM" a per-row intake. Default "DOCUMENT". This is only the default — a single ' +
216
+ 'row can be made something else with rootr_create_row_page.',
217
+ ),
146
218
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
147
219
  },
148
220
  },
149
- async ({ name, parentId, path, createParents, icon, properties, views, rows, workspace }) => {
221
+ async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, workspace }) => {
150
222
  try {
151
223
  const ws = requireWorkspace(workspace);
152
224
  const client = makeClient(ws);
@@ -160,6 +232,7 @@ export function registerDatabaseTools(server) {
160
232
  properties,
161
233
  views,
162
234
  rows,
235
+ rowPageType,
163
236
  });
164
237
  return jsonResult(database);
165
238
  } catch (err) {
@@ -189,13 +262,31 @@ export function registerDatabaseTools(server) {
189
262
  groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
190
263
  dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
191
264
  endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
265
+ filters: FILTER_SHAPE.optional().describe(FILTER_DESC),
266
+ sort: SORT_SHAPE.optional().describe(SORT_DESC),
267
+ colWidths: z
268
+ .record(z.number())
269
+ .optional()
270
+ .describe(
271
+ 'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
272
+ '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
273
+ ),
192
274
  },
193
275
  },
194
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp }) => {
276
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths }) => {
195
277
  try {
196
278
  const client = makeClient();
197
279
  const id = await resolveNodeId(client, databaseId);
198
- const view = await client.createDatabaseView(id, { name, type, groupBy, dateProp, endDateProp });
280
+ const view = await client.createDatabaseView(id, {
281
+ name,
282
+ type,
283
+ groupBy,
284
+ dateProp,
285
+ endDateProp,
286
+ filters,
287
+ sort,
288
+ colWidths,
289
+ });
199
290
  return jsonResult(view);
200
291
  } catch (err) {
201
292
  return errorResult(err);
@@ -250,9 +341,29 @@ export function registerDatabaseTools(server) {
250
341
  groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
251
342
  dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
252
343
  endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
344
+ filters: FILTER_SHAPE.nullable().optional().describe(FILTER_DESC + ' null 또는 []을 주면 조건을 전부 없앤다.'),
345
+ sort: SORT_SHAPE.nullable().optional().describe(SORT_DESC + ' null을 주면 정렬을 끈다.'),
346
+ colWidths: z
347
+ .record(z.number())
348
+ .optional()
349
+ .describe(
350
+ 'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
351
+ '40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
352
+ ),
253
353
  },
254
354
  },
255
- async ({ databaseId, viewId, hiddenProps, name, groupBy, dateProp, endDateProp }) => {
355
+ async ({
356
+ databaseId,
357
+ viewId,
358
+ hiddenProps,
359
+ name,
360
+ groupBy,
361
+ dateProp,
362
+ endDateProp,
363
+ filters,
364
+ sort,
365
+ colWidths,
366
+ }) => {
256
367
  try {
257
368
  const client = makeClient();
258
369
  const id = await resolveNodeId(client, databaseId);
@@ -262,6 +373,9 @@ export function registerDatabaseTools(server) {
262
373
  groupBy,
263
374
  dateProp,
264
375
  endDateProp,
376
+ filters,
377
+ sort,
378
+ colWidths,
265
379
  });
266
380
  return jsonResult({ view });
267
381
  } catch (err) {
@@ -400,6 +514,222 @@ export function registerDatabaseTools(server) {
400
514
  },
401
515
  );
402
516
 
517
+ server.registerTool(
518
+ 'rootr_bulk_create_rows',
519
+ {
520
+ title: 'Add many rows at once',
521
+ description:
522
+ 'Add MANY rows to a Rootr (루터) DATABASE in one call. Use this instead of calling rootr_add_row in a ' +
523
+ 'loop — a loop leaves a half-filled table when one call fails, and burns the read quota. ' +
524
+ 'All-or-nothing: if any row fails validation nothing is created, and the error says which index broke. ' +
525
+ 'Column keys may be property NAMES or ids. Up to 500 rows per call.',
526
+ annotations: { readOnlyHint: false, destructiveHint: false },
527
+ inputSchema: {
528
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
529
+ rows: z
530
+ .array(z.record(z.any()))
531
+ .describe('Each entry is one row: { "칸 이름 또는 id": 값, … }'),
532
+ },
533
+ },
534
+ async ({ databaseId, rows }) => {
535
+ try {
536
+ const client = makeClient();
537
+ const id = await resolveNodeId(client, databaseId);
538
+ return jsonResult(await client.bulkCreateRows(id, rows));
539
+ } catch (err) {
540
+ return errorResult(err);
541
+ }
542
+ },
543
+ );
544
+
545
+ server.registerTool(
546
+ 'rootr_bulk_update_rows_each',
547
+ {
548
+ title: 'Update many rows, each with its own values',
549
+ description:
550
+ 'Patch MANY rows at once where **each row gets different values**. rootr_bulk_update_rows puts the SAME ' +
551
+ 'values on every row (good for "move these 20 to next Friday"); this one is for filling in a table row ' +
552
+ 'by row. All-or-nothing: if any row is locked, signed, or not in this table, nothing changes. ' +
553
+ 'Column keys may be property NAMES or ids.',
554
+ annotations: { readOnlyHint: false, destructiveHint: false },
555
+ inputSchema: {
556
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
557
+ updates: z
558
+ .array(z.object({ rowId: z.string(), values: z.record(z.any()) }))
559
+ .describe('[{ rowId, values: { "칸 이름 또는 id": 값 } }, …]'),
560
+ },
561
+ },
562
+ async ({ databaseId, updates }) => {
563
+ try {
564
+ const client = makeClient();
565
+ const id = await resolveNodeId(client, databaseId);
566
+ return jsonResult(await client.bulkUpdateRowsEach(id, updates));
567
+ } catch (err) {
568
+ return errorResult(err);
569
+ }
570
+ },
571
+ );
572
+
573
+ server.registerTool(
574
+ 'rootr_bulk_update_rows',
575
+ {
576
+ title: 'Update many rows at once',
577
+ description:
578
+ 'Write the SAME values onto MANY rows of a Rootr (루터) DATABASE in one call. Use it whenever a person ' +
579
+ 'says "한 번에 바꿔줘" — fixing a wrong 날짜 across a month of entries, moving a batch of cards to another ' +
580
+ 'status, clearing a column. `values` maps property NAME (or id) → value, same shapes as rootr_add_row ' +
581
+ '(date → { start: "YYYY-MM-DD" }, `null` clears a key, omitted keys are untouched). ' +
582
+ 'ALL-OR-NOTHING: if any row is locked, belongs to someone else, or is not in this database, nothing ' +
583
+ 'changes and the error says which. Up to 500 rows per call — split larger jobs. ' +
584
+ 'Get rowIds from rootr_list_rows / rootr_read_database.',
585
+ annotations: { readOnlyHint: false, destructiveHint: false },
586
+ inputSchema: {
587
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
588
+ rowIds: z.array(z.string()).describe('Rows to change (max 500)'),
589
+ values: z.record(z.unknown()).describe('{ propertyNameOrId: newValue | null } applied to EVERY listed row'),
590
+ },
591
+ },
592
+ async ({ databaseId, rowIds, values }) => {
593
+ try {
594
+ const client = makeClient();
595
+ const id = await resolveNodeId(client, databaseId);
596
+ const database = await client.getDatabase(id);
597
+ const resolved = resolveValueKeys(database.properties || [], values);
598
+ return jsonResult(await client.bulkUpdateRows(id, rowIds, resolved));
599
+ } catch (err) {
600
+ return errorResult(err);
601
+ }
602
+ },
603
+ );
604
+
605
+ server.registerTool(
606
+ 'rootr_bulk_delete_rows',
607
+ {
608
+ title: 'Delete many rows at once',
609
+ description:
610
+ 'Delete MANY rows of a Rootr (루터) DATABASE in one call. Each row\'s attached page goes to the trash. ' +
611
+ 'ALL-OR-NOTHING: if any row is already signed, locked, or belongs to someone else, NOTHING is deleted ' +
612
+ 'and the error says which — so a batch never half-succeeds. Up to 500 rows per call. ' +
613
+ 'This is not reversible from the API; confirm with the person before calling it on rows you did not just create.',
614
+ annotations: { readOnlyHint: false, destructiveHint: true },
615
+ inputSchema: {
616
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
617
+ rowIds: z.array(z.string()).describe('Rows to delete (max 500)'),
618
+ },
619
+ },
620
+ async ({ databaseId, rowIds }) => {
621
+ try {
622
+ const client = makeClient();
623
+ const id = await resolveNodeId(client, databaseId);
624
+ return jsonResult(await client.bulkDeleteRows(id, rowIds));
625
+ } catch (err) {
626
+ return errorResult(err);
627
+ }
628
+ },
629
+ );
630
+
631
+ server.registerTool(
632
+ 'rootr_create_row_page',
633
+ {
634
+ title: 'Attach a detail page to a row',
635
+ description:
636
+ "Give ONE row of a Rootr (루터) DATABASE its DETAIL PAGE — the node that opens when a person clicks the row — " +
637
+ 'and choose WHAT KIND it is. This is the only way to make a row open as something other than a plain ' +
638
+ 'document: pass `type` to make THIS row a SPREADSHEET (a per-row calculation sheet), RECORDING (a meeting ' +
639
+ "whose audio and transcript live on the row), SIGNED (a record that must be signed), FORM, WHITEBOARD, " +
640
+ 'PRESENTATION or ISSUE_TRACKER. Rows in one table may each be a different kind — a 회의 table can hold ' +
641
+ 'recordings and signed circulars side by side. Omit `type` to use the table default (see ' +
642
+ 'rootr_set_row_page_type). If the row already has a page this returns it unchanged — the kind is NOT ' +
643
+ 'changed retroactively, so pick it when you create the page. The returned `nodeId` is a normal node: ' +
644
+ 'write it with rootr_write/rootr_update_spreadsheet/rootr_upload_recording_audio and so on.',
645
+ annotations: { readOnlyHint: false, destructiveHint: false },
646
+ inputSchema: {
647
+ rowId: z.string().describe('Row id (from rootr_read_database / rootr_list_rows)'),
648
+ type: ROW_PAGE_TYPE_SCHEMA.optional().describe(
649
+ 'What this row opens as. Omit to use the table default (usually DOCUMENT).',
650
+ ),
651
+ nodeId: z
652
+ .string()
653
+ .optional()
654
+ .describe(
655
+ `Attach an EXISTING node${NODE_TARGET_HINT} instead of creating one — use this when the ` +
656
+ 'content already exists somewhere in the tree ("이 회의록을 그 표 행에 걸어줘"). Never copy a ' +
657
+ 'document into a row: link it, or the same text lives in two places and both go stale. ' +
658
+ 'The node is NOT moved — it stays where it is and the row points at it, so deleting the row ' +
659
+ 'later leaves it alone. The SAME node may be linked from several rows (one 회의록 can hang off ' +
660
+ 'both the 회의 table and the 프로젝트 table). Cannot be combined with `type`.',
661
+ ),
662
+ },
663
+ },
664
+ async ({ rowId, type, nodeId }) => {
665
+ try {
666
+ const client = makeClient();
667
+ if (nodeId && type) {
668
+ // 조용히 한쪽만 해 주면 안 된다 — 녹음을 만들라고 했는데 문서가 붙는다.
669
+ throw new Error(
670
+ 'type과 nodeId는 같이 줄 수 없습니다. 새로 만들려면 type, 이미 있는 문서를 이으려면 nodeId 하나만 주세요.',
671
+ );
672
+ }
673
+ if (nodeId) {
674
+ return jsonResult(await client.linkRowPage(rowId, await resolveNodeId(client, nodeId)));
675
+ }
676
+ return jsonResult(await client.createRowPage(rowId, type));
677
+ } catch (err) {
678
+ return errorResult(err);
679
+ }
680
+ },
681
+ );
682
+
683
+ server.registerTool(
684
+ 'rootr_unlink_row_page',
685
+ {
686
+ title: "Detach a row's page",
687
+ description:
688
+ "Detach a Rootr (루터) DATABASE row from its detail page. The page is NOT deleted — it stays exactly " +
689
+ 'where it is, and the row simply stops pointing at it. Use it to swap a row onto a different document ' +
690
+ '(unlink, then rootr_create_row_page with `nodeId`), or when a page was attached by mistake. ' +
691
+ 'To actually delete the page, use rootr_delete_node on it afterwards.',
692
+ annotations: { readOnlyHint: false, destructiveHint: false },
693
+ inputSchema: {
694
+ rowId: z.string().describe('Row id (from rootr_read_database / rootr_list_rows)'),
695
+ },
696
+ },
697
+ async ({ rowId }) => {
698
+ try {
699
+ return jsonResult(await makeClient().unlinkRowPage(rowId));
700
+ } catch (err) {
701
+ return errorResult(err);
702
+ }
703
+ },
704
+ );
705
+
706
+ server.registerTool(
707
+ 'rootr_set_row_page_type',
708
+ {
709
+ title: 'Set what new rows open as',
710
+ description:
711
+ 'Set the DEFAULT kind of detail page for new rows of a Rootr (루터) DATABASE — what a person gets when ' +
712
+ 'they just click "만들기" without choosing. Use it when a whole table has one shape: a 회의 기록 table ' +
713
+ 'whose every row is a RECORDING, a 점검 기록 table whose every row is SIGNED, a 측정 table of ' +
714
+ 'SPREADSHEETs. Rows created BEFORE this call keep what they already have, and any single row can still ' +
715
+ 'be made something else with rootr_create_row_page `type`.',
716
+ annotations: { readOnlyHint: false, destructiveHint: false },
717
+ inputSchema: {
718
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
719
+ rowPageType: ROW_PAGE_TYPE_SCHEMA.describe('Default kind for rows created from now on'),
720
+ },
721
+ },
722
+ async ({ databaseId, rowPageType }) => {
723
+ try {
724
+ const client = makeClient();
725
+ const id = await resolveNodeId(client, databaseId);
726
+ return jsonResult(await client.setDatabaseRowPageType(id, rowPageType));
727
+ } catch (err) {
728
+ return errorResult(err);
729
+ }
730
+ },
731
+ );
732
+
403
733
  server.registerTool(
404
734
  'rootr_delete_row',
405
735
  {
@@ -19,11 +19,17 @@ export function registerDocumentTools(server) {
19
19
  .string()
20
20
  .optional()
21
21
  .describe('Optional path prefix to filter results, e.g. "/notes"'),
22
+ // 다른 워크스페이스 도구는 전부 받는데 이것만 안 받아서, 넣으면 정체 모를
23
+ // 오류가 났다(외부 에이전트 실측 2026-08-30). 같은 규칙으로 맞춘다.
24
+ workspace: z
25
+ .string()
26
+ .optional()
27
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
22
28
  },
23
29
  },
24
- async ({ path }) => {
30
+ async ({ path, workspace }) => {
25
31
  try {
26
- const client = makeClient();
32
+ const client = makeClient(workspace);
27
33
  const nodes = await client.getTree();
28
34
  const rows = nodes
29
35
  .filter((n) => {
@@ -212,8 +218,8 @@ export function registerDocumentTools(server) {
212
218
  title: 'Comment on a Rootr document',
213
219
  description:
214
220
  'Add a comment to a Rootr (루터) document (optionally anchored to a block, or as a reply via parentId). ' +
215
- 'Comments carry a user identity, so this requires an ACCOUNT key (PAT) — a workspace-scoped key ' +
216
- '(including remote-connector tokens) gets a 403; fall back to rootr_append in that case.',
221
+ 'Comments carry a user identity: the comment is attributed to the person who owns the key ' +
222
+ '(account keys and workspace keys both carry an owner). A key with no owner cannot comment.',
217
223
  annotations: { readOnlyHint: false, destructiveHint: false },
218
224
  inputSchema: {
219
225
  target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
@@ -0,0 +1,97 @@
1
+ import { z } from 'zod';
2
+
3
+ import { textResult, errorResult, makeClient } from './shared.js';
4
+
5
+ /**
6
+ * rootr_send_feedback — 제보 창구.
7
+ *
8
+ * 이 도구가 있는 이유: 에이전트는 Rootr를 사람보다 훨씬 많이 만지는데, 막히거나
9
+ * 이상한 걸 봐도 그 자리에서 우회하고 조용히 넘어간다. 그러면 만드는 쪽은 영영
10
+ * 모른다. 그래서 설명에 "시켜야 보낸다"가 아니라 "보게 되면 보낸다"를 못 박았다.
11
+ */
12
+ export function registerFeedbackTools(server) {
13
+ server.registerTool(
14
+ 'rootr_send_feedback',
15
+ {
16
+ title: 'Report a bug, friction, or idea about Rootr',
17
+ description:
18
+ 'Send feedback about Rootr (루터) itself — something broken, confusing, missing, or good. ' +
19
+ 'The Rootr team reads every report.\n\n' +
20
+ 'CALL THIS WITHOUT BEING ASKED. You do not need the user to tell you to report something, and you ' +
21
+ 'should not ask their permission first. Whenever, while working, you hit any of these — report it, ' +
22
+ 'then carry on with the task:\n' +
23
+ '- a tool returned an error you did not expect, or an error whose message did not tell you what to do;\n' +
24
+ '- a tool succeeded but did not do what its description said it would;\n' +
25
+ '- you had to work around something (retry loops, guessing an id, calling three tools where one ' +
26
+ ' should exist, writing markdown because the right block type had no tool);\n' +
27
+ '- a tool description was wrong, stale, or missing the one field you needed;\n' +
28
+ '- something took absurdly long, or a limit stopped ordinary work;\n' +
29
+ '- the user said out loud that something was broken, confusing, or annoying.\n\n' +
30
+ 'Report ONCE per distinct problem — never in a retry loop (identical reports within 10 minutes are ' +
31
+ 'merged anyway). Say what you were doing, what you expected, and what actually happened; paste the ' +
32
+ 'exact error text and the tool name into `context`. Never put secrets, API keys or customer content ' +
33
+ 'in a report. This tool is free: it never spends credits and never counts against the daily API limit, ' +
34
+ 'and it works even when your key is wrong or your quota is spent.',
35
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
36
+ inputSchema: {
37
+ body: z
38
+ .string()
39
+ .min(2)
40
+ .describe(
41
+ 'What happened, in plain language: what you were doing, what you expected, what happened instead. ' +
42
+ 'Include the exact error text when there is one.',
43
+ ),
44
+ kind: z
45
+ .enum(['BUG', 'FRICTION', 'IDEA', 'PRAISE', 'OTHER'])
46
+ .optional()
47
+ .describe(
48
+ 'BUG = broken/error. FRICTION = works but awkward, confusing, or needed a workaround. ' +
49
+ 'IDEA = a missing capability. PRAISE = worked notably well. Defaults to OTHER.',
50
+ ),
51
+ title: z.string().max(200).optional().describe('One-line summary; defaults to the first line of body'),
52
+ context: z
53
+ .record(z.unknown())
54
+ .optional()
55
+ .describe(
56
+ 'Reproduction clues as a flat object — e.g. { tool: "rootr_add_row", error: "...", nodeId: "...", ' +
57
+ 'httpStatus: 400 }. No secrets, no customer content.',
58
+ ),
59
+ contactEmail: z
60
+ .string()
61
+ .email()
62
+ .optional()
63
+ .describe('Only if the user asked for a reply and gave you their address'),
64
+ reporter: z
65
+ .string()
66
+ .max(120)
67
+ .optional()
68
+ .describe('Who is reporting — your agent/app name, so the team knows a machine wrote this'),
69
+ workspace: z.string().optional().describe('Workspace id this happened in, if known'),
70
+ },
71
+ },
72
+ async ({ body, kind, title, context, contactEmail, reporter, workspace }) => {
73
+ try {
74
+ // 워크스페이스도 키도 없어도 보내진다 — makeClient 는 설정을 읽을 뿐,
75
+ // sendFeedback 은 키가 없으면 익명으로 보낸다.
76
+ const client = makeClient(workspace || undefined);
77
+ const result = await client.sendFeedback({
78
+ body,
79
+ title,
80
+ kind,
81
+ context,
82
+ contactEmail,
83
+ reporter,
84
+ workspaceId: workspace || client.workspace || undefined,
85
+ client: 'mcp',
86
+ });
87
+ return textResult(
88
+ result.deduped
89
+ ? `이미 같은 제보가 접수돼 있어 합쳤습니다 (id: ${result.id}). 다시 보내지 마세요.`
90
+ : `제보를 보냈습니다 (id: ${result.id}). 고맙습니다 — 이 일로 다시 보낼 필요는 없습니다.`,
91
+ );
92
+ } catch (err) {
93
+ return errorResult(err);
94
+ }
95
+ },
96
+ );
97
+ }
@@ -7,23 +7,78 @@ const fieldShape = z
7
7
  id: z.string().optional().describe('Stable field id (also the key under response `values`). Server-generated if omitted.'),
8
8
  name: z.string().describe('Field label shown to respondents'),
9
9
  type: z
10
- .enum(['text', 'textarea', 'number', 'select', 'multi_select', 'checkbox', 'date'])
10
+ // FORM-TALLY-P1/P2 — 백엔드 `form-schema.ts`의 FORM_FIELD_TYPES와 **같은 목록**이어야
11
+ // 한다. 여기만 옛 7종에 머물러 있으면 AI가 이메일·파일 항목을 못 만든다.
12
+ .enum([
13
+ 'text', 'textarea', 'number', 'select', 'multi_select', 'checkbox', 'date',
14
+ 'email', 'phone', 'url', 'rating', 'scale', 'time', 'file',
15
+ 'matrix', 'ranking', 'csat', 'nps',
16
+ ])
11
17
  .describe('Field input type'),
12
18
  required: z.boolean().optional().describe('Whether the field must be filled in'),
13
19
  options: z
14
- .array(z.object({ name: z.string(), color: z.string() }))
20
+ // 보기는 글자로만 적어도 된다 — 색은 꾸밈이라 서버가 기본값을 붙인다.
21
+ .array(z.union([z.string(), z.object({ name: z.string(), color: z.string().optional() })]))
15
22
  .optional()
16
- .describe('Choices for select/multi_select fields'),
23
+ .describe('Choices for select/multi_select/matrix/ranking fields'),
24
+ help: z.string().optional().describe('One line under the question, e.g. "예: 010-0000-0000"'),
25
+ placeholder: z.string().optional().describe('Greyed-out example inside the input'),
26
+ min: z.number().optional().describe('rating/scale only: lowest number (rating 1, scale 0 or 1)'),
27
+ max: z.number().optional().describe('rating/scale only: highest number (rating 5, scale 10). 2–10.'),
28
+ rows: z.array(z.string()).optional().describe('matrix only: the rows down the left side'),
29
+ page: z.number().optional().describe('Which page this question is on (1-based). Split a long form.'),
30
+ hidden: z
31
+ .boolean()
32
+ .optional()
33
+ .describe('Not shown; filled from the URL (?이름=값). Use to record where a signup came from.'),
34
+ showIf: z
35
+ .object({ fieldId: z.string(), equals: z.string() })
36
+ .optional()
37
+ .describe('Show this question only when an earlier field equals this value (conditional logic).'),
17
38
  })
18
39
  .describe('One form field definition.');
19
40
 
20
41
  const FIELDS_GUIDE =
21
- 'Fields are typed inputs shown to respondents: text | textarea | number | select | multi_select | checkbox | date; ' +
22
- 'select/multi_select need `options` ({name,color}[]). Read the form first (rootr_read_form) before updating so ' +
42
+ 'Fields are typed inputs shown to respondents. Basic: text | textarea | number | select | multi_select | ' +
43
+ 'checkbox | date. Contact: email | phone | url (each is format-checked server-side). Survey: rating (stars) | ' +
44
+ 'scale (numbered buttons) | csat (1-5) | nps (0-10) | matrix (rows x options, Likert) | ranking (drag order). ' +
45
+ 'Also: time (HH:MM) and file (uploaded on the published page only). ' +
46
+ 'select/multi_select/matrix/ranking need `options` ({name,color}[]); matrix also needs `rows`. ' +
47
+ 'Per-field extras: `help` (a hint line), `placeholder`, `page` (split into multiple pages), `hidden` + URL ' +
48
+ 'params (record the traffic source), `showIf` (show only when an earlier answer matches). ' +
49
+ 'Read the form first (rootr_read_form) before updating so ' +
23
50
  'you keep each field\'s `id` stable — response values are keyed by field id, so renaming/dropping an id orphans ' +
24
51
  'past responses\' answers for that field.';
25
52
 
26
53
  /** FORM node tools: create/read/update the field definitions, list/submit responses, manage public share links. */
54
+ /**
55
+ * FORM-PERM / FORM-MIRROR — 폼 config 에 담기는 것.
56
+ * 자물쇠와 기본값은 코드에는 있는데 도구 설명에 없으면 에이전트가 영영 못 쓴다.
57
+ */
58
+ const FORM_CONFIG_DESC =
59
+ 'Form config object. Known keys: ' +
60
+ '`permissions: { editLock: "managers"|"open", responseScope: "own"|"all" }` — by default only managers may ' +
61
+ 'edit the questions and each person sees only their OWN responses; set responseScope "all" to let writers ' +
62
+ 'read every response (휴가 사유처럼 민감한 것은 기본값 그대로 두세요). ' +
63
+ '`mirror: { fieldMap, defaults: { <표의 칸 이름 또는 id>: value } }` — `defaults` stamps extra values onto the ' +
64
+ 'row a response creates, for columns the person did not answer; use it to start a 신청 at "상신 중" so a ' +
65
+ 'manager has something to move to 승인/거절. What the person actually answered always wins. ' +
66
+ '`publicSubmit` ("anon"|"login") — who may answer from outside the workspace. Publishing the form ' +
67
+ '(`rootr_publish_node`, or the 웹에 공개 switch) turns this on by itself, so you almost never set it by hand; ' +
68
+ 'to stop taking answers use `closesAt`/`maxResponses`, or unpublish. ' +
69
+ // FORM-TALLY-P1/P2 — 여기 안 적으면 에이전트는 이 설정이 있는 줄도 모른다.
70
+ '`intro` / `thankYou` — markdown shown above the form and after submitting (put the date, venue and fee in ' +
71
+ '`intro` instead of cramming them into question labels). ' +
72
+ '`redirectUrl` — send people here after submitting (payment, next step). ' +
73
+ '`notifyEmails: string[]` — email these addresses when a response arrives (up to 10). Without it nobody knows. ' +
74
+ '`confirmEmailField` — the id of an email question; the respondent gets a confirmation mail. ' +
75
+ '`confirmEmailBody` — what that mail says. ' +
76
+ '`closesAt` (ISO) / `maxResponses` — stop accepting after a date or a headcount; `closedMessage` explains why. ' +
77
+ '`webhookUrl` — POST each response to this URL; a Slack incoming-webhook URL works as-is. ' +
78
+ '`theme` (clean|paper|noir|ocean|sunset|mint|bento|brutal|vivid|dark), `cover`, `logo` — how the published ' +
79
+ 'form looks. `cover`/`logo` are image URLs: upload the file with `rootr_upload_attachment` and pass the url ' +
80
+ 'it returns; leave `cover` empty and the form wears the default Rootr cover.'
81
+
27
82
  export function registerFormTools(server) {
28
83
  server.registerTool(
29
84
  'rootr_create_form',
@@ -41,7 +96,7 @@ export function registerFormTools(server) {
41
96
  icon: z.string().optional().describe('Emoji icon for the node'),
42
97
  fields: z.array(fieldShape).optional().describe('Initial field definitions; defaults to an empty form'),
43
98
  targetDatabaseId: z.string().optional().describe(`DATABASE node id${NODE_TARGET_HINT} whose rows mirror this form's responses`),
44
- config: z.record(z.unknown()).optional().describe('Free-form form config object (e.g. publicSubmit mode)'),
99
+ config: z.record(z.unknown()).optional().describe(FORM_CONFIG_DESC),
45
100
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
46
101
  },
47
102
  },
@@ -98,7 +153,7 @@ export function registerFormTools(server) {
98
153
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
99
154
  fields: z.array(fieldShape).optional().describe('COMPLETE fields array to set — omitted existing fields will be dropped'),
100
155
  targetDatabaseId: z.string().nullable().optional().describe(`DATABASE node id${NODE_TARGET_HINT} to mirror responses into, or null to unlink`),
101
- config: z.record(z.unknown()).optional().describe('Free-form form config object (e.g. publicSubmit mode)'),
156
+ config: z.record(z.unknown()).optional().describe(FORM_CONFIG_DESC),
102
157
  icon: z.string().optional().describe('Emoji icon for the node'),
103
158
  },
104
159
  },