rootr-cli 0.14.1 → 0.16.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 +78 -5
- package/bin/rootr.js +261 -11
- package/lib/client.js +73 -6
- package/lib/mcp-tools/databases.js +144 -3
- package/lib/mcp-tools/documents.js +22 -5
- package/lib/mcp-tools/forms.js +2 -1
- package/lib/mcp-tools/misc.js +43 -6
- package/lib/mcp-tools/pages.js +239 -0
- package/lib/mcp-tools/recordings.js +2 -0
- package/lib/mcp-tools/spreadsheets.js +2 -1
- package/lib/mcp-tools/templates.js +2 -1
- package/lib/mcp-tools/whiteboards.js +2 -1
- package/lib/mcp.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Rootr(마크다운 지식협업 SaaS) 공식 CLI + MCP 서버. 에이전트나
|
|
|
6
6
|
- `rootr` CLI: `ls`, `read`, `write`, `append`, `edit`, `search`, `rm`, `attach`, `ws`, `ask`, `config`
|
|
7
7
|
- `rootr mcp`: stdio 기반 MCP 서버 (Claude Code / Claude Desktop 등에 등록해서 사용), 도구 90종+
|
|
8
8
|
— 문서·워크스페이스/스캐폴딩·LOG 데이터스토어·RCA ask·이슈·웹훅·첨부파일에 더해
|
|
9
|
-
|
|
9
|
+
스프레드시트/화이트보드/폼/프레젠테이션/페이지/녹음/CRM/데이터베이스까지. 정확한 목록은 MCP `tools/list`로 확인하세요.
|
|
10
10
|
|
|
11
11
|
순수 Node.js ESM, 빌드 과정 없음. Node 18 이상 필요(내장 `fetch` 사용).
|
|
12
12
|
|
|
@@ -147,8 +147,11 @@ rootr rm /notes/todo.md # 확인 후 삭제
|
|
|
147
147
|
rootr rm /notes/todo.md --yes # 확인 없이 (스크립트용)
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
휴지통으로 옮기는 **복구 가능한** 삭제입니다.
|
|
151
|
-
|
|
150
|
+
휴지통으로 옮기는 **복구 가능한** 삭제입니다. 30일이 지나면 자동으로 완전 삭제됩니다.
|
|
151
|
+
되돌리려면 `rootr restore <id>` — 화면에서 Ctrl+Z 를 누르는 것과 같은 일입니다.
|
|
152
|
+
**경로가 아니라 id** 입니다(휴지통에 든 것은 이미 트리에 없어 경로로는 못 찾습니다).
|
|
153
|
+
`rootr rm` 이 마지막 줄에 그 id 를 찍어 주니 그대로 쓰면 됩니다. MCP 로는
|
|
154
|
+
`rootr_restore_node`. `--yes` 없이 실행하면 확인을 묻습니다
|
|
152
155
|
(파이프/CI처럼 입력이 없는 환경에서는 "아니오"로 처리되어 안전합니다).
|
|
153
156
|
`docs:write` 스코프가 필요합니다.
|
|
154
157
|
|
|
@@ -394,8 +397,8 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
394
397
|
| `rootr_bulk_update_rows_each` | 여러 행을 **행마다 다른 값**으로 한 번에 수정 |
|
|
395
398
|
| `rootr_delete_row` | 행 삭제 |
|
|
396
399
|
| `rootr_bulk_delete_rows` | 행 여러 개를 한 번에 삭제 |
|
|
397
|
-
| `rootr_create_view` | 보기 추가 — 테이블/보드/캘린더/타임라인/갤러리 |
|
|
398
|
-
| `rootr_update_view` | 보기 수정 — 열 숨기기·이름·그룹·필터·정렬·열
|
|
400
|
+
| `rootr_create_view` | 보기 추가 — 테이블/보드/캘린더/타임라인/갤러리 (달력은 `calendarScale`로 주·달·해) |
|
|
401
|
+
| `rootr_update_view` | 보기 수정 — 열 숨기기·이름·그룹·필터·정렬·열 너비·달력 단위(`calendarScale`) |
|
|
399
402
|
| `rootr_delete_view` | 보기 삭제 |
|
|
400
403
|
| `rootr_create_row_page` | 행에 상세 페이지 붙이기(문서 또는 서명문서) |
|
|
401
404
|
| `rootr_set_row_page_type` | 앞으로 만드는 행이 무엇으로 열릴지 정하기 |
|
|
@@ -407,6 +410,9 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
407
410
|
| `rootr_set_database_permissions` | 표 잠그기 — 칸·뷰 잠금 / 본인 행만 / 관리자만 고칠 칸 |
|
|
408
411
|
| `rootr_read_database_notifications` | 표 알림 규칙 보기 |
|
|
409
412
|
| `rootr_set_database_notifications` | 표 알림 규칙 저장 — 누구에게·언제·어떤 것만 |
|
|
413
|
+
| `rootr_list_database_buttons` | 표 위 **버튼** 목록 보기(고칠 수 있는지 `myLevel`도 함께) |
|
|
414
|
+
| `rootr_set_database_buttons` | 버튼 만들고 고치기 — **목록 통째로 교체**(남길 것도 같이 보내야 함) |
|
|
415
|
+
| `rootr_run_database_button` | 버튼 누르기 — 행 만들기·값 채우기·AI로 칸 채우기가 순서대로 |
|
|
410
416
|
| `rootr_list_document_commits` | 공개 문서에 온 변경 제안 목록 |
|
|
411
417
|
| `rootr_merge_document_commit` | 변경 제안 반영(세 벌 병합 — 덮어쓰기 아님) |
|
|
412
418
|
| `rootr_reject_document_commit` | 변경 제안 거절(사유는 제안자에게 전달) |
|
|
@@ -418,6 +424,35 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
418
424
|
| `rootr_delete_form_response` | 폼 응답 한 건 삭제 |
|
|
419
425
|
| `rootr_create_form_collector` | 응답 모을 표·스프레드시트를 **만들어서** 연결(표는 질문 그대로 칸 생성) |
|
|
420
426
|
|
|
427
|
+
**표 버튼 — 매번 하는 준비 동작을 한 번에.** 새 건이 생길 때마다 상태=접수, 담당=나,
|
|
428
|
+
마감=오늘+7 을 손으로 다시 채우는 일을 버튼 하나로 만듭니다. 단계는 **적힌 순서대로**
|
|
429
|
+
돌고 **앞 단계가 만든 행을 뒤 단계가 받으므로**, "행을 만들고 → 그 행의 요약을 AI가 쓰고
|
|
430
|
+
→ 그 행을 연다"가 한 번 누름으로 끝납니다. 값에는 `@today` · `@today+7` · `@me` 를 쓸 수
|
|
431
|
+
있고 누르는 그 시점에 채워집니다. 버튼은 권한을 늘리지 않습니다 — **누른 사람 권한 그대로**
|
|
432
|
+
돕니다(만들기·고치기는 관리자, 누르기는 편집 권한).
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
rootr db buttons <표> # 지금 달린 버튼 보기
|
|
436
|
+
rootr db buttons <표> --file buttons.json # 목록 통째로 저장(남길 것도 같이)
|
|
437
|
+
rootr db button-run <표> <버튼id> # 한 번 누르기 (--rows id,id 로 고른 행 지정)
|
|
438
|
+
rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해 중에서
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
```json
|
|
442
|
+
[{ "label": "접수", "icon": "📥", "steps": [
|
|
443
|
+
{ "kind": "add_row", "values": { "상태": "접수", "담당": "@me", "마감": "@today+7" } },
|
|
444
|
+
{ "kind": "ai_fill", "target": "created", "propId": "요약", "prompt": "{제목}을 한 문장으로 요약해." },
|
|
445
|
+
{ "kind": "open_row" }
|
|
446
|
+
] }]
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
스캐폴드로 표를 지을 때도 `database.buttons` 에 같은 모양을 넣으면 **표와 함께** 달립니다.
|
|
450
|
+
|
|
451
|
+
**행에 댓글 달기.** 표의 한 행에 댓글을 달려면 `rootr_comment_document` 에 **표**를
|
|
452
|
+
`target` 으로, **행 id**를 `blockId` 으로 줍니다(읽기는 `rootr_list_document_comments`,
|
|
453
|
+
명령줄은 `rootr comments <표> --block <행id>`). 상세 문서가 없는 행에도 달립니다.
|
|
454
|
+
`@[userId]` 로 사람을 부르면 알림이 가고, 그 알림은 **그 행**을 열면서 댓글을 짚어 줍니다.
|
|
455
|
+
|
|
421
456
|
**사람 칸은 여러 명을 담습니다.** 회의 참석자·공동 담당처럼 여럿이 들어가는 자리가
|
|
422
457
|
흔해서, 값은 이름 목록(`["김대표","이팀장"]`)입니다. 이름 하나만 보내도 받아 주고
|
|
423
458
|
목록으로 맞춰 돌려줍니다. 새로 들어온 사람에게만 알림이 갑니다.
|
|
@@ -623,6 +658,44 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
623
658
|
| `rootr_update_node` | 아무 노드나 이름·아이콘 변경 / 옮기기 |
|
|
624
659
|
| `rootr_delete_node` | 노드를 휴지통으로 이동(복구 가능) |
|
|
625
660
|
|
|
661
|
+
#### 페이지 (웹페이지)
|
|
662
|
+
|
|
663
|
+
페이지(PAGE)는 **바깥 사람이 볼 화면**입니다 — 행사 안내·제품 소개·신청 받기처럼
|
|
664
|
+
링크 하나로 보여 줄 것. 전용 MCP 도구(`rootr_create_page`, `rootr_read_page`,
|
|
665
|
+
`rootr_append_page_blocks`, `rootr_update_page`, `rootr_update_page_block`,
|
|
666
|
+
`rootr_read_page_live_data`)로 저작합니다.
|
|
667
|
+
|
|
668
|
+
HTML을 짜는 것이 아니라 **정해진 블록 14종을 골라 쌓습니다** — `hero` `text`
|
|
669
|
+
`features` `gallery` `pricing` `testimonials` `faq` `timeline` `cta` `footer` +
|
|
670
|
+
표에서 값을 끌어오는 `liveStats` `liveTable` `liveChart` + 신청을 받는 `form` + 자유 블록 `custom`.
|
|
671
|
+
|
|
672
|
+
`custom`은 **격리된 방**입니다: HTML/CSS를 마음대로 쓰되 `sandbox` 아이프레임 안에서만
|
|
673
|
+
그려집니다(스크립트 없음·같은 출처 아님·바깥 통신 차단). 그래서 대담한 CSS도 바깥 화면을
|
|
674
|
+
못 덮습니다 — 저장할 때 `<script>`와 `on*=`는 지워집니다. 방 안에서는 페이지 분위기 색을
|
|
675
|
+
`var(--pg-text)` `var(--pg-card)` `var(--pg-accent)` 처럼 쓸 수 있습니다.
|
|
676
|
+
블록별 props는 **`/llms.txt`의 "Pages" 섹션**에 표로 정리돼 있습니다.
|
|
677
|
+
|
|
678
|
+
```bash
|
|
679
|
+
# 페이지 만들기 (블록을 생략하면 hero/text/footer 뼈대로 시작)
|
|
680
|
+
curl -X POST "$BASE/workspaces/$WS/pages" -H "x-api-key: $KEY" \
|
|
681
|
+
-H 'Content-Type: application/json' \
|
|
682
|
+
-d '{"name":"가을 행사","config":{"theme":"midnight"},
|
|
683
|
+
"blocks":[{"type":"hero","props":{"title":"가을 개발자 밋업","subtitle":"10월 12일 · 서울"}}]}'
|
|
684
|
+
|
|
685
|
+
# 블록 덧붙이기(있는 블록은 그대로) / 통째 교체 / 라이브 값 확인
|
|
686
|
+
curl -X POST "$BASE/pages/$ID/blocks" -d '{"blocks":[{"type":"faq","props":{"items":[...]}}]}' ...
|
|
687
|
+
curl -X PATCH "$BASE/pages/$ID" -d '{"config":{"theme":"warm"}}' ...
|
|
688
|
+
curl "$BASE/pages/$ID/live-data" -H "x-api-key: $KEY"
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`liveStats`/`liveTable`/`liveChart`의 `source.nodeId`는 같은 워크스페이스의
|
|
692
|
+
DATABASE·LOG 노드이고, 칸은 **이름**으로 가리킵니다(칸 id가 아닙니다).
|
|
693
|
+
`form` 블록의 `formNodeId`에 폼 노드를 걸면 방문자가 **그 자리에서 신청**합니다 —
|
|
694
|
+
폼을 따로 공개할 필요가 없습니다. 비공개(PRIVATE) 서브트리에 있는 표·폼은 공개
|
|
695
|
+
페이지에서 조용히 빠집니다(일부러 그렇습니다).
|
|
696
|
+
|
|
697
|
+
공개는 여느 노드와 같습니다: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
|
|
698
|
+
|
|
626
699
|
#### 프레젠테이션
|
|
627
700
|
|
|
628
701
|
프레젠테이션(PRESENTATION)은 전용 MCP 도구(`rootr_create_presentation`,
|
package/bin/rootr.js
CHANGED
|
@@ -23,6 +23,7 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
23
23
|
rootr search <query> 워크스페이스 검색
|
|
24
24
|
rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
|
|
25
25
|
rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
|
|
26
|
+
rootr restore <id> 지운 것 되돌리기 (화면의 Ctrl+Z 와 같은 일 — 경로 아닌 id)
|
|
26
27
|
rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
|
|
27
28
|
rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
|
|
28
29
|
rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
|
|
@@ -31,6 +32,9 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
31
32
|
rootr db rows <path|id> [--body] [--backlinks] [--json] 표의 행 목록 (--body: 상세 내용까지, --backlinks: 나를 가리키는 다른 표의 행까지)
|
|
32
33
|
rootr db lock <path|id> [--schema managers] [--rows own] [--locked "승인,점수"] 표 자물쇠 보기·걸기
|
|
33
34
|
rootr db notify <path|id> [--file rules.json] [--clear] 표 알림 규칙 보기·저장
|
|
35
|
+
rootr db buttons <path|id> [--file buttons.json] [--clear] 표 위 "누르면 일하는 버튼" 보기·저장
|
|
36
|
+
rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
|
|
37
|
+
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] 뷰 목록·달력 주/달/해 단위
|
|
34
38
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
|
|
35
39
|
rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
|
|
36
40
|
rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
|
|
@@ -45,6 +49,7 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
45
49
|
rootr publish <path|id> --off 공개 닫기 (딸려 나간 하위도 같이 닫힘)
|
|
46
50
|
rootr publish <path|id> --status 지금 공개인지·주소·하위 포함 여부
|
|
47
51
|
rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
|
|
52
|
+
rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
|
|
48
53
|
rootr readreq <path|id> [--json] 읽음 확인 현황 — 누가 읽었나·완료율
|
|
49
54
|
rootr readreq <path|id> --users <id,id> [--due <ISO>] [--ack] 읽어야 할 사람 지정(명단 통째로 교체)
|
|
50
55
|
rootr readreq <path|id> --clear 읽음 확인 해제
|
|
@@ -353,7 +358,32 @@ async function cmdRm(args) {
|
|
|
353
358
|
|
|
354
359
|
await client.deleteNode(id);
|
|
355
360
|
console.log(`휴지통으로 옮겼습니다: ${resolved.path || id}`);
|
|
356
|
-
|
|
361
|
+
// 되돌리려면 id 가 있어야 한다 — 휴지통에 든 것은 경로로는 못 찾는다.
|
|
362
|
+
console.log(`되돌리려면: rootr restore ${id} (30일 뒤 자동 삭제)`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* UNDO-DELETE — `rootr restore <id>`
|
|
367
|
+
*
|
|
368
|
+
* 지운 것을 제자리로 되살린다. 화면의 Ctrl+Z 와 같은 일이다.
|
|
369
|
+
* **경로가 아니라 id** 다 — 휴지통에 든 것은 이미 트리에 없어서 경로로는 못 찾는다.
|
|
370
|
+
* `rootr rm` 이 마지막 줄에 그 id 를 찍어 준다.
|
|
371
|
+
*/
|
|
372
|
+
async function cmdRestore(args) {
|
|
373
|
+
const id = args._[0];
|
|
374
|
+
if (!id) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
'사용법: rootr restore <id>\n' +
|
|
377
|
+
' (경로가 아니라 id 입니다 — 휴지통에 든 것은 경로로 못 찾습니다. rootr rm 이 찍어 준 id 를 쓰세요.)',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const client = makeClient();
|
|
381
|
+
const node = await client.restoreNode(id);
|
|
382
|
+
if (args.json) {
|
|
383
|
+
console.log(JSON.stringify(node, null, 2));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
console.log(`되돌렸습니다: ${node.path ?? node.name ?? id}`);
|
|
357
387
|
}
|
|
358
388
|
|
|
359
389
|
/** Minimal y/N prompt. Non-interactive stdin (pipes, CI) answers "no". */
|
|
@@ -1131,20 +1161,35 @@ async function cmdDbRowsRm(args) {
|
|
|
1131
1161
|
/** PUBLIC-DB-DESIGN — 서버가 받는 값과 **같은 목록**이어야 한다. */
|
|
1132
1162
|
const DB_LAYOUTS = ['table', 'cards', 'gallery', 'list', 'board', 'timeline', 'calendar', 'chart', 'ranking'];
|
|
1133
1163
|
const DB_SKINS = ['clean', 'paper', 'dark', 'vivid', 'bento', 'brutal'];
|
|
1164
|
+
// DOCUMENT만 갖는 스킨 목록 — 서버(`public-config.ts`의 SKIN_IDS)와 같아야 한다.
|
|
1165
|
+
const DOC_SKINS = [
|
|
1166
|
+
'classic', 'minimal', 'magazine', 'paper', 'tech-dark', 'vivid', 'manual', 'book',
|
|
1167
|
+
'editorial', 'bento', 'glass', 'mesh', 'immersive', 'brutal', 'neumorph', 'y2k',
|
|
1168
|
+
];
|
|
1169
|
+
|
|
1170
|
+
/** `--comments` 단독이면 true, `--comments false`면 false, 안 주면 undefined. */
|
|
1171
|
+
function flagToBool(v) {
|
|
1172
|
+
if (v === undefined) return undefined;
|
|
1173
|
+
return v !== 'false' && v !== '0';
|
|
1174
|
+
}
|
|
1134
1175
|
|
|
1135
1176
|
async function cmdPublish(args) {
|
|
1136
1177
|
const target = args._[0];
|
|
1137
1178
|
if (!target) {
|
|
1138
1179
|
throw new Error(
|
|
1139
|
-
'사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>]
|
|
1180
|
+
'사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] ' +
|
|
1181
|
+
'[--comments] [--expires <ISO>] | --off | --status',
|
|
1140
1182
|
);
|
|
1141
1183
|
}
|
|
1142
1184
|
const client = makeClient();
|
|
1143
1185
|
const id = await resolveDatabaseId(client, target);
|
|
1186
|
+
// DOCUMENT는 예전(더 풍부한) 경로를 쓴다 — 댓글(편집 제안) 허용, 문서 전용 스킨
|
|
1187
|
+
// 16종, 만료일이 이쪽에만 있다. 노드 공통 공개 경로(표·시트 등)는 이 셋을 모른다.
|
|
1188
|
+
const isDocument = (await client.nodePublicStatus(id)).nodeType === 'DOCUMENT';
|
|
1144
1189
|
|
|
1145
1190
|
if (args.status) {
|
|
1146
|
-
const st = await client.nodePublicStatus(id);
|
|
1147
|
-
if (!st.publishable) {
|
|
1191
|
+
const st = isDocument ? await client.getDocumentPublicStatus(id) : await client.nodePublicStatus(id);
|
|
1192
|
+
if (!isDocument && !st.publishable) {
|
|
1148
1193
|
console.log('이 종류는 공개할 수 없습니다(폴더).');
|
|
1149
1194
|
return;
|
|
1150
1195
|
}
|
|
@@ -1154,21 +1199,52 @@ async function cmdPublish(args) {
|
|
|
1154
1199
|
}
|
|
1155
1200
|
console.log(`공개 중: /p/${st.slug}`);
|
|
1156
1201
|
if (st.expiresAt) console.log(` 만료: ${st.expiresAt}`);
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
console.log(
|
|
1202
|
+
if (isDocument) {
|
|
1203
|
+
console.log(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`);
|
|
1204
|
+
if (st.publicConfig?.skin) console.log(` 스킨: ${st.publicConfig.skin}`);
|
|
1205
|
+
} else {
|
|
1206
|
+
if (st.hasPassword) console.log(' 비밀번호 걸려 있음');
|
|
1207
|
+
console.log(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`);
|
|
1208
|
+
console.log(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`);
|
|
1209
|
+
if (st.design && (st.design.layout || st.design.skin)) {
|
|
1210
|
+
console.log(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`);
|
|
1211
|
+
}
|
|
1162
1212
|
}
|
|
1163
1213
|
return;
|
|
1164
1214
|
}
|
|
1165
1215
|
|
|
1166
1216
|
if (args.off) {
|
|
1167
|
-
await client.
|
|
1217
|
+
if (isDocument) await client.setDocumentPublic(id, { isPublic: false });
|
|
1218
|
+
else await client.setNodePublic(id, { isPublic: false });
|
|
1168
1219
|
console.log('공개를 닫았습니다. 딸려 나갔던 하위 문서도 같이 닫혔습니다(주소는 남아 있습니다).');
|
|
1169
1220
|
return;
|
|
1170
1221
|
}
|
|
1171
1222
|
|
|
1223
|
+
if (isDocument) {
|
|
1224
|
+
if (args.skin !== undefined && !DOC_SKINS.includes(args.skin)) {
|
|
1225
|
+
throw new Error(`--skin 은 다음 중 하나여야 합니다: ${DOC_SKINS.join(', ')}`);
|
|
1226
|
+
}
|
|
1227
|
+
const cur = await client.getDocumentPublicStatus(id);
|
|
1228
|
+
if (!cur.isPublic) {
|
|
1229
|
+
const res = await client.publishDocument(id);
|
|
1230
|
+
console.log(`공개했습니다: /p/${res.slug}`);
|
|
1231
|
+
}
|
|
1232
|
+
const patch = {};
|
|
1233
|
+
const comments = flagToBool(args.comments);
|
|
1234
|
+
if (comments !== undefined) patch.allowCommits = comments;
|
|
1235
|
+
if (typeof args.expires === 'string') patch.expiresAt = args.expires;
|
|
1236
|
+
if (typeof args.skin === 'string') patch.skin = args.skin;
|
|
1237
|
+
if (Object.keys(patch).length) {
|
|
1238
|
+
await client.setDocumentPublic(id, patch);
|
|
1239
|
+
if (patch.allowCommits !== undefined) {
|
|
1240
|
+
console.log(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`);
|
|
1241
|
+
}
|
|
1242
|
+
if (patch.expiresAt !== undefined) console.log(` 만료: ${patch.expiresAt}`);
|
|
1243
|
+
if (patch.skin !== undefined) console.log(` 스킨: ${patch.skin}`);
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1172
1248
|
// PUBLIC-DB-DESIGN — 표는 표 모양으로만 보일 이유가 없다. 준 것만 바뀐다.
|
|
1173
1249
|
const design = {
|
|
1174
1250
|
...(typeof args.layout === 'string' ? { layout: args.layout } : {}),
|
|
@@ -1274,9 +1350,12 @@ async function cmdDb(args) {
|
|
|
1274
1350
|
if (sub === 'row-page-type') return cmdDbRowPageType(args);
|
|
1275
1351
|
if (sub === 'lock') return cmdDbLock(args);
|
|
1276
1352
|
if (sub === 'notify') return cmdDbNotify(args);
|
|
1353
|
+
if (sub === 'buttons') return cmdDbButtons(args);
|
|
1354
|
+
if (sub === 'button-run') return cmdDbButtonRun(args);
|
|
1355
|
+
if (sub === 'view') return cmdDbView(args);
|
|
1277
1356
|
if (sub !== 'create') {
|
|
1278
1357
|
throw new Error(
|
|
1279
|
-
'사용법: rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify … (자세한 건 rootr --help)',
|
|
1358
|
+
'사용법: rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (자세한 건 rootr --help)',
|
|
1280
1359
|
);
|
|
1281
1360
|
}
|
|
1282
1361
|
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
|
@@ -1390,6 +1469,175 @@ async function cmdDbNotify(args) {
|
|
|
1390
1469
|
}
|
|
1391
1470
|
}
|
|
1392
1471
|
|
|
1472
|
+
/**
|
|
1473
|
+
* `rootr db view <path|id> <뷰이름|뷰id> [--scale week|month|year] [--name 새이름]`
|
|
1474
|
+
*
|
|
1475
|
+
* 뷰 하나만 고친다. 인자 없이 부르면 이 표의 뷰 목록을 보여 준다.
|
|
1476
|
+
* `--scale` 은 **달력 뷰**가 주·달·해 중 무엇으로 열릴지다(기본 달).
|
|
1477
|
+
* 해 보기는 열두 달을 한 번에 보여 주고, 고른 단위는 뷰에 저장돼 모두에게 그대로 보인다.
|
|
1478
|
+
*/
|
|
1479
|
+
async function cmdDbView(args) {
|
|
1480
|
+
const target = args._[1];
|
|
1481
|
+
if (!target) throw new Error('사용법: rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--json]');
|
|
1482
|
+
|
|
1483
|
+
const client = makeClient();
|
|
1484
|
+
const id = await resolveDatabaseId(client, target);
|
|
1485
|
+
const viewRef = args._[2];
|
|
1486
|
+
|
|
1487
|
+
if (!viewRef) {
|
|
1488
|
+
const db = await client.getDatabase(id);
|
|
1489
|
+
const views = db.views ?? [];
|
|
1490
|
+
if (args.json) {
|
|
1491
|
+
console.log(JSON.stringify(views, null, 2));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (!views.length) {
|
|
1495
|
+
console.log('뷰가 없습니다.');
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const SPAN = { week: '주', month: '달', year: '해' };
|
|
1499
|
+
for (const v of views) {
|
|
1500
|
+
const span = v.type === 'calendar' ? ` · ${SPAN[v.config?.calendarScale] ?? '달'} 단위` : '';
|
|
1501
|
+
console.log(`- ${v.name} (${v.type})${span} ${v.id}`);
|
|
1502
|
+
}
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const patch = {};
|
|
1507
|
+
if (args.scale !== undefined) {
|
|
1508
|
+
const scale = String(args.scale);
|
|
1509
|
+
if (!['week', 'month', 'year'].includes(scale)) {
|
|
1510
|
+
throw new Error('--scale 은 week · month · year 중 하나여야 합니다.');
|
|
1511
|
+
}
|
|
1512
|
+
patch.calendarScale = scale;
|
|
1513
|
+
}
|
|
1514
|
+
if (typeof args.name === 'string') patch.name = args.name;
|
|
1515
|
+
if (!Object.keys(patch).length) {
|
|
1516
|
+
throw new Error('바꿀 것을 주세요: --scale week|month|year 또는 --name 새이름');
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const view = await client.updateDatabaseView(id, viewRef, patch);
|
|
1520
|
+
if (args.json) {
|
|
1521
|
+
console.log(JSON.stringify(view, null, 2));
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
const SPAN = { week: '주', month: '달', year: '해' };
|
|
1525
|
+
const span = view.type === 'calendar' ? ` · ${SPAN[view.config?.calendarScale] ?? '달'} 단위` : '';
|
|
1526
|
+
console.log(`뷰를 고쳤습니다: ${view.name} (${view.type})${span}`);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* DB-BUTTON — 표 위의 "누르면 일하는 버튼".
|
|
1532
|
+
*
|
|
1533
|
+
* rootr db buttons <path|id> 지금 달린 버튼 보기
|
|
1534
|
+
* rootr db buttons <path|id> --file b.json 버튼 목록을 통째로 갈아끼우기
|
|
1535
|
+
* rootr db buttons <path|id> --clear 버튼 전부 없애기
|
|
1536
|
+
*
|
|
1537
|
+
* `--file` 은 **통째로 갈아끼운다** — 남길 버튼도 파일에 같이 있어야 한다.
|
|
1538
|
+
* 파일 모양: [{ "label":"접수", "icon":"📥",
|
|
1539
|
+
* "steps":[{"kind":"add_row","values":{"상태":"접수","담당":"@me","마감":"@today+7"}}] }]
|
|
1540
|
+
*/
|
|
1541
|
+
async function cmdDbButtons(args) {
|
|
1542
|
+
const target = args._[1];
|
|
1543
|
+
if (!target) throw new Error('사용법: rootr db buttons <path|id> [--file buttons.json] [--clear] [--json]');
|
|
1544
|
+
|
|
1545
|
+
const client = makeClient();
|
|
1546
|
+
const id = await resolveDatabaseId(client, target);
|
|
1547
|
+
|
|
1548
|
+
if (args.clear) {
|
|
1549
|
+
const cleared = await client.setDatabaseButtons(id, []);
|
|
1550
|
+
console.log('버튼을 모두 지웠습니다.');
|
|
1551
|
+
if (args.json) console.log(JSON.stringify(cleared, null, 2));
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (args.file) {
|
|
1555
|
+
const raw = readFileSync(args.file, 'utf8');
|
|
1556
|
+
let buttons;
|
|
1557
|
+
try {
|
|
1558
|
+
buttons = JSON.parse(raw);
|
|
1559
|
+
} catch {
|
|
1560
|
+
throw new Error('버튼 파일을 JSON으로 읽을 수 없습니다. [{ "label": "...", "steps": [...] }] 형태여야 합니다.');
|
|
1561
|
+
}
|
|
1562
|
+
if (!Array.isArray(buttons)) throw new Error('버튼 파일은 배열이어야 합니다(버튼 목록 전체).');
|
|
1563
|
+
const saved = await client.setDatabaseButtons(id, buttons);
|
|
1564
|
+
console.log(`버튼 ${saved.buttons.length}개를 저장했습니다.`);
|
|
1565
|
+
if (args.json) console.log(JSON.stringify(saved, null, 2));
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
const { buttons, myLevel } = await client.listDatabaseButtons(id);
|
|
1570
|
+
if (args.json) {
|
|
1571
|
+
console.log(JSON.stringify(buttons, null, 2));
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
if (!buttons.length) {
|
|
1575
|
+
console.log('버튼이 없습니다.');
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const WHAT = {
|
|
1579
|
+
add_row: '행 만들기',
|
|
1580
|
+
update_rows: '값 바꾸기',
|
|
1581
|
+
ai_fill: 'AI로 칸 채우기',
|
|
1582
|
+
open_row: '만든 행 열기',
|
|
1583
|
+
};
|
|
1584
|
+
const WHERE = { created: '방금 만든 행', selected: '고른 행' };
|
|
1585
|
+
for (const b of buttons) {
|
|
1586
|
+
console.log(`${b.icon ? b.icon + ' ' : ''}${b.label} (${b.id})`);
|
|
1587
|
+
for (const [i, st] of (b.steps ?? []).entries()) {
|
|
1588
|
+
const what = WHAT[st.kind] ?? st.kind;
|
|
1589
|
+
const where = st.target ? ` · ${WHERE[st.target] ?? st.target}` : '';
|
|
1590
|
+
const detail =
|
|
1591
|
+
st.kind === 'ai_fill'
|
|
1592
|
+
? ` · ${st.propId} 칸 ← "${st.prompt}"`
|
|
1593
|
+
: st.values
|
|
1594
|
+
? ' · ' + Object.entries(st.values).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
1595
|
+
: '';
|
|
1596
|
+
console.log(` ${i + 1}. ${what}${where}${detail}`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (myLevel && myLevel !== 'MANAGE' && myLevel !== 'FULL') {
|
|
1600
|
+
console.log('\n(버튼을 고치려면 이 표의 관리 권한이 필요합니다. 누르는 것은 지금 권한으로도 됩니다.)');
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* `rootr db button-run <path|id> <buttonId> [--rows id1,id2]`
|
|
1606
|
+
*
|
|
1607
|
+
* 버튼을 한 번 누른다. `--rows` 는 "고른 행"을 대상으로 하는 단계가 있을 때만 쓴다.
|
|
1608
|
+
* 누른 사람 권한 그대로 돈다 — 잠긴 칸을 건드리는 버튼은 거기서 막힌다.
|
|
1609
|
+
*/
|
|
1610
|
+
async function cmdDbButtonRun(args) {
|
|
1611
|
+
const target = args._[1];
|
|
1612
|
+
const buttonId = args._[2];
|
|
1613
|
+
if (!target || !buttonId) {
|
|
1614
|
+
throw new Error('사용법: rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--json]');
|
|
1615
|
+
}
|
|
1616
|
+
const client = makeClient();
|
|
1617
|
+
const id = await resolveDatabaseId(client, target);
|
|
1618
|
+
const rowIds =
|
|
1619
|
+
typeof args.rows === 'string'
|
|
1620
|
+
? args.rows.split(',').map((x) => x.trim()).filter(Boolean)
|
|
1621
|
+
: undefined;
|
|
1622
|
+
|
|
1623
|
+
const r = await client.runDatabaseButton(id, buttonId, { rowIds });
|
|
1624
|
+
if (args.json) {
|
|
1625
|
+
console.log(JSON.stringify(r, null, 2));
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
// created 는 **만든 행의 id 목록**이다(개수가 아니다) — 세어서 보여 준다.
|
|
1629
|
+
const madeCount = Array.isArray(r.created) ? r.created.length : (r.created ?? 0);
|
|
1630
|
+
const parts = [];
|
|
1631
|
+
if (madeCount) parts.push(`행 ${madeCount}개 만듦`);
|
|
1632
|
+
if (r.updated) parts.push(`행 ${r.updated}개 고침`);
|
|
1633
|
+
if (r.filled) parts.push(`AI로 칸 ${r.filled}개 채움`);
|
|
1634
|
+
console.log(parts.length ? parts.join(' · ') : '바뀐 것이 없습니다.');
|
|
1635
|
+
if (r.openRowId) console.log(`열 행: ${r.openRowId}`);
|
|
1636
|
+
// AI가 못 채워도 버튼은 계속 돈다 — 조용히 넘어가면 사람이 왜 빈칸인지 모른다.
|
|
1637
|
+
if (r.aiFailed) console.log(`AI가 못 채운 칸 ${r.aiFailed}개${r.aiError ? ` — ${r.aiError}` : ''}`);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
|
|
1393
1641
|
async function cmdDbRows(args) {
|
|
1394
1642
|
const target = args._[1];
|
|
1395
1643
|
if (!target) throw new Error('사용법: rootr db rows <path|id> [--body] [--backlinks] [--json]');
|
|
@@ -1823,6 +2071,8 @@ async function main() {
|
|
|
1823
2071
|
return cmdMv(args);
|
|
1824
2072
|
case 'rm':
|
|
1825
2073
|
return cmdRm(args);
|
|
2074
|
+
case 'restore':
|
|
2075
|
+
return cmdRestore(args);
|
|
1826
2076
|
case 'ws':
|
|
1827
2077
|
return cmdWs(args);
|
|
1828
2078
|
case 'ask':
|
package/lib/client.js
CHANGED
|
@@ -350,11 +350,19 @@ export class RootrClient {
|
|
|
350
350
|
return this.request(`/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* UNDO-DELETE — 휴지통에 든 것을 제자리로 되살린다.
|
|
355
|
+
* 화면의 Ctrl+Z 와 같은 일을 하는 자리다. 지운 사람이 아니어도 쓰기 권한이면 된다.
|
|
356
|
+
*/
|
|
357
|
+
async restoreNode(id) {
|
|
358
|
+
return this.request(`/nodes/${encodeURIComponent(id)}/restore`, { method: 'POST' });
|
|
359
|
+
}
|
|
360
|
+
|
|
353
361
|
/** Rename and/or move a node. `parentId: null` moves it to the workspace root. */
|
|
354
|
-
async updateNode(id, { name, icon, tags, parentId, position } = {}) {
|
|
362
|
+
async updateNode(id, { name, icon, tags, parentId, position, graphSyncOff } = {}) {
|
|
355
363
|
return this.request(`/nodes/${encodeURIComponent(id)}`, {
|
|
356
364
|
method: 'PATCH',
|
|
357
|
-
body: { name, icon, tags, parentId, position },
|
|
365
|
+
body: { name, icon, tags, parentId, position, graphSyncOff },
|
|
358
366
|
});
|
|
359
367
|
}
|
|
360
368
|
|
|
@@ -443,6 +451,32 @@ export class RootrClient {
|
|
|
443
451
|
});
|
|
444
452
|
}
|
|
445
453
|
|
|
454
|
+
// ── DB-BUTTON — 표 위에 두는 "누르면 일하는 버튼" ────────────────────────
|
|
455
|
+
//
|
|
456
|
+
// 화면에서만 만들 수 있으면, 에이전트는 표를 지어 놓고도 "접수 버튼 하나 달아
|
|
457
|
+
// 줘"를 못 한다. 만들 수 있으면 눌러 줄 수도 있어야 한다.
|
|
458
|
+
|
|
459
|
+
/** 이 표의 버튼 목록. `myLevel` 로 내가 고칠 수 있는지도 함께 온다. */
|
|
460
|
+
async listDatabaseButtons(databaseId) {
|
|
461
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/buttons`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 버튼 목록을 **통째로** 갈아끼운다(MANAGE 필요). 남길 버튼도 같이 보내야 한다. */
|
|
465
|
+
async setDatabaseButtons(databaseId, buttons) {
|
|
466
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/buttons`, {
|
|
467
|
+
method: 'PUT',
|
|
468
|
+
body: { buttons },
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 버튼 한 번 누르기. `rowIds` 는 "고른 행"을 대상으로 하는 단계가 있을 때만 쓴다. */
|
|
473
|
+
async runDatabaseButton(databaseId, buttonId, { rowIds } = {}) {
|
|
474
|
+
return this.request(
|
|
475
|
+
`/databases/${encodeURIComponent(databaseId)}/buttons/${encodeURIComponent(buttonId)}/run`,
|
|
476
|
+
{ method: 'POST', body: { rowIds } },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
446
480
|
// ── PUBLIC-DOCS — 발행한 문서에 올라온 변경 제안 ────────────────────────
|
|
447
481
|
|
|
448
482
|
async listDocumentCommits(documentId, status = 'OPEN') {
|
|
@@ -760,10 +794,13 @@ export class RootrClient {
|
|
|
760
794
|
* building a table by adding columns one at a time made the order depend on
|
|
761
795
|
* the order of the calls, which is how migrated tables came out scrambled.
|
|
762
796
|
*/
|
|
763
|
-
async createDatabase(
|
|
797
|
+
async createDatabase(
|
|
798
|
+
workspace,
|
|
799
|
+
{ name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType, graphSync } = {},
|
|
800
|
+
) {
|
|
764
801
|
return this.request(`/workspaces/${encodeURIComponent(workspace)}/databases`, {
|
|
765
802
|
method: 'POST',
|
|
766
|
-
body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType },
|
|
803
|
+
body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType, graphSync },
|
|
767
804
|
});
|
|
768
805
|
}
|
|
769
806
|
|
|
@@ -784,11 +821,11 @@ export class RootrClient {
|
|
|
784
821
|
*/
|
|
785
822
|
async createDatabaseView(
|
|
786
823
|
databaseId,
|
|
787
|
-
{ name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths } = {},
|
|
824
|
+
{ name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale } = {},
|
|
788
825
|
) {
|
|
789
826
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
|
|
790
827
|
method: 'POST',
|
|
791
|
-
body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths },
|
|
828
|
+
body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale },
|
|
792
829
|
});
|
|
793
830
|
}
|
|
794
831
|
|
|
@@ -1159,6 +1196,36 @@ export class RootrClient {
|
|
|
1159
1196
|
});
|
|
1160
1197
|
}
|
|
1161
1198
|
|
|
1199
|
+
// ---- Pages (block-assembled web pages) ----------------------------------
|
|
1200
|
+
|
|
1201
|
+
/** PAGE 노드를 한 번에 만든다(자리 + 블록 + 테마). */
|
|
1202
|
+
async createPage(workspaceId, { name, parentId, path, createParents, autoRename, icon, blocks, config } = {}) {
|
|
1203
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/pages`, {
|
|
1204
|
+
method: 'POST',
|
|
1205
|
+
body: { name, parentId, path, createParents, autoRename, icon, blocks, config },
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** 페이지 정본: 블록 배열 + 설정(테마·강조색). */
|
|
1210
|
+
async getPage(pageId) {
|
|
1211
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}`);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** blocks를 주면 **통째로 교체**된다 — 남길 블록도 같이 보내야 한다. */
|
|
1215
|
+
async updatePage(pageId, { blocks, config } = {}) {
|
|
1216
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}`, { method: 'PATCH', body: { blocks, config } });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** 맨 뒤에 덧붙이기 — 있는 블록은 그대로 둔다. */
|
|
1220
|
+
async appendPageBlocks(pageId, blocks) {
|
|
1221
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}/blocks`, { method: 'POST', body: { blocks } });
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** 라이브 블록이 지금 무엇을 보여 주는가(집계·행·차트 값). */
|
|
1225
|
+
async getPageLiveData(pageId) {
|
|
1226
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}/live-data`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1162
1229
|
// ---- Presentations (slide decks) ----------------------------------------
|
|
1163
1230
|
|
|
1164
1231
|
async createPresentation(workspaceId, { name, parentId, theme, slides, config, icon } = {}) {
|
|
@@ -135,7 +135,11 @@ export function registerDatabaseTools(server) {
|
|
|
135
135
|
'Views default to a single table view; view types are table | board | gallery | calendar | timeline | list — ' +
|
|
136
136
|
'a "board" view needs config.groupBy (a select/status property id), a "timeline"/"calendar" view needs ' +
|
|
137
137
|
'config.dateProp. Row `values` are keyed by column NAME (or id); date values are ' +
|
|
138
|
-
'{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids.'
|
|
138
|
+
'{ start: "YYYY-MM-DDTHH:mm" or "YYYY-MM-DD", end? }, multi_select is string[], relation is an array of row ids. ' +
|
|
139
|
+
'COST NOTE: by default, every row added to this table (now or later) queues that table for graph extraction ' +
|
|
140
|
+
'(embeddings + LLM) — fine for a handful of rows, expensive for a bulk-loaded roster/log table. Pass ' +
|
|
141
|
+
'graphSync:false for tables you are about to bulk-load; flip it back with ' +
|
|
142
|
+
'`rootr_update_node({ graphSyncOff: true|false })` any time after.',
|
|
139
143
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
140
144
|
inputSchema: {
|
|
141
145
|
name: z.string().describe('Table name, e.g. "업무 목록"'),
|
|
@@ -215,10 +219,18 @@ export function registerDatabaseTools(server) {
|
|
|
215
219
|
'calculation sheet, "FORM" a per-row intake. Default "DOCUMENT". This is only the default — a single ' +
|
|
216
220
|
'row can be made something else with rootr_create_row_page.',
|
|
217
221
|
),
|
|
222
|
+
graphSync: z
|
|
223
|
+
.boolean()
|
|
224
|
+
.optional()
|
|
225
|
+
.describe(
|
|
226
|
+
'Default true. Set to false for tables you will bulk-load (rosters, logs, imports) — otherwise ' +
|
|
227
|
+
'every row added queues this table for graph extraction (embeddings + LLM), one job per row. ' +
|
|
228
|
+
'Flip it later with rootr_update_node({ graphSyncOff: true|false }).',
|
|
229
|
+
),
|
|
218
230
|
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
219
231
|
},
|
|
220
232
|
},
|
|
221
|
-
async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, workspace }) => {
|
|
233
|
+
async ({ name, parentId, path, createParents, icon, properties, views, rows, rowPageType, graphSync, workspace }) => {
|
|
222
234
|
try {
|
|
223
235
|
const ws = requireWorkspace(workspace);
|
|
224
236
|
const client = makeClient(ws);
|
|
@@ -233,6 +245,7 @@ export function registerDatabaseTools(server) {
|
|
|
233
245
|
views,
|
|
234
246
|
rows,
|
|
235
247
|
rowPageType,
|
|
248
|
+
graphSync,
|
|
236
249
|
});
|
|
237
250
|
return jsonResult(database);
|
|
238
251
|
} catch (err) {
|
|
@@ -271,9 +284,17 @@ export function registerDatabaseTools(server) {
|
|
|
271
284
|
'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
|
|
272
285
|
'40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
|
|
273
286
|
),
|
|
287
|
+
calendarScale: z
|
|
288
|
+
.enum(['week', 'month', 'year'])
|
|
289
|
+
.optional()
|
|
290
|
+
.describe(
|
|
291
|
+
'calendar view only: which span the calendar opens on — week / month / year. ' +
|
|
292
|
+
'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
|
|
293
|
+
'Saved on the view, so everyone who opens it sees that span.',
|
|
294
|
+
),
|
|
274
295
|
},
|
|
275
296
|
},
|
|
276
|
-
async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths }) => {
|
|
297
|
+
async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale }) => {
|
|
277
298
|
try {
|
|
278
299
|
const client = makeClient();
|
|
279
300
|
const id = await resolveNodeId(client, databaseId);
|
|
@@ -286,6 +307,7 @@ export function registerDatabaseTools(server) {
|
|
|
286
307
|
filters,
|
|
287
308
|
sort,
|
|
288
309
|
colWidths,
|
|
310
|
+
calendarScale,
|
|
289
311
|
});
|
|
290
312
|
return jsonResult(view);
|
|
291
313
|
} catch (err) {
|
|
@@ -350,6 +372,14 @@ export function registerDatabaseTools(server) {
|
|
|
350
372
|
'Column widths in px for this view, keyed by column NAME or id — e.g. {"공고명": 420}. ' +
|
|
351
373
|
'40~1200. Use it when a column holds long text and is being cut off. {} resets every column to auto.',
|
|
352
374
|
),
|
|
375
|
+
calendarScale: z
|
|
376
|
+
.enum(['week', 'month', 'year'])
|
|
377
|
+
.optional()
|
|
378
|
+
.describe(
|
|
379
|
+
'calendar view only: which span the calendar opens on — week / month / year. ' +
|
|
380
|
+
'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
|
|
381
|
+
'Saved on the view, so everyone who opens it sees that span.',
|
|
382
|
+
),
|
|
353
383
|
},
|
|
354
384
|
},
|
|
355
385
|
async ({
|
|
@@ -363,6 +393,7 @@ export function registerDatabaseTools(server) {
|
|
|
363
393
|
filters,
|
|
364
394
|
sort,
|
|
365
395
|
colWidths,
|
|
396
|
+
calendarScale,
|
|
366
397
|
}) => {
|
|
367
398
|
try {
|
|
368
399
|
const client = makeClient();
|
|
@@ -376,6 +407,7 @@ export function registerDatabaseTools(server) {
|
|
|
376
407
|
filters,
|
|
377
408
|
sort,
|
|
378
409
|
colWidths,
|
|
410
|
+
calendarScale,
|
|
379
411
|
});
|
|
380
412
|
return jsonResult({ view });
|
|
381
413
|
} catch (err) {
|
|
@@ -1048,4 +1080,113 @@ export function registerDatabaseTools(server) {
|
|
|
1048
1080
|
}
|
|
1049
1081
|
},
|
|
1050
1082
|
);
|
|
1083
|
+
|
|
1084
|
+
// ── DB-BUTTON — 표 위에 두는 "누르면 일하는 버튼" ──────────────────────────
|
|
1085
|
+
//
|
|
1086
|
+
// 사람은 화면에서 버튼을 만들 수 있는데 에이전트는 못 만들면, "이 표에 접수 버튼
|
|
1087
|
+
// 하나 달아 줘"에서 멈춘다. 만들 수 있으면 눌러 줄 수도 있어야 한다.
|
|
1088
|
+
|
|
1089
|
+
server.registerTool(
|
|
1090
|
+
'rootr_list_database_buttons',
|
|
1091
|
+
{
|
|
1092
|
+
title: 'List the action buttons on a database',
|
|
1093
|
+
description:
|
|
1094
|
+
'List the action buttons pinned to the top of a Rootr (루터) DATABASE. Each button is a named, ' +
|
|
1095
|
+
'ordered list of steps (add a row / set values / let AI fill a cell / open the new row) that runs ' +
|
|
1096
|
+
'when someone presses it — the repeated setup a table owner would otherwise do by hand every time. ' +
|
|
1097
|
+
'Returns `myLevel` too: editing the buttons needs MANAGE, pressing one only needs WRITE.',
|
|
1098
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1099
|
+
inputSchema: {
|
|
1100
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
async ({ databaseId }) => {
|
|
1104
|
+
try {
|
|
1105
|
+
const client = makeClient();
|
|
1106
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1107
|
+
return jsonResult(await client.listDatabaseButtons(id));
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return errorResult(err);
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
server.registerTool(
|
|
1115
|
+
'rootr_set_database_buttons',
|
|
1116
|
+
{
|
|
1117
|
+
title: 'Create or edit the action buttons on a database',
|
|
1118
|
+
description:
|
|
1119
|
+
'Replace the WHOLE button list of a Rootr (루터) DATABASE (needs MANAGE). This is a full replace, not a ' +
|
|
1120
|
+
'merge — call rootr_list_database_buttons first and send back the buttons you want to KEEP, or they are ' +
|
|
1121
|
+
'dropped. Up to 12 buttons, 8 steps each.\n' +
|
|
1122
|
+
'Steps run in order and the row a step created is what the NEXT step works on, which is how ' +
|
|
1123
|
+
'"add a row → have AI write its summary → open it" becomes one press:\n' +
|
|
1124
|
+
'· {kind:"add_row", values?} — make a row, optionally pre-filled. Column keys may be NAMES or ids.\n' +
|
|
1125
|
+
'· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
|
|
1126
|
+
'made, or on the rows the person ticked in the table.\n' +
|
|
1127
|
+
'· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
|
|
1128
|
+
'{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
|
|
1129
|
+
'· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
|
|
1130
|
+
'Values accept @today, @today+7 and @me — the server fills them in at press time, not now.\n' +
|
|
1131
|
+
'A button never grants permission: it runs as whoever presses it.',
|
|
1132
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1133
|
+
inputSchema: {
|
|
1134
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1135
|
+
buttons: z
|
|
1136
|
+
.array(
|
|
1137
|
+
z.object({
|
|
1138
|
+
id: z.string().optional().describe('Keep an existing button by passing its id; omit to create one'),
|
|
1139
|
+
label: z.string().describe('Button text, e.g. "접수"'),
|
|
1140
|
+
icon: z.string().optional().describe('One emoji shown on the button'),
|
|
1141
|
+
steps: z
|
|
1142
|
+
.array(z.record(z.any()))
|
|
1143
|
+
.describe('Ordered steps — see the tool description for each kind (max 8)'),
|
|
1144
|
+
}),
|
|
1145
|
+
)
|
|
1146
|
+
.describe('The COMPLETE button list for this table. [] removes every button.'),
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
async ({ databaseId, buttons }) => {
|
|
1150
|
+
try {
|
|
1151
|
+
const client = makeClient();
|
|
1152
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1153
|
+
return jsonResult(await client.setDatabaseButtons(id, buttons));
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
return errorResult(err);
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
server.registerTool(
|
|
1161
|
+
'rootr_run_database_button',
|
|
1162
|
+
{
|
|
1163
|
+
title: 'Press an action button on a database',
|
|
1164
|
+
description:
|
|
1165
|
+
'Press one button on a Rootr (루터) DATABASE and run its steps in order (needs WRITE). ' +
|
|
1166
|
+
'Pass `rowIds` only when a step targets "selected" — that is the set of rows the person would have ' +
|
|
1167
|
+
'ticked in the table; without it such a step reaches no rows and the server says so. ' +
|
|
1168
|
+
'Returns `created` (the ARRAY of row ids it made, not a count), `updated` and `filled` counts, the ' +
|
|
1169
|
+
'`openRowId` a final "open_row" step points at, and — if the AI could not write a cell — `aiFailed` ' +
|
|
1170
|
+
'with the reason (the rest of the button still ran). ' +
|
|
1171
|
+
'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.',
|
|
1172
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1173
|
+
inputSchema: {
|
|
1174
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1175
|
+
buttonId: z.string().describe('Button id from rootr_list_database_buttons'),
|
|
1176
|
+
rowIds: z
|
|
1177
|
+
.array(z.string())
|
|
1178
|
+
.optional()
|
|
1179
|
+
.describe('Rows for steps whose target is "selected". Omit for buttons that only add rows.'),
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
async ({ databaseId, buttonId, rowIds }) => {
|
|
1183
|
+
try {
|
|
1184
|
+
const client = makeClient();
|
|
1185
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1186
|
+
return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds }));
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
return errorResult(err);
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
);
|
|
1051
1192
|
}
|
|
@@ -194,11 +194,17 @@ export function registerDocumentTools(server) {
|
|
|
194
194
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
195
195
|
inputSchema: {
|
|
196
196
|
query: z.string().describe('Search query text'),
|
|
197
|
+
// rootr_list와 같은 이유로 필요하다: 이것만 안 받으면 다중 워크스페이스
|
|
198
|
+
// 연결에서 정체 모를 오류가 난다(#99).
|
|
199
|
+
workspace: z
|
|
200
|
+
.string()
|
|
201
|
+
.optional()
|
|
202
|
+
.describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
|
|
197
203
|
},
|
|
198
204
|
},
|
|
199
|
-
async ({ query }) => {
|
|
205
|
+
async ({ query, workspace }) => {
|
|
200
206
|
try {
|
|
201
|
-
const client = makeClient();
|
|
207
|
+
const client = makeClient(workspace);
|
|
202
208
|
const results = await client.search(query);
|
|
203
209
|
const rows = results.map((r) => {
|
|
204
210
|
const label = r.path || r.name || r.nodeId;
|
|
@@ -218,13 +224,19 @@ export function registerDocumentTools(server) {
|
|
|
218
224
|
title: 'Comment on a Rootr document',
|
|
219
225
|
description:
|
|
220
226
|
'Add a comment to a Rootr (루터) document (optionally anchored to a block, or as a reply via parentId). ' +
|
|
227
|
+
'DB-ROW-COMMENT — to comment on a **row of a table**, pass the DATABASE as `target` and the ROW id as `blockId`; that is exactly what the row screen does, and the row will show it above its body. ' +
|
|
228
|
+
'Mentioning someone with @[userId] notifies them, and their notification opens that row and ' +
|
|
229
|
+
'highlights this comment. ' +
|
|
221
230
|
'Comments carry a user identity: the comment is attributed to the person who owns the key ' +
|
|
222
231
|
'(account keys and workspace keys both carry an owner). A key with no owner cannot comment.',
|
|
223
232
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
224
233
|
inputSchema: {
|
|
225
234
|
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
226
235
|
body: z.string().describe('Comment text (markdown)'),
|
|
227
|
-
blockId: z
|
|
236
|
+
blockId: z
|
|
237
|
+
.string()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe('Block id to anchor the comment to — or a DATABASE ROW id to comment on that row'),
|
|
228
240
|
parentId: z.string().optional().describe('Parent comment id, to reply in a thread'),
|
|
229
241
|
},
|
|
230
242
|
},
|
|
@@ -244,11 +256,16 @@ export function registerDocumentTools(server) {
|
|
|
244
256
|
{
|
|
245
257
|
title: 'List comments on a Rootr document',
|
|
246
258
|
description:
|
|
247
|
-
'List the comment threads on a Rootr (루터) document, optionally filtered to one block.
|
|
259
|
+
'List the comment threads on a Rootr (루터) document, optionally filtered to one block. ' +
|
|
260
|
+
'DB-ROW-COMMENT — for the comments on ONE ROW of a table, pass the DATABASE as `target` and the ' +
|
|
261
|
+
'ROW id as `blockId`. Works with any key.',
|
|
248
262
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
249
263
|
inputSchema: {
|
|
250
264
|
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
251
|
-
blockId: z
|
|
265
|
+
blockId: z
|
|
266
|
+
.string()
|
|
267
|
+
.optional()
|
|
268
|
+
.describe('Only comments anchored to this block — or a DATABASE ROW id for that row\'s comments'),
|
|
252
269
|
},
|
|
253
270
|
},
|
|
254
271
|
async ({ target, blockId }) => {
|
package/lib/mcp-tools/forms.js
CHANGED
|
@@ -148,7 +148,8 @@ export function registerFormTools(server) {
|
|
|
148
148
|
'rootr_read_form first and resend fields you want to keep (with their existing ids) so past responses ' +
|
|
149
149
|
'stay attributable. Pass `targetDatabaseId: null` to unlink the mirrored DATABASE. ' +
|
|
150
150
|
FIELDS_GUIDE,
|
|
151
|
-
|
|
151
|
+
// `fields`를 주면 필드 전체를 갈아끼운다 — 기존 id 를 안 보내면 응답 귀속이 끊긴다.
|
|
152
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
152
153
|
inputSchema: {
|
|
153
154
|
formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
|
|
154
155
|
fields: z.array(fieldShape).optional().describe('COMPLETE fields array to set — omitted existing fields will be dropped'),
|
package/lib/mcp-tools/misc.js
CHANGED
|
@@ -95,11 +95,12 @@ export function registerMiscTools(server) {
|
|
|
95
95
|
{
|
|
96
96
|
title: 'Rename / re-icon / move any node',
|
|
97
97
|
description:
|
|
98
|
-
'Change a Rootr (루터) node\'s NAME, ICON, TAGS, or
|
|
99
|
-
'(DOCUMENT, FOLDER, DATABASE, SPREADSHEET, FORM, …). Until now icons could only be set when a node ' +
|
|
98
|
+
'Change a Rootr (루터) node\'s NAME, ICON, TAGS, PARENT, or graph auto-extraction setting — works for every ' +
|
|
99
|
+
'node type (DOCUMENT, FOLDER, DATABASE, SPREADSHEET, FORM, …). Until now icons could only be set when a node ' +
|
|
100
100
|
'was CREATED, so matching a migrated tree to its Notion icons was impossible without recreating ' +
|
|
101
101
|
'everything. Only the fields you pass change. `parentId: null` moves the node to the workspace root; ' +
|
|
102
|
-
'a node cannot be moved inside its own subtree.'
|
|
102
|
+
'a node cannot be moved inside its own subtree. `graphSyncOff: true` stops rows/edits from queuing this ' +
|
|
103
|
+
'node (and DATABASE tables especially) for graph extraction — useful right before a bulk load.',
|
|
103
104
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
104
105
|
inputSchema: {
|
|
105
106
|
target: z.string().describe('Node path (starting with "/") or node id'),
|
|
@@ -111,9 +112,16 @@ export function registerMiscTools(server) {
|
|
|
111
112
|
.nullable()
|
|
112
113
|
.optional()
|
|
113
114
|
.describe('Move under this folder/document/database (id or path). null = workspace root.'),
|
|
115
|
+
graphSyncOff: z
|
|
116
|
+
.boolean()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe(
|
|
119
|
+
'true = stop queuing this node for graph extraction (embeddings + LLM) on every edit; false = ' +
|
|
120
|
+
're-enable it (and backfill affected documents).',
|
|
121
|
+
),
|
|
114
122
|
},
|
|
115
123
|
},
|
|
116
|
-
async ({ target, name, icon, tags, parentId }) => {
|
|
124
|
+
async ({ target, name, icon, tags, parentId, graphSyncOff }) => {
|
|
117
125
|
try {
|
|
118
126
|
const client = makeClient();
|
|
119
127
|
const id = requireResolved(await resolveTarget(client, target));
|
|
@@ -122,7 +130,7 @@ export function registerMiscTools(server) {
|
|
|
122
130
|
if (typeof parentId === 'string' && parentId) {
|
|
123
131
|
parent = requireResolved(await resolveTarget(client, parentId));
|
|
124
132
|
}
|
|
125
|
-
const node = await client.updateNode(id, { name, icon, tags, parentId: parent });
|
|
133
|
+
const node = await client.updateNode(id, { name, icon, tags, parentId: parent, graphSyncOff });
|
|
126
134
|
return jsonResult(node);
|
|
127
135
|
} catch (err) {
|
|
128
136
|
return errorResult(err);
|
|
@@ -148,7 +156,36 @@ export function registerMiscTools(server) {
|
|
|
148
156
|
const resolved = await resolveTarget(client, target);
|
|
149
157
|
const id = requireResolved(resolved);
|
|
150
158
|
await client.deleteNode(id);
|
|
151
|
-
return jsonResult({
|
|
159
|
+
return jsonResult({
|
|
160
|
+
deleted: id,
|
|
161
|
+
note: 'moved to trash, recoverable — rootr_restore_node puts it back',
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return errorResult(err);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
server.registerTool(
|
|
170
|
+
'rootr_restore_node',
|
|
171
|
+
{
|
|
172
|
+
title: 'Restore a deleted node from the trash',
|
|
173
|
+
description:
|
|
174
|
+
'Put a trashed Rootr (루터) document, folder or table back where it was — the undo for ' +
|
|
175
|
+
'rootr_delete_node, and the same thing Ctrl+Z does in the app. Pass the node id you got back ' +
|
|
176
|
+
'from the delete (a path will NOT find it: a trashed node is no longer on the tree). ' +
|
|
177
|
+
'Children that went to the trash with it come back too. Needs WRITE on the node.',
|
|
178
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
179
|
+
inputSchema: {
|
|
180
|
+
nodeId: z
|
|
181
|
+
.string()
|
|
182
|
+
.describe('Node id of the trashed item (from the rootr_delete_node result or a notification)'),
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
async ({ nodeId }) => {
|
|
186
|
+
try {
|
|
187
|
+
const client = makeClient();
|
|
188
|
+
return jsonResult(await client.restoreNode(nodeId));
|
|
152
189
|
} catch (err) {
|
|
153
190
|
return errorResult(err);
|
|
154
191
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
* PAGE (web page) tools.
|
|
15
|
+
*
|
|
16
|
+
* A Rootr page is an ordered list of typed blocks — that is the safety model:
|
|
17
|
+
* a published page cannot carry a script or a phishing overlay. The agent's job
|
|
18
|
+
* is to CHOOSE BLOCKS. The one escape hatch is the `custom` block, whose free
|
|
19
|
+
* HTML/CSS renders inside a locked-down sandboxed iframe (no scripts, opaque
|
|
20
|
+
* origin, no network), so even bold CSS stays inside its own box.
|
|
21
|
+
*
|
|
22
|
+
* The guide below is repeated (shortened) on every tool, so an agent that only
|
|
23
|
+
* sees one of these tools still knows the block vocabulary.
|
|
24
|
+
*/
|
|
25
|
+
const BLOCK_GUIDE =
|
|
26
|
+
'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
|
|
27
|
+
'You assemble a page from these 15 block types:\n' +
|
|
28
|
+
'· hero {title, subtitle?, align?:"left"|"center", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner\n' +
|
|
29
|
+
'· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
|
|
30
|
+
'· features {columns?:1|2|3, items:[{title, body, icon?(emoji)}]} — "what it does" grid\n' +
|
|
31
|
+
'· gallery {images:[{attachmentId, caption?}]} — images must be workspace attachments (no external image URLs)\n' +
|
|
32
|
+
'· pricing {plans:[{name, price, period?, features:[string], highlight?, buttonLabel?, target?}]}\n' +
|
|
33
|
+
'· testimonials {items:[{quote, name, affiliation?}]}\n' +
|
|
34
|
+
'· faq {items:[{q, a}]}\n' +
|
|
35
|
+
'· timeline {items:[{date, title, body?}]}\n' +
|
|
36
|
+
'· cta {title, buttonLabel, target, body?} — closing call to action\n' +
|
|
37
|
+
'· footer {links:[{label, target}], contact?, copyright?}\n' +
|
|
38
|
+
'· liveStats {cards:[{label, icon?, source:{nodeId(DATABASE/LOG node), agg:"count"|"sum"|"avg"|"min"|"max", column?}}]} — numbers read LIVE from a table\n' +
|
|
39
|
+
'· liveTable {source:{nodeId}, columns?:[name], rowLimit?(1-200), orderBy?:{column, dir:"asc"|"desc"}}\n' +
|
|
40
|
+
'· liveChart {source:{nodeId}, chart:"bar"|"line"|"donut", labelColumn, valueColumn, agg:"count"|"sum"|"avg"}\n' +
|
|
41
|
+
'· form {formNodeId(FORM node), headline?, submitLabel?} — visitors submit right on the page\n' +
|
|
42
|
+
'· custom {html, height?(40-4000, default 260)} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
|
|
43
|
+
'(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
|
|
44
|
+
'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
|
|
45
|
+
'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS.\n' +
|
|
46
|
+
'LIVE/FORM BLOCKS: `source.nodeId` / `formNodeId` must be a node in the SAME workspace, and `column`/`labelColumn`/' +
|
|
47
|
+
'`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
|
|
48
|
+
'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
|
|
49
|
+
'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
|
|
50
|
+
'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
|
|
51
|
+
'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
|
|
52
|
+
'not published — so link to nodes rather than pasting addresses.\n' +
|
|
53
|
+
'CONFIG: {theme, accentColor?, navEnabled?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
|
|
54
|
+
'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent. ' +
|
|
55
|
+
'Every published page carries the Rootr signature at the bottom.\n' +
|
|
56
|
+
'Block ids are optional — omit them and they are generated. Keep the ids you get back if you want to edit one block later.';
|
|
57
|
+
|
|
58
|
+
/** PAGE (block-assembled web page) node tools: create/read/update/append + live values. */
|
|
59
|
+
export function registerPageTools(server) {
|
|
60
|
+
server.registerTool(
|
|
61
|
+
'rootr_create_page',
|
|
62
|
+
{
|
|
63
|
+
title: 'Create a Rootr page (block-assembled web page)',
|
|
64
|
+
description:
|
|
65
|
+
'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
|
|
66
|
+
'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
|
|
67
|
+
'for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
|
|
68
|
+
'Omit `blocks` to start from a hero/text/footer skeleton. ' +
|
|
69
|
+
BLOCK_GUIDE,
|
|
70
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
71
|
+
inputSchema: {
|
|
72
|
+
name: z.string().optional().describe('Page name (also the browser tab title of the published page)'),
|
|
73
|
+
parentId: z.string().optional().describe(`Parent folder/node id${NODE_TARGET_HINT}`),
|
|
74
|
+
path: z.string().optional().describe('Full path including the name, e.g. "/마케팅/가을 행사" — wins over parentId+name'),
|
|
75
|
+
createParents: z.boolean().optional().describe('Create missing parent folders in `path`'),
|
|
76
|
+
autoRename: z.boolean().optional().describe('On a name clash, append " (2)" instead of failing with 409'),
|
|
77
|
+
icon: z.string().optional().describe('Emoji icon for the page node'),
|
|
78
|
+
blocks: z.array(z.record(z.unknown())).optional().describe('Ordered blocks — see the block vocabulary in this description'),
|
|
79
|
+
config: z
|
|
80
|
+
.record(z.unknown())
|
|
81
|
+
.optional()
|
|
82
|
+
.describe('{theme, accentColor?, navEnabled?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight'),
|
|
83
|
+
workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
async ({ name, parentId, path, createParents, autoRename, icon, blocks, config, workspace }) => {
|
|
87
|
+
try {
|
|
88
|
+
const ws = requireWorkspace(workspace);
|
|
89
|
+
const client = makeClient(ws);
|
|
90
|
+
parentId = await resolveOptionalNodeId(client, parentId);
|
|
91
|
+
return jsonResult(
|
|
92
|
+
await client.createPage(ws, { name, parentId, path, createParents, autoRename, icon, blocks, config }),
|
|
93
|
+
);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return errorResult(err);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.registerTool(
|
|
101
|
+
'rootr_read_page',
|
|
102
|
+
{
|
|
103
|
+
title: 'Read a Rootr page',
|
|
104
|
+
description:
|
|
105
|
+
'Read a Rootr (루터) PAGE node — its full blocks array and config. Read this before rootr_update_page or ' +
|
|
106
|
+
'rootr_update_page_block so you know the current block ids, order and contents.',
|
|
107
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
108
|
+
inputSchema: {
|
|
109
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
async ({ pageId }) => {
|
|
113
|
+
try {
|
|
114
|
+
const client = makeClient();
|
|
115
|
+
pageId = await resolveNodeId(client, pageId);
|
|
116
|
+
return jsonResult(await client.getPage(pageId));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return errorResult(err);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
server.registerTool(
|
|
124
|
+
'rootr_append_page_blocks',
|
|
125
|
+
{
|
|
126
|
+
title: 'Append blocks to a Rootr page (preferred way to add sections)',
|
|
127
|
+
description:
|
|
128
|
+
'Append one or more blocks to the END of a Rootr (루터) PAGE, leaving existing blocks untouched. ' +
|
|
129
|
+
'PREFER this over rootr_update_page when you are only adding sections — update replaces the whole page. ' +
|
|
130
|
+
BLOCK_GUIDE,
|
|
131
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
132
|
+
inputSchema: {
|
|
133
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
134
|
+
blocks: z.array(z.record(z.unknown())).describe('Blocks to append, in order'),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
async ({ pageId, blocks }) => {
|
|
138
|
+
try {
|
|
139
|
+
const client = makeClient();
|
|
140
|
+
pageId = await resolveNodeId(client, pageId);
|
|
141
|
+
return jsonResult(await client.appendPageBlocks(pageId, blocks));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return errorResult(err);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
server.registerTool(
|
|
149
|
+
'rootr_update_page',
|
|
150
|
+
{
|
|
151
|
+
title: 'Update a Rootr page (blocks/theme)',
|
|
152
|
+
description:
|
|
153
|
+
'Update a Rootr (루터) PAGE. Passing `blocks` REPLACES THE WHOLE PAGE — call rootr_read_page first and ' +
|
|
154
|
+
'resend every block you want to keep, or use rootr_append_page_blocks / rootr_update_page_block for ' +
|
|
155
|
+
'incremental edits. Passing only `config` changes the theme/accent without touching content. ' +
|
|
156
|
+
BLOCK_GUIDE,
|
|
157
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
158
|
+
inputSchema: {
|
|
159
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
160
|
+
blocks: z
|
|
161
|
+
.array(z.record(z.unknown()))
|
|
162
|
+
.optional()
|
|
163
|
+
.describe('COMPLETE blocks array to set — any block you omit is deleted'),
|
|
164
|
+
config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?}'),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
async ({ pageId, blocks, config }) => {
|
|
168
|
+
try {
|
|
169
|
+
const client = makeClient();
|
|
170
|
+
pageId = await resolveNodeId(client, pageId);
|
|
171
|
+
return jsonResult(await client.updatePage(pageId, { blocks, config }));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return errorResult(err);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
'rootr_update_page_block',
|
|
180
|
+
{
|
|
181
|
+
title: 'Edit one block of a Rootr page',
|
|
182
|
+
description:
|
|
183
|
+
'Replace the props of a SINGLE block on a Rootr (루터) PAGE, leaving every other block alone — the safe way ' +
|
|
184
|
+
'to fix one section (a headline, a price, a live table source). The block keeps its id and position. ' +
|
|
185
|
+
'Get block ids from rootr_read_page. ' +
|
|
186
|
+
BLOCK_GUIDE,
|
|
187
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
188
|
+
inputSchema: {
|
|
189
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
190
|
+
blockId: z.string().describe('Block id, from rootr_read_page'),
|
|
191
|
+
props: z.record(z.unknown()).describe('COMPLETE props object for this block type (replaces the old props)'),
|
|
192
|
+
type: z.string().optional().describe('Change the block type too (rare) — props must then match the new type'),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
async ({ pageId, blockId, props, type }) => {
|
|
196
|
+
try {
|
|
197
|
+
const client = makeClient();
|
|
198
|
+
pageId = await resolveNodeId(client, pageId);
|
|
199
|
+
const page = await client.getPage(pageId);
|
|
200
|
+
const blocks = Array.isArray(page.blocks) ? page.blocks : [];
|
|
201
|
+
const idx = blocks.findIndex((b) => b && b.id === blockId);
|
|
202
|
+
if (idx === -1) {
|
|
203
|
+
// 있는 id를 알려 준다 — 못 찾았다고만 하면 다음 시도도 똑같이 틀린다.
|
|
204
|
+
const ids = blocks.map((b) => `${b.id}(${b.type})`).join(', ') || '(none)';
|
|
205
|
+
return errorResult(new Error(`block "${blockId}" not found on this page. Blocks: ${ids}`));
|
|
206
|
+
}
|
|
207
|
+
const next = blocks.map((b, i) => (i === idx ? { ...b, ...(type ? { type } : {}), props } : b));
|
|
208
|
+
return jsonResult(await client.updatePage(pageId, { blocks: next }));
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return errorResult(err);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
server.registerTool(
|
|
216
|
+
'rootr_read_page_live_data',
|
|
217
|
+
{
|
|
218
|
+
title: 'Read what a page\'s live blocks currently show',
|
|
219
|
+
description:
|
|
220
|
+
'Read the values a Rootr (루터) PAGE\'s live blocks (liveStats/liveTable/liveChart) are showing right now — ' +
|
|
221
|
+
'the aggregates, rows and chart points computed from their source tables. Use it to check that a live block ' +
|
|
222
|
+
'points at the right table and column: an empty result usually means the source is missing, is in a PRIVATE ' +
|
|
223
|
+
'subtree, or the column name does not match.',
|
|
224
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
225
|
+
inputSchema: {
|
|
226
|
+
pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
async ({ pageId }) => {
|
|
230
|
+
try {
|
|
231
|
+
const client = makeClient();
|
|
232
|
+
pageId = await resolveNodeId(client, pageId);
|
|
233
|
+
return jsonResult(await client.getPageLiveData(pageId));
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return errorResult(err);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -208,6 +208,7 @@ export function registerRecordingTools(server) {
|
|
|
208
208
|
'UPLOADED = audio stored but no transcript yet — call rootr_transcribe_recording to create one; ' +
|
|
209
209
|
'TRANSCRIBING = speech-to-text still running (poll again later); ' +
|
|
210
210
|
'READY = transcript available; FAILED = last transcription errored (see `error`).',
|
|
211
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
211
212
|
inputSchema: {
|
|
212
213
|
recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
|
|
213
214
|
},
|
|
@@ -242,6 +243,7 @@ export function registerRecordingTools(server) {
|
|
|
242
243
|
'Assign display names to the diarized speaker labels of a Rootr (루터) RECORDING node, e.g. ' +
|
|
243
244
|
'{"S1":"김대표","S2":"이팀장"}. Merge-patch: only the keys you send change. The names apply to the ' +
|
|
244
245
|
'transcript everywhere (web UI, rootr_read_recording, knowledge graph).',
|
|
246
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
245
247
|
inputSchema: {
|
|
246
248
|
recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
|
|
247
249
|
speakerNames: z
|
|
@@ -88,7 +88,8 @@ export function registerSpreadsheetTools(server) {
|
|
|
88
88
|
'Merge-patch a Rootr (루터) SPREADSHEET: update config/icon, or REPLACE THE WHOLE sheets array (if you ' +
|
|
89
89
|
'pass `sheets`, it replaces every existing sheet — call rootr_read_spreadsheet first and resend sheets ' +
|
|
90
90
|
'you want to keep, or prefer rootr_create_sheet / rootr_patch_spreadsheet_cells for incremental changes).',
|
|
91
|
-
|
|
91
|
+
// `sheets`를 주면 시트 배열 전체를 갈아끼운다 — 안 보낸 시트는 사라진다.
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
92
93
|
inputSchema: {
|
|
93
94
|
spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
|
|
94
95
|
sheets: z
|
|
@@ -132,7 +132,8 @@ export function registerTemplateTools(server) {
|
|
|
132
132
|
'Change a Rootr (루터) template — its name, emoji, description or `spec`. Editing a builtin transparently ' +
|
|
133
133
|
'creates a workspace copy, so the builtin itself is never damaged. Use this for "change the standard format" ' +
|
|
134
134
|
'requests instead of saving a second near-identical template.',
|
|
135
|
-
|
|
135
|
+
// `spec`을 주면 템플릿 정의를 통째로 덮어쓴다(버전 기록 없음).
|
|
136
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
136
137
|
inputSchema: {
|
|
137
138
|
templateId: z.string().describe('Template id from rootr_list_templates'),
|
|
138
139
|
name: z.string().optional().describe('New name'),
|
|
@@ -139,7 +139,8 @@ export function registerWhiteboardTools(server) {
|
|
|
139
139
|
'content). merge=false/omitted REPLACES the whole scene — call rootr_read_whiteboard first and resend ' +
|
|
140
140
|
'anything you want to keep. ' +
|
|
141
141
|
AUTHORING_GUIDE,
|
|
142
|
-
|
|
142
|
+
// merge=false 면 장면 전체를 갈아끼운다 — 사람이 손으로 그린 것까지 날아간다.
|
|
143
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
143
144
|
inputSchema: {
|
|
144
145
|
whiteboardId: z.string().describe(`WHITEBOARD node id${NODE_TARGET_HINT}`),
|
|
145
146
|
scene: z
|
package/lib/mcp.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerMiscTools } from './mcp-tools/misc.js';
|
|
|
13
13
|
import { registerPublicTools } from './mcp-tools/public.js';
|
|
14
14
|
import { registerCrmTools } from './mcp-tools/crm.js';
|
|
15
15
|
import { registerPresentationTools } from './mcp-tools/presentations.js';
|
|
16
|
+
import { registerPageTools } from './mcp-tools/pages.js';
|
|
16
17
|
import { registerSpreadsheetTools } from './mcp-tools/spreadsheets.js';
|
|
17
18
|
import { registerWhiteboardTools } from './mcp-tools/whiteboards.js';
|
|
18
19
|
import { registerFormTools } from './mcp-tools/forms.js';
|
|
@@ -261,6 +262,7 @@ export function buildMcpServer({ name, version }) {
|
|
|
261
262
|
registerPublicTools(server);
|
|
262
263
|
registerCrmTools(server);
|
|
263
264
|
registerPresentationTools(server);
|
|
265
|
+
registerPageTools(server);
|
|
264
266
|
registerSpreadsheetTools(server);
|
|
265
267
|
registerWhiteboardTools(server);
|
|
266
268
|
registerFormTools(server);
|