rootr-cli 0.23.3 → 0.25.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.ko.md CHANGED
@@ -305,6 +305,32 @@ rootr sign ledger # 워크스페이스 원장 전체 재계산
305
305
  원본은 `SUPERSEDED`로 남습니다 — 지침이 "고친 흔적과 원본이 모두 보여야 한다"를
306
306
  요구하기 때문입니다.
307
307
 
308
+ ### 문서 보내기 · 문서함 (send · mail)
309
+
310
+ 어떤 노드든(폴더 포함) 이메일 1~20명에게 보낼 수 있습니다. **복사본이 아니라 원본 그 문서**를 그 이메일에
311
+ 열어 주는 것입니다 — `--access read`(보기, 기본) · `comment`(댓글) · `write`(편집). 받는 사람은 언제나 최신
312
+ 내용을 보고, 편집을 받았으면 원본을 직접 고칩니다. 그 이메일(인증된)의 루터 계정은 우리 워크스페이스 구성원이
313
+ 아니어도 문서함에서 그 문서만 엽니다(트리·구성원·검색은 안 보임). 계정이 없으면 메일 링크에서 받은 이메일을
314
+ 넣어야 읽기 전용으로 보입니다. 보내려면 그 노드에 구성원으로서 쓰기(WRITE) 권한이 있어야 하고(공유와 같은
315
+ 문턱), 내 권한까지만 줄 수 있습니다.
316
+
317
+ ```bash
318
+ rootr send "/보고서/3분기 실적" --to partner@example.com,cfo@example.com --access comment --message "말씀드린 자료입니다"
319
+ rootr mail # 받은 문서 (안 읽은 수 함께)
320
+ rootr mail sent # 보낸 문서 — 권한·읽었는지·메일 실패
321
+ rootr mail show <id> # 지금 내용 (문서는 마크다운 본문, 그 밖은 --json 으로 전부, 폴더 안은 --node <아래 id>)
322
+ rootr mail access <id> write # 받는 사람 권한 바꾸기
323
+ rootr mail revoke <id> # 회수 — 받는 사람이 바로 못 열고 링크는 410
324
+ rootr mail hide <id> # 내 문서함에서만 지우기
325
+ rootr mail webhook add https://example.com/hooks/rootr # 내 계정 웹훅: doc_mail.received / revoked / read
326
+ rootr mail webhook list | rm <id> | test <id>
327
+ ```
328
+
329
+ 다른 명령을 쓰고 나면 새로 받은 문서가 있을 때 stderr 로 한 줄이 나옵니다(`📬 받은 문서 2건 — … · rootr mail`).
330
+ stdout 과 `--json` 출력은 그대로입니다. 끄려면 `ROOTR_MAIL_NOTICE=0`. MCP 서버는 같은 소식을 아무 도구 응답 끝에
331
+ 키마다 한 번 붙입니다. 웹훅 요청은 서명되어 있습니다 — `add` 때 한 번 보이는 비밀값으로
332
+ `X-Rootr-Signature: sha256=HMAC_SHA256(secret, 원문)` 을 확인하세요. 몸통에는 문서 내용이 없습니다(`apiUrl` 을 부르세요).
333
+
308
334
  ### 제보 보내기 (feedback)
309
335
 
310
336
  ```bash
@@ -352,7 +378,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
352
378
 
353
379
  ## MCP 서버로 사용하기
354
380
 
355
- `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 186종:
381
+ `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 202종:
356
382
 
357
383
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
358
384
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -635,7 +661,7 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
635
661
 
636
662
  | 도구 | 설명 |
637
663
  |---|---|
638
- | `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱, 하위까지 |
664
+ | `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱·페이지, 하위까지. **페이지(PAGE)는 Team 플랜부터**(무료는 403 `PLAN_UPGRADE_REQUIRED`, 하위 포함 시 페이지는 건너뛰고 개수를 알려 줌) |
639
665
  | `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료) |
640
666
  | `rootr_node_public_status` | 지금 공개돼 있는지, 주소는 무엇인지 |
641
667
  | `rootr_publish_document` | 문서 전용 발행(구형 — 새로 짤 때는 `rootr_publish_node`) |
@@ -799,6 +825,23 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
799
825
  주세요 — 그 표는 행을 열 때마다 서명문서(연구노트)가 만들어집니다(실험 기록 대장,
800
826
  점검 대장).
801
827
 
828
+ #### 문서 보내기 · 문서함 (docs:read/docs:write)
829
+
830
+ | 도구 | 설명 |
831
+ |---|---|
832
+ | `rootr_send_document` | 노드 하나를 이메일 1~20명에게 보내기 — 복사본이 아니라 원본을 `access`(read 보기·comment 댓글·write 편집)만큼 열어 줌 |
833
+ | `rootr_list_doc_mail` | 내 문서함 — `box`: inbox(받은, 기본) / sent(보낸), 안 읽은 수·권한·원본 상태 함께 |
834
+ | `rootr_read_doc_mail` | 한 통 열기 — 원본의 지금 내용(읽기 전용)·권한·원본 주소, 폴더면 `node` 로 아래 것, 받은 것은 읽음 처리 |
835
+ | `rootr_set_doc_mail_access` | 보낸 문서의 받는 사람 권한 바꾸기 (보낸 사람만, 내 권한까지) |
836
+ | `rootr_revoke_doc_mail` | 보낸 문서 회수 — 받는 사람이 바로 못 연다 (보낸 사람만) |
837
+ | `rootr_add_doc_mail_webhook` | 내 계정 받은 문서 웹훅 만들기 — doc_mail.received·revoked·read, HMAC 서명, 비밀값은 한 번만 |
838
+ | `rootr_list_doc_mail_webhooks` | 내 받은 문서 웹훅 목록(마지막 전달 상태) |
839
+ | `rootr_remove_doc_mail_webhook` | 내 받은 문서 웹훅 지우기 |
840
+ | `rootr_test_doc_mail_webhook` | 시험 전송(doc_mail.test) 후 전달 결과 |
841
+
842
+ 문서함은 워크스페이스가 아니라 **사람(이메일)**에 붙습니다. 사람 없는 워크스페이스 키(원격 커넥터)는
843
+ 그 키를 만든 구성원으로 동작합니다.
844
+
802
845
  #### 문서 버전 / 삭제
803
846
 
804
847
  | 도구 | 설명 |
@@ -871,6 +914,10 @@ DATABASE·SPREADSHEET·LOG 노드이고, 칸은 **이름**으로 가리킵니다
871
914
  페이지에서 조용히 빠집니다(일부러 그렇습니다).
872
915
 
873
916
  공개는 여느 노드와 같습니다: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
917
+ 단 **페이지를 웹에 게시하는 것은 Team 플랜부터**입니다 — 무료 워크스페이스는 403 `PLAN_UPGRADE_REQUIRED`
918
+ (아무것도 바뀌지 않음). 무료에서도 페이지를 만들고 편집하고 워크스페이스 안에서 보는 것은 그대로 됩니다.
919
+ Team 에서 공개한 뒤 무료로 내려가면 공개 설정은 남아 있지만 방문자에게는 "게시되어 있지 않습니다" 화면이
920
+ 나가고, 다시 Team 으로 올리면 같은 주소가 다시 발행하지 않아도 열립니다.
874
921
 
875
922
  #### 프레젠테이션
876
923
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
11
11
  *(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
12
12
 
13
13
  - `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
14
- - `rootr mcp` — stdio MCP server, **193 tools** (see [MCP tools](#mcp-tools))
14
+ - `rootr mcp` — stdio MCP server, **202 tools** (see [MCP tools](#mcp-tools))
15
15
 
16
16
  Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
17
17
 
@@ -269,7 +269,15 @@ rootr publish /handbook --status # is it public, at what URL, chi
269
269
  rootr publish /projects/tasks --layout board --skin paper # 9 layouts × 6 skins
270
270
  rootr publish /handbook/policy.md --comments --expires 2026-12-31T00:00:00Z
271
271
  rootr publish /handbook --off
272
+ ```
273
+
274
+ Every node type publishes on the Free plan **except PAGE** (the block-built web page): publishing a
275
+ page to the web needs the **Team plan**. On Free, `rootr publish <page>` fails with
276
+ `PLAN_UPGRADE_REQUIRED` and nothing changes; `--children` skips pages underneath and says how many.
277
+ A page published on Team stays saved if the workspace later drops to Free — visitors see a
278
+ "not published" screen until it is upgraded again, and `--status` says so.
272
279
 
280
+ ```bash
273
281
  rootr access /handbook --private # or --public / --inherit
274
282
  rootr access /handbook --grant <userId> --level WRITE
275
283
  rootr access /handbook --revoke <userId>
@@ -327,6 +335,34 @@ A signed document is never edited in place. You issue a **revision**
327
335
  (`rootr sign revise <id> --note "…"`) and the original stays as `SUPERSEDED`, because the
328
336
  regulations require both the original and the trace of the change to remain visible.
329
337
 
338
+ ### Sending a document to someone (mailbox)
339
+
340
+ Any node — a folder too — can be sent to 1–20 email addresses. It is **not a copy**: the address
341
+ gets access to the **original** at `--access read` (view, default) · `comment` · `write` (edit), so
342
+ they always see the latest version and, with `write`, edit the original itself. A Rootr account on
343
+ that (verified) email opens it from its inbox even if it is not in your workspace — it sees that
344
+ node only, never your tree, members or search. Without an account the email link asks for the
345
+ address and then shows it read-only. Sending needs WRITE on the node as a member (the same bar as
346
+ sharing); you can give at most your own level.
347
+
348
+ ```bash
349
+ rootr send "/reports/Q3 numbers" --to partner@example.com,cfo@example.com --access comment --message "As discussed"
350
+ rootr mail # received (with the unread count)
351
+ rootr mail sent # what you sent — access, read or not, email failures
352
+ rootr mail show <id> # the current content (a document prints its markdown; --json for any type; --node <child> inside a folder)
353
+ rootr mail access <id> write # change what the recipient can do
354
+ rootr mail revoke <id> # withdraw: the recipient loses access at once, the link answers 410
355
+ rootr mail hide <id> # remove from your own mailbox only
356
+ rootr mail webhook add https://example.com/hooks/rootr # your own webhook: doc_mail.received / revoked / read
357
+ rootr mail webhook list | rm <id> | test <id>
358
+ ```
359
+
360
+ After any other command, newly received documents print one line to stderr
361
+ (`📬 2 new documents received — … · rootr mail`); stdout and `--json` stay clean. Turn it off with
362
+ `ROOTR_MAIL_NOTICE=0`. The MCP server appends the same news to the end of any tool result, once
363
+ per key. Webhook requests are signed: verify `X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body)`
364
+ with the secret shown once at `add`. The body never contains the document — GET its `apiUrl`.
365
+
330
366
  ### Team memory
331
367
 
332
368
  ```bash
@@ -408,7 +444,7 @@ is refused and says when the grace was used.
408
444
 
409
445
  ## MCP tools
410
446
 
411
- `rootr mcp` speaks MCP over stdio. 193 tools:
447
+ `rootr mcp` speaks MCP over stdio. 202 tools:
412
448
 
413
449
  | Group | Count | Tools |
414
450
  |---|---|---|
@@ -416,6 +452,7 @@ is refused and says when the grace was used.
416
452
  | Databases | 32 | create, columns, views, rows (single & bulk), row pages, permissions, notifications, buttons, duplicates (find / merge / keep & link), history & point-in-time restore (row / whole table) |
417
453
  | CRM | 18 | companies, contacts, deals (incl. pipeline moves), activities, tasks, CSV import |
418
454
  | Signed documents & research notes | 14 | create, sign, revise, reject, reopen, approval lines, signing codes, ledger verification, research-note preset |
455
+ | Sending documents (mailbox) | 4 | send any node as a frozen copy to email addresses (no account needed to open), list received / sent, read, withdraw |
419
456
  | Forms | 10 | create, read, update, responses, share links, collectors |
420
457
  | Presentations & images | 10 | decks, slides, diagram slides, image generation and background removal |
421
458
  | Workspaces & scaffolding | 10 | list, create, delete, switch, set the emblem (sidebar logo), plan / review / apply a document tree, health check |
@@ -525,7 +562,8 @@ takes `width: half | third | twoThirds` so a page becomes a **live dashboard**
525
562
  published separately. Tables and forms that live under a PRIVATE subtree are silently dropped
526
563
  from a public page, on purpose.
527
564
 
528
- Publishing works like any other node: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
565
+ Publishing works like any other node: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}` —
566
+ **Team plan and up** for pages (Free → `403 PLAN_UPGRADE_REQUIRED`; pages still work inside the workspace).
529
567
 
530
568
  ### Embedding a table inside a document
531
569
 
package/bin/rootr.js CHANGED
@@ -6,6 +6,7 @@ import { dirname, join, basename, extname } from 'node:path';
6
6
  import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
7
7
  import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
8
8
  import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
9
+ import { setGlobalDocMailSink } from '../lib/context.js';
9
10
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
10
11
  import { t as tr, lang } from '../lib/i18n.js';
11
12
 
@@ -95,6 +96,16 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
95
96
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
96
97
  rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 올리기+서명 (코드 하나로, 초안 포함)
97
98
  rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
99
+ rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
100
+ 문서(어떤 타입이든)를 이메일로 보내기 — 복사본이 아니라 원본을 그 이메일에 열어 줌(기본 보기)
101
+ rootr mail [inbox|sent] [--limit N] [--q <찾기>] [--json] 문서함 — 받은 문서(기본)·보낸 문서
102
+ rootr mail show <id> [--node <아래 노드 id>] [--json] 받은/보낸 문서의 지금 내용(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
103
+ rootr mail access <id> read|comment|write 보낸 문서의 받는 사람 권한 바꾸기
104
+ rootr mail revoke <id> 보낸 문서 회수 — 받는 사람이 원본을 더 못 연다
105
+ rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(받은 것을 지우면 그 계정으로는 더 안 열린다)
106
+ rootr mail webhook add <url> [--events …] | list | rm <id> | test <id>
107
+ 받은 문서·회수·읽음을 내 주소로 알려 주는 웹훅(내 계정 것, HMAC 서명)
108
+ ※ 다른 명령 뒤 새로 받은 문서가 있으면 stderr 로 한 줄 알림(끄기: ROOTR_MAIL_NOTICE=0)
98
109
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
99
110
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
100
111
  rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
@@ -108,7 +119,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
108
119
  rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
109
120
  rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
110
121
  rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
111
- rootr mcp MCP stdio 서버 구동 (도구 178종 — README 참고)
122
+ rootr mcp MCP stdio 서버 구동 (도구 197종 — README 참고)
112
123
  rootr --help 도움말
113
124
  rootr --version 버전 출력
114
125
 
@@ -201,6 +212,13 @@ usage:
201
212
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] list signed documents (--pending: waiting on you)
202
213
  rootr sign bulk <id…> --code <6 digits> submit+sign many at once (one code, drafts included)
203
214
  rootr sign ledger [--json] verify the whole workspace ledger (hash chain)
215
+ rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
216
+ give email addresses access to any node — the original itself, not a copy (default: read)
217
+ rootr mail [inbox|sent] [--limit N] [--q <text>] [--json] your document mailbox — received (default) or sent
218
+ rootr mail show <id> [--node <child id>] [--json] the document's current content (body for docs, a summary otherwise); marks received ones read
219
+ rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
220
+ rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
221
+ rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
204
222
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
205
223
  rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
206
224
  rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
@@ -214,7 +232,7 @@ usage:
214
232
  rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
215
233
  rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
216
234
  rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
217
- rootr mcp run the MCP stdio server (178 tools — see the README)
235
+ rootr mcp run the MCP stdio server (197 tools — see the README)
218
236
  rootr --help this help
219
237
  rootr --version print the version
220
238
 
@@ -1739,6 +1757,18 @@ async function cmdPublish(args) {
1739
1757
  console.log(tr('이 종류는 공개할 수 없습니다(폴더).', `This node type cannot be published (folder).`));
1740
1758
  return;
1741
1759
  }
1760
+ if (st.closed === 'PAGE_PUBLISH_REQUIRES_TEAM') {
1761
+ // PAGE-PUBLISH-TEAM — 공개는 켜져 있지만 워크스페이스가 무료라 방문자에게 안 보인다. Team 으로 올리면 같은 주소가 다시 열린다.
1762
+ console.log(tr(`공개가 켜져 있지만 지금은 방문자에게 보이지 않습니다(주소: /p/${st.slug}).`, `Publishing is on, but visitors cannot see it right now (address: /p/${st.slug}).`));
1763
+ console.log(
1764
+ ' ' +
1765
+ tr(
1766
+ '페이지를 웹에 게시하는 기능은 Team 플랜에서만 됩니다. Team 으로 올리면 다시 발행하지 않아도 같은 주소로 열립니다.',
1767
+ 'Publishing pages to the web needs the Team plan. Upgrade to Team and the same address opens again — no need to publish again.',
1768
+ ),
1769
+ );
1770
+ return;
1771
+ }
1742
1772
  if (st.closed) {
1743
1773
  // 공개를 끈 것과 다르다 — 설정은 켜져 있고, "본인이 만든 행만" 표의 행에서 나온 것이라 지금 닫혀 있다(서버 응답의 closed)
1744
1774
  console.log(tr(`공개가 켜져 있지만 지금은 닫혀 있습니다(주소: /p/${st.slug}).`, `Publishing is on, but it is closed right now (address: /p/${st.slug}).`));
@@ -1823,6 +1853,15 @@ async function cmdPublish(args) {
1823
1853
  });
1824
1854
  console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
1825
1855
  if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
1856
+ if (res.skippedPagesRequireTeam) {
1857
+ // PAGE-PUBLISH-TEAM — 무료 워크스페이스에서는 아래의 페이지(PAGE)는 따라 나가지 않는다.
1858
+ console.log(
1859
+ tr(
1860
+ ` 단, 아래의 페이지 ${res.skippedPagesRequireTeam}개는 공개하지 않았습니다 — 페이지를 웹에 게시하는 기능은 Team 플랜부터입니다.`,
1861
+ ` ${res.skippedPagesRequireTeam} page(s) underneath were NOT published — publishing pages to the web needs the Team plan.`,
1862
+ ),
1863
+ );
1864
+ }
1826
1865
  if (Object.keys(design).length) {
1827
1866
  console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
1828
1867
  }
@@ -3061,6 +3100,223 @@ async function cmdSign(args) {
3061
3100
  }
3062
3101
  }
3063
3102
 
3103
+ // ---- doc mail (DOC-MAIL — 문서 송수신) ------------------------------------
3104
+
3105
+ const MAIL_USAGE =
3106
+ usage(`rootr mail [inbox|sent] | show <id> | access <id> <read|comment|write> | revoke <id> | hide <id> | webhook …\n`) +
3107
+ tr(' (자세한 건 rootr --help)', ` (see rootr --help)`);
3108
+
3109
+ const NODE_TYPE_KO = {
3110
+ DOCUMENT: tr('문서', 'document'),
3111
+ DATABASE: tr('표', 'table'),
3112
+ SPREADSHEET: tr('스프레드시트', 'spreadsheet'),
3113
+ WHITEBOARD: tr('화이트보드', 'whiteboard'),
3114
+ PRESENTATION: tr('발표자료', 'deck'),
3115
+ FORM: tr('폼', 'form'),
3116
+ ISSUE_TRACKER: tr('이슈', 'issues'),
3117
+ LOG: tr('로그', 'log'),
3118
+ CRM: 'CRM',
3119
+ RECORDING: tr('녹음', 'recording'),
3120
+ PAGE: tr('페이지', 'page'),
3121
+ SIGNED: tr('서명문서', 'signed document'),
3122
+ };
3123
+
3124
+ const ACCESS_LABEL = {
3125
+ READ: tr('보기', 'view'),
3126
+ COMMENT: tr('댓글', 'comment'),
3127
+ WRITE: tr('편집', 'edit'),
3128
+ };
3129
+
3130
+ async function cmdSend(args) {
3131
+ const target = args._[0];
3132
+ const toArg = typeof args.to === 'string' ? args.to : '';
3133
+ const to = toArg.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
3134
+ const SEND_USAGE = `rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]`;
3135
+ if (!target || !to.length) throw new Error(usage(SEND_USAGE));
3136
+ const access = typeof args.access === 'string' ? args.access.trim().toLowerCase() : undefined;
3137
+ if (access && !['read', 'comment', 'write', 'view', 'edit'].includes(access)) {
3138
+ throw new Error(tr(`--access 는 read(보기)·comment(댓글)·write(편집) 중 하나입니다.`, `--access must be read, comment or write.`));
3139
+ }
3140
+ const client = makeClient();
3141
+ const nodeId = requireResolved(await resolveTarget(client, target));
3142
+ const out = await client.sendNode(nodeId, { to, access, message: typeof args.message === 'string' ? args.message : undefined });
3143
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3144
+ const items = out.items ?? [];
3145
+ const lvl = ACCESS_LABEL[out.access] ?? out.access;
3146
+ console.log(tr(`${items.length}명에게 보냈습니다 — 원본을 ${lvl} 권한으로 열어 줌 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} — they can ${lvl} the original (batch ${out.batchId})`));
3147
+ for (const it of items) {
3148
+ console.log(
3149
+ ` ${it.to}\t${it.id}\t` +
3150
+ (it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 권한은 그대로 있습니다(받은 문서함)`, `email failed${it.error ? `: ${it.error}` : ''} — the access still stands (their inbox)`)) +
3151
+ (it.resent ? tr(' (다시 보냄 — 권한·메시지만 바꿈)', ' (re-sent — access/message updated)') : ''),
3152
+ );
3153
+ }
3154
+ }
3155
+
3156
+ function mailWhen(iso) {
3157
+ return iso ? String(iso).replace('T', ' ').slice(0, 16) : '';
3158
+ }
3159
+
3160
+ async function cmdMail(args) {
3161
+ const sub = args._[0];
3162
+ const client = makeClient();
3163
+ if (!sub || sub === 'inbox' || sub === 'sent' || sub === 'list') {
3164
+ const box = sub === 'sent' || args.box === 'sent' ? 'sent' : 'inbox';
3165
+ const limit = args.limit ? Number(args.limit) : undefined;
3166
+ const out = await client.listDocMail({ box, limit, q: typeof args.q === 'string' ? args.q : undefined, cursor: typeof args.cursor === 'string' ? args.cursor : undefined });
3167
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3168
+ if (box === 'inbox') console.log(tr(`받은 문서 — 안 읽은 것 ${out.unread}개`, `Received — ${out.unread} unread`));
3169
+ else console.log(tr('보낸 문서', 'Sent'));
3170
+ if (!out.items?.length) return console.log(tr(' (없음)', ' (none)'));
3171
+ for (const m of out.items) {
3172
+ const type = NODE_TYPE_KO[m.nodeType] ?? m.nodeType;
3173
+ const state = m.revoked
3174
+ ? tr('[회수됨]', '[withdrawn]')
3175
+ : m.originalDeleted
3176
+ ? tr('[원본 삭제됨]', '[original deleted]')
3177
+ : box === 'inbox'
3178
+ ? m.readAt ? '' : tr('[새 문서]', '[new]')
3179
+ : m.readAt ? tr(`[읽음 ${mailWhen(m.readAt)}]`, `[read ${mailWhen(m.readAt)}]`) : tr('[안 읽음]', '[unread]');
3180
+ const who = box === 'inbox' ? `${m.senderName} <${m.senderEmail}>` : `→ ${m.recipientEmail}`;
3181
+ const mailFail = box === 'sent' && m.emailed === false ? tr(' [메일 실패]', ' [email failed]') : '';
3182
+ const lvl = ACCESS_LABEL[m.access] ? ` (${ACCESS_LABEL[m.access]})` : '';
3183
+ console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}${lvl}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
3184
+ if (m.message) console.log(` ${String(m.message).split('\n')[0].slice(0, 100)}`);
3185
+ }
3186
+ if (out.nextCursor) console.log(tr(`\n다음 쪽: rootr mail ${box} --cursor ${out.nextCursor}`, `\nNext page: rootr mail ${box} --cursor ${out.nextCursor}`));
3187
+ return;
3188
+ }
3189
+ if (sub === 'webhook' || sub === 'webhooks') return cmdMailWebhook(client, args);
3190
+ const id = args._[1];
3191
+ if (!id) throw new Error(MAIL_USAGE);
3192
+ switch (sub) {
3193
+ case 'show':
3194
+ case 'read': {
3195
+ const m = await client.getDocMail(id, { node: typeof args.node === 'string' ? args.node : undefined });
3196
+ if (args.json) return console.log(JSON.stringify(m, null, 2));
3197
+ console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType} · ${tr('권한', 'access')} ${ACCESS_LABEL[m.access] ?? m.access})`);
3198
+ console.log(tr(`보낸 사람: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`, `From: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`));
3199
+ console.log(tr(`받는 사람: ${m.recipientEmail} · 보낸 때 ${mailWhen(m.createdAt)}${m.readAt ? ` · 읽음 ${mailWhen(m.readAt)}` : ''}`, `To: ${m.recipientEmail} · sent ${mailWhen(m.createdAt)}${m.readAt ? ` · read ${mailWhen(m.readAt)}` : ''}`));
3200
+ if (m.message) console.log(tr(`메시지: ${m.message}`, `Message: ${m.message}`));
3201
+ if (m.revoked && m.viewer !== 'sender') return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
3202
+ if (m.originalDeleted) return console.log(tr('\n원본이 삭제되었습니다.', '\nThe original has been deleted.'));
3203
+ if (m.revoked) console.log(tr('(회수함 — 받는 사람은 더 못 엽니다)', '(withdrawn — the recipient can no longer open it)'));
3204
+ if (m.openUrl) console.log(tr(`원본 열기(편집기): ${m.openUrl}`, `Open the original (editor): ${m.openUrl}`));
3205
+ if (m.picked) console.log(tr(`보고 있는 것: ${m.picked.name} (${m.picked.id})`, `Showing: ${m.picked.name} (${m.picked.id})`));
3206
+ const p = m.payload ?? {};
3207
+ console.log('');
3208
+ if (typeof m.contentMd === 'string' || p.kind === 'doc') console.log(m.contentMd ?? p.content ?? '');
3209
+ else if (p.kind === 'database') {
3210
+ const props = p.properties ?? [];
3211
+ const rows = p.rows ?? [];
3212
+ console.log(props.map((c) => c.name).join('\t'));
3213
+ for (const r of rows) {
3214
+ console.log(props.map((c) => {
3215
+ const v = r.values?.[c.id];
3216
+ return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
3217
+ }).join('\t'));
3218
+ }
3219
+ if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 보입니다`, `… showing ${p.truncated.shown} of ${p.truncated.total} rows`));
3220
+ if (p.rowsHidden === 'booking') console.log(tr('(예약을 받는 표라 행은 안 보입니다)', '(a booking table — rows are not shown)'));
3221
+ } else if (p.kind) {
3222
+ const keys = Object.keys(p).filter((k) => k !== 'kind');
3223
+ console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
3224
+ }
3225
+ if (m.children?.length) {
3226
+ console.log(tr('\n안에 든 것 (rootr mail show <id> --node <아래 id>):', '\nInside (rootr mail show <id> --node <child id>):'));
3227
+ for (const c of m.children) console.log(` ${c.id}\t${NODE_TYPE_KO[c.type] ?? c.type}\t${c.name}`);
3228
+ }
3229
+ return;
3230
+ }
3231
+ case 'access': {
3232
+ const level = args._[2];
3233
+ if (!level || !['read', 'comment', 'write', 'view', 'edit'].includes(String(level).toLowerCase())) throw new Error(MAIL_USAGE);
3234
+ const out = await client.setDocMailAccess(id, String(level).toLowerCase());
3235
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3236
+ return console.log(tr(`받는 사람 권한을 ${ACCESS_LABEL[out.access] ?? out.access}(으)로 바꿨습니다.`, `The recipient can now ${ACCESS_LABEL[out.access] ?? out.access}.`));
3237
+ }
3238
+ case 'revoke': {
3239
+ const out = await client.revokeDocMail(id);
3240
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3241
+ return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 사람이 원본을 더 못 엽니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient can no longer open the original.`));
3242
+ }
3243
+ case 'hide':
3244
+ case 'rm': {
3245
+ const box = args.box === 'inbox' || args.box === 'sent' ? args.box : undefined;
3246
+ const out = await client.hideDocMail(id, { box });
3247
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3248
+ return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다(받은 것을 지웠으면 내 계정으로는 더 안 열립니다).`, `Removed from your mailbox (${out.hidden}). The other side still has it (a removed received document no longer opens for your account).`));
3249
+ }
3250
+ default:
3251
+ throw new Error(MAIL_USAGE);
3252
+ }
3253
+ }
3254
+
3255
+ const WEBHOOK_USAGE = usage(
3256
+ tr(
3257
+ `rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <이름>]\n` +
3258
+ ` rootr mail webhook list | rm <id> | test <id>`,
3259
+ `rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <name>]\n` +
3260
+ ` rootr mail webhook list | rm <id> | test <id>`,
3261
+ ),
3262
+ );
3263
+
3264
+ /** DOC-MAIL-HOOK — 내(계정) 받은 문서 웹훅. 워크스페이스 웹훅과 따로 — 받은 문서는 사람의 것이다. */
3265
+ async function cmdMailWebhook(client, args) {
3266
+ const op = args._[1];
3267
+ const arg = args._[2];
3268
+ switch (op) {
3269
+ case 'add': {
3270
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3271
+ const events = typeof args.events === 'string' ? args.events.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
3272
+ const out = await client.addDocMailWebhook({ url: arg, events, name: typeof args.name === 'string' ? args.name : undefined });
3273
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3274
+ console.log(tr(`웹훅을 만들었습니다: ${out.id}`, `Webhook created: ${out.id}`));
3275
+ console.log(` url: ${out.url}`);
3276
+ console.log(` events: ${out.events.join(', ')}`);
3277
+ console.log(tr(` 비밀값(지금 한 번만 보입니다 — 서명 X-Rootr-Signature 검증용): ${out.secret}`, ` secret (shown once — verify X-Rootr-Signature with it): ${out.secret}`));
3278
+ return;
3279
+ }
3280
+ case 'list':
3281
+ case 'ls':
3282
+ case undefined: {
3283
+ const out = await client.listDocMailWebhooks();
3284
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3285
+ if (!out.items?.length) return console.log(tr('웹훅이 없습니다. rootr mail webhook add <url>', 'No webhooks. rootr mail webhook add <url>'));
3286
+ for (const h of out.items) {
3287
+ const last = h.lastDelivery ? ` · ${h.lastDelivery.event} ${h.lastDelivery.status}${h.lastDelivery.responseCode ? ` ${h.lastDelivery.responseCode}` : ''}` : '';
3288
+ console.log(`${h.id}\t${h.active ? 'on' : 'off'}\t${h.url}\t${h.events.join(',')}${last}`);
3289
+ }
3290
+ return;
3291
+ }
3292
+ case 'rm':
3293
+ case 'remove':
3294
+ case 'delete': {
3295
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3296
+ const out = await client.removeDocMailWebhook(arg);
3297
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3298
+ return console.log(tr(`웹훅을 지웠습니다: ${arg}`, `Webhook removed: ${arg}`));
3299
+ }
3300
+ case 'test': {
3301
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3302
+ const queued = await client.testDocMailWebhook(arg);
3303
+ let d = null;
3304
+ for (let i = 0; i < 10; i++) {
3305
+ await new Promise((r) => setTimeout(r, 750));
3306
+ d = (await client.docMailWebhookDeliveries(arg)).items?.find((x) => x.id === queued.deliveryId) ?? null;
3307
+ if (d && d.status !== 'PENDING') break;
3308
+ }
3309
+ if (args.json) return console.log(JSON.stringify({ ...queued, delivery: d }, null, 2));
3310
+ if (!d || d.status === 'PENDING') return console.log(tr(`시험 전송을 큐에 넣었습니다(${queued.deliveryId}) — 아직 결과가 없습니다.`, `Test queued (${queued.deliveryId}) — no result yet.`));
3311
+ if (d.status === 'SUCCESS') return console.log(tr(`시험 전송 성공 (HTTP ${d.responseCode})`, `Test delivered (HTTP ${d.responseCode})`));
3312
+ process.exitCode = 1;
3313
+ return console.log(tr(`시험 전송 실패: ${d.error ?? d.responseCode} — 다시 시도합니다(최대 5번).`, `Test failed: ${d.error ?? d.responseCode} — it will be retried (up to 5 times).`));
3314
+ }
3315
+ default:
3316
+ throw new Error(WEBHOOK_USAGE);
3317
+ }
3318
+ }
3319
+
3064
3320
  async function main() {
3065
3321
  const argv = process.argv.slice(2);
3066
3322
  const args = parseArgs(argv);
@@ -3126,6 +3382,10 @@ async function main() {
3126
3382
  return cmdAccess(args);
3127
3383
  case 'sign':
3128
3384
  return cmdSign(args);
3385
+ case 'send':
3386
+ return cmdSend(args);
3387
+ case 'mail':
3388
+ return cmdMail(args);
3129
3389
  case 'feedback':
3130
3390
  return cmdFeedback(args);
3131
3391
  case 'scaffold':
@@ -3141,4 +3401,31 @@ async function main() {
3141
3401
  }
3142
3402
  }
3143
3403
 
3144
- main().catch(printErrorAndExit);
3404
+ /**
3405
+ * DOC-MAIL-NOTIFY — 아무 명령이든 끝난 뒤, 그 명령이 부른 API 응답 머리줄에 **새로 받은 문서**가 있었으면 stderr 로 한 줄
3406
+ * (stdout·--json 출력은 더럽히지 않는다). 따로 묻는 호출은 없다. 서버가 키마다 한 번만 알려 준다(channel 'cli').
3407
+ * `rootr mail`·`rootr mcp` 에서는 안 낸다(이미 문서함을 보고 있다 / 표준 입출력이 MCP 통로다).
3408
+ * 끄기: `ROOTR_MAIL_NOTICE=0`.
3409
+ */
3410
+ const docMailSink = { channel: 'cli', items: [] };
3411
+ const firstArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
3412
+ const docMailNoticeOn = process.env.ROOTR_MAIL_NOTICE !== '0' && firstArg !== 'mail' && firstArg !== 'mcp';
3413
+ if (docMailNoticeOn) setGlobalDocMailSink(docMailSink);
3414
+ function printDocMailNotice() {
3415
+ if (!docMailNoticeOn || !docMailSink.items.length) return;
3416
+ const n = Math.max(docMailSink.items.length, docMailSink.newCount || 0);
3417
+ const first = docMailSink.items[0];
3418
+ console.error(
3419
+ tr(
3420
+ `📬 받은 문서 ${n}건 — 「${first.title}」 ${first.sender}${n > 1 ? ' 외' : ''} · rootr mail`,
3421
+ `📬 ${n} new document${n > 1 ? 's' : ''} received — "${first.title}" from ${first.sender}${n > 1 ? ' and more' : ''} · rootr mail`,
3422
+ ),
3423
+ );
3424
+ }
3425
+
3426
+ main()
3427
+ .then(printDocMailNotice)
3428
+ .catch((err) => {
3429
+ printDocMailNotice();
3430
+ printErrorAndExit(err);
3431
+ });
package/lib/client.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * can render consistent, helpful messages.
7
7
  */
8
8
 
9
- import { currentRateLimitSink } from './context.js';
9
+ import { currentDocMailSink, currentRateLimitSink } from './context.js';
10
10
  import { t as tr } from './i18n.js';
11
11
 
12
12
  /** Copy the API's X-RateLimit-* headers into the ambient per-call sink, if any. */
@@ -23,6 +23,25 @@ function captureRateLimit(res) {
23
23
  sink.upgradeUrl = res.headers.get('x-ratelimit-upgrade-url') || undefined;
24
24
  }
25
25
 
26
+ /** DOC-MAIL-NOTIFY — 응답의 안 읽은 받은 문서 머리줄을 그릇에 모은다(그릇이 없으면 아무 일도 안 한다). */
27
+ function captureDocMail(res, sink) {
28
+ if (!sink) return;
29
+ const unread = Number(res.headers.get('x-rootr-doc-mail-unread'));
30
+ if (Number.isFinite(unread) && res.headers.get('x-rootr-doc-mail-unread') !== null) sink.unread = unread;
31
+ const raw = res.headers.get('x-rootr-doc-mail-new-items');
32
+ if (!raw) return;
33
+ try {
34
+ const items = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
35
+ sink.items = sink.items || [];
36
+ for (const it of Array.isArray(items) ? items : []) {
37
+ if (it && it.id && !sink.items.some((x) => x.id === it.id)) sink.items.push(it);
38
+ }
39
+ sink.newCount = Math.max(sink.newCount || 0, Number(res.headers.get('x-rootr-doc-mail-new')) || 0);
40
+ } catch {
41
+ /* 알림만 빠진다 */
42
+ }
43
+ }
44
+
26
45
  export class RootrApiError extends Error {
27
46
  constructor(message, { status, body, hint } = {}) {
28
47
  super(message);
@@ -154,6 +173,8 @@ export class RootrClient {
154
173
  ...this.defaultHeaders,
155
174
  ...(opts.headers || {}),
156
175
  };
176
+ const docMailSink = currentDocMailSink();
177
+ if (docMailSink?.channel) headers['X-Rootr-Doc-Mail-Channel'] = docMailSink.channel;
157
178
 
158
179
  let body = opts.body;
159
180
  if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
@@ -173,6 +194,7 @@ export class RootrClient {
173
194
  }
174
195
 
175
196
  captureRateLimit(res);
197
+ if (res.ok) captureDocMail(res, docMailSink);
176
198
 
177
199
  if (opts.returnResponse) {
178
200
  if (!res.ok) {
@@ -2049,6 +2071,80 @@ export class RootrClient {
2049
2071
  return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/ledger/verify`);
2050
2072
  }
2051
2073
 
2074
+ // ---- Doc mail (DOC-MAIL — 문서 송수신) ---------------------------------
2075
+ //
2076
+ // 노드 하나를 이메일 1~20명에게 **보낸다 = 원본을 그 이메일에 열어 준다**(복사본이 아니다 — DOC-MAIL-LIVE).
2077
+ // 받는 사람은 원본을 실시간으로 보고, 받은 권한(read 보기·comment 댓글·write 편집)만큼 쓴다. 계정이 없으면 메일 링크에서
2078
+ // 받은 이메일을 넣어 읽기 전용으로 열고, 그 이메일로 가입하면 문서함에서 원본이 열린다. 문서함은 사람(이메일)에 붙으므로
2079
+ // 워크스페이스 주소 밑이 아니다.
2080
+
2081
+ /** 보내기 — 구성원 규칙으로 WRITE 이상. access: read|comment|write(기본 read). 응답 `{batchId, access, items:[{id,to,emailed,resent}]}`. */
2082
+ async sendNode(nodeId, { to, message, access } = {}) {
2083
+ return this.request(`/nodes/${encodeURIComponent(nodeId)}/send`, {
2084
+ method: 'POST',
2085
+ body: { to, ...(message ? { message } : {}), ...(access ? { access } : {}) },
2086
+ });
2087
+ }
2088
+
2089
+ /** 문서함 목록 — box: inbox(받은, 기본) | sent(보낸). 본문 없이 요약 + 안 읽은 수(unread). */
2090
+ async listDocMail({ box, limit, cursor, q } = {}) {
2091
+ const qs = new URLSearchParams();
2092
+ if (box) qs.set('box', box);
2093
+ if (limit) qs.set('limit', String(limit));
2094
+ if (cursor) qs.set('cursor', cursor);
2095
+ if (q) qs.set('q', q);
2096
+ const s = qs.toString();
2097
+ return this.request(`/doc-mail${s ? `?${s}` : ''}`);
2098
+ }
2099
+
2100
+ /**
2101
+ * 한 통 열기 — 원본의 **지금** 내용(읽기 전용: 문서면 contentMd, 그 밖은 payload)·받은 권한(access)·원본 주소(openUrl).
2102
+ * 폴더를 받았으면 children 이 오고, node 로 그 아래 것을 골라 읽는다. 받는 사람이 열면 읽음으로 바뀐다.
2103
+ */
2104
+ async getDocMail(id, { node } = {}) {
2105
+ return this.request(`/doc-mail/${encodeURIComponent(id)}${node ? `?node=${encodeURIComponent(node)}` : ''}`);
2106
+ }
2107
+
2108
+ /** 회수 — 보낸 사람만. 받는 사람의 원본 접근이 바로 닫힌다. */
2109
+ async revokeDocMail(id) {
2110
+ return this.request(`/doc-mail/${encodeURIComponent(id)}/revoke`, { method: 'POST', body: {} });
2111
+ }
2112
+
2113
+ // ---- Doc mail webhooks (DOC-MAIL-HOOK — 사람 단위) ---------------------
2114
+ /** 내 받은 문서 웹훅 만들기 — events: doc_mail.received|doc_mail.revoked|doc_mail.read(안 주면 셋 다). 비밀값은 이 응답에만. */
2115
+ async addDocMailWebhook({ url, events, name } = {}) {
2116
+ return this.request('/doc-mail-webhooks', { method: 'POST', body: { url, ...(events?.length ? { events } : {}), ...(name ? { name } : {}) } });
2117
+ }
2118
+
2119
+ async listDocMailWebhooks() {
2120
+ return this.request('/doc-mail-webhooks');
2121
+ }
2122
+
2123
+ async removeDocMailWebhook(id) {
2124
+ return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}`, { method: 'DELETE' });
2125
+ }
2126
+
2127
+ /** 시험 전송 한 통(doc_mail.test). 결과는 docMailWebhookDeliveries(id). */
2128
+ async testDocMailWebhook(id) {
2129
+ return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}/test`, { method: 'POST', body: {} });
2130
+ }
2131
+
2132
+ async docMailWebhookDeliveries(id) {
2133
+ return this.request(`/doc-mail-webhooks/${encodeURIComponent(id)}/deliveries`);
2134
+ }
2135
+
2136
+ /** 받은 사람의 권한 바꾸기 — 보낸 사람만, 내 수준까지. */
2137
+ async setDocMailAccess(id, access) {
2138
+ return this.request(`/doc-mail/${encodeURIComponent(id)}`, { method: 'PATCH', body: { access } });
2139
+ }
2140
+
2141
+ /** 내 문서함에서만 지우기(상대 화면은 그대로). box 로 나에게 보낸 것의 한쪽만 고를 수 있다. */
2142
+ async hideDocMail(id, { box } = {}) {
2143
+ return this.request(`/doc-mail/${encodeURIComponent(id)}${box ? `?box=${encodeURIComponent(box)}` : ''}`, {
2144
+ method: 'DELETE',
2145
+ });
2146
+ }
2147
+
2052
2148
  /**
2053
2149
  * FEEDBACK — 제보를 보낸다. 다른 메서드와 달리 `request()`를 쓰지 않고 직접
2054
2150
  * fetch 한다: **API 키가 없어도, 키가 틀렸어도 보낼 수 있어야** 하기 때문이다.
package/lib/context.js CHANGED
@@ -36,3 +36,24 @@ export function withRateLimitSink(sink, fn) {
36
36
  export function currentRateLimitSink() {
37
37
  return rateLimitStorage.getStore();
38
38
  }
39
+
40
+ /**
41
+ * DOC-MAIL-NOTIFY — 안 읽은 받은 문서 알림을 모으는 그릇. `RootrClient.request` 가 그릇이 있으면 요청에
42
+ * `X-Rootr-Doc-Mail-Channel: <channel>` 을 붙이고, 응답의 `X-Rootr-Doc-Mail-Unread/New/New-Items` 를 여기 모은다(서버가
43
+ * 그 채널에 한 번만 알린다). MCP 는 도구 호출마다 async-local 그릇(channel 'mcp'), CLI 명령은 프로세스 전체 그릇(channel 'cli').
44
+ * 원격 MCP(API 프로세스 안)는 async-local 만 쓴다 — 전역 그릇은 CLI 실행 파일만 놓는다(여러 사람이 한 프로세스를 나눠 쓴다).
45
+ */
46
+ const docMailStorage = new AsyncLocalStorage();
47
+ let globalDocMailSink = null;
48
+
49
+ export function withDocMailSink(sink, fn) {
50
+ return docMailStorage.run(sink, fn);
51
+ }
52
+
53
+ export function setGlobalDocMailSink(sink) {
54
+ globalDocMailSink = sink;
55
+ }
56
+
57
+ export function currentDocMailSink() {
58
+ return docMailStorage.getStore() ?? globalDocMailSink;
59
+ }
@@ -0,0 +1,238 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
+
5
+ /**
6
+ * DOC-MAIL — 문서 송수신(창업자 지시 2026-10-20, 같은 날 정정 "문서자체를 보내란 뜻임").
7
+ *
8
+ * 어떤 타입이든(문서·폴더·표·시트·화이트보드·덱·폼·이슈·로그·CRM·녹음·페이지·서명문서) 노드 하나를 특정 이메일에게
9
+ * **보낸다 = 원본 그 자체를 그 이메일에 열어 준다**(보기·댓글·편집). 복사본이 아니라서 보낸 사람이 고치면 받는 사람에게
10
+ * 바로 보이고, 편집을 받았으면 받는 사람이 고친 것이 원본에 남는다. 받은 문서는 받는 사람의 **이메일 주소**에 붙는다 —
11
+ * 계정이 없어도 메일 링크(받은 이메일을 넣어야 열림, 읽기 전용)로 열리고, 그 이메일로 가입하면 문서함에서 원본이 열린다.
12
+ */
13
+ export function registerDocMailTools(server) {
14
+ server.registerTool(
15
+ 'rootr_send_document',
16
+ {
17
+ title: 'Send a Rootr node to email addresses (give them access to the original)',
18
+ description:
19
+ 'Send any Rootr node (document, folder, table, spreadsheet, whiteboard, deck, form, issues, log, CRM, recording, ' +
20
+ 'page, signed document) to 1–20 email addresses. This is NOT a copy: each address gets access to the ORIGINAL ' +
21
+ 'node (and everything under it, if it is a folder) at `access` = read (view, default) | comment | write (edit). ' +
22
+ 'They always see the latest version; with write their edits land in the original. People with a Rootr account ' +
23
+ 'on that (verified) email open it from their inbox (rootr_list_doc_mail) with that access, even if they are not ' +
24
+ 'members of your workspace — they never see your workspace tree, members or search. People without an account ' +
25
+ 'get an email link where they must type their email address to read it (read-only). ' +
26
+ 'Needs WRITE on the node as a workspace member (the same bar as sharing); you can give at most your own level, ' +
27
+ 'never manage. Re-sending to the same address updates access/message instead of adding a second grant. ' +
28
+ 'Withdraw with rootr_revoke_doc_mail; change access with rootr_set_doc_mail_access. ' +
29
+ 'The result lists each recipient with `emailed` (false = the email failed; the access still stands). ' +
30
+ 'Only send to addresses the user asked for — this emails real people and opens the document to them.',
31
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
32
+ inputSchema: {
33
+ node: z.string().describe(`보낼 노드 id${NODE_TARGET_HINT}`),
34
+ to: z.array(z.string()).min(1).max(20).describe('받는 사람 이메일 주소들(1~20개). 대소문자만 다른 주소는 같은 사람'),
35
+ message: z.string().max(2000).optional().describe('보내며 함께 적을 말(메일과 문서함에 보인다, 2000자까지)'),
36
+ access: z.enum(['read', 'comment', 'write']).optional().describe('받는 사람이 원본에서 할 수 있는 것: read=보기(기본) · comment=댓글 · write=편집. 내 권한까지만'),
37
+ workspace: z.string().optional().describe('경로로 줄 때 찾을 워크스페이스 id (생략하면 설정값)'),
38
+ },
39
+ },
40
+ async ({ node, to, message, access, workspace }) => {
41
+ try {
42
+ const client = makeClient(workspace);
43
+ return jsonResult(await client.sendNode(await resolveNodeId(client, node), { to, message, access }));
44
+ } catch (err) {
45
+ return errorResult(err);
46
+ }
47
+ },
48
+ );
49
+
50
+ server.registerTool(
51
+ 'rootr_list_doc_mail',
52
+ {
53
+ title: 'List my received or sent Rootr documents',
54
+ description:
55
+ "List the caller's document mailbox: box=inbox (documents sent TO the caller's account email — default) or " +
56
+ 'box=sent (documents the caller sent). Summaries only (title, type, sender, recipient, message, sentAt, ' +
57
+ 'readAt, revoked); open one with rootr_read_doc_mail. `unread` is the number of unread received documents. ' +
58
+ 'Page with `cursor` = the previous `nextCursor`. Belongs to the person (their email), not to a workspace. ' +
59
+ 'Each item has `access` (READ/COMMENT/WRITE), `original` (live | trashed | deleted) and, for live received ones, ' +
60
+ '`nodeId`/`workspaceId` of the original.',
61
+ annotations: { readOnlyHint: true },
62
+ inputSchema: {
63
+ box: z.enum(['inbox', 'sent']).optional().describe('inbox=받은 문서(기본), sent=보낸 문서'),
64
+ limit: z.number().int().min(1).max(100).optional().describe('한 번에 몇 개(기본 30)'),
65
+ cursor: z.string().optional().describe('다음 쪽 — 앞 응답의 nextCursor'),
66
+ q: z.string().optional().describe('제목·보낸 사람·받는 사람으로 찾기'),
67
+ },
68
+ },
69
+ async ({ box, limit, cursor, q }) => {
70
+ try {
71
+ return jsonResult(await makeClient().listDocMail({ box, limit, cursor, q }));
72
+ } catch (err) {
73
+ return errorResult(err);
74
+ }
75
+ },
76
+ );
77
+
78
+ server.registerTool(
79
+ 'rootr_read_doc_mail',
80
+ {
81
+ title: 'Read a received or sent Rootr document',
82
+ description:
83
+ 'Open one document from the mailbox (id from rootr_list_doc_mail). Returns the summary plus the CURRENT content ' +
84
+ 'of the original, read-only, in the same shape a public page uses: `contentMd` for documents, `payload.kind` for ' +
85
+ 'the rest (database → `properties` + `rows`; spreadsheet → `data`; …), `children` (items inside — pass one as ' +
86
+ '`node` to read it), `access` (what the recipient may do), `openUrl` (the original in the Rootr editor). ' +
87
+ 'Opening a received one marks it read. Withdrawn → `revoked` true and no content; original deleted → ' +
88
+ '`originalDeleted` true. This works with any of the recipient\'s keys, even one bound to another workspace.',
89
+ annotations: { readOnlyHint: false },
90
+ inputSchema: {
91
+ id: z.string().describe('문서함 항목 id (rootr_list_doc_mail 의 items[].id)'),
92
+ node: z.string().optional().describe('폴더(부모)를 받았을 때 그 아래 노드 id (앞 응답의 children[].id)'),
93
+ },
94
+ },
95
+ async ({ id, node }) => {
96
+ try {
97
+ return jsonResult(await makeClient().getDocMail(id, { node }));
98
+ } catch (err) {
99
+ return errorResult(err);
100
+ }
101
+ },
102
+ );
103
+
104
+ server.registerTool(
105
+ 'rootr_revoke_doc_mail',
106
+ {
107
+ title: 'Withdraw a sent Rootr document',
108
+ description:
109
+ 'Withdraw a document the caller sent (id from rootr_list_doc_mail box=sent). The recipient loses access to the ' +
110
+ 'original immediately (open editors are disconnected), sees only the title and "withdrawn", and the email link ' +
111
+ 'answers 410. Only the sender can do this; it cannot be undone (send it again instead).',
112
+ annotations: { readOnlyHint: false, destructiveHint: true },
113
+ inputSchema: {
114
+ id: z.string().describe('보낸 문서함 항목 id'),
115
+ },
116
+ },
117
+ async ({ id }) => {
118
+ try {
119
+ return jsonResult(await makeClient().revokeDocMail(id));
120
+ } catch (err) {
121
+ return errorResult(err);
122
+ }
123
+ },
124
+ );
125
+
126
+ server.registerTool(
127
+ 'rootr_set_doc_mail_access',
128
+ {
129
+ title: 'Change what the recipient of a sent Rootr document can do',
130
+ description:
131
+ 'Change the access of one recipient of a document the caller sent (id from rootr_list_doc_mail box=sent): ' +
132
+ 'read (view) | comment | write (edit). Only the sender; at most the sender\'s own level on the node. Lowering it ' +
133
+ 'takes effect immediately. No email is sent.',
134
+ annotations: { readOnlyHint: false, destructiveHint: false },
135
+ inputSchema: {
136
+ id: z.string().describe('보낸 문서함 항목 id'),
137
+ access: z.enum(['read', 'comment', 'write']).describe('read=보기 · comment=댓글 · write=편집'),
138
+ },
139
+ },
140
+ async ({ id, access }) => {
141
+ try {
142
+ return jsonResult(await makeClient().setDocMailAccess(id, access));
143
+ } catch (err) {
144
+ return errorResult(err);
145
+ }
146
+ },
147
+ );
148
+
149
+ // ── DOC-MAIL-HOOK — 사람 단위 웹훅 ──────────────────────────────────────
150
+ server.registerTool(
151
+ 'rootr_add_doc_mail_webhook',
152
+ {
153
+ title: 'Get a webhook when documents arrive for me',
154
+ description:
155
+ "Register a webhook on the caller's own account (not a workspace) for their document mailbox. Rootr POSTs JSON to " +
156
+ '`url` on doc_mail.received (a document was sent to the account\'s verified email), doc_mail.revoked (a received one ' +
157
+ 'was withdrawn) and doc_mail.read (a recipient opened one the caller sent). The body has id, title, nodeType, sender, ' +
158
+ 'message, access, apiUrl (GET it with your key for the content) and appUrl — never the document content. Each request ' +
159
+ 'is signed: X-Rootr-Signature: sha256=HMAC_SHA256(secret, raw body). The `secret` is returned ONCE, here — store it. ' +
160
+ 'Only public http(s) URLs (private/loopback addresses are refused). Max 5 per account.',
161
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
162
+ inputSchema: {
163
+ url: z.string().describe('받을 주소(공인 http/https)'),
164
+ events: z.array(z.enum(['doc_mail.received', 'doc_mail.revoked', 'doc_mail.read'])).optional().describe('받을 사건(안 주면 셋 다)'),
165
+ name: z.string().max(100).optional().describe('알아보기 쉬운 이름'),
166
+ },
167
+ },
168
+ async ({ url, events, name }) => {
169
+ try {
170
+ return jsonResult(await makeClient().addDocMailWebhook({ url, events, name }));
171
+ } catch (err) {
172
+ return errorResult(err);
173
+ }
174
+ },
175
+ );
176
+
177
+ server.registerTool(
178
+ 'rootr_list_doc_mail_webhooks',
179
+ {
180
+ title: "List my document webhooks",
181
+ description: "List the caller's document-mailbox webhooks (url, events, active, last delivery status). Secrets are not shown.",
182
+ annotations: { readOnlyHint: true },
183
+ inputSchema: {},
184
+ },
185
+ async () => {
186
+ try {
187
+ return jsonResult(await makeClient().listDocMailWebhooks());
188
+ } catch (err) {
189
+ return errorResult(err);
190
+ }
191
+ },
192
+ );
193
+
194
+ server.registerTool(
195
+ 'rootr_remove_doc_mail_webhook',
196
+ {
197
+ title: 'Remove one of my document webhooks',
198
+ description: 'Delete a document-mailbox webhook of the caller (id from rootr_list_doc_mail_webhooks). Its delivery log goes with it.',
199
+ annotations: { readOnlyHint: false, destructiveHint: true },
200
+ inputSchema: { id: z.string().describe('웹훅 id') },
201
+ },
202
+ async ({ id }) => {
203
+ try {
204
+ return jsonResult(await makeClient().removeDocMailWebhook(id));
205
+ } catch (err) {
206
+ return errorResult(err);
207
+ }
208
+ },
209
+ );
210
+
211
+ server.registerTool(
212
+ 'rootr_test_doc_mail_webhook',
213
+ {
214
+ title: 'Send a test event to one of my document webhooks',
215
+ description:
216
+ 'Queue a signed doc_mail.test delivery to the webhook (re-enables it if it was turned off), then report the latest ' +
217
+ 'deliveries a few seconds later (status, HTTP code, error).',
218
+ annotations: { readOnlyHint: false, openWorldHint: true },
219
+ inputSchema: { id: z.string().describe('웹훅 id') },
220
+ },
221
+ async ({ id }) => {
222
+ try {
223
+ const client = makeClient();
224
+ const queued = await client.testDocMailWebhook(id);
225
+ let deliveries = null;
226
+ for (let i = 0; i < 8; i++) {
227
+ await new Promise((r) => setTimeout(r, 750));
228
+ deliveries = await client.docMailWebhookDeliveries(id);
229
+ const d = deliveries?.items?.find((x) => x.id === queued.deliveryId);
230
+ if (d && d.status !== 'PENDING') break;
231
+ }
232
+ return jsonResult({ ...queued, deliveries: deliveries?.items?.slice(0, 5) ?? [] });
233
+ } catch (err) {
234
+ return errorResult(err);
235
+ }
236
+ },
237
+ );
238
+ }
@@ -109,7 +109,8 @@ export function registerPageTools(server) {
109
109
  title: 'Create a Rootr page (block-assembled web page)',
110
110
  description:
111
111
  'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
112
- 'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
112
+ 'can then be published to the web at a public address (see rootr_publish_node — web publishing of pages needs ' +
113
+ 'the Team plan; on Free the page still works inside the workspace). Use this when someone asks ' +
113
114
  'for a DASHBOARD / report / KPI board over their tables, spreadsheets or logs (46 live chart kinds, numbers stay ' +
114
115
  'current), or for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
115
116
  'Omit `blocks` to start from a hero/text/footer skeleton. ' +
@@ -29,9 +29,13 @@ export function registerPublicTools(server) {
29
29
  description:
30
30
  'Publish ANY Rootr (루터) node at a public read-only URL (rootr.io/p/{slug}) — a DATABASE (visitors get a ' +
31
31
  'searchable table and can clone it into their own workspace), SPREADSHEET, WHITEBOARD, FORM (questions only), ' +
32
- 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED or DOCUMENT. Use this instead of ' +
32
+ 'ISSUE_TRACKER, LOG, CRM, RECORDING (transcript only), SIGNED, DOCUMENT or PAGE. Use this instead of ' +
33
33
  'rootr_publish_document unless you specifically want the document-only extras (skins, edit proposals). ' +
34
34
  'FOLDER cannot be published. ' +
35
+ 'PAGE (the block-assembled web page) can be published only on the Team plan and up — a Free workspace gets ' +
36
+ '403 PLAN_UPGRADE_REQUIRED (nothing is changed; tell the user to upgrade the workspace to Team). Every other type ' +
37
+ 'publishes on Free as before. With `includeChildren` on Free, PAGE nodes underneath are skipped and the response ' +
38
+ 'says how many (`skippedPagesRequireTeam`). ' +
35
39
  'Set `includeChildren: true` to publish everything underneath as well — the WHOLE subtree, grandchildren ' +
36
40
  'included, each getting its own URL and listed on the parent page. Leave it off and only this one node goes ' +
37
41
  'out (its sub-page names do not even appear). Turning it back off, closing the parent, moving a child out, ' +
@@ -90,6 +94,7 @@ export function registerPublicTools(server) {
90
94
  'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
91
95
  '`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
92
96
  'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
97
+ '`{"isPublic": true}` on a PAGE needs the Team plan (Free → 403 PLAN_UPGRADE_REQUIRED); closing it works on any plan. ' +
93
98
  'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
94
99
  'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
95
100
  annotations: { readOnlyHint: false, destructiveHint: false },
@@ -132,6 +137,8 @@ export function registerPublicTools(server) {
132
137
  '`publishable: false` means this type cannot be published at all (FOLDER). ' +
133
138
  '`closed: "PUBLIC_OWN_ROW_PAGE"` (with `isPublic: false` and a `closedReason`) means publishing is switched on but the ' +
134
139
  'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
140
+ '`closed: "PAGE_PUBLISH_REQUIRES_TEAM"` means a PAGE was published but its workspace is on the Free plan, so ' +
141
+ 'visitors see a "not published" screen — upgrading to Team reopens the same URL without publishing again. ' +
135
142
  'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
136
143
  annotations: { readOnlyHint: true, destructiveHint: false },
137
144
  inputSchema: {
package/lib/mcp.js CHANGED
@@ -24,7 +24,8 @@ import { registerAttachmentTools } from './mcp-tools/attachments.js';
24
24
  import { registerSignedDocTools } from './mcp-tools/signed.js';
25
25
  import { registerFeedbackTools } from './mcp-tools/feedback.js';
26
26
  import { registerMemoryTools } from './mcp-tools/memory.js';
27
- import { withRateLimitSink } from './context.js';
27
+ import { registerDocMailTools } from './mcp-tools/doc-mail.js';
28
+ import { withDocMailSink, withRateLimitSink } from './context.js';
28
29
  import { makeClient } from './mcp-tools/shared.js';
29
30
  import { t as tr } from './i18n.js';
30
31
 
@@ -76,6 +77,39 @@ function withQuotaWarnings(server) {
76
77
  }
77
78
 
78
79
 
80
+ /**
81
+ * DOC-MAIL-NOTIFY — 어떤 루터 도구를 부르든, 아직 알리지 않은 **새 받은 문서**가 있으면 응답 끝에 한 줄을 붙인다(창업자 지시
82
+ * 2026-10-20 — 클라이언트와 상관없이 루터를 쓰는 순간 알게). 도구가 부른 API 응답의 머리줄(`X-Rootr-Doc-Mail-New-Items`)을
83
+ * 그릇에 모아 쓴다 — 따로 묻는 호출이 없다. 서버가 키마다·채널(mcp)마다 한 번만 알려 준다(같은 문서를 두 번 알리지 않는다).
84
+ * 문서함 도구 자신(`*doc_mail*`)의 응답에는 붙이지 않는다 — 이미 문서함을 보고 있다.
85
+ */
86
+ function docMailNotice(items) {
87
+ if (!items?.length) return null;
88
+ const shown = items.slice(0, 5);
89
+ const list = shown.map((i) => tr(`「${i.title}」 — ${i.sender} (id: ${i.id})`, `"${i.title}" from ${i.sender} (id: ${i.id})`)).join(', ');
90
+ const more = items.length > shown.length ? tr(` 외 ${items.length - shown.length}건`, ` and ${items.length - shown.length} more`) : '';
91
+ return tr(
92
+ `\n\n📬 새로 받은 문서 (${items.length}): ${list}${more} — rootr_read_doc_mail {id} 로 읽으세요. 사용자에게 알려 주세요.`,
93
+ `\n\n📬 New documents received (${items.length}): ${list}${more} — read with rootr_read_doc_mail {id}. Tell the user.`,
94
+ );
95
+ }
96
+
97
+ function withDocMailNotices(server) {
98
+ const original = server.registerTool.bind(server);
99
+ server.registerTool = (name, spec, handler) =>
100
+ original(name, spec, async (...args) => {
101
+ const sink = { channel: 'mcp', items: [] };
102
+ const result = await withDocMailSink(sink, () => handler(...args));
103
+ if (/doc_mail/.test(name)) return result;
104
+ const notice = docMailNotice(sink.items);
105
+ if (!notice || !result || !Array.isArray(result.content)) return result;
106
+ const last = [...result.content].reverse().find((c) => c && c.type === 'text');
107
+ if (!last) return { ...result, content: [...result.content, { type: 'text', text: notice.trim() }] };
108
+ return { ...result, content: result.content.map((c) => (c === last ? { ...c, text: `${c.text}${notice}` } : c)) };
109
+ });
110
+ return server;
111
+ }
112
+
79
113
  /**
80
114
  * MEM-001 — **기억을 눈앞에 놓는다. 이 기능의 성패가 여기 달렸다.**
81
115
  *
@@ -224,6 +258,14 @@ export function buildMcpServer({ name, version }) {
224
258
  'rootr_request_signing_code first and ASK THE HUMAN for the 6 digits that arrive in their mail — ' +
225
259
  'that code is the only evidence a person (not an agent) pressed sign; ' +
226
260
  'FOLDER = grouping; LOG store = typed event table whose relation fields auto-build a lineage graph (metrics, incidents, pipeline events — use the rootr_*_log_* tools).\n\n' +
261
+ 'Sending a document to someone by email (문서 보내기 · 문서함): rootr_send_document gives 1-20 email addresses ' +
262
+ 'access to the ORIGINAL node (not a copy) at access read|comment|write — they see live edits, and with write ' +
263
+ 'their edits land in the original; an account on that email opens it from its inbox, others read it via the ' +
264
+ 'email link after typing their address; rootr_list_doc_mail (box inbox|sent) / rootr_read_doc_mail / ' +
265
+ 'rootr_set_doc_mail_access / rootr_revoke_doc_mail cover the mailbox. Only send to addresses the user gave you. ' +
266
+ 'When any tool result ends with "📬 New documents received", tell the user right away (title and sender) and offer to ' +
267
+ 'open them with rootr_read_doc_mail. rootr_add_doc_mail_webhook makes Rootr POST doc_mail.received / revoked / read ' +
268
+ 'to the user\'s own URL (for their automations).\n\n' +
227
269
  'Scaffolding discipline — treat "set up a workspace for X" exactly like "build me this app": you ask a ' +
228
270
  'few clarifying questions, write it, compile it, run the tests, leave a README, and report. The user ' +
229
271
  'should only have to type that one sentence; everything else finishes inside the conversation. Never ask ' +
@@ -263,6 +305,7 @@ export function buildMcpServer({ name, version }) {
263
305
  // 한도 경고가 그 뒤에 붙는다 — 경고는 언제나 맨 끝에 있어야 눈에 띈다.
264
306
  withMemoryRecall(server);
265
307
  withQuotaWarnings(server);
308
+ withDocMailNotices(server);
266
309
 
267
310
  registerDocumentTools(server);
268
311
  registerTemplateTools(server);
@@ -286,6 +329,7 @@ export function buildMcpServer({ name, version }) {
286
329
  registerSignedDocTools(server);
287
330
  registerFeedbackTools(server);
288
331
  registerMemoryTools(server);
332
+ registerDocMailTools(server);
289
333
 
290
334
  return server;
291
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.23.3",
3
+ "version": "0.25.0",
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": {