rootr-cli 0.14.0 → 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 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
- 스프레드시트/화이트보드/폼/프레젠테이션/녹음/CRM/데이터베이스까지. 정확한 목록은 MCP `tools/list`로 확인하세요.
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
- 30일이 지나면 자동으로 완전 삭제됩니다. `--yes` 없이 실행하면 확인을 묻습니다
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
 
@@ -275,6 +278,22 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
275
278
  워크스페이스의 지식그래프(GraphRAG)에 질문하고 답변 본문 + 인용(문서 경로: 인용문)을 출력합니다.
276
279
  `ask` 스코프가 필요합니다. `--workspace`를 생략하면 설정된 기본 워크스페이스를 사용합니다.
277
280
 
281
+ ### 팀의 기억 (memory)
282
+
283
+ ```bash
284
+ rootr memory # 전체 회상 (상태·id·종류·출처)
285
+ rootr memory --query 세미나 --kind 지시 # 걸러서 보기
286
+ rootr memory add "보고 시각은 KST로만 표기한다" --kind 지시 --source /규칙.md
287
+ rootr memory edit <id> --content "고친 문장" # 그 기억만 고치기(준 칸만 바뀜)
288
+ rootr memory edit <id> --kind 참고 # 종류만 고치기
289
+ rootr memory forget <id> # 폐기(--hard면 행 삭제)
290
+ ```
291
+
292
+ 세션이 끝나도 남는, 워크스페이스 전체가 공유하는 한 줄 사실입니다. MCP의
293
+ `rootr_remember`/`rootr_recall`/`rootr_forget`과 같은 길을 터미널에서 쓰는
294
+ 명령이고, 화면의 "기억" 메뉴와도 같은 데이터를 봅니다. **긴 건 문서로, 한 줄은
295
+ 기억으로** — 자세한 규칙은 MCP 도구 설명을 참고하세요.
296
+
278
297
  ## MCP 서버로 사용하기
279
298
 
280
299
  `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 152종:
@@ -378,8 +397,8 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
378
397
  | `rootr_bulk_update_rows_each` | 여러 행을 **행마다 다른 값**으로 한 번에 수정 |
379
398
  | `rootr_delete_row` | 행 삭제 |
380
399
  | `rootr_bulk_delete_rows` | 행 여러 개를 한 번에 삭제 |
381
- | `rootr_create_view` | 보기 추가 — 테이블/보드/캘린더/타임라인/갤러리 |
382
- | `rootr_update_view` | 보기 수정 — 열 숨기기·이름·그룹·필터·정렬·열 너비 |
400
+ | `rootr_create_view` | 보기 추가 — 테이블/보드/캘린더/타임라인/갤러리 (달력은 `calendarScale`로 주·달·해) |
401
+ | `rootr_update_view` | 보기 수정 — 열 숨기기·이름·그룹·필터·정렬·열 너비·달력 단위(`calendarScale`) |
383
402
  | `rootr_delete_view` | 보기 삭제 |
384
403
  | `rootr_create_row_page` | 행에 상세 페이지 붙이기(문서 또는 서명문서) |
385
404
  | `rootr_set_row_page_type` | 앞으로 만드는 행이 무엇으로 열릴지 정하기 |
@@ -391,6 +410,9 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
391
410
  | `rootr_set_database_permissions` | 표 잠그기 — 칸·뷰 잠금 / 본인 행만 / 관리자만 고칠 칸 |
392
411
  | `rootr_read_database_notifications` | 표 알림 규칙 보기 |
393
412
  | `rootr_set_database_notifications` | 표 알림 규칙 저장 — 누구에게·언제·어떤 것만 |
413
+ | `rootr_list_database_buttons` | 표 위 **버튼** 목록 보기(고칠 수 있는지 `myLevel`도 함께) |
414
+ | `rootr_set_database_buttons` | 버튼 만들고 고치기 — **목록 통째로 교체**(남길 것도 같이 보내야 함) |
415
+ | `rootr_run_database_button` | 버튼 누르기 — 행 만들기·값 채우기·AI로 칸 채우기가 순서대로 |
394
416
  | `rootr_list_document_commits` | 공개 문서에 온 변경 제안 목록 |
395
417
  | `rootr_merge_document_commit` | 변경 제안 반영(세 벌 병합 — 덮어쓰기 아님) |
396
418
  | `rootr_reject_document_commit` | 변경 제안 거절(사유는 제안자에게 전달) |
@@ -402,6 +424,35 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
402
424
  | `rootr_delete_form_response` | 폼 응답 한 건 삭제 |
403
425
  | `rootr_create_form_collector` | 응답 모을 표·스프레드시트를 **만들어서** 연결(표는 질문 그대로 칸 생성) |
404
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
+
405
456
  **사람 칸은 여러 명을 담습니다.** 회의 참석자·공동 담당처럼 여럿이 들어가는 자리가
406
457
  흔해서, 값은 이름 목록(`["김대표","이팀장"]`)입니다. 이름 하나만 보내도 받아 주고
407
458
  목록으로 맞춰 돌려줍니다. 새로 들어온 사람에게만 알림이 갑니다.
@@ -527,6 +578,28 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
527
578
  | `rootr_save_as_template` | 지금 노드를(또는 즉석 정의를) 템플릿으로 저장 |
528
579
  | `rootr_update_template` | 템플릿 수정 |
529
580
 
581
+ #### 기억 (docs:read / docs:write)
582
+
583
+ | 도구 | 설명 |
584
+ |---|---|
585
+ | `rootr_remember` | 한 줄 사실을 기억시키기(같은 내용이면 갈아끼움). `memoryId`를 함께 주면 그 기억을 그 자리에서 고치기 |
586
+ | `rootr_recall` | 이 워크스페이스가 아는 것 꺼내 오기 |
587
+ | `rootr_forget` | 더 이상 맞지 않는 기억 폐기 |
588
+
589
+ **에이전트에게**: 이건 세션이 끝나도 남는, **워크스페이스 전체가 공유하는** 기억입니다
590
+ (개인 메모는 여기 두지 말고 자기 메모리에 두세요). **긴 건 문서로, 한 줄은
591
+ 기억으로, 그리고 기억은 반드시 문서를 가리키게** 하세요 — 이야기를 기억에 밀어
592
+ 넣으면 기억이 "짧은 문서 더미"가 되면서 둘 다 망합니다. 규칙·결정한 이유·한 번
593
+ 겪은 함정을 남기고, 그 근거가 된 문서 경로를 `source`로 같이 주세요(그래야 그
594
+ 문서가 바뀔 때 루터가 "확인 필요" 표시를 답니다).
595
+
596
+ 기억은 워크스페이스를 처음 만질 때 **도구 응답에 자동으로 딸려옵니다** — 굳이
597
+ `rootr_recall`을 먼저 부르지 않아도 눈앞에 놓입니다. 이 자동 회상은 무료 조회
598
+ 한도를 쓰지 않습니다.
599
+
600
+ 사람이 직접 보고 고치려면 워크스페이스 왼쪽 메뉴의 **기억**에서 볼 수 있고,
601
+ "표로 열기"를 누르면 평범한 표로 열려 권한·기록이 그대로 따라옵니다.
602
+
530
603
  #### 제보 (인증 불필요)
531
604
 
532
605
  | 도구 | 설명 |
@@ -585,6 +658,44 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
585
658
  | `rootr_update_node` | 아무 노드나 이름·아이콘 변경 / 옮기기 |
586
659
  | `rootr_delete_node` | 노드를 휴지통으로 이동(복구 가능) |
587
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
+
588
699
  #### 프레젠테이션
589
700
 
590
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 읽음 확인 해제
@@ -82,6 +87,10 @@ const USAGE = `Rootr CLI v${pkg.version}
82
87
  rootr scaffold apply --file tree.json [--root /경로] 짓기 3단계 — 실제로 만들기(검사 결과도 같이 나옴)
83
88
  rootr scaffold built [--root /경로] [--json] 짓고 나서 — 실제로 만들어진 것의 **모양**을 검사
84
89
  rootr scaffold test [--root /경로] [--json] 짓고 나서 — 실제로 **돌려 본다**(관계·롤업·수식·폼·시트). 실패 시 exit 1
90
+ rootr memory [--query <q>] [--kind <k>] [--json] 팀의 기억 회상 — AI 에이전트가 읽는 같은 목록
91
+ rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
92
+ rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
93
+ rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
85
94
  rootr mcp MCP stdio 서버 구동 (도구 151종 — README 참고)
86
95
  rootr --help 도움말
87
96
  rootr --version 버전 출력
@@ -349,7 +358,32 @@ async function cmdRm(args) {
349
358
 
350
359
  await client.deleteNode(id);
351
360
  console.log(`휴지통으로 옮겼습니다: ${resolved.path || id}`);
352
- console.log('복구하려면 앱의 휴지통에서 되돌리세요 (30일 뒤 자동 삭제).');
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}`);
353
387
  }
354
388
 
355
389
  /** Minimal y/N prompt. Non-interactive stdin (pipes, CI) answers "no". */
@@ -567,6 +601,93 @@ async function cmdMcp() {
567
601
  await startMcpServer({ name: 'rootr', version: pkg.version });
568
602
  }
569
603
 
604
+ // ---- memory (MEM-001) ---------------------------------------------------
605
+ //
606
+ // 팀의 기억 — 저장·회상·고치기·잊기. MCP의 rootr_remember/recall/forget과 같은 길을
607
+ // 터미널에서도 쓸 수 있게. 고치기(edit)는 2026-09-01에 붙었다.
608
+
609
+ const MEMORY_KINDS = ['지시', '사람', '프로젝트', '참고'];
610
+
611
+ function parseRelated(val) {
612
+ if (!val) return undefined;
613
+ if (Array.isArray(val)) return val;
614
+ return String(val)
615
+ .split(',')
616
+ .map((s) => s.trim())
617
+ .filter(Boolean);
618
+ }
619
+
620
+ async function cmdMemory(args) {
621
+ const sub = args._.shift();
622
+ const client = makeClient();
623
+
624
+ if (!sub || sub === 'ls' || sub === 'list') {
625
+ const result = await client.recall({ query: args.query, kind: args.kind, limit: args.limit });
626
+ if (args.json) {
627
+ console.log(JSON.stringify(result, null, 2));
628
+ return;
629
+ }
630
+ if (!result.memories.length) {
631
+ console.log('아직 기억이 없습니다. rootr memory add "한 줄 사실" 으로 남겨 보세요.');
632
+ return;
633
+ }
634
+ for (const m of result.memories) {
635
+ const when = String(m.updatedAt ?? '').slice(0, 10);
636
+ console.log(`- [${m.status}] ${m.content}`);
637
+ console.log(` ${m.id} · ${m.kind}${m.source ? ` · ${m.source}` : ''} · ${when}`);
638
+ }
639
+ return;
640
+ }
641
+
642
+ if (sub === 'add') {
643
+ const content = args._.join(' ');
644
+ if (!content) throw new Error('사용법: rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b]');
645
+ if (args.kind && !MEMORY_KINDS.includes(args.kind)) {
646
+ throw new Error(`종류는 ${MEMORY_KINDS.join('/')} 중 하나여야 합니다: ${args.kind}`);
647
+ }
648
+ const result = await client.remember({
649
+ content,
650
+ kind: args.kind,
651
+ source: args.source,
652
+ related: parseRelated(args.related),
653
+ });
654
+ console.log(`${result.replaced ? '기억을 갈아끼웠습니다' : '기억했습니다'}: ${result.memory.content}`);
655
+ console.log(`id: ${result.memory.id} · 상태: ${result.memory.status}`);
656
+ return;
657
+ }
658
+
659
+ if (sub === 'edit') {
660
+ const id = args._[0];
661
+ if (!id) throw new Error('사용법: rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b]');
662
+ const content = args._.slice(1).join(' ') || args.content;
663
+ if (!content && !args.kind && !args.source && !args.related) {
664
+ throw new Error('고칠 칸이 하나도 없습니다 — --content/--kind/--source/--related 중 하나는 주어야 합니다.');
665
+ }
666
+ if (args.kind && !MEMORY_KINDS.includes(args.kind)) {
667
+ throw new Error(`종류는 ${MEMORY_KINDS.join('/')} 중 하나여야 합니다: ${args.kind}`);
668
+ }
669
+ const result = await client.revise(id, {
670
+ ...(content ? { content } : {}),
671
+ ...(args.kind ? { kind: args.kind } : {}),
672
+ ...(args.source !== undefined ? { source: args.source } : {}),
673
+ ...(args.related !== undefined ? { related: parseRelated(args.related) } : {}),
674
+ });
675
+ console.log(`고쳤습니다: ${result.content}`);
676
+ console.log(`상태: ${result.status}${result.mergedIds?.length ? ` · 같은 사실 ${result.mergedIds.length}건을 폐기로 내렸습니다` : ''}`);
677
+ return;
678
+ }
679
+
680
+ if (sub === 'forget') {
681
+ const id = args._[0];
682
+ if (!id) throw new Error('사용법: rootr memory forget <id> [--hard]');
683
+ await client.forget(id, { hard: args.hard === true });
684
+ console.log(args.hard ? '기억을 지웠습니다.' : '기억을 폐기 상태로 내렸습니다.');
685
+ return;
686
+ }
687
+
688
+ throw new Error(`알 수 없는 하위명령: ${sub}\n사용법: rootr memory [ls|add|edit|forget] …`);
689
+ }
690
+
570
691
  // ---- entry ------------------------------------------------------------
571
692
 
572
693
 
@@ -1040,20 +1161,35 @@ async function cmdDbRowsRm(args) {
1040
1161
  /** PUBLIC-DB-DESIGN — 서버가 받는 값과 **같은 목록**이어야 한다. */
1041
1162
  const DB_LAYOUTS = ['table', 'cards', 'gallery', 'list', 'board', 'timeline', 'calendar', 'chart', 'ranking'];
1042
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
+ }
1043
1175
 
1044
1176
  async function cmdPublish(args) {
1045
1177
  const target = args._[0];
1046
1178
  if (!target) {
1047
1179
  throw new Error(
1048
- '사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] | --off | --status',
1180
+ '사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] ' +
1181
+ '[--comments] [--expires <ISO>] | --off | --status',
1049
1182
  );
1050
1183
  }
1051
1184
  const client = makeClient();
1052
1185
  const id = await resolveDatabaseId(client, target);
1186
+ // DOCUMENT는 예전(더 풍부한) 경로를 쓴다 — 댓글(편집 제안) 허용, 문서 전용 스킨
1187
+ // 16종, 만료일이 이쪽에만 있다. 노드 공통 공개 경로(표·시트 등)는 이 셋을 모른다.
1188
+ const isDocument = (await client.nodePublicStatus(id)).nodeType === 'DOCUMENT';
1053
1189
 
1054
1190
  if (args.status) {
1055
- const st = await client.nodePublicStatus(id);
1056
- if (!st.publishable) {
1191
+ const st = isDocument ? await client.getDocumentPublicStatus(id) : await client.nodePublicStatus(id);
1192
+ if (!isDocument && !st.publishable) {
1057
1193
  console.log('이 종류는 공개할 수 없습니다(폴더).');
1058
1194
  return;
1059
1195
  }
@@ -1063,21 +1199,52 @@ async function cmdPublish(args) {
1063
1199
  }
1064
1200
  console.log(`공개 중: /p/${st.slug}`);
1065
1201
  if (st.expiresAt) console.log(` 만료: ${st.expiresAt}`);
1066
- if (st.hasPassword) console.log(' 비밀번호 걸려 있음');
1067
- console.log(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`);
1068
- console.log(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`);
1069
- if (st.design && (st.design.layout || st.design.skin)) {
1070
- console.log(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`);
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
+ }
1071
1212
  }
1072
1213
  return;
1073
1214
  }
1074
1215
 
1075
1216
  if (args.off) {
1076
- await client.setNodePublic(id, { isPublic: false });
1217
+ if (isDocument) await client.setDocumentPublic(id, { isPublic: false });
1218
+ else await client.setNodePublic(id, { isPublic: false });
1077
1219
  console.log('공개를 닫았습니다. 딸려 나갔던 하위 문서도 같이 닫혔습니다(주소는 남아 있습니다).');
1078
1220
  return;
1079
1221
  }
1080
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
+
1081
1248
  // PUBLIC-DB-DESIGN — 표는 표 모양으로만 보일 이유가 없다. 준 것만 바뀐다.
1082
1249
  const design = {
1083
1250
  ...(typeof args.layout === 'string' ? { layout: args.layout } : {}),
@@ -1183,9 +1350,12 @@ async function cmdDb(args) {
1183
1350
  if (sub === 'row-page-type') return cmdDbRowPageType(args);
1184
1351
  if (sub === 'lock') return cmdDbLock(args);
1185
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);
1186
1356
  if (sub !== 'create') {
1187
1357
  throw new Error(
1188
- '사용법: 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)',
1189
1359
  );
1190
1360
  }
1191
1361
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
@@ -1299,6 +1469,175 @@ async function cmdDbNotify(args) {
1299
1469
  }
1300
1470
  }
1301
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
+
1302
1641
  async function cmdDbRows(args) {
1303
1642
  const target = args._[1];
1304
1643
  if (!target) throw new Error('사용법: rootr db rows <path|id> [--body] [--backlinks] [--json]');
@@ -1732,6 +2071,8 @@ async function main() {
1732
2071
  return cmdMv(args);
1733
2072
  case 'rm':
1734
2073
  return cmdRm(args);
2074
+ case 'restore':
2075
+ return cmdRestore(args);
1735
2076
  case 'ws':
1736
2077
  return cmdWs(args);
1737
2078
  case 'ask':
@@ -1756,6 +2097,8 @@ async function main() {
1756
2097
  return cmdFeedback(args);
1757
2098
  case 'scaffold':
1758
2099
  return cmdScaffold(args);
2100
+ case 'memory':
2101
+ return cmdMemory(args);
1759
2102
  case 'mcp':
1760
2103
  return cmdMcp(args);
1761
2104
  default: