rootr-cli 0.7.1 → 0.8.1

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
@@ -296,15 +296,20 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
296
296
 
297
297
  | 도구 | 설명 |
298
298
  |---|---|
299
- | `rootr_read_database` | 스키마(열 정의)+행 전체 읽기 — **다른 표 도구를 쓰기 전에 먼저** |
300
- | `rootr_list_rows` | 행만 조회(스키마 제외) |
301
- | `rootr_add_row` | 행 추가 (`values`는 열 이름 또는 id로 지정) |
302
- | `rootr_update_row` | 행 수정 (칸반 카드 이동 = select 열 값 변경) |
299
+ | `rootr_read_database` | 스키마(열 정의)+행 전체 읽기 — **다른 표 도구를 쓰기 전에 먼저** (`includeBody: true` 면 행마다 상세 내용까지) |
300
+ | `rootr_list_rows` | 행만 조회(스키마 제외, `includeBody` 지원) |
301
+ | `rootr_add_row` | 행 추가 (`values`는 열 이름 또는 id로 지정, `body`로 상세 내용까지) |
302
+ | `rootr_update_row` | 행 수정 (칸반 카드 이동 = select 열 값 변경, `body`로 상세 내용 교체) |
303
303
  | `rootr_delete_row` | 행 삭제 |
304
304
  | `rootr_add_column` | **열 추가** — 기존 열·행은 그대로 |
305
305
  | `rootr_update_column` | 열 이름 변경 / select 선택지 교체 |
306
306
  | `rootr_delete_column` | 열 삭제(파괴적) |
307
307
 
308
+ 행의 **상세 내용(본문 메모)** — 화면에서 칸반 카드를 열면 속성 아래에 적는 그 글 — 은
309
+ `body`로 오갑니다. 실체는 표 밑에 달리는 문서 한 장이고, 없으면 쓸 때 만들어집니다.
310
+ 명령줄에서도 됩니다: `rootr db row-add <표> --values '{"제목":"킥오프"}' --body "..."`,
311
+ `rootr db row-body <표> <rowId> --file memo.md`, `rootr db rows <표> --body`.
312
+
308
313
  열은 **한 번에 하나씩** 다룹니다. REST의 `PATCH /databases/:id`는 `properties`
309
314
  배열을 통째로 교체하기 때문에, 에이전트가 배열을 다시 만들다 빠뜨린 열이 그대로
310
315
  사라집니다. 위 세 도구는 서버가 병합하므로 나머지 열이 없어지지 않습니다.
