rootr-cli 0.22.0 → 0.23.3

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
@@ -192,6 +192,7 @@ rootr rm /notes/todo.md --yes # 확인 없이 (스크립트용)
192
192
  ```bash
193
193
  rootr ws # 목록 (현재 워크스페이스에 *, 못 여는 곳은 표시)
194
194
  rootr use "영업팀" # 전환 — id · 이름 · rootr.io 주소 아무거나
195
+ rootr emblem ./logo.png # 워크스페이스 엠블럼(사이드바 로고, 관리자) — --clear 면 지움
195
196
  ```
196
197
 
197
198
  `rootr ws` 출력은 `id\tname` 목록입니다. 등록된 키가 워크스페이스 전용 키뿐이면 목록에는
@@ -401,6 +402,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
401
402
  | `rootr_create_workspace` | **계정 키(PAT) + workspaces:create** | 짓기 3단계 — 계획된 트리로 새 워크스페이스 생성(검사 결과도 같이 돌려준다) |
402
403
  | `rootr_scaffold_apply` | docs:write | 짓기 3단계 — 기존 워크스페이스에 트리 적용(검사 결과도 같이 돌려준다) |
403
404
  | `rootr_delete_workspace` | **계정 키(PAT) + OWNER** | 짓다 실패해 남은 **빈 워크스페이스 치우기**. 이름을 그대로 다시 적어야 지워진다(되돌릴 수 없음) |
405
+ | `rootr_set_workspace_emblem` | docs:write + **ADMIN** | 워크스페이스 **엠블럼**(사이드바 배지·전환기 로고) 바꾸기 — 이미지 파일·이미 올린 첨부 id·`clear` 중 하나 |
404
406
 
405
407
  스캐폴딩은 **plan → 물어보기 → 설계 → review → 고치기 → apply** 한 바퀴입니다.
406
408
  `review` 를 건너뛰면 "얇은 마크다운 파일 묶음"이 됩니다. CLI 로도 같은 세 단계를 쓸 수 있습니다:
@@ -653,6 +655,11 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
653
655
  | `rootr_set_booking` | 예약 켜기/끄기와 규칙(요일·시간·길이·여유·며칠 전·하루 최대·쉬는 날·**막는 시간**·확정 방식·물어볼 칸·**결과 메일**·**구글 밋**·**방문자 직접 취소**·**방문자 직접 시간 변경**·**미리 알림**) |
654
656
  | `rootr_booking_slots` | 방문자에게 실제로 보이는 빈 시간(규칙이 뜻대로 걸렸는지 확인) |
655
657
 
658
+ 여유 시간은 둘입니다. `bufferMin`은 이 표로 잡힌 **예약** 앞뒤(CLI `--buffer`), `calendarBufferMin`은
659
+ 연결한 구글 캘린더에 **이미 있던 일정** 앞뒤(CLI `--calendar-buffer 60`) — 밖에서 하는 미팅의 이동 시간
660
+ 같은 것입니다. 예: 구글 쪽 60분·예약 0분이면 구글에 있는 14~15시 미팅 때문에 13~16시가 닫히고, 온라인
661
+ 상담 예약끼리는 붙여 잡힙니다. 이 표가 구글에 적은 예약은 "이미 있던 일정"으로 세지 않습니다.
662
+
656
663
  방문자 화면은 **방문자 자신의 시간대**로 빈 시간을 보여주고(고칠 수도 있습니다), 서버와
657
664
  주고받는 값은 끝까지 주인 시간대입니다. 확정·이동 메일에는 **일정 초대장(.ics)** 이 붙어
658
665
  방문자 캘린더에 바로 들어가고, 시간을 옮기면 그 일정이 고쳐지고 취소하면 사라집니다.
@@ -822,6 +829,13 @@ HTML을 짜는 것이 아니라 **정해진 블록 15종을 골라 쌓습니다*
822
829
  화면 한 장이 되고(숫자 `height`를 같이 주면 그 값이 하한), `bleed: true`와 함께 쓰면 화면을 꽉 채우는
823
830
  섹션이 됩니다. 맨 위 이름 줄은 `config.navEnabled: false`로 끕니다.
824
831
 
832
+ **메타(페이스북·인스타그램) 광고 성과 재기** — `config.metaPixelId`에 픽셀 ID(숫자)를 넣으면 발행한
833
+ 페이지가 방문(`PageView`)과 `booking` 블록의 예약 완료(`Schedule`)를 그 픽셀로 보냅니다. 편집 화면에서는
834
+ 싣지 않고, `""`를 보내면 꺼집니다. `config`는 고칠 때 통째로 바뀌니 다른 값도 같이 보내세요. 어느 광고에서
835
+ 온 예약인지 표에서 바로 보려면 예약 표에 유입 경로 기록을 켭니다(`rootr booking set <표> --track-source on`,
836
+ MCP는 `rootr_set_booking`의 `trackSource: true`). "유입 경로" 칸에 방문자가 들어온 주소의 꼬리표가
837
+ `source=meta, medium=paid, campaign=ax, content=3-docs`처럼 적힙니다.
838
+
825
839
  **스크롤 등장 효과** — 어느 블록이나 `reveal: "fade"`(서서히) 또는 `"fade-up"`(아래에서 위로)을 주면
826
840
  (기본 `"none"`), 발행한 페이지에서 그 블록이 화면에 들어올 때 한 번 나타납니다(큰 제목은 제목 → 부제 →
827
841
  버튼 순서로). `custom`에 `reveal`을 주면 **틀 안의 CSS 애니메이션도 그 순간에 시작**합니다 — 틀을 블록이
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, **192 tools** (see [MCP tools](#mcp-tools))
14
+ - `rootr mcp` — stdio MCP server, **193 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
 
@@ -180,6 +180,7 @@ as-is. `write --if-match` returns 412 if the document changed in the meantime.
180
180
  ```bash
181
181
  rootr ws # id<TAB>name
182
182
  rootr use "Sales" # switch — id, name, or rootr.io URL
183
+ rootr emblem ./logo.png # workspace emblem (sidebar logo, admin) — --clear removes it
183
184
  rootr ask "why did latency go up after last week's deploy?"
184
185
  rootr ask "why did payment failures rise?" --workspace ws_123
185
186
  ```
@@ -407,7 +408,7 @@ is refused and says when the grace was used.
407
408
 
408
409
  ## MCP tools
409
410
 
410
- `rootr mcp` speaks MCP over stdio. 192 tools:
411
+ `rootr mcp` speaks MCP over stdio. 193 tools:
411
412
 
412
413
  | Group | Count | Tools |
413
414
  |---|---|---|
@@ -417,7 +418,7 @@ is refused and says when the grace was used.
417
418
  | Signed documents & research notes | 14 | create, sign, revise, reject, reopen, approval lines, signing codes, ledger verification, research-note preset |
418
419
  | Forms | 10 | create, read, update, responses, share links, collectors |
419
420
  | Presentations & images | 10 | decks, slides, diagram slides, image generation and background removal |
420
- | Workspaces & scaffolding | 9 | list, create, delete, switch, plan / review / apply a document tree, health check |
421
+ | Workspaces & scaffolding | 10 | list, create, delete, switch, set the emblem (sidebar logo), plan / review / apply a document tree, health check |
421
422
  | Pages | 6 | HTML page nodes: create, read, update, append and patch blocks, live data |
422
423
  | Spreadsheets | 7 | create, read, update, per-sheet create/update/delete, patch cells & formulas |
423
424
  | Recordings | 7 | create, upload & download audio, transcribe, summarize, rename speakers |
@@ -433,6 +434,10 @@ is refused and says when the grace was used.
433
434
  | Root-cause Q&A | 1 | ask the workspace knowledge graph, answer with citations |
434
435
  | Feedback | 1 | send feedback to the Rootr team |
435
436
 
437
+ Two gaps keep breathing room: `bufferMin` around bookings made through the table (CLI `--buffer`) and
438
+ `calendarBufferMin` around events already on the connected Google Calendar, e.g. travel time to an outside
439
+ meeting (CLI `--calendar-buffer 60`). Bookings the table wrote to Google count as bookings, not existing events.
440
+
436
441
  Open hours can be narrowed by blocked times — one afternoon, lunch every day, Monday mornings, or a whole
437
442
  weekday — with `blockedTimes` on `rootr_set_booking` (CLI: `--block-time "daily 12:00-13:00, mon 09:00-11:00, fri"`).
438
443
 
@@ -478,6 +483,14 @@ viewport tall on every device (a numeric `height` given with it is the floor), s
478
483
  `bleed: true` + `minHeight: "screen"` is a section that fills the screen. Pair them with
479
484
  `config.navEnabled: false`.
480
485
 
486
+ To **measure Meta (Facebook/Instagram) ads**, set `config.metaPixelId` to the numeric Pixel ID: the
487
+ published page then reports `PageView` on load and `Schedule` when a visitor completes a booking in a
488
+ `booking` block. The editor never loads the pixel; send `""` to turn it off. `config` is replaced as a
489
+ whole on update, so resend the other keys with it. To see which ad each booking came from in the table
490
+ itself, turn on source tracking on the booking table (`rootr booking set <table> --track-source on`,
491
+ or `trackSource: true` in `rootr_set_booking`): a "유입 경로" column is filled from the visitor's
492
+ address, e.g. `source=meta, medium=paid, campaign=ax, content=3-docs`.
493
+
481
494
  For **scroll-in animation**, any block takes `reveal: "fade"` or `"fade-up"` (default `"none"`): on
482
495
  the published page it appears once, when it scrolls into view (a hero staggers title → subtitle →
483
496
  button). On a `custom` block `reveal` also **starts the CSS animations inside the frame at that
@@ -536,7 +549,10 @@ Decks are authored with `rootr_create_presentation`, `rootr_read_presentation`,
536
549
  `rootr_append_presentation_slides`, `rootr_update_presentation_slide`,
537
550
  `rootr_reorder_presentation_slides` and `rootr_update_presentation`. Artwork can be produced with
538
551
  `rootr_generate_image` (image from a sentence) and `rootr_remove_image_background` — both spend
539
- AI credits. The same thing works over plain REST with the same API key (`x-api-key`,
552
+ AI credits. Slides may also carry `videos[]` (uploaded mp4/webm ≤75MB, https video files, YouTube/Vimeo links;
553
+ Team plan and above), and any element in slide `html` can animate with `data-anim="fade-up"` (also fade,
554
+ fade-down, fly-left, fly-right, zoom, pop, blur, and looping pulse/float/spin/blink/shake; timing via
555
+ `--anim-dur`/`--anim-delay` inline CSS variables; all plans). The same thing works over plain REST with the same API key (`x-api-key`,
540
556
  `docs:write`):
541
557
 
542
558
  ```bash
package/bin/rootr.js CHANGED
@@ -28,6 +28,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
28
28
  rootr restore <id> 지운 것 되돌리기 (화면의 Ctrl+Z 와 같은 일 — 경로 아닌 id)
29
29
  rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
30
30
  rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
31
+ rootr emblem <이미지 파일> | --clear 워크스페이스 엠블럼(사이드바 로고) 바꾸기·지우기 (관리자)
31
32
  rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
32
33
  rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
33
34
  rootr db create [--file <spec.json>] [--parent <path|id>] 표(DATABASE) 생성 — 컬럼 순서 그대로, 행까지 한 번에
@@ -134,6 +135,7 @@ usage:
134
135
  rootr restore <id> undo a delete (same as Ctrl+Z in the app — id, not path)
135
136
  rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] upload a file (+ insert it into a document)
136
137
  rootr ws my workspaces (id\tname; unreachable ones are marked)
138
+ rootr emblem <image file> | --clear set or clear the workspace emblem (sidebar logo; admin)
137
139
  rootr use <id|name|url> switch the active workspace (remembers its key too)
138
140
  rootr ask <question> [--workspace <w>] GraphRAG question answering (RCA), prints citations
139
141
  rootr db create [--file <spec.json>] [--parent <path|id>] create a DATABASE — columns in order, rows included
@@ -597,6 +599,30 @@ async function cmdAttach(args) {
597
599
  console.log(markdown);
598
600
  }
599
601
 
602
+ /** WS-EMBLEM — 설정 → 일반 → 엠블럼과 같은 일. 이미지를 올려 걸거나(--clear 면) 이름 첫 글자 배지로 되돌린다. */
603
+ async function cmdEmblem(args) {
604
+ const filePath = args._[0];
605
+ if (!filePath && !args.clear) throw new Error(usage(`rootr emblem <image file> | --clear`));
606
+ const cfg = loadConfig();
607
+ const workspace = args.workspace || cfg.workspace;
608
+ if (!workspace) {
609
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
610
+ hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
611
+ });
612
+ }
613
+ const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
614
+ let id = null;
615
+ if (filePath) {
616
+ const mimeType = args.mime || MIME_BY_EXT[extname(filePath).toLowerCase()];
617
+ if (!mimeType || !mimeType.startsWith('image/')) {
618
+ throw new RootrApiError(tr('엠블럼은 이미지 파일만 됩니다 (png·jpg·webp·svg·gif).', 'The emblem must be an image file (png, jpg, webp, svg, gif).'));
619
+ }
620
+ ({ id } = await client.uploadAttachment(workspace, { filename: basename(filePath), mimeType, data: readFileSync(filePath) }));
621
+ }
622
+ await client.setWorkspaceEmblem(workspace, id);
623
+ console.log(id ? tr(`엠블럼을 바꿨습니다: ${client.attachmentRawUrl(id)}`, `Emblem set: ${client.attachmentRawUrl(id)}`) : tr('엠블럼을 지웠습니다 (이름 첫 글자 배지).', 'Emblem cleared (initial-letter badge).'));
624
+ }
625
+
600
626
  async function cmdWs() {
601
627
  const cfg = loadConfig();
602
628
  const candidates = candidateClients(cfg);
@@ -1917,24 +1943,24 @@ async function cmdBooking(args) {
1917
1943
  usage(tr(`rootr booking show <path|id>`, `rootr booking show <path|id>`)) +
1918
1944
  tr(
1919
1945
  `\n rootr booking set <path|id> [--on|--off] [--days 1,2,3,4,5] [--hours 10:00-18:00]\n` +
1920
- ` [--duration 30] [--buffer 10] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1946
+ ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1921
1947
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1922
1948
  ` [--block-time "daily 12:00-13:00=점심, mon 09:00-11:00, fri"]\n` +
1923
1949
  ` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
1924
1950
  ` [--self-move on|off] [--remind 1d,1h (안 보내려면 --remind off)]\n` +
1925
- ` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5]\n` +
1951
+ ` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1926
1952
  ` [--after https://example.com/thanks]\n` +
1927
1953
  ` [--hosts "김상담=a@acme.com,이데모=b@acme.com"] [--assign roundrobin|collective|off]\n` +
1928
1954
  ` [--ask "연락처:required,상담 종류"] [--message "안내 문구"]\n` +
1929
1955
  ` [--gcal-connection <id>] [--gcal-busy <캘린더id,...>] [--gcal-write <캘린더id|none>]\n` +
1930
1956
  ` rootr booking slots <path|id> [--from 2026-10-01] [--to 2026-10-31] [--type <종류 이름표>]\n`,
1931
1957
  `\n rootr booking set <path|id> [--on|--off] [--days 1,2,3,4,5] [--hours 10:00-18:00]\n` +
1932
- ` [--duration 30] [--buffer 10] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1958
+ ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1933
1959
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1934
1960
  ` [--block-time "daily 12:00-13:00=lunch, mon 09:00-11:00, fri"]\n` +
1935
1961
  ` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
1936
1962
  ` [--self-move on|off] [--remind 1d,1h (--remind off to stop)]\n` +
1937
- ` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5]\n` +
1963
+ ` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1938
1964
  ` [--after https://example.com/thanks]\n` +
1939
1965
  ` [--hosts "Sam=a@acme.com,Lee=b@acme.com"] [--assign roundrobin|collective|off]\n` +
1940
1966
  ` [--ask "Phone:required,Topic"] [--message "note for visitors"]\n` +
@@ -1958,7 +1984,7 @@ async function cmdBooking(args) {
1958
1984
  const days = (b.weekdays || []).map((d) => DAY_NAMES[d]).join('·');
1959
1985
  console.log(tr(`방문자 일정 등록: 켜짐`, `Visitor booking: on`));
1960
1986
  console.log(tr(` 받는 때: ${days} ${b.startTime}~${b.endTime} (${b.timezone})`, ` when: ${days} ${b.startTime}-${b.endTime} (${b.timezone})`));
1961
- console.log(tr(` 한 번 길이: ${b.durationMin}분 · 사이 여유 ${b.bufferMin}분`, ` slot: ${b.durationMin} min · gap ${b.bufferMin} min`));
1987
+ console.log(tr(` 한 번 길이: ${b.durationMin}분 · 예약 앞뒤 여유 ${b.bufferMin}분 · 구글 기존 일정 앞뒤 여유 ${b.calendarBufferMin ?? b.bufferMin}분`, ` slot: ${b.durationMin} min · gap around bookings ${b.bufferMin} min · gap around existing Google events ${b.calendarBufferMin ?? b.bufferMin} min`));
1962
1988
  console.log(tr(` ${b.minNoticeHours}시간 전까지 · ${b.maxDaysAhead}일 뒤까지 · 하루 ${b.maxPerDay || '제한 없음'}`, ` ${b.minNoticeHours}h notice · up to ${b.maxDaysAhead} days · per day ${b.maxPerDay || 'unlimited'}`));
1963
1989
  console.log(tr(` 확정: ${b.approval === 'manual' ? '승인 후' : '바로'}`, ` confirm: ${b.approval === 'manual' ? 'after approval' : 'instantly'}`));
1964
1990
  if (b.blockedDates?.length) console.log(tr(` 쉬는 날: ${b.blockedDates.join(', ')}`, ` days off: ${b.blockedDates.join(', ')}`));
@@ -1976,6 +2002,7 @@ async function cmdBooking(args) {
1976
2002
  if (b.allowGuests && b.maxGuests > 0) {
1977
2003
  console.log(tr(` 손님 함께 부르기: 최대 ${b.maxGuests}명`, ` guests: up to ${b.maxGuests}`));
1978
2004
  }
2005
+ if (b.trackSource) console.log(tr(' 유입 경로 기록: 켜짐', ' source tracking: on'));
1979
2006
  if (b.redirectUrl) console.log(tr(` 잡은 뒤 보낼 곳: ${b.redirectUrl}`, ` after booking: ${b.redirectUrl}`));
1980
2007
  if (b.allowVisitorCancel === false) console.log(tr(' 방문자 직접 취소: 꺼짐', ' visitor self-cancel: off'));
1981
2008
  if (b.allowVisitorReschedule === false) console.log(tr(' 방문자 직접 시간 변경: 꺼짐', ' visitor reschedule: off'));
@@ -2043,6 +2070,8 @@ async function cmdBooking(args) {
2043
2070
  }
2044
2071
  if (args.duration !== undefined) body.durationMin = Number(args.duration);
2045
2072
  if (args.buffer !== undefined) body.bufferMin = Number(args.buffer);
2073
+ // 구글 캘린더에 이미 있던 일정 앞뒤 여유(이동 시간 등). --buffer 는 이 표로 잡힌 예약 앞뒤.
2074
+ if (args['calendar-buffer'] !== undefined) body.calendarBufferMin = Number(args['calendar-buffer']);
2046
2075
  if (args.notice !== undefined) body.minNoticeHours = Number(args.notice);
2047
2076
  if (args.ahead !== undefined) body.maxDaysAhead = Number(args.ahead);
2048
2077
  if (args['per-day'] !== undefined) body.maxPerDay = Number(args['per-day']);
@@ -2054,6 +2083,7 @@ async function cmdBooking(args) {
2054
2083
  if (args['self-move'] !== undefined) body.allowVisitorReschedule = args['self-move'] !== 'off' && args['self-move'] !== false;
2055
2084
  if (args['guests'] !== undefined) body.allowGuests = args['guests'] !== 'off' && args['guests'] !== false;
2056
2085
  if (args['max-guests'] !== undefined) body.maxGuests = Number(args['max-guests']);
2086
+ if (args['track-source'] !== undefined) body.trackSource = args['track-source'] !== 'off' && args['track-source'] !== false;
2057
2087
  if (args['after'] !== undefined) body.redirectUrl = String(args['after'] === true ? '' : args['after']).trim();
2058
2088
  if (args['assign'] !== undefined) {
2059
2089
  const a = String(args['assign']).trim().toLowerCase();
@@ -3072,6 +3102,8 @@ async function main() {
3072
3102
  return cmdRestore(args);
3073
3103
  case 'ws':
3074
3104
  return cmdWs(args);
3105
+ case 'emblem':
3106
+ return cmdEmblem(args);
3075
3107
  case 'ask':
3076
3108
  return cmdAsk(args);
3077
3109
  case 'config':
package/lib/client.js CHANGED
@@ -757,6 +757,16 @@ export class RootrClient {
757
757
  * 이름을 그대로 다시 적게 하는 것은 서버가 요구하는 안전장치다 — 지울 것을
758
758
  * 정말 보고 있는지 확인한다.
759
759
  */
760
+ /**
761
+ * WS-EMBLEM — set (attachment id) or clear (null) the workspace emblem shown in the
762
+ * sidebar badge and workspace switcher. The server only accepts the relative raw path
763
+ * of an image attachment already uploaded to this same workspace.
764
+ */
765
+ async setWorkspaceEmblem(workspaceId, attachmentId) {
766
+ const iconUrl = attachmentId ? `/api/v1/attachments/${encodeURIComponent(attachmentId)}/raw` : null;
767
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}`, { method: 'PATCH', body: { iconUrl } });
768
+ }
769
+
760
770
  async deleteWorkspace({ workspaceId, confirmName }) {
761
771
  return this.request(`/workspaces/${workspaceId}`, {
762
772
  method: 'DELETE',
@@ -82,7 +82,10 @@ export function registerAttachmentTools(server) {
82
82
  '`markdown` is ready to paste into a document body (![alt](url)). For the common case of inserting an ' +
83
83
  'image straight into a document, prefer rootr_attach_image_to_document instead. For a large file (or ' +
84
84
  'over the remote connector, where filePath is not available), use rootr_create_upload_url → PUT → ' +
85
- 'rootr_complete_upload instead of inlining the bytes as base64Data.',
85
+ 'rootr_complete_upload instead of inlining the bytes as base64Data. ' +
86
+ 'Video files (mp4/webm/mov, up to 75MB) are allowed; to put one on a slide, upload it with nodeId = the deck and add it to the slide\'s ' +
87
+ '`videos[]` (Team plan and above for decks). Videos play via signed URLs (`vt=`) that the server adds when it returns the slide — a bare /raw link ' +
88
+ 'opened from another website is blocked.',
86
89
  annotations: { readOnlyHint: false, destructiveHint: false },
87
90
  inputSchema: uploadInputSchema,
88
91
  },
@@ -154,6 +157,60 @@ export function registerAttachmentTools(server) {
154
157
  },
155
158
  );
156
159
 
160
+ // 엠블럼은 워크스페이스에 붙는 것이라 노드 칸(nodeId)은 받지 않는다.
161
+ const { nodeId: _unusedNodeId, ...emblemUploadSchema } = uploadInputSchema;
162
+ // WS-EMBLEM — 화면(설정 → 일반 → 엠블럼)에서만 되던 일을 에이전트도 하게. 새 파일을 주면
163
+ // 올린 뒤 걸고, 이미 올린 첨부 id 를 주면 그것을 걸고, clear 면 이름 첫 글자 배지로 되돌린다.
164
+ server.registerTool(
165
+ 'rootr_set_workspace_emblem',
166
+ {
167
+ title: 'Set or clear the workspace emblem (logo)',
168
+ description:
169
+ 'Set the Rootr (루터) workspace emblem — the small square logo shown in the sidebar badge and the ' +
170
+ 'workspace switcher for every member. Give ONE of: an image file (filePath or base64Data, with filename ' +
171
+ '+ mimeType — it is uploaded to this workspace first), `attachmentId` of an image already uploaded to ' +
172
+ 'this workspace (e.g. from rootr_upload_attachment / rootr_complete_upload), or `clear: true` to go back ' +
173
+ 'to the default initial-letter badge. Requires ADMIN on the workspace. A square PNG/SVG/WebP of 256px+ ' +
174
+ 'looks best; it is shown cropped to a rounded square.',
175
+ annotations: { readOnlyHint: false, destructiveHint: false },
176
+ inputSchema: {
177
+ ...emblemUploadSchema,
178
+ filename: emblemUploadSchema.filename.optional(),
179
+ mimeType: emblemUploadSchema.mimeType.optional(),
180
+ attachmentId: z.string().optional().describe('Id of an image attachment already in this workspace'),
181
+ clear: z.boolean().optional().describe('true = remove the emblem (back to the initial-letter badge)'),
182
+ },
183
+ },
184
+ async ({ filename, mimeType, filePath, base64Data, attachmentId, clear, workspace }) => {
185
+ try {
186
+ const ws = requireWorkspace(workspace);
187
+ const client = makeClient(ws);
188
+ const given = [Boolean(filePath || base64Data), Boolean(attachmentId), Boolean(clear)].filter(Boolean).length;
189
+ if (given !== 1) {
190
+ throw new RootrApiError('파일(filePath/base64Data), attachmentId, clear 중 하나만 주세요.');
191
+ }
192
+ let id = null;
193
+ if (attachmentId) {
194
+ if (!ATTACHMENT_ID.test(attachmentId)) throw new RootrApiError(`첨부 id 모양이 아닙니다: ${attachmentId}`);
195
+ id = attachmentId;
196
+ } else if (!clear) {
197
+ if (!filename || !mimeType) throw new RootrApiError('파일을 올릴 때는 filename 과 mimeType 이 필요합니다.');
198
+ if (!mimeType.startsWith('image/')) throw new RootrApiError('엠블럼은 이미지(image/*)만 됩니다.');
199
+ const data = readAttachmentBytes({ filePath, base64Data });
200
+ ({ id } = await client.uploadAttachment(ws, { filename, mimeType, data }));
201
+ }
202
+ const updated = await client.setWorkspaceEmblem(ws, id);
203
+ return jsonResult({
204
+ workspaceId: ws,
205
+ iconUrl: updated?.iconUrl ?? null,
206
+ emblemUrl: id ? client.attachmentRawUrl(id) : null,
207
+ });
208
+ } catch (err) {
209
+ return errorResult(err);
210
+ }
211
+ },
212
+ );
213
+
157
214
  // ISSUE-222 — rootr_upload_attachment/rootr_attach_image_to_document require
158
215
  // the full file as base64Data over the remote connector (it can't read
159
216
  // filePath), which breaks down for larger images. This gives the caller a
@@ -119,7 +119,18 @@ export function registerBookingTools(server) {
119
119
  .number()
120
120
  .optional()
121
121
  .describe('Length of one booking in minutes: 15, 20, 30, 45, 60, 90, 120, 180 or 240.'),
122
- bufferMin: z.number().optional().describe('Gap kept before AND after each booking, in minutes.'),
122
+ bufferMin: z
123
+ .number()
124
+ .optional()
125
+ .describe('Gap kept before AND after each booking made through this table, in minutes (0-240).'),
126
+ calendarBufferMin: z
127
+ .number()
128
+ .optional()
129
+ .describe(
130
+ 'Gap kept before AND after every event already on the connected Google Calendar (travel time etc.), in minutes (0-240). ' +
131
+ 'Separate from bufferMin: e.g. 60 here and 0 in bufferMin blocks an hour around outside meetings while online bookings may sit back to back. ' +
132
+ 'Bookings this table wrote to Google are not "existing events" — they get bufferMin. If never set, it follows bufferMin.',
133
+ ),
123
134
  minNoticeHours: z.number().optional().describe('No bookings closer than this many hours from now.'),
124
135
  maxDaysAhead: z.number().optional().describe('How far ahead visitors may book, in days.'),
125
136
  maxPerDay: z.number().optional().describe('Max bookings per day. 0 = unlimited.'),
@@ -209,6 +220,15 @@ export function registerBookingTools(server) {
209
220
  .optional()
210
221
  .describe('Let the visitor add other email addresses. Guests get the same emails and the same calendar invite.'),
211
222
  maxGuests: z.number().int().optional().describe('How many guests a visitor may add (default 5). 0 turns it off.'),
223
+ trackSource: z
224
+ .boolean()
225
+ .optional()
226
+ .describe(
227
+ 'Record where each booking came from. Adds a text column ("유입 경로" unless you pass sourceLabel) and fills it ' +
228
+ 'from the address the visitor arrived on, e.g. "source=meta, medium=paid, campaign=ax, content=3-docs" ' +
229
+ '(utm_* first, else the ad click id, else the referring site). Left empty when nothing is known.',
230
+ ),
231
+ sourceLabel: z.string().optional().describe('Name for the column trackSource creates (default "유입 경로").'),
212
232
  redirectUrl: z
213
233
  .string()
214
234
  .optional()
@@ -94,8 +94,10 @@ const BLOCK_GUIDE =
94
94
  'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
95
95
  'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
96
96
  'not published — so link to nodes rather than pasting addresses.\n' +
97
- 'CONFIG: {theme, accentColor?, navEnabled?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
- 'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent. ' +
97
+ 'CONFIG: {theme, accentColor?, navEnabled?, metaPixelId?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
+ 'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent.' +
99
+ ' metaPixelId is the numeric Meta (Facebook/Instagram) Pixel ID: when set, the PUBLISHED page reports PageView and, when a visitor books through a booking block, Schedule to that pixel (never in the editor); send "" to turn it off. ' +
100
+ 'config is replaced as a whole on update, so resend the keys you want to keep. ' +
99
101
  'Every published page carries the Rootr signature at the bottom.\n' +
100
102
  'Block ids are optional — omit them and they are generated. Keep the ids you get back if you want to edit one block later.';
101
103
 
@@ -129,7 +131,7 @@ export function registerPageTools(server) {
129
131
  config: z
130
132
  .record(z.unknown())
131
133
  .optional()
132
- .describe('{theme, accentColor?, navEnabled?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight'),
134
+ .describe('{theme, accentColor?, navEnabled?, metaPixelId?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight; metaPixelId: numeric Meta Pixel ID'),
133
135
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
134
136
  },
135
137
  },
@@ -211,7 +213,7 @@ export function registerPageTools(server) {
211
213
  .array(z.record(z.unknown()))
212
214
  .optional()
213
215
  .describe('COMPLETE blocks array to set — any block you omit is deleted'),
214
- config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?}'),
216
+ config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?, metaPixelId?} — replaces the whole config'),
215
217
  },
216
218
  },
217
219
  async ({ pageId, blocks, config }) => {
@@ -24,6 +24,8 @@ const AUTHORING_GUIDE =
24
24
  'placement does not draw the image. For `images[]`, x/y/w/h give freeform PPT-style placement in 1280x720 canvas ' +
25
25
  'coords (drawn only when both x and y are set). ALWAYS add an `alt` caption to every image — alt text is part of the graph text spine, so a captioned image ' +
26
26
  'connects into the knowledge graph while an uncaptioned one does not.\n' +
27
+ 'Videos (Team plan and above — a Free workspace gets 403 PLAN_UPGRADE_REQUIRED when it ADDS a video; existing ones stay editable/removable): `videos[]` (max 20 per slide) holds {id?, src, title?, poster?, x?, y?, w?, h?} in 1280x720 canvas coords (default 640x360 centred). `src` is an uploaded video file URL (upload mp4/webm ≤75MB via rootr_upload_attachment with nodeId = this deck, then use /api/v1/attachments/{id}/raw), an https video-file URL, or a YouTube/Vimeo link; `poster` is an image URL (thumbnail). `title` is a caption and joins the graph text spine. Slide `html` may instead contain <video src="…/v1/attachments/{id}/raw" controls>, <div data-video-embed="https://www.youtube.com/watch?v=…">, or a YouTube/Vimeo <iframe>. Uploaded videos play through short-lived signed URLs (`vt=` query) the server adds when it returns the slide — never write `vt=` yourself; it is stripped on save.\n' +
28
+ 'Animations (all plans): in slide `html`, add data-anim="<effect>" to any element to animate it when the slide opens in view/present mode. Entrance effects: fade, fade-up, fade-down, fly-left, fly-right, zoom, pop, blur. Looping effects: pulse, float, spin, blink, shake. Duration/delay go in inline CSS variables, e.g. <div data-anim="fade-up" style="--anim-delay:.4s;--anim-dur:.8s">; stagger delays to set the order. Thumbnails and PDF/PPTX exports show the finished state. Your own @keyframes/animation CSS in the html also plays.\n' +
27
29
  'Use kind to mark each slide\'s role: "cover" (deck title slide), "section" (chapter divider), "content" (body slide, ' +
28
30
  'the default), "closing" (last slide / call to action).\n' +
29
31
  'Page numbers: "content" and "section" slides automatically show a page number that follows the slide order (it ' +
@@ -70,6 +72,19 @@ const imageShape = z
70
72
  })
71
73
  .describe('A slide image. Set src (uploaded/https/data: URI) or prompt (AI-generate); always add an alt caption.');
72
74
 
75
+ const videoShape = z
76
+ .object({
77
+ id: z.string().optional().describe('Stable video id. Auto-generated by the editor if omitted.'),
78
+ src: z.string().describe('Uploaded video URL (/api/v1/attachments/{id}/raw), https video file URL, or a YouTube/Vimeo link'),
79
+ title: z.string().optional().describe('Caption — part of the graph text spine'),
80
+ poster: z.string().optional().describe('Thumbnail image URL for file videos'),
81
+ x: z.number().optional().describe('Left in 1280x720 canvas px'),
82
+ y: z.number().optional().describe('Top in 1280x720 canvas px'),
83
+ w: z.number().optional().describe('Width px (default 640)'),
84
+ h: z.number().optional().describe('Height px (default 360)'),
85
+ })
86
+ .describe('A video placed on a slide. Team plan and above.');
87
+
73
88
  const slideShape = z
74
89
  .object({
75
90
  id: z.string().optional().describe('Unique slide id, e.g. "SLD-001". Auto-generated if omitted.'),
@@ -108,6 +123,10 @@ const slideShape = z
108
123
  .array(imageShape)
109
124
  .optional()
110
125
  .describe('Additional inserted/uploaded images placed on the slide (up to 50). Each should carry an alt caption.'),
126
+ videos: z
127
+ .array(videoShape)
128
+ .optional()
129
+ .describe('Videos placed on the slide (Team plan+, up to 20): {src (uploaded /attachments/{id}/raw URL, https video file, or YouTube/Vimeo link), title?, poster?, x?, y?, w?, h? in 1280x720 coords}.'),
111
130
  code: z.string().optional().describe('Code-first React slide source (max design freedom, advanced use)'),
112
131
  html: z.string().optional().describe('Rendered self-contained slide HTML (usually machine-produced, not hand-authored)'),
113
132
  notes: z.string().optional().describe('Speaker notes — text spine'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.3",
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": {