rootr-cli 0.1.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 +275 -0
- package/bin/rootr.js +319 -0
- package/lib/client.js +468 -0
- package/lib/config.js +71 -0
- package/lib/mcp-tools/ask.js +43 -0
- package/lib/mcp-tools/documents.js +193 -0
- package/lib/mcp-tools/issues.js +162 -0
- package/lib/mcp-tools/logs.js +158 -0
- package/lib/mcp-tools/misc.js +56 -0
- package/lib/mcp-tools/shared.js +48 -0
- package/lib/mcp-tools/webhooks.js +115 -0
- package/lib/mcp-tools/workspaces.js +118 -0
- package/lib/mcp.js +50 -0
- package/lib/resolve.js +30 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# rootr-cli
|
|
2
|
+
|
|
3
|
+
Rootr(마크다운 지식협업 SaaS) 공식 CLI + MCP 서버. 에이전트나 사람이 Rootr 문서를
|
|
4
|
+
로컬 파일처럼 읽고/쓰고/검색할 수 있게 해줍니다.
|
|
5
|
+
|
|
6
|
+
- `rootr` CLI: `ls`, `read`, `write`, `append`, `edit`, `search`, `ws`, `ask`, `config`
|
|
7
|
+
- `rootr mcp`: stdio 기반 MCP 서버 (Claude Code / Claude Desktop 등에 등록해서 사용), 도구 27종
|
|
8
|
+
— 문서 6종 + 워크스페이스/스캐폴딩 4종 + LOG 데이터스토어 5종 + RCA ask 1종 + 이슈 6종 + 웹훅 3종 + 문서버전/삭제 2종
|
|
9
|
+
|
|
10
|
+
순수 Node.js ESM, 빌드 과정 없음. Node 18 이상 필요(내장 `fetch` 사용).
|
|
11
|
+
|
|
12
|
+
## 설치
|
|
13
|
+
|
|
14
|
+
이 저장소 안에서 바로 실행하거나, 전역 설치할 수 있습니다.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd cli
|
|
18
|
+
npm install
|
|
19
|
+
|
|
20
|
+
# 방법 1: 직접 실행
|
|
21
|
+
node bin/rootr.js --help
|
|
22
|
+
|
|
23
|
+
# 방법 2: 전역 설치 (심볼릭 링크로 rootr 명령 사용)
|
|
24
|
+
npm install -g .
|
|
25
|
+
rootr --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 설정
|
|
29
|
+
|
|
30
|
+
우선순위: **환경변수 > `~/.rootr/config.json`**
|
|
31
|
+
|
|
32
|
+
| 환경변수 | 설정파일 키 | 설명 |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `ROOTR_API_KEY` | `apiKey` | `rootr_...` 형태의 API 키. 모든 요청에 `x-api-key` 헤더로 전송됩니다. |
|
|
35
|
+
| `ROOTR_WORKSPACE` | `workspace` | 워크스페이스 id |
|
|
36
|
+
| `ROOTR_BASE_URL` | `baseUrl` | 기본값 `https://rootr.io/api/v1` |
|
|
37
|
+
|
|
38
|
+
설정 파일에 저장하기:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
rootr config --api-key rootr_xxxxxxxxxxxxxxxx --workspace ws_123
|
|
42
|
+
# 필요하면 온프레미스/개발 서버용 base-url도 지정 가능
|
|
43
|
+
rootr config --api-key rootr_xxx --workspace ws_123 --base-url https://dev.rootr.io/api/v1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`~/.rootr/config.json`은 `chmod 600`으로 생성됩니다.
|
|
47
|
+
|
|
48
|
+
### 키 2종
|
|
49
|
+
|
|
50
|
+
Rootr API 키는 두 종류가 있고, 도구별로 요구하는 종류가 다릅니다.
|
|
51
|
+
|
|
52
|
+
- **워크스페이스 키**: 특정 워크스페이스 안에서만 동작. 스코프는
|
|
53
|
+
`docs:read`/`docs:write`/`graph:read`/`ask`/`webhooks:manage` 중 일부.
|
|
54
|
+
대부분의 문서/LOG/이슈/웹훅/ask 도구는 이 키로 충분합니다.
|
|
55
|
+
- **계정 키 (PAT)**: 사용자 계정 전체로 동작. `workspaces:create` 스코프가 있으면
|
|
56
|
+
새 워크스페이스를 만들 수 있습니다 (`rootr_create_workspace`). 워크스페이스 키로
|
|
57
|
+
워크스페이스 생성을 시도하면 403과 함께 계정 키가 필요하다는 힌트가 출력됩니다.
|
|
58
|
+
|
|
59
|
+
현재 설정 확인 (API 키는 앞 12자만 표시):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
rootr config
|
|
63
|
+
# config file: /home/you/.rootr/config.json
|
|
64
|
+
# apiKey: rootr_abcd...
|
|
65
|
+
# workspace: ws_123
|
|
66
|
+
# baseUrl: https://rootr.io/api/v1
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## CLI 사용법
|
|
70
|
+
|
|
71
|
+
경로 인자 규칙: `/`로 시작하면 **경로**로 취급되어 by-path API로 노드 id를 알아냅니다
|
|
72
|
+
(워크스페이스 설정 필요). `/`로 시작하지 않으면 **노드 id**로 취급합니다.
|
|
73
|
+
|
|
74
|
+
### 트리 조회
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
rootr ls # 전체 트리
|
|
78
|
+
rootr ls /notes # /notes 하위만
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
출력: `TYPE\tpath` 형식, 경로 정렬.
|
|
82
|
+
|
|
83
|
+
### 문서 읽기
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
rootr read /notes/todo.md # 마크다운 원문을 stdout으로
|
|
87
|
+
rootr read /notes/todo.md --json # 메타데이터 포함 JSON 전체
|
|
88
|
+
rootr read <nodeId> # id로도 가능
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 문서 쓰기 (전체 교체, 없으면 생성)
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
echo "# 제목" | rootr write /notes/new.md
|
|
95
|
+
rootr write /notes/new.md --file ./local.md
|
|
96
|
+
rootr write <nodeId> --file ./local.md --if-match "\"abc123\"" # 낙관적 동시성 제어
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
경로가 아직 없으면 자동으로 문서를 생성합니다(`createParents: true`).
|
|
100
|
+
`--if-match`를 지정했는데 그 사이 문서가 바뀌었으면 412 오류와 함께
|
|
101
|
+
"문서가 그 사이 변경됨: 다시 읽고 재시도" 힌트가 출력됩니다.
|
|
102
|
+
|
|
103
|
+
### 안전하게 추가 (append) — 충돌 걱정 없는 기본 수단
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
rootr append /notes/log.md "오늘 배포 완료"
|
|
107
|
+
rootr append /notes/log.md "- 항목 하나 더" --heading "## 할 일"
|
|
108
|
+
cat diff.md | rootr append /notes/log.md
|
|
109
|
+
rootr append /notes/log.md --file ./chunk.md
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`--heading`을 지정하면 해당 헤딩 섹션 끝에 추가되고, 없으면 문서 끝에 추가됩니다.
|
|
113
|
+
append는 전체 내용을 다시 쓰지 않으므로 다른 프로세스와 동시에 써도 충돌하지 않습니다.
|
|
114
|
+
|
|
115
|
+
### 텍스트 치환 (edit)
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
rootr edit /notes/todo.md --find "- [ ] 배포" --replace "- [x] 배포"
|
|
119
|
+
rootr edit /notes/todo.md --find "TODO" --replace "DONE" --all
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`--find`한 텍스트가 없거나 여러 번 나타나면(고유하지 않으면) 서버가 409를 반환하고
|
|
123
|
+
그 이유가 그대로 출력됩니다. `--all`을 주면 모든 일치 항목을 치환합니다.
|
|
124
|
+
|
|
125
|
+
### 검색
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
rootr search "배포 절차"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
출력: `path — snippet` 목록.
|
|
132
|
+
|
|
133
|
+
### 워크스페이스 목록
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rootr ws
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
출력: `id\tname` 목록 (계정 키면 소속된 전체 워크스페이스, 워크스페이스 키면 그 워크스페이스만).
|
|
140
|
+
|
|
141
|
+
### RCA 질의응답 (ask)
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
rootr ask "지난주 배포 후 지연이 왜 늘었어?"
|
|
145
|
+
rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
워크스페이스의 지식그래프(GraphRAG)에 질문하고 답변 본문 + 인용(문서 경로: 인용문)을 출력합니다.
|
|
149
|
+
`ask` 스코프가 필요합니다. `--workspace`를 생략하면 설정된 기본 워크스페이스를 사용합니다.
|
|
150
|
+
|
|
151
|
+
## MCP 서버로 사용하기
|
|
152
|
+
|
|
153
|
+
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 27종:
|
|
154
|
+
|
|
155
|
+
#### 문서 (workspace 키, docs:read/docs:write)
|
|
156
|
+
|
|
157
|
+
| 도구 | 설명 |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `rootr_list` | 워크스페이스 트리 목록 |
|
|
160
|
+
| `rootr_read` | 문서 마크다운 읽기 |
|
|
161
|
+
| `rootr_append` | **(권장)** 문서에 안전하게 추가 — 충돌 불가능 |
|
|
162
|
+
| `rootr_edit` | 정확한 텍스트 스니펫 치환 |
|
|
163
|
+
| `rootr_write` | 문서 전체 교체(파괴적) 또는 신규 생성 |
|
|
164
|
+
| `rootr_search` | 워크스페이스 검색 |
|
|
165
|
+
|
|
166
|
+
에이전트에게는 가능하면 `rootr_append`/`rootr_edit`을 우선 사용하고,
|
|
167
|
+
`rootr_write`는 정말 전체를 다시 쓸 때만 (가능하면 `ifMatch`와 함께) 쓰도록
|
|
168
|
+
각 도구 설명에 명시되어 있습니다.
|
|
169
|
+
|
|
170
|
+
#### 워크스페이스 / 스캐폴딩
|
|
171
|
+
|
|
172
|
+
| 도구 | 필요 스코프 | 설명 |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `rootr_workspaces` | 계정 키 또는 워크스페이스 키 | 내 워크스페이스 목록 |
|
|
175
|
+
| `rootr_scaffold_plan` | 워크스페이스 불필요 | 의도(intent) 문장으로 문서 트리 계획 생성 |
|
|
176
|
+
| `rootr_create_workspace` | **계정 키(PAT) + workspaces:create** | 계획된 트리로 새 워크스페이스 생성 |
|
|
177
|
+
| `rootr_scaffold_apply` | docs:write | 기존 워크스페이스에 트리 적용 |
|
|
178
|
+
|
|
179
|
+
#### LOG 데이터스토어 (docs:read/docs:write)
|
|
180
|
+
|
|
181
|
+
| 도구 | 설명 |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `rootr_create_log_store` | 타입드 LOG 스토어 생성 (필드: string/int/float/bool/datetime/json/enum/level/relation) |
|
|
184
|
+
| `rootr_update_log_fields` | 필드 스키마 갱신 — **전체 교체**(유지할 필드도 다시 보내야 함) |
|
|
185
|
+
| `rootr_add_log_entries` | 엔트리(행) 추가 |
|
|
186
|
+
| `rootr_query_log_entries` | 엔트리 조회/필터(시간범위·source·level·anomalyOnly) |
|
|
187
|
+
| `rootr_log_stats` | 집계 통계(hour/day/source/level × count/avg/max/min/sum) |
|
|
188
|
+
|
|
189
|
+
#### RCA 질의응답 (ask 스코프)
|
|
190
|
+
|
|
191
|
+
| 도구 | 설명 |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `rootr_ask` | 지식그래프(GraphRAG)에 자연어 질문, 답변+인용 반환 |
|
|
194
|
+
|
|
195
|
+
#### 이슈 트래커 (docs:read/docs:write)
|
|
196
|
+
|
|
197
|
+
| 도구 | 설명 |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `rootr_create_issue_tracker` | 이슈 트래커 생성 |
|
|
200
|
+
| `rootr_list_issues` | 이슈 목록(state/label/type/assignee/검색어 필터) |
|
|
201
|
+
| `rootr_create_issue` | 이슈 생성(labels/type/parentIssueId 지원) |
|
|
202
|
+
| `rootr_get_issue` | 이슈 상세 조회 |
|
|
203
|
+
| `rootr_update_issue` | 이슈 갱신(닫기: state=CLOSED + stateReason) |
|
|
204
|
+
| `rootr_comment_issue` | 이슈에 댓글 추가 |
|
|
205
|
+
|
|
206
|
+
#### 웹훅 (webhooks:manage 스코프)
|
|
207
|
+
|
|
208
|
+
| 도구 | 설명 |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `rootr_list_webhooks` | 워크스페이스 웹훅 목록 |
|
|
211
|
+
| `rootr_create_webhook` | 웹훅 생성 — 응답의 secret은 **1회만 노출** |
|
|
212
|
+
| `rootr_delete_webhook` | 웹훅 삭제 |
|
|
213
|
+
|
|
214
|
+
#### 문서 버전 / 삭제
|
|
215
|
+
|
|
216
|
+
| 도구 | 설명 |
|
|
217
|
+
|---|---|
|
|
218
|
+
| `rootr_document_versions` | 문서 버전 히스토리 조회 |
|
|
219
|
+
| `rootr_delete_node` | 노드를 휴지통으로 이동(복구 가능) |
|
|
220
|
+
|
|
221
|
+
모든 도구 설명은 영어(에이전트가 소비하는 스키마)로 작성되어 있고, JSON을 반환하는
|
|
222
|
+
도구는 보기 좋게 들여쓴 JSON 텍스트로 응답합니다. `workspace` 인자를 받는 도구는
|
|
223
|
+
생략 시 `ROOTR_WORKSPACE`/설정파일의 기본 워크스페이스를 사용하며, 그것도 없으면
|
|
224
|
+
명확한 에러를 반환합니다.
|
|
225
|
+
|
|
226
|
+
### Claude Code / Claude Desktop에 등록하기
|
|
227
|
+
|
|
228
|
+
`~/.claude.json`(Claude Code) 또는 Claude Desktop의 MCP 설정 파일에 다음을 추가:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"mcpServers": {
|
|
233
|
+
"rootr": {
|
|
234
|
+
"command": "node",
|
|
235
|
+
"args": ["/path/to/rootr/backend/cli/bin/rootr.js", "mcp"],
|
|
236
|
+
"env": {
|
|
237
|
+
"ROOTR_API_KEY": "rootr_xxxxxxxxxxxxxxxx",
|
|
238
|
+
"ROOTR_WORKSPACE": "ws_123"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
전역 설치했다면 `command`를 `"rootr"`, `args`를 `["mcp"]`로만 써도 됩니다.
|
|
246
|
+
|
|
247
|
+
## 에러 처리
|
|
248
|
+
|
|
249
|
+
API가 비-2xx를 반환하면 응답 본문의 `message`(있으면)를 stderr에 출력하고
|
|
250
|
+
exit code 1로 종료합니다. 상황별 힌트:
|
|
251
|
+
|
|
252
|
+
- `401`/`403` → API 키/워크스페이스 스코프 확인 안내
|
|
253
|
+
- `412` → "문서가 그 사이 변경됨: 다시 읽고 재시도" 안내 (If-Match 불일치)
|
|
254
|
+
- `409` → 서버가 준 이유 메시지를 그대로 표시 (예: edit의 find 불일치/중복)
|
|
255
|
+
|
|
256
|
+
## 개발 메모
|
|
257
|
+
|
|
258
|
+
- 의존성: `@modelcontextprotocol/sdk`(MCP 서버), `zod`(도구 입력 스키마).
|
|
259
|
+
CLI 자체(`bin/rootr.js`, `lib/config.js`, `lib/client.js`, `lib/resolve.js`)는
|
|
260
|
+
외부 의존성 없이 동작합니다.
|
|
261
|
+
- 빌드 스텝 없음 — 순수 ESM 자바스크립트.
|
|
262
|
+
- 파일 구성:
|
|
263
|
+
- `bin/rootr.js` — 엔트리포인트, 명령 디스패치
|
|
264
|
+
- `lib/config.js` — 설정 로드/저장
|
|
265
|
+
- `lib/client.js` — Rootr REST API 클라이언트(문서/워크스페이스/스캐폴딩/LOG/ask/이슈/웹훅)
|
|
266
|
+
- `lib/resolve.js` — path/id 타겟 해석 공통 로직
|
|
267
|
+
- `lib/mcp.js` — MCP stdio 서버 진입점, 도구 모듈 조립
|
|
268
|
+
- `lib/mcp-tools/shared.js` — MCP 도구 공통 헬퍼(textResult/jsonResult/errorResult/makeClient/requireWorkspace)
|
|
269
|
+
- `lib/mcp-tools/documents.js` — 문서 도구 6종
|
|
270
|
+
- `lib/mcp-tools/workspaces.js` — 워크스페이스/스캐폴딩 도구 4종
|
|
271
|
+
- `lib/mcp-tools/logs.js` — LOG 데이터스토어 도구 5종
|
|
272
|
+
- `lib/mcp-tools/ask.js` — RCA ask 도구 1종
|
|
273
|
+
- `lib/mcp-tools/issues.js` — 이슈 트래커 도구 6종
|
|
274
|
+
- `lib/mcp-tools/webhooks.js` — 웹훅 도구 3종
|
|
275
|
+
- `lib/mcp-tools/misc.js` — 문서버전/삭제 도구 2종
|
package/bin/rootr.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { loadConfig, saveConfig, maskKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
7
|
+
import { RootrClient, RootrApiError } from '../lib/client.js';
|
|
8
|
+
import { resolveTarget, requireResolved } from '../lib/resolve.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
const USAGE = `Rootr CLI v${pkg.version}
|
|
14
|
+
|
|
15
|
+
사용법:
|
|
16
|
+
rootr ls [path] 워크스페이스 트리 조회
|
|
17
|
+
rootr read <path|id> [--json] 문서 읽기 (마크다운 또는 JSON)
|
|
18
|
+
rootr write <path|id> [--file <f>] [--if-match <etag>] 문서 전체 교체(파일 없으면 생성)
|
|
19
|
+
rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] 문서 끝(또는 헤딩 아래)에 안전하게 추가
|
|
20
|
+
rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
|
|
21
|
+
rootr search <query> 워크스페이스 검색
|
|
22
|
+
rootr ws 내 워크스페이스 목록 (id\tname)
|
|
23
|
+
rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
|
|
24
|
+
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
25
|
+
rootr mcp MCP stdio 서버 구동 (도구 27종 — README 참고)
|
|
26
|
+
rootr --help 도움말
|
|
27
|
+
rootr --version 버전 출력
|
|
28
|
+
|
|
29
|
+
설정 우선순위: 환경변수(ROOTR_API_KEY, ROOTR_WORKSPACE, ROOTR_BASE_URL) > ~/.rootr/config.json
|
|
30
|
+
|
|
31
|
+
경로 인자는 '/'로 시작해야 합니다 (예: /notes/todo.md). 그 외 값은 노드 id로 취급됩니다.
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const args = { _: [] };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const tok = argv[i];
|
|
38
|
+
if (tok.startsWith('--')) {
|
|
39
|
+
const key = tok.slice(2);
|
|
40
|
+
const next = argv[i + 1];
|
|
41
|
+
if (next === undefined || next.startsWith('--')) {
|
|
42
|
+
args[key] = true;
|
|
43
|
+
} else {
|
|
44
|
+
args[key] = next;
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
args._.push(tok);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readStdin() {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
if (process.stdin.isTTY) {
|
|
57
|
+
resolve('');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let data = '';
|
|
61
|
+
process.stdin.setEncoding('utf8');
|
|
62
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
63
|
+
process.stdin.on('end', () => resolve(data));
|
|
64
|
+
process.stdin.on('error', reject);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeClient() {
|
|
69
|
+
const cfg = loadConfig();
|
|
70
|
+
return new RootrClient(cfg);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printErrorAndExit(err) {
|
|
74
|
+
if (err instanceof RootrApiError) {
|
|
75
|
+
console.error(`오류: ${err.message}`);
|
|
76
|
+
if (err.status) console.error(`(HTTP ${err.status})`);
|
|
77
|
+
if (err.hint) console.error(`힌트: ${err.hint}`);
|
|
78
|
+
} else {
|
|
79
|
+
console.error(`오류: ${err.message || err}`);
|
|
80
|
+
}
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- commands -------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
async function cmdLs(args) {
|
|
87
|
+
const client = makeClient();
|
|
88
|
+
const filterPath = args._[0];
|
|
89
|
+
const nodes = await client.getTree();
|
|
90
|
+
|
|
91
|
+
const rows = nodes
|
|
92
|
+
.filter((n) => {
|
|
93
|
+
if (!filterPath) return true;
|
|
94
|
+
const p = n.path || '';
|
|
95
|
+
return p === filterPath || p.startsWith(filterPath.endsWith('/') ? filterPath : filterPath + '/');
|
|
96
|
+
})
|
|
97
|
+
.map((n) => ({ type: n.type || '?', path: n.path || n.name || n.id }))
|
|
98
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
99
|
+
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
console.log(`${row.type}\t${row.path}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function cmdRead(args) {
|
|
106
|
+
const target = args._[0];
|
|
107
|
+
if (!target) throw new Error('사용법: rootr read <path|id> [--json]');
|
|
108
|
+
|
|
109
|
+
const client = makeClient();
|
|
110
|
+
const resolved = await resolveTarget(client, target);
|
|
111
|
+
const id = requireResolved(resolved);
|
|
112
|
+
|
|
113
|
+
if (args.json) {
|
|
114
|
+
const doc = await client.getDocumentJson(id);
|
|
115
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
116
|
+
} else {
|
|
117
|
+
const { content } = await client.getDocumentMarkdown(id);
|
|
118
|
+
process.stdout.write(content.endsWith('\n') ? content : content + '\n');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function cmdWrite(args) {
|
|
123
|
+
const target = args._[0];
|
|
124
|
+
if (!target) throw new Error('사용법: rootr write <path|id> [--file <f>] [--if-match <etag>]');
|
|
125
|
+
|
|
126
|
+
let content;
|
|
127
|
+
if (typeof args.file === 'string') {
|
|
128
|
+
content = readFileSync(args.file, 'utf8');
|
|
129
|
+
} else {
|
|
130
|
+
content = await readStdin();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const client = makeClient();
|
|
134
|
+
const resolved = await resolveTarget(client, target);
|
|
135
|
+
|
|
136
|
+
if (!resolved.existed) {
|
|
137
|
+
if (!resolved.path) {
|
|
138
|
+
throw new Error(`대상을 찾을 수 없습니다: ${target}`);
|
|
139
|
+
}
|
|
140
|
+
// 생성 자체가 쓰기 — 곧바로 PUT을 또 하면 버전/추출만 이중으로 쌓인다
|
|
141
|
+
const created = await client.createNode({
|
|
142
|
+
type: 'DOCUMENT',
|
|
143
|
+
path: resolved.path,
|
|
144
|
+
createParents: true,
|
|
145
|
+
autoRename: false,
|
|
146
|
+
content,
|
|
147
|
+
});
|
|
148
|
+
const id = created.id || (created.node && created.node.id);
|
|
149
|
+
console.error(`created ${resolved.path} (id ${id})`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { etag } = await client.putDocument(resolved.id, content, { ifMatch: args['if-match'] });
|
|
154
|
+
console.error(`wrote ${target} (etag ${etag || 'n/a'})`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function cmdAppend(args) {
|
|
158
|
+
const target = args._[0];
|
|
159
|
+
if (!target) throw new Error('사용법: rootr append <path|id> [<text>] [--heading "## X"] [--file <f>]');
|
|
160
|
+
|
|
161
|
+
let content;
|
|
162
|
+
if (typeof args._[1] === 'string') {
|
|
163
|
+
content = args._[1];
|
|
164
|
+
} else if (typeof args.file === 'string') {
|
|
165
|
+
content = readFileSync(args.file, 'utf8');
|
|
166
|
+
} else {
|
|
167
|
+
content = await readStdin();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!content) {
|
|
171
|
+
throw new Error('추가할 내용이 없습니다 (텍스트 인자, --file, 또는 stdin 필요).');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const client = makeClient();
|
|
175
|
+
const resolved = await resolveTarget(client, target);
|
|
176
|
+
const id = requireResolved(resolved);
|
|
177
|
+
|
|
178
|
+
const result = await client.appendDocument(id, {
|
|
179
|
+
content,
|
|
180
|
+
underHeading: args.heading,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
console.error(`appended to ${target} (etag ${result.etag || 'n/a'})`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function cmdEdit(args) {
|
|
187
|
+
const target = args._[0];
|
|
188
|
+
if (!target) throw new Error('사용법: rootr edit <path|id> --find <text> --replace <text> [--all]');
|
|
189
|
+
if (typeof args.find !== 'string' || typeof args.replace !== 'string') {
|
|
190
|
+
throw new Error('--find와 --replace가 모두 필요합니다.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const client = makeClient();
|
|
194
|
+
const resolved = await resolveTarget(client, target);
|
|
195
|
+
const id = requireResolved(resolved);
|
|
196
|
+
|
|
197
|
+
await client.patchDocumentAnchor(id, {
|
|
198
|
+
find: args.find,
|
|
199
|
+
replace: args.replace,
|
|
200
|
+
replaceAll: Boolean(args.all),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
console.error(`edited ${target}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function cmdSearch(args) {
|
|
207
|
+
const query = args._[0];
|
|
208
|
+
if (!query) throw new Error('사용법: rootr search <query>');
|
|
209
|
+
|
|
210
|
+
const client = makeClient();
|
|
211
|
+
const results = await client.search(query);
|
|
212
|
+
|
|
213
|
+
for (const r of results) {
|
|
214
|
+
console.log(`${r.path || r.name || r.nodeId} — ${r.snippet || ''}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function cmdWs() {
|
|
219
|
+
const client = makeClient();
|
|
220
|
+
const workspaces = await client.listWorkspaces();
|
|
221
|
+
for (const w of workspaces) {
|
|
222
|
+
console.log(`${w.id}\t${w.name || w.slug || ''}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function cmdAsk(args) {
|
|
227
|
+
const question = args._.join(' ');
|
|
228
|
+
if (!question) throw new Error('사용법: rootr ask <question> [--workspace <w>]');
|
|
229
|
+
|
|
230
|
+
const cfg = loadConfig();
|
|
231
|
+
const workspace = args.workspace || cfg.workspace;
|
|
232
|
+
if (!workspace) {
|
|
233
|
+
throw new RootrApiError('워크스페이스가 설정되지 않았습니다.', {
|
|
234
|
+
hint: '--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const client = new RootrClient({ ...cfg, workspace });
|
|
239
|
+
const result = await client.ask(workspace, question);
|
|
240
|
+
console.log(result.text || '(응답 없음)');
|
|
241
|
+
const citations = Array.isArray(result.citations) ? result.citations : [];
|
|
242
|
+
if (citations.length) {
|
|
243
|
+
console.log('\nCitations:');
|
|
244
|
+
for (const c of citations) {
|
|
245
|
+
console.log(`- ${c.documentPath}: ${c.quote}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function cmdConfig(args) {
|
|
251
|
+
const hasAny = args['api-key'] || args.workspace || args['base-url'];
|
|
252
|
+
if (!hasAny) {
|
|
253
|
+
const cfg = loadConfig();
|
|
254
|
+
console.log(`config file: ${CONFIG_PATH}`);
|
|
255
|
+
console.log(`apiKey: ${maskKey(cfg.apiKey)}`);
|
|
256
|
+
console.log(`workspace: ${cfg.workspace || '(not set)'}`);
|
|
257
|
+
console.log(`baseUrl: ${cfg.baseUrl || DEFAULT_BASE_URL}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const partial = {};
|
|
262
|
+
if (typeof args['api-key'] === 'string') partial.apiKey = args['api-key'];
|
|
263
|
+
if (typeof args.workspace === 'string') partial.workspace = args.workspace;
|
|
264
|
+
if (typeof args['base-url'] === 'string') partial.baseUrl = args['base-url'];
|
|
265
|
+
|
|
266
|
+
saveConfig(partial);
|
|
267
|
+
console.log(`설정을 저장했습니다: ${CONFIG_PATH}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function cmdMcp() {
|
|
271
|
+
const { startMcpServer } = await import('../lib/mcp.js');
|
|
272
|
+
await startMcpServer({ name: 'rootr', version: pkg.version });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- entry ------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
async function main() {
|
|
278
|
+
const argv = process.argv.slice(2);
|
|
279
|
+
const args = parseArgs(argv);
|
|
280
|
+
const cmd = args._.shift();
|
|
281
|
+
|
|
282
|
+
if (args.version) {
|
|
283
|
+
console.log(pkg.version);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!cmd || args.help || cmd === 'help') {
|
|
287
|
+
console.log(USAGE);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
switch (cmd) {
|
|
292
|
+
case 'ls':
|
|
293
|
+
return cmdLs(args);
|
|
294
|
+
case 'read':
|
|
295
|
+
return cmdRead(args);
|
|
296
|
+
case 'write':
|
|
297
|
+
return cmdWrite(args);
|
|
298
|
+
case 'append':
|
|
299
|
+
return cmdAppend(args);
|
|
300
|
+
case 'edit':
|
|
301
|
+
return cmdEdit(args);
|
|
302
|
+
case 'search':
|
|
303
|
+
return cmdSearch(args);
|
|
304
|
+
case 'ws':
|
|
305
|
+
return cmdWs(args);
|
|
306
|
+
case 'ask':
|
|
307
|
+
return cmdAsk(args);
|
|
308
|
+
case 'config':
|
|
309
|
+
return cmdConfig(args);
|
|
310
|
+
case 'mcp':
|
|
311
|
+
return cmdMcp(args);
|
|
312
|
+
default:
|
|
313
|
+
console.error(`알 수 없는 명령: ${cmd}\n`);
|
|
314
|
+
console.log(USAGE);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
main().catch(printErrorAndExit);
|