package/bin/rootr.js CHANGED
@@ -26,6 +26,10 @@ const USAGE = `Rootr CLI v${pkg.version}
26
26
  rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
27
27
  rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
28
28
  rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
29
+ rootr db create [--file <spec.json>] [--parent <path|id>] 표(DATABASE) 생성 — 컬럼 순서 그대로, 행까지 한 번에
30
+ rootr db rows <path|id> [--body] [--json] 표의 행 목록 (--body: 상세 내용까지)
31
+ rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
32
+ rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
29
33
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
30
34
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
31
35
  rootr mcp MCP stdio 서버 구동 (도구 90종+ — README 참고)
@@ -433,6 +437,127 @@ async function cmdMcp() {
433
437
 
434
438
  // ---- entry ------------------------------------------------------------
435
439
 
440
+
441
+ /**
442
+ * 표(DATABASE) 만들기 — 컬럼 순서를 그대로 지키려면 한 번에 만들어야 한다.
443
+ *
444
+ * 스펙은 JSON으로 받는다(파일 또는 표준입력): `{ name, properties[], views?, rows? }`.
445
+ * 컬럼을 하나씩 붙이는 방식은 호출 순서가 곧 컬럼 순서가 되어 옮겨온 표가 뒤죽박죽이
446
+ * 됐다 — 그래서 CLI도 "한 번에" 쪽만 제공한다.
447
+ */
448
+ async function cmdDb(args) {
449
+ const sub = args._[0];
450
+ if (sub === 'rows') return cmdDbRows(args);
451
+ if (sub === 'row-add') return cmdDbRowAdd(args);
452
+ if (sub === 'row-body') return cmdDbRowBody(args);
453
+ if (sub !== 'create') {
454
+ throw new Error(
455
+ '사용법: rootr db create|rows|row-add|row-body … (자세한 건 rootr --help)',
456
+ );
457
+ }
458
+ const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
459
+ let spec;
460
+ try {
461
+ spec = JSON.parse(raw);
462
+ } catch {
463
+ throw new Error('스펙을 JSON으로 읽을 수 없습니다. { "name": "...", "properties": [...] } 형태여야 합니다.');
464
+ }
465
+ if (!spec.name && !spec.path) throw new Error('스펙에 name 또는 path가 있어야 합니다.');
466
+
467
+ const client = makeClient();
468
+ let parentId = spec.parentId;
469
+ if (typeof args.parent === 'string') {
470
+ const resolved = await resolveTarget(client, args.parent);
471
+ if (!resolved.existed) throw new Error(`부모를 찾을 수 없습니다: ${args.parent}`);
472
+ parentId = resolved.id;
473
+ }
474
+ const ws = args.ws || client.workspace;
475
+ if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
476
+
477
+ const db = await client.createDatabase(ws, { ...spec, parentId });
478
+ const cols = (db.properties || []).map((p) => p.name).join(', ');
479
+ console.log(`표 생성: ${db.path ?? db.name} (${db.id})`);
480
+ if (cols) console.log(`컬럼 순서: ${cols}`);
481
+ if (typeof db.rowCount === 'number') console.log(`행: ${db.rowCount}개`);
482
+ }
483
+
484
+ /** 표의 행 목록. --body 를 주면 행마다 상세 내용(본문 메모)까지 가져온다. */
485
+ async function cmdDbRows(args) {
486
+ const target = args._[1];
487
+ if (!target) throw new Error('사용법: rootr db rows <path|id> [--body] [--json]');
488
+ const client = makeClient();
489
+ const id = await resolveDatabaseId(client, target);
490
+ const rows = await client.listRows(id, { withBody: !!args.body });
491
+ if (args.json) {
492
+ console.log(JSON.stringify(rows, null, 2));
493
+ return;
494
+ }
495
+ for (const row of rows) {
496
+ const values = Object.values(row.values || {})
497
+ .map((v) => (v && typeof v === 'object' ? JSON.stringify(v) : String(v)))
498
+ .join(' | ');
499
+ console.log(`${row.id}\t${values}`);
500
+ if (args.body && row.body) {
501
+ for (const line of String(row.body).trimEnd().split('\n')) console.log(` ${line}`);
502
+ }
503
+ }
504
+ }
505
+
506
+ /** 행 추가. 값은 JSON으로, 상세 내용(본문 메모)은 --body / --body-file 로. */
507
+ async function cmdDbRowAdd(args) {
508
+ const target = args._[1];
509
+ if (!target || typeof args.values !== 'string') {
510
+ throw new Error("사용법: rootr db row-add <path|id> --values '{\"제목\":\"...\"}' [--body <md>|--body-file <f>]");
511
+ }
512
+ let values;
513
+ try {
514
+ values = JSON.parse(args.values);
515
+ } catch {
516
+ throw new Error('--values 는 JSON이어야 합니다. 예: --values \'{"제목":"킥오프"}\'');
517
+ }
518
+ const body =
519
+ typeof args['body-file'] === 'string'
520
+ ? readFileSync(args['body-file'], 'utf8')
521
+ : typeof args.body === 'string'
522
+ ? args.body
523
+ : undefined;
524
+ const client = makeClient();
525
+ const id = await resolveDatabaseId(client, target);
526
+ const row = await client.createRow(id, { values, body });
527
+ console.log(`행 추가: ${row.id}`);
528
+ if (row.pageNodeId) console.log(`상세 내용 문서: ${row.pageNodeId}`);
529
+ }
530
+
531
+ /** 행의 상세 내용(본문 메모)을 통째로 쓴다. 내용은 인자·--file·표준입력 순으로 받는다. */
532
+ async function cmdDbRowBody(args) {
533
+ const target = args._[1];
534
+ const rowId = args._[2];
535
+ if (!target || !rowId) {
536
+ throw new Error('사용법: rootr db row-body <path|id> <rowId> [<본문>] [--file <f>]');
537
+ }
538
+ const body =
539
+ typeof args.file === 'string'
540
+ ? readFileSync(args.file, 'utf8')
541
+ : args._[3] !== undefined
542
+ ? String(args._[3])
543
+ : await readStdin();
544
+ if (!body) throw new Error('쓸 내용이 없습니다 (본문 인자, --file, 또는 표준입력 필요).');
545
+ const client = makeClient();
546
+ const id = await resolveDatabaseId(client, target);
547
+ const row = await client.updateRow(id, rowId, { body });
548
+ console.log(`상세 내용 저장: 행 ${rowId}${row.pageNodeId ? ` → 문서 ${row.pageNodeId}` : ''}`);
549
+ }
550
+
551
+ /** 경로로 줘도 되게 — 표 인자도 문서 도구들과 같은 규칙을 따른다. */
552
+ async function resolveDatabaseId(client, target) {
553
+ if (typeof target === 'string' && target.startsWith('/')) {
554
+ const resolved = await resolveTarget(client, target);
555
+ if (!resolved.existed) throw new Error(`표를 찾을 수 없습니다: ${target}`);
556
+ return resolved.id;
557
+ }
558
+ return target;
559
+ }
560
+
436
561
  async function main() {
437
562
  const argv = process.argv.slice(2);
438
563
  const args = parseArgs(argv);
@@ -474,6 +599,8 @@ async function main() {
474
599
  return cmdConfig(args);
475
600
  case 'use':
476
601
  return cmdUse(args);
602
+ case 'db':
603
+ return cmdDb(args);
477
604
  case 'mcp':
478
605
  return cmdMcp(args);
479
606
  default:
package/lib/client.js CHANGED
@@ -550,32 +550,97 @@ export class RootrClient {
550
550
  });
551
551
  }
552
552
 
553
- // ---- Databases (rows) -------------------------------------------------
553
+ // ---- Templates ---------------------------------------------------------
554
+ // 문서 양식(템플릿). 인앱 어시스턴트는 예전부터 쓸 수 있었는데 MCP·CLI에는
555
+ // 없어서, 밖에서 붙은 에이전트는 양식이 있는 줄도 모르고 매번 맨바닥부터
556
+ // 만들었다(2026-08-18 점검에서 드러난 구멍).
557
+
558
+ async listTemplates(workspace) {
559
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/templates`);
560
+ }
561
+
562
+ async applyTemplate(workspace, templateId, { parentId } = {}) {
563
+ return this.request(
564
+ `/workspaces/${encodeURIComponent(workspace)}/templates/${encodeURIComponent(templateId)}/apply`,
565
+ { method: 'POST', body: { parentId } },
566
+ );
567
+ }
568
+
569
+ async createTemplate(workspace, { name, emoji, description, kind, spec } = {}) {
570
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/templates`, {
571
+ method: 'POST',
572
+ body: { name, emoji, description, kind, spec },
573
+ });
574
+ }
575
+
576
+ async saveTemplateFromNode(workspace, nodeId, { name, emoji, description } = {}) {
577
+ return this.request(
578
+ `/workspaces/${encodeURIComponent(workspace)}/templates/from-node/${encodeURIComponent(nodeId)}`,
579
+ { method: 'POST', body: { name, emoji, description } },
580
+ );
581
+ }
582
+
583
+ async updateTemplate(workspace, templateId, patch = {}) {
584
+ return this.request(
585
+ `/workspaces/${encodeURIComponent(workspace)}/templates/${encodeURIComponent(templateId)}`,
586
+ { method: 'PATCH', body: patch },
587
+ );
588
+ }
589
+
590
+ // ---- Databases --------------------------------------------------------
591
+
592
+ /**
593
+ * Create a DATABASE node with its columns (and optionally its first rows) in
594
+ * ONE call. `properties` order IS the column order — that is the whole point:
595
+ * building a table by adding columns one at a time made the order depend on
596
+ * the order of the calls, which is how migrated tables came out scrambled.
597
+ */
598
+ async createDatabase(workspace, { name, parentId, path, createParents, autoRename, icon, properties, views, rows } = {}) {
599
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/databases`, {
600
+ method: 'POST',
601
+ body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows },
602
+ });
603
+ }
604
+
605
+ /**
606
+ * 뷰 하나만 고친다(이름·감출 컬럼·그룹 기준). PATCH /databases/:id 는 views 배열을
607
+ * 통째로 갈아끼워서 빠뜨린 뷰가 사라지는데, 여기서는 서버가 그 뷰만 병합한다.
608
+ */
609
+ async updateDatabaseView(databaseId, viewId, patch = {}) {
610
+ return this.request(
611
+ `/databases/${encodeURIComponent(databaseId)}/views/${encodeURIComponent(viewId)}`,
612
+ { method: 'PATCH', body: patch },
613
+ );
614
+ }
554
615
 
555
616
  /** Database definition: { id, name, icon, path, properties[], views[], rowCount }. */
556
617
  async getDatabase(databaseId) {
557
618
  return this.request(`/databases/${encodeURIComponent(databaseId)}`);
558
619
  }
559
620
 
560
- /** All rows of a database, in position order. Returns the raw rows array. */
561
- async listRows(databaseId) {
562
- const data = await this.request(`/databases/${encodeURIComponent(databaseId)}/rows`);
621
+ /**
622
+ * All rows of a database, in position order. Returns the raw rows array.
623
+ * DB-BODY — `withBody` 를 주면 행마다 상세 내용(본문 메모)까지 함께 온다.
624
+ */
625
+ async listRows(databaseId, { withBody = false } = {}) {
626
+ const query = withBody ? '?body=1' : '';
627
+ const data = await this.request(`/databases/${encodeURIComponent(databaseId)}/rows${query}`);
563
628
  if (Array.isArray(data)) return data;
564
629
  if (data && Array.isArray(data.rows)) return data.rows;
565
630
  return [];
566
631
  }
567
632
 
568
- async createRow(databaseId, { values, position } = {}) {
633
+ async createRow(databaseId, { values, position, body } = {}) {
569
634
  return this.request(`/databases/${encodeURIComponent(databaseId)}/rows`, {
570
635
  method: 'POST',
571
- body: { values, position },
636
+ body: { values, position, body },
572
637
  });
573
638
  }
574
639
 
575
- async updateRow(databaseId, rowId, { values, position } = {}) {
640
+ async updateRow(databaseId, rowId, { values, position, body } = {}) {
576
641
  return this.request(
577
642
  `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}`,
578
- { method: 'PATCH', body: { values, position } },
643
+ { method: 'PATCH', body: { values, position, body } },
579
644
  );
580
645
  }
581
646
 
@@ -1,10 +1,27 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
3
+ import {
4
+ jsonResult,
5
+ errorResult,
6
+ makeClient,
7
+ requireWorkspace,
8
+ resolveNodeId,
9
+ resolveOptionalNodeId,
10
+ NODE_TARGET_HINT,
11
+ } from './shared.js';
4
12
 
5
13
  /** Human text for the `databaseId` param — accepts a path just like the document tools. */
6
14
  const DATABASE_TARGET_DESC = `DATABASE node id${NODE_TARGET_HINT} (e.g. "/제작 공정/칸반")`;
7
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
+
8
25
  /**
9
26
  * Resolve a `{propertyNameOrId: value}` map to `{propertyId: value}` using the
10
27
  * database schema, so agents can address columns by their human name OR their
@@ -33,6 +50,118 @@ function resolveValueKeys(properties, values) {
33
50
  * "status" column = move a kanban card between columns).
34
51
  */
35
52
  export function registerDatabaseTools(server) {
53
+ server.registerTool(
54
+ 'rootr_create_database',
55
+ {
56
+ title: 'Create a Rootr database (table / board / kanban)',
57
+ description:
58
+ 'Create a DATABASE node — a typed table with columns, views and rows — in ONE call, optionally seeded with ' +
59
+ 'its first rows. **Use this whenever you need a table**: moving a Notion database over, building a kanban ' +
60
+ 'board, a task/requirement/risk tracker, anything with trackable rows. Do NOT fake a table with a markdown ' +
61
+ 'document, and do NOT create an empty database and then add columns one by one — `properties` order IS the ' +
62
+ 'column order, so one call keeps the columns in exactly the order you list them. ' +
63
+ 'The FIRST property must be type "title" (the row label). Other types: text | number | select | ' +
64
+ 'multi_select | date | checkbox | url | person. select/multi_select take options:[{name,color}]. ' +
65
+ 'Views default to a single table view; a "board" view needs config.groupBy = a select property id, a ' +
66
+ '"timeline" view needs config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
67
+ '{ start: "YYYY-MM-DD", end? }, multi_select is string[].',
68
+ annotations: { readOnlyHint: false, destructiveHint: false },
69
+ inputSchema: {
70
+ name: z.string().describe('Table name, e.g. "업무 목록"'),
71
+ parentId: z.string().optional().describe(`Parent folder id${NODE_TARGET_HINT}; workspace root if omitted`),
72
+ path: z.string().optional().describe('Full path incl. name — wins over parentId+name, e.g. "/팀/업무 목록"'),
73
+ createParents: z.boolean().optional().describe('Create missing folders in `path` (default false)'),
74
+ icon: z.string().optional().describe('Emoji icon for the node'),
75
+ properties: z
76
+ .array(
77
+ z.object({
78
+ name: z.string().describe('Column header, e.g. "담당자"'),
79
+ type: z
80
+ .enum(['title', 'text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
81
+ .describe('Column type; the FIRST property must be "title"'),
82
+ options: z
83
+ .array(z.object({ name: z.string(), color: z.string() }))
84
+ .optional()
85
+ .describe('select/multi_select only: the choices'),
86
+ }),
87
+ )
88
+ .optional()
89
+ .describe('Columns IN DISPLAY ORDER. Defaults to a single title column named "Name".'),
90
+ views: z
91
+ .array(z.record(z.unknown()))
92
+ .optional()
93
+ .describe('Views, e.g. [{ name: "보드", type: "board", config: { groupBy: "<select property id>" } }]'),
94
+ rows: z
95
+ .array(z.object({ values: z.record(z.unknown()) }))
96
+ .optional()
97
+ .describe('Initial rows, e.g. [{ values: { "이름": "킥오프", "기한": { "start": "2026-08-01" } } }]'),
98
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
99
+ },
100
+ },
101
+ async ({ name, parentId, path, createParents, icon, properties, views, rows, workspace }) => {
102
+ try {
103
+ const ws = requireWorkspace(workspace);
104
+ const client = makeClient(ws);
105
+ parentId = await resolveOptionalNodeId(client, parentId);
106
+ const database = await client.createDatabase(ws, {
107
+ name,
108
+ parentId,
109
+ path,
110
+ createParents,
111
+ icon,
112
+ properties,
113
+ views,
114
+ rows,
115
+ });
116
+ return jsonResult(database);
117
+ } catch (err) {
118
+ return errorResult(err);
119
+ }
120
+ },
121
+ );
122
+
123
+ server.registerTool(
124
+ 'rootr_update_view',
125
+ {
126
+ title: 'Edit a database view (hide columns, rename, regroup)',
127
+ description:
128
+ 'Change ONE view of a Rootr (루터) DATABASE. Use `hiddenProps` to choose which columns that view shows — ' +
129
+ 'the same table can look different per view (a "담당자" view hiding the amounts, etc). Hiding a column ' +
130
+ 'does NOT erase its values: it disappears from that view only and comes back when you unhide it. ' +
131
+ 'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
132
+ 'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched.',
133
+ annotations: { readOnlyHint: false, destructiveHint: false },
134
+ inputSchema: {
135
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
136
+ viewId: z.string().describe('View id or view NAME (from rootr_read_database)'),
137
+ hiddenProps: z
138
+ .array(z.string())
139
+ .optional()
140
+ .describe('Columns to hide in this view, by name or id. [] shows every column again.'),
141
+ name: z.string().optional().describe('New view name'),
142
+ groupBy: z.string().optional().describe('board view: the select column to group by (name or id)'),
143
+ dateProp: z.string().optional().describe('calendar/timeline view: the date column (name or id)'),
144
+ endDateProp: z.string().optional().describe('timeline view: the end-date column (name or id)'),
145
+ },
146
+ },
147
+ async ({ databaseId, viewId, hiddenProps, name, groupBy, dateProp, endDateProp }) => {
148
+ try {
149
+ const client = makeClient();
150
+ const id = await resolveNodeId(client, databaseId);
151
+ const view = await client.updateDatabaseView(id, viewId, {
152
+ hiddenProps,
153
+ name,
154
+ groupBy,
155
+ dateProp,
156
+ endDateProp,
157
+ });
158
+ return jsonResult({ view });
159
+ } catch (err) {
160
+ return errorResult(err);
161
+ }
162
+ },
163
+ );
164
+
36
165
  server.registerTool(
37
166
  'rootr_read_database',
38
167
  {
@@ -44,15 +173,19 @@ export function registerDatabaseTools(server) {
44
173
  annotations: { readOnlyHint: true, destructiveHint: false },
45
174
  inputSchema: {
46
175
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
176
+ includeBody: z
177
+ .boolean()
178
+ .optional()
179
+ .describe("true → also return each row's detail body (the note under a card). Off by default."),
47
180
  },
48
181
  },
49
- async ({ databaseId }) => {
182
+ async ({ databaseId, includeBody }) => {
50
183
  try {
51
184
  const client = makeClient();
52
185
  const id = await resolveNodeId(client, databaseId);
53
186
  const [database, rows] = await Promise.all([
54
187
  client.getDatabase(id),
55
- client.listRows(id),
188
+ client.listRows(id, { withBody: includeBody === true }),
56
189
  ]);
57
190
  return jsonResult({ database, rows });
58
191
  } catch (err) {
@@ -71,12 +204,18 @@ export function registerDatabaseTools(server) {
71
204
  annotations: { readOnlyHint: true, destructiveHint: false },
72
205
  inputSchema: {
73
206
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
207
+ includeBody: z
208
+ .boolean()
209
+ .optional()
210
+ .describe("true → also return each row's detail body (the note under a card). Off by default."),
74
211
  },
75
212
  },
76
- async ({ databaseId }) => {
213
+ async ({ databaseId, includeBody }) => {
77
214
  try {
78
215
  const client = makeClient();
79
- const rows = await client.listRows(await resolveNodeId(client, databaseId));
216
+ const rows = await client.listRows(await resolveNodeId(client, databaseId), {
217
+ withBody: includeBody === true,
218
+ });
80
219
  return jsonResult({ rows });
81
220
  } catch (err) {
82
221
  return errorResult(err);
@@ -92,21 +231,24 @@ export function registerDatabaseTools(server) {
92
231
  'Append a row to a Rootr (루터) DATABASE. `values` maps property NAME (or id) → value. ' +
93
232
  'Value shapes by type: title/text/url/select/person → string; number → number; multi_select → string[]; ' +
94
233
  'checkbox → boolean; date → { start: "YYYY-MM-DD", end? }. A select value is the OPTION NAME. ' +
95
- 'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index.',
234
+ 'Call rootr_read_database first to learn the columns. Optionally set `position` to insert at an index. ' +
235
+ 'Pass `body` to also write the row\'s DETAIL PAGE (the note shown under a kanban card\'s properties) — ' +
236
+ 'that page is created on demand, so a row with `body` is the one-call equivalent of "메모 추가".',
96
237
  annotations: { readOnlyHint: false, destructiveHint: false },
97
238
  inputSchema: {
98
239
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
99
240
  values: z.record(z.unknown()).describe('{ propertyNameOrId: value } for the new row'),
100
241
  position: z.number().optional().describe('0-based insert index; appended at the end if omitted'),
242
+ body: z.string().optional().describe(BODY_DESC),
101
243
  },
102
244
  },
103
- async ({ databaseId, values, position }) => {
245
+ async ({ databaseId, values, position, body }) => {
104
246
  try {
105
247
  const client = makeClient();
106
248
  const id = await resolveNodeId(client, databaseId);
107
249
  const database = await client.getDatabase(id);
108
250
  const resolved = resolveValueKeys(database.properties || [], values);
109
- const row = await client.createRow(id, { values: resolved, position });
251
+ const row = await client.createRow(id, { values: resolved, position, body });
110
252
  return jsonResult(row);
111
253
  } catch (err) {
112
254
  return errorResult(err);
@@ -129,9 +271,10 @@ export function registerDatabaseTools(server) {
129
271
  rowId: z.string().describe('Row id (must belong to the database)'),
130
272
  values: z.record(z.unknown()).optional().describe('{ propertyNameOrId: newValue | null } partial patch'),
131
273
  position: z.number().optional().describe('New 0-based position for the row'),
274
+ body: z.string().optional().describe(BODY_DESC),
132
275
  },
133
276
  },
134
- async ({ databaseId, rowId, values, position }) => {
277
+ async ({ databaseId, rowId, values, position, body }) => {
135
278
  try {
136
279
  const client = makeClient();
137
280
  const id = await resolveNodeId(client, databaseId);
@@ -140,7 +283,7 @@ export function registerDatabaseTools(server) {
140
283
  const database = await client.getDatabase(id);
141
284
  resolved = resolveValueKeys(database.properties || [], values);
142
285
  }
143
- const row = await client.updateRow(id, rowId, { values: resolved, position });
286
+ const row = await client.updateRow(id, rowId, { values: resolved, position, body });
144
287
  return jsonResult(row);
145
288
  } catch (err) {
146
289
  return errorResult(err);
@@ -0,0 +1,156 @@
1
+ import { z } from 'zod';
2
+
3
+ import {
4
+ jsonResult,
5
+ errorResult,
6
+ makeClient,
7
+ requireWorkspace,
8
+ resolveNodeId,
9
+ resolveOptionalNodeId,
10
+ NODE_TARGET_HINT,
11
+ } from './shared.js';
12
+
13
+ /**
14
+ * 문서 양식(템플릿) 도구.
15
+ *
16
+ * REST와 인앱 어시스턴트에는 예전부터 있었는데 MCP·CLI에만 없었다. 그래서 밖에서
17
+ * 붙은 에이전트(Claude 등)는 워크스페이스에 쓸 만한 양식이 이미 있는 줄도 모르고
18
+ * 매번 맨바닥부터 문서를 지어냈다 — 2026-08-18 점검에서 드러난 구멍이다.
19
+ *
20
+ * 양식은 세 종류다: `document`(마크다운 한 장), `database`(컬럼·뷰가 짜인 표),
21
+ * `tree`(폴더+문서+표가 얽힌 묶음). 빌트인 양식과 워크스페이스가 직접 만든 양식이
22
+ * 같은 목록에 함께 나온다.
23
+ */
24
+ export function registerTemplateTools(server) {
25
+ server.registerTool(
26
+ 'rootr_list_templates',
27
+ {
28
+ title: 'List document/database templates',
29
+ description:
30
+ 'List the templates available in a Rootr (루터) workspace — builtins plus anything this workspace saved. ' +
31
+ 'Each has an id, name, emoji, description and `kind` (document | database | tree). ' +
32
+ '**Call this before building a document or table from scratch**: if a fitting template exists, ' +
33
+ 'rootr_apply_template gives the team their own agreed format instead of a shape you invented.',
34
+ annotations: { readOnlyHint: true, destructiveHint: false },
35
+ inputSchema: {
36
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
37
+ },
38
+ },
39
+ async ({ workspace }) => {
40
+ try {
41
+ const ws = requireWorkspace(workspace);
42
+ return jsonResult(await makeClient(ws).listTemplates(ws));
43
+ } catch (err) {
44
+ return errorResult(err);
45
+ }
46
+ },
47
+ );
48
+
49
+ server.registerTool(
50
+ 'rootr_apply_template',
51
+ {
52
+ title: 'Create a node from a template',
53
+ description:
54
+ 'Instantiate a Rootr (루터) template — creates the real node(s) it describes and returns the created node. ' +
55
+ 'A `document` template makes one document, `database` makes a table with its columns and views already set, ' +
56
+ '`tree` makes a whole folder/document/table structure. Get ids from rootr_list_templates.',
57
+ annotations: { readOnlyHint: false, destructiveHint: false },
58
+ inputSchema: {
59
+ templateId: z.string().describe('Template id from rootr_list_templates'),
60
+ parentId: z
61
+ .string()
62
+ .optional()
63
+ .describe(`Parent folder id${NODE_TARGET_HINT}; workspace root if omitted`),
64
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
65
+ },
66
+ },
67
+ async ({ templateId, parentId, workspace }) => {
68
+ try {
69
+ const ws = requireWorkspace(workspace);
70
+ const client = makeClient(ws);
71
+ parentId = await resolveOptionalNodeId(client, parentId);
72
+ return jsonResult(await client.applyTemplate(ws, templateId, { parentId }));
73
+ } catch (err) {
74
+ return errorResult(err);
75
+ }
76
+ },
77
+ );
78
+
79
+ server.registerTool(
80
+ 'rootr_save_as_template',
81
+ {
82
+ title: 'Save a node (or an inline spec) as a template',
83
+ description:
84
+ 'Turn something into a reusable Rootr (루터) template. Either pass `nodeId` to capture an existing ' +
85
+ 'document/table/folder as-is, or pass `kind` + `spec` to define one inline. Use this when the user says ' +
86
+ '"make this the standard format" or asks for a new form/양식. ' +
87
+ 'NOTE: a template is workspace-wide and readable by every member — do not capture a restricted node ' +
88
+ 'unless the user means to share its content with the whole workspace.',
89
+ annotations: { readOnlyHint: false, destructiveHint: false },
90
+ inputSchema: {
91
+ name: z.string().describe('Template name, e.g. "주간 보고서 양식"'),
92
+ nodeId: z
93
+ .string()
94
+ .optional()
95
+ .describe(`Capture this existing node${NODE_TARGET_HINT}. Omit when passing kind+spec instead.`),
96
+ kind: z
97
+ .enum(['document', 'database', 'tree'])
98
+ .optional()
99
+ .describe('Inline definition: document (markdown) | database (columns+views) | tree (structure)'),
100
+ spec: z
101
+ .record(z.unknown())
102
+ .optional()
103
+ .describe('Inline definition body, matching `kind`. Ignored when nodeId is given.'),
104
+ emoji: z.string().optional().describe('Emoji shown in the template gallery'),
105
+ description: z.string().optional().describe('One line on when to use it'),
106
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
107
+ },
108
+ },
109
+ async ({ name, nodeId, kind, spec, emoji, description, workspace }) => {
110
+ try {
111
+ const ws = requireWorkspace(workspace);
112
+ const client = makeClient(ws);
113
+ if (nodeId) {
114
+ const id = await resolveNodeId(client, nodeId);
115
+ return jsonResult(await client.saveTemplateFromNode(ws, id, { name, emoji, description }));
116
+ }
117
+ if (!kind || !spec) {
118
+ throw new Error('Pass either nodeId, or both kind and spec.');
119
+ }
120
+ return jsonResult(await client.createTemplate(ws, { name, emoji, description, kind, spec }));
121
+ } catch (err) {
122
+ return errorResult(err);
123
+ }
124
+ },
125
+ );
126
+
127
+ server.registerTool(
128
+ 'rootr_update_template',
129
+ {
130
+ title: 'Edit a template',
131
+ description:
132
+ 'Change a Rootr (루터) template — its name, emoji, description or `spec`. Editing a builtin transparently ' +
133
+ 'creates a workspace copy, so the builtin itself is never damaged. Use this for "change the standard format" ' +
134
+ 'requests instead of saving a second near-identical template.',
135
+ annotations: { readOnlyHint: false, destructiveHint: false },
136
+ inputSchema: {
137
+ templateId: z.string().describe('Template id from rootr_list_templates'),
138
+ name: z.string().optional().describe('New name'),
139
+ emoji: z.string().optional().describe('New emoji'),
140
+ description: z.string().optional().describe('New one-line description'),
141
+ spec: z.record(z.unknown()).optional().describe('Replacement body, matching the template kind'),
142
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
143
+ },
144
+ },
145
+ async ({ templateId, name, emoji, description, spec, workspace }) => {
146
+ try {
147
+ const ws = requireWorkspace(workspace);
148
+ return jsonResult(
149
+ await makeClient(ws).updateTemplate(ws, templateId, { name, emoji, description, spec }),
150
+ );
151
+ } catch (err) {
152
+ return errorResult(err);
153
+ }
154
+ },
155
+ );
156
+ }
package/lib/mcp.js CHANGED
@@ -7,6 +7,7 @@ import { registerLogTools } from './mcp-tools/logs.js';
7
7
  import { registerAskTools } from './mcp-tools/ask.js';
8
8
  import { registerIssueTools } from './mcp-tools/issues.js';
9
9
  import { registerDatabaseTools } from './mcp-tools/databases.js';
10
+ import { registerTemplateTools } from './mcp-tools/templates.js';
10
11
  import { registerWebhookTools } from './mcp-tools/webhooks.js';
11
12
  import { registerMiscTools } from './mcp-tools/misc.js';
12
13
  import { registerCrmTools } from './mcp-tools/crm.js';
@@ -122,6 +123,7 @@ export function buildMcpServer({ name, version }) {
122
123
  withQuotaWarnings(server);
123
124
 
124
125
  registerDocumentTools(server);
126
+ registerTemplateTools(server);
125
127
  registerWorkspaceTools(server);
126
128
  registerLogTools(server);
127
129
  registerAskTools(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {