rootr-cli 0.5.0 → 0.7.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 +33 -4
- package/bin/rootr.js +19 -1
- package/lib/client.js +46 -0
- package/lib/mcp-tools/databases.js +98 -0
- package/lib/mcp-tools/documents.js +39 -10
- package/lib/mcp-tools/shared.js +1 -1
- package/lib/mcp-tools/workspaces.js +131 -17
- package/lib/resolve.js +99 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -169,9 +169,16 @@ rootr config --add-key rootr_xxx --label "영업팀"
|
|
|
169
169
|
rootr config # 등록된 키 목록 확인
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
이후 MCP에서 `rootr_use_workspace` 도구로
|
|
173
|
-
바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정
|
|
174
|
-
소속된 어느 워크스페이스로든 바로 전환됩니다.
|
|
172
|
+
이후 MCP에서 `rootr_use_workspace` 도구로 **이름·id·루터 웹 주소** 중 아무거나 주면 활성
|
|
173
|
+
워크스페이스가 바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정
|
|
174
|
+
키(PAT)를 쓰면 소속된 어느 워크스페이스로든 바로 전환됩니다.
|
|
175
|
+
|
|
176
|
+
워크스페이스 키는 자기 워크스페이스 밖에서는 열리지 않습니다 — `rootr ws`(=`rootr_workspaces`)
|
|
177
|
+
목록에 보여도 접근은 안 되는 경우가 있어, 등록된 키가 워크스페이스 키뿐이면 목록에
|
|
178
|
+
`[등록된 키로는 접근 불가]`를 함께 표시합니다.
|
|
179
|
+
|
|
180
|
+
문서를 가리키는 웹 주소(`https://rootr.io/ko/w/<ws>/d/<doc>`)는 `rootr_read` 등 모든 노드
|
|
181
|
+
인자에 그대로 붙여넣을 수 있습니다. 다른 워크스페이스의 주소면 "먼저 전환하라"고 알려줍니다.
|
|
175
182
|
|
|
176
183
|
> 원격 커넥터(`https://rootr.io/mcp`)는 승인할 때 고른 워크스페이스 하나에만 연결됩니다.
|
|
177
184
|
> 다른 워크스페이스가 필요하면 그 워크스페이스로 커넥터를 새로 발급하세요.
|
|
@@ -204,7 +211,7 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
|
|
|
204
211
|
|
|
205
212
|
## MCP 서버로 사용하기
|
|
206
213
|
|
|
207
|
-
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구
|
|
214
|
+
`rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 96종:
|
|
208
215
|
|
|
209
216
|
노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
|
|
210
217
|
`recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
|
|
@@ -282,6 +289,28 @@ rootr ask "왜 결제 실패가 늘었지?" --workspace ws_123
|
|
|
282
289
|
| `rootr_create_webhook` | 웹훅 생성 — 응답의 secret은 **1회만 노출** |
|
|
283
290
|
| `rootr_delete_webhook` | 웹훅 삭제 |
|
|
284
291
|
|
|
292
|
+
#### 표(DATABASE) — 행과 열 (docs:read/docs:write)
|
|
293
|
+
|
|
294
|
+
| 도구 | 설명 |
|
|
295
|
+
|---|---|
|
|
296
|
+
| `rootr_read_database` | 스키마(열 정의)+행 전체 읽기 — **다른 표 도구를 쓰기 전에 먼저** |
|
|
297
|
+
| `rootr_list_rows` | 행만 조회(스키마 제외) |
|
|
298
|
+
| `rootr_add_row` | 행 추가 (`values`는 열 이름 또는 id로 지정) |
|
|
299
|
+
| `rootr_update_row` | 행 수정 (칸반 카드 이동 = select 열 값 변경) |
|
|
300
|
+
| `rootr_delete_row` | 행 삭제 |
|
|
301
|
+
| `rootr_add_column` | **열 추가** — 기존 열·행은 그대로 |
|
|
302
|
+
| `rootr_update_column` | 열 이름 변경 / select 선택지 교체 |
|
|
303
|
+
| `rootr_delete_column` | 열 삭제(파괴적) |
|
|
304
|
+
|
|
305
|
+
열은 **한 번에 하나씩** 다룹니다. REST의 `PATCH /databases/:id`는 `properties`
|
|
306
|
+
배열을 통째로 교체하기 때문에, 에이전트가 배열을 다시 만들다 빠뜨린 열이 그대로
|
|
307
|
+
사라집니다. 위 세 도구는 서버가 병합하므로 나머지 열이 없어지지 않습니다.
|
|
308
|
+
|
|
309
|
+
- **열을 추가해도 기존 행 데이터는 건드리지 않습니다** — 새 열은 값이 채워지기 전까지 빈 칸으로 읽힙니다.
|
|
310
|
+
- **열의 `type`은 바꿀 수 없습니다**(모든 행의 값을 다시 써야 하므로). 새 열을 만들어 값을 옮기세요.
|
|
311
|
+
- `title` 열은 추가로 만들 수도, 삭제할 수도 없습니다.
|
|
312
|
+
- 삭제한 열의 값은 행 안에 남아 있지만 화면에는 나오지 않습니다 — 사용자 확인 후 호출하세요.
|
|
313
|
+
|
|
285
314
|
#### 문서 버전 / 삭제
|
|
286
315
|
|
|
287
316
|
| 도구 | 설명 |
|
package/bin/rootr.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join, basename, extname } from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { loadConfig, saveConfig, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
7
7
|
import { RootrClient, RootrApiError } from '../lib/client.js';
|
|
8
|
-
import { resolveTarget, requireResolved } from '../lib/resolve.js';
|
|
8
|
+
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
9
9
|
|
|
10
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -19,6 +19,7 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
19
19
|
rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] 문서 끝(또는 헤딩 아래)에 안전하게 추가
|
|
20
20
|
rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
|
|
21
21
|
rootr search <query> 워크스페이스 검색
|
|
22
|
+
rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
|
|
22
23
|
rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
|
|
23
24
|
rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
|
|
24
25
|
rootr ws 내 워크스페이스 목록 (id\tname)
|
|
@@ -218,6 +219,21 @@ async function cmdSearch(args) {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
async function cmdMv(args) {
|
|
223
|
+
const source = args._[0];
|
|
224
|
+
const dest = args._[1];
|
|
225
|
+
if (!source || !dest) throw new Error('사용법: rootr mv <path|id> <새 경로|폴더 id>');
|
|
226
|
+
|
|
227
|
+
const client = makeClient();
|
|
228
|
+
const resolved = await resolveTarget(client, source);
|
|
229
|
+
const id = requireResolved(resolved);
|
|
230
|
+
|
|
231
|
+
const { parentId, name } = await resolveMoveDestination(client, dest);
|
|
232
|
+
const updated = await client.updateNode(id, { parentId, name });
|
|
233
|
+
|
|
234
|
+
console.log(`이동했습니다: ${resolved.path || id} → ${updated.path || dest}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
221
237
|
async function cmdRm(args) {
|
|
222
238
|
const target = args._[0];
|
|
223
239
|
if (!target) throw new Error('사용법: rootr rm <path|id> [--yes]');
|
|
@@ -405,6 +421,8 @@ async function main() {
|
|
|
405
421
|
return cmdSearch(args);
|
|
406
422
|
case 'attach':
|
|
407
423
|
return cmdAttach(args);
|
|
424
|
+
case 'mv':
|
|
425
|
+
return cmdMv(args);
|
|
408
426
|
case 'rm':
|
|
409
427
|
return cmdRm(args);
|
|
410
428
|
case 'ws':
|
package/lib/client.js
CHANGED
|
@@ -33,6 +33,19 @@ export class RootrApiError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function hintFor(status, body) {
|
|
36
|
+
// WS-SCOPE — the single most confusing failure: a workspace-scoped key used
|
|
37
|
+
// against another workspace. The API says so precisely; without repeating it
|
|
38
|
+
// as an instruction the assistant reads the generic hint below and starts
|
|
39
|
+
// guessing at invitations/permissions for a workspace the user already owns.
|
|
40
|
+
const detail = body && (body.message || body.error || '');
|
|
41
|
+
if (status === 403 && typeof detail === 'string' && /does not belong to this workspace/i.test(detail)) {
|
|
42
|
+
return (
|
|
43
|
+
'이 키는 다른 워크스페이스 전용 키입니다. 목록에 보인다고 접근되는 것은 아닙니다.\n' +
|
|
44
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하거나, ' +
|
|
45
|
+
'그 워크스페이스용 키를 `rootr config --add-key <키> --workspace <워크스페이스 id>` 로 추가하세요. ' +
|
|
46
|
+
'(rootr_workspaces 로 어떤 워크스페이스가 실제로 열리는지 확인할 수 있습니다.)'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
36
49
|
if (status === 401 || status === 403) {
|
|
37
50
|
return (
|
|
38
51
|
'API 키 또는 워크스페이스 스코프를 확인하세요 (rootr config로 현재 설정 확인). ' +
|
|
@@ -298,6 +311,14 @@ export class RootrClient {
|
|
|
298
311
|
return this.request(`/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
299
312
|
}
|
|
300
313
|
|
|
314
|
+
/** Rename and/or move a node. `parentId: null` moves it to the workspace root. */
|
|
315
|
+
async updateNode(id, { name, icon, tags, parentId, position } = {}) {
|
|
316
|
+
return this.request(`/nodes/${encodeURIComponent(id)}`, {
|
|
317
|
+
method: 'PATCH',
|
|
318
|
+
body: { name, icon, tags, parentId, position },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
301
322
|
// ---- Comments / publishing / duplication (document-level extras) --------
|
|
302
323
|
|
|
303
324
|
/** Comments carry a user identity — needs an account key (PAT) or user session; workspace keys 403. */
|
|
@@ -565,6 +586,31 @@ export class RootrClient {
|
|
|
565
586
|
);
|
|
566
587
|
}
|
|
567
588
|
|
|
589
|
+
// ---- DB-COL: one column at a time --------------------------------------
|
|
590
|
+
// PATCH /databases/:id replaces the whole properties array, so rebuilding it
|
|
591
|
+
// client-side drops any column left out. These merge server-side instead.
|
|
592
|
+
|
|
593
|
+
async addColumn(databaseId, { name, type, options } = {}) {
|
|
594
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/columns`, {
|
|
595
|
+
method: 'POST',
|
|
596
|
+
body: { name, type, options },
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async updateColumn(databaseId, propertyId, { name, options } = {}) {
|
|
601
|
+
return this.request(
|
|
602
|
+
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
603
|
+
{ method: 'PATCH', body: { name, options } },
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async deleteColumn(databaseId, propertyId) {
|
|
608
|
+
return this.request(
|
|
609
|
+
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
610
|
+
{ method: 'DELETE' },
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
568
614
|
// ---- CRM ----------------------------------------------------------------
|
|
569
615
|
|
|
570
616
|
/** Create a CRM node. pipelines/fields/config optional (server fills sane defaults). */
|
|
@@ -171,4 +171,102 @@ export function registerDatabaseTools(server) {
|
|
|
171
171
|
}
|
|
172
172
|
},
|
|
173
173
|
);
|
|
174
|
+
|
|
175
|
+
// ---- DB-COL: columns ----------------------------------------------------
|
|
176
|
+
// Row tools above cover the data; these cover the SHAPE. Deliberately one
|
|
177
|
+
// column per call: the underlying PATCH /databases/:id takes the entire
|
|
178
|
+
// properties array, and a model rebuilding that array drops whatever it
|
|
179
|
+
// forgets. Here the server merges, so nothing else in the table can be lost.
|
|
180
|
+
|
|
181
|
+
server.registerTool(
|
|
182
|
+
'rootr_add_column',
|
|
183
|
+
{
|
|
184
|
+
title: 'Add a column to a database',
|
|
185
|
+
description:
|
|
186
|
+
'Add ONE column to a Rootr (루터) DATABASE. The existing columns and every row are left untouched — ' +
|
|
187
|
+
'rows simply read empty for the new column until you fill them with rootr_update_row. ' +
|
|
188
|
+
'type ∈ text | number | select | multi_select | date | checkbox | url | person ' +
|
|
189
|
+
'("title" is not allowed: a database already has exactly one title column). ' +
|
|
190
|
+
'For select/multi_select pass options: [{ name, color }]. Returns the created column WITH ITS id.',
|
|
191
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
192
|
+
inputSchema: {
|
|
193
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
194
|
+
name: z.string().describe('Column header, e.g. "담당자"'),
|
|
195
|
+
type: z
|
|
196
|
+
.enum(['text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'person'])
|
|
197
|
+
.describe('Column type'),
|
|
198
|
+
options: z
|
|
199
|
+
.array(z.object({ name: z.string(), color: z.string() }))
|
|
200
|
+
.optional()
|
|
201
|
+
.describe('select/multi_select only: the choices, e.g. [{ name: "진행중", color: "blue" }]'),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
async ({ databaseId, name, type, options }) => {
|
|
205
|
+
try {
|
|
206
|
+
const client = makeClient();
|
|
207
|
+
const id = await resolveNodeId(client, databaseId);
|
|
208
|
+
const property = await client.addColumn(id, { name, type, options });
|
|
209
|
+
return jsonResult({ property });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return errorResult(err);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
server.registerTool(
|
|
217
|
+
'rootr_update_column',
|
|
218
|
+
{
|
|
219
|
+
title: 'Rename a column / set its options',
|
|
220
|
+
description:
|
|
221
|
+
'Rename ONE column of a Rootr (루터) DATABASE, and/or replace the choices of a select/multi_select column. ' +
|
|
222
|
+
'The column TYPE cannot be changed — that would rewrite every row — so add a new column and move the values instead. ' +
|
|
223
|
+
'Call rootr_read_database first to get the exact propertyId.',
|
|
224
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
225
|
+
inputSchema: {
|
|
226
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
227
|
+
propertyId: z.string().describe('Column id from rootr_read_database (NOT the header text)'),
|
|
228
|
+
name: z.string().optional().describe('New header text'),
|
|
229
|
+
options: z
|
|
230
|
+
.array(z.object({ name: z.string(), color: z.string() }))
|
|
231
|
+
.optional()
|
|
232
|
+
.describe('select/multi_select only: REPLACES the full choice list'),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
async ({ databaseId, propertyId, name, options }) => {
|
|
236
|
+
try {
|
|
237
|
+
const client = makeClient();
|
|
238
|
+
const id = await resolveNodeId(client, databaseId);
|
|
239
|
+
const property = await client.updateColumn(id, propertyId, { name, options });
|
|
240
|
+
return jsonResult({ property });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return errorResult(err);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.registerTool(
|
|
248
|
+
'rootr_delete_column',
|
|
249
|
+
{
|
|
250
|
+
title: 'Delete a column from a database',
|
|
251
|
+
description:
|
|
252
|
+
'Remove ONE column from a Rootr (루터) DATABASE. The values stored under it are NOT erased from the rows, ' +
|
|
253
|
+
'so re-adding a column is not enough to bring them back in the UI — confirm with the user before calling this. ' +
|
|
254
|
+
'The title column cannot be deleted. Views grouped/dated by the column are cleared automatically.',
|
|
255
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
256
|
+
inputSchema: {
|
|
257
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
258
|
+
propertyId: z.string().describe('Column id from rootr_read_database'),
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
async ({ databaseId, propertyId }) => {
|
|
262
|
+
try {
|
|
263
|
+
const client = makeClient();
|
|
264
|
+
const id = await resolveNodeId(client, databaseId);
|
|
265
|
+
const property = await client.deleteColumn(id, propertyId);
|
|
266
|
+
return jsonResult({ deleted: true, property });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return errorResult(err);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
);
|
|
174
272
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
import { RootrApiError } from '../client.js';
|
|
4
|
-
import { resolveTarget, requireResolved } from '../resolve.js';
|
|
4
|
+
import { resolveTarget, requireResolved, resolveMoveDestination } from '../resolve.js';
|
|
5
5
|
import { textResult, jsonResult, errorResult, makeClient } from './shared.js';
|
|
6
6
|
|
|
7
7
|
/** Registers the original 6 document tools: list/read/append/edit/write/search. */
|
|
@@ -54,7 +54,7 @@ export function registerDocumentTools(server) {
|
|
|
54
54
|
'Read a document before editing or appending to it so you know its current structure.',
|
|
55
55
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
56
56
|
inputSchema: {
|
|
57
|
-
target: z.string().describe('Document path (e.g. "/notes/todo.md") or node id'),
|
|
57
|
+
target: z.string().describe('Document path (e.g. "/notes/todo.md") or node id, or a full rootr.io URL'),
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
async ({ target }) => {
|
|
@@ -80,7 +80,7 @@ export function registerDocumentTools(server) {
|
|
|
80
80
|
'you just need to add new information (log entries, notes, findings) to an existing document.',
|
|
81
81
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
82
82
|
inputSchema: {
|
|
83
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
83
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
84
84
|
content: z.string().describe('Markdown content to append'),
|
|
85
85
|
underHeading: z
|
|
86
86
|
.string()
|
|
@@ -115,7 +115,7 @@ export function registerDocumentTools(server) {
|
|
|
115
115
|
'you are only adding new content.',
|
|
116
116
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
117
117
|
inputSchema: {
|
|
118
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
118
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
119
119
|
find: z.string().describe('Exact existing text to find (must be unique unless replaceAll is set)'),
|
|
120
120
|
replace: z.string().describe('Text to replace it with'),
|
|
121
121
|
replaceAll: z.boolean().optional().describe('Replace all occurrences instead of requiring uniqueness'),
|
|
@@ -144,7 +144,7 @@ export function registerDocumentTools(server) {
|
|
|
144
144
|
'as ifMatch to avoid clobbering concurrent changes. Prefer rootr_append or rootr_edit for incremental changes.',
|
|
145
145
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
146
146
|
inputSchema: {
|
|
147
|
-
target: z.string().describe('Document path (starting with "/", created if missing) or node id'),
|
|
147
|
+
target: z.string().describe('Document path (starting with "/", created if missing) or node id, or a full rootr.io URL'),
|
|
148
148
|
content: z.string().describe('Full new markdown content for the document'),
|
|
149
149
|
ifMatch: z
|
|
150
150
|
.string()
|
|
@@ -216,7 +216,7 @@ export function registerDocumentTools(server) {
|
|
|
216
216
|
'(including remote-connector tokens) gets a 403; fall back to rootr_append in that case.',
|
|
217
217
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
218
218
|
inputSchema: {
|
|
219
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
219
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
220
220
|
body: z.string().describe('Comment text (markdown)'),
|
|
221
221
|
blockId: z.string().optional().describe('Block id to anchor the comment to'),
|
|
222
222
|
parentId: z.string().optional().describe('Parent comment id, to reply in a thread'),
|
|
@@ -241,7 +241,7 @@ export function registerDocumentTools(server) {
|
|
|
241
241
|
'List the comment threads on a Rootr (루터) document, optionally filtered to one block. Works with any key.',
|
|
242
242
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
243
243
|
inputSchema: {
|
|
244
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
244
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
245
245
|
blockId: z.string().optional().describe('Only comments anchored to this block'),
|
|
246
246
|
},
|
|
247
247
|
},
|
|
@@ -270,7 +270,7 @@ export function registerDocumentTools(server) {
|
|
|
270
270
|
'Returns the slug/URL. Check rootr_document_public_status first; unpublish or change settings via rootr_set_document_public.',
|
|
271
271
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
272
272
|
inputSchema: {
|
|
273
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
273
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
274
274
|
},
|
|
275
275
|
},
|
|
276
276
|
async ({ target }) => {
|
|
@@ -297,7 +297,7 @@ export function registerDocumentTools(server) {
|
|
|
297
297
|
'Owner-level action (MANAGE): works with an account key (PAT), not with workspace-scoped/connector tokens.',
|
|
298
298
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
299
299
|
inputSchema: {
|
|
300
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
300
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
301
301
|
isPublic: z.boolean().optional().describe('false to unpublish, true to re-enable'),
|
|
302
302
|
allowCommits: z.boolean().optional().describe('Whether public readers can propose git-style edit suggestions'),
|
|
303
303
|
expiresAt: z
|
|
@@ -354,7 +354,7 @@ export function registerDocumentTools(server) {
|
|
|
354
354
|
description: 'Whether a Rootr (루터) document is published, and at which public slug/URL.',
|
|
355
355
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
356
356
|
inputSchema: {
|
|
357
|
-
target: z.string().describe('Document path (starting with "/") or node id'),
|
|
357
|
+
target: z.string().describe('Document path (starting with "/") or node id, or a full rootr.io URL'),
|
|
358
358
|
},
|
|
359
359
|
},
|
|
360
360
|
async ({ target }) => {
|
|
@@ -392,4 +392,33 @@ export function registerDocumentTools(server) {
|
|
|
392
392
|
}
|
|
393
393
|
},
|
|
394
394
|
);
|
|
395
|
+
|
|
396
|
+
server.registerTool(
|
|
397
|
+
'rootr_move_node',
|
|
398
|
+
{
|
|
399
|
+
title: 'Move or rename a Rootr node',
|
|
400
|
+
description:
|
|
401
|
+
'Move a Rootr (루터) document or folder to a different folder, and/or rename it. This is the only way ' +
|
|
402
|
+
'to relocate a node that already exists — creating with a target path only works up front. ' +
|
|
403
|
+
'`destination` accepts: a folder id, a folder path ending in "/" (moves into it, keeps the name), ' +
|
|
404
|
+
'a full new path incl. name (moves and renames in one call), or "/" for the workspace root.',
|
|
405
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
406
|
+
inputSchema: {
|
|
407
|
+
target: z.string().describe('Node path (starting with "/") or node id to move'),
|
|
408
|
+
destination: z
|
|
409
|
+
.string()
|
|
410
|
+
.describe('Destination folder id or path — see description for the accepted forms'),
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
async ({ target, destination }) => {
|
|
414
|
+
try {
|
|
415
|
+
const client = makeClient();
|
|
416
|
+
const id = requireResolved(await resolveTarget(client, target));
|
|
417
|
+
const { parentId, name } = await resolveMoveDestination(client, destination);
|
|
418
|
+
return jsonResult(await client.updateNode(id, { parentId, name }));
|
|
419
|
+
} catch (err) {
|
|
420
|
+
return errorResult(err);
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
);
|
|
395
424
|
}
|
package/lib/mcp-tools/shared.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveTarget, requireResolved } from '../resolve.js';
|
|
|
8
8
|
* takes a path too, exactly like rootr_read, so an agent that only knows where
|
|
9
9
|
* a node lives in the tree never has to hunt for its uuid.
|
|
10
10
|
*/
|
|
11
|
-
export const NODE_TARGET_HINT = ', or its path starting with "/"';
|
|
11
|
+
export const NODE_TARGET_HINT = ', or its path starting with "/", or a full rootr.io URL';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Resolve a path-or-id argument to a node id. Paths go through the by-path
|
|
@@ -4,6 +4,7 @@ import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } fro
|
|
|
4
4
|
import { loadConfig, saveConfig, keyForWorkspace, upsertKey } from '../config.js';
|
|
5
5
|
import { currentClientOverride } from '../context.js';
|
|
6
6
|
import { RootrClient } from '../client.js';
|
|
7
|
+
import { parseRootrUrl } from '../resolve.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Scaffold `tree` argument schema. Declared as an ARRAY of node objects so MCP
|
|
@@ -61,33 +62,54 @@ export function registerWorkspaceTools(server) {
|
|
|
61
62
|
'(A hosted/remote connector is bound to a single workspace at consent time and cannot switch.)',
|
|
62
63
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
63
64
|
inputSchema: {
|
|
64
|
-
workspace: z
|
|
65
|
+
workspace: z
|
|
66
|
+
.string()
|
|
67
|
+
.describe('Workspace id, its name (case-insensitive), or a rootr.io URL to a page in it'),
|
|
65
68
|
},
|
|
66
69
|
},
|
|
67
70
|
async ({ workspace }) => {
|
|
68
71
|
try {
|
|
72
|
+
const wantedUrl = parseRootrUrl(workspace);
|
|
73
|
+
const wanted = (wantedUrl ? wantedUrl.workspaceId : workspace).trim();
|
|
74
|
+
const wantedLower = wanted.toLowerCase();
|
|
75
|
+
|
|
69
76
|
if (currentClientOverride()) {
|
|
77
|
+
const hosted = currentClientOverride();
|
|
78
|
+
if (wanted === hosted.workspace) {
|
|
79
|
+
return textResult(`이미 이 워크스페이스(${hosted.workspace})에 연결되어 있습니다. 그대로 진행하세요.`);
|
|
80
|
+
}
|
|
70
81
|
return errorResult(
|
|
71
82
|
new Error(
|
|
72
|
-
|
|
73
|
-
'
|
|
83
|
+
`이 원격 커넥터는 워크스페이스 ${hosted.workspace} 하나에만 연결되어 있어 ${wanted} 로 전환할 수 없습니다.\n` +
|
|
84
|
+
'조치: rootr.io > 설정 > 연동에서 그 워크스페이스로 커넥터를 하나 더 발급한 뒤 다시 시도하세요. ' +
|
|
85
|
+
'(사용자가 직접 해야 하는 작업입니다.)',
|
|
74
86
|
),
|
|
75
87
|
);
|
|
76
88
|
}
|
|
77
|
-
const wanted = workspace.trim();
|
|
78
|
-
const wantedLower = wanted.toLowerCase();
|
|
79
89
|
const candidates = candidateClients();
|
|
90
|
+
if (!candidates.length) {
|
|
91
|
+
return errorResult(
|
|
92
|
+
new Error(
|
|
93
|
+
'이 컴퓨터에 등록된 Rootr 키가 없습니다.\n' +
|
|
94
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급받아 `rootr config --api-key <키>` 로 등록하세요.',
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
80
98
|
|
|
81
99
|
// Step 1 — resolve the name/id to a real workspace. /workspaces lists
|
|
82
100
|
// the KEY OWNER's workspaces, so any key can answer this.
|
|
83
101
|
let target = null;
|
|
102
|
+
let listed = 0;
|
|
103
|
+
const visible = [];
|
|
84
104
|
for (const candidate of candidates) {
|
|
85
105
|
let list = [];
|
|
86
106
|
try {
|
|
87
107
|
list = await candidate.client.listWorkspaces();
|
|
108
|
+
listed += 1;
|
|
88
109
|
} catch {
|
|
89
110
|
continue;
|
|
90
111
|
}
|
|
112
|
+
for (const w of list) if (!visible.some((v) => v.id === w.id)) visible.push(w);
|
|
91
113
|
const hit = list.find(
|
|
92
114
|
(w) => w.id === wanted || (w.name || '').toLowerCase() === wantedLower || (w.slug || '').toLowerCase() === wantedLower,
|
|
93
115
|
);
|
|
@@ -102,32 +124,69 @@ export function registerWorkspaceTools(server) {
|
|
|
102
124
|
// owner's workspaces while being rejected by every one but its own.
|
|
103
125
|
// Only a workspace-scoped call proves the binding.
|
|
104
126
|
let usable = null;
|
|
127
|
+
let lastRejection = null;
|
|
105
128
|
if (target) {
|
|
106
129
|
for (const candidate of candidates) {
|
|
107
130
|
try {
|
|
108
131
|
await candidate.client.request(`/workspaces/${encodeURIComponent(target.id)}`);
|
|
109
132
|
usable = candidate;
|
|
110
133
|
break;
|
|
111
|
-
} catch {
|
|
134
|
+
} catch (err) {
|
|
135
|
+
lastRejection = err;
|
|
112
136
|
continue;
|
|
113
137
|
}
|
|
114
138
|
}
|
|
115
139
|
}
|
|
116
|
-
|
|
140
|
+
// Listing can be unavailable (a legacy key with no owner attached gets
|
|
141
|
+
// 403 on /workspaces) — but that key may still be able to open the very
|
|
142
|
+
// workspace being asked for. Ask the workspace directly before giving up.
|
|
143
|
+
let match = target && usable ? { workspace: target, candidate: usable } : null;
|
|
144
|
+
if (!match && !target) {
|
|
145
|
+
for (const candidate of candidates) {
|
|
146
|
+
try {
|
|
147
|
+
const ws = await candidate.client.request(`/workspaces/${encodeURIComponent(wanted)}`);
|
|
148
|
+
if (ws && ws.id) {
|
|
149
|
+
match = { workspace: ws, candidate };
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
lastRejection = err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
117
157
|
|
|
158
|
+
// Listed-but-unreachable is the common real case: the workspace IS the
|
|
159
|
+
// user's, but every registered key is workspace-scoped to another one.
|
|
160
|
+
// Name that cause explicitly — a vague "no key" message makes the
|
|
161
|
+
// assistant invent permission problems (asking for an invite to the
|
|
162
|
+
// user's OWN workspace).
|
|
118
163
|
if (target && !usable) {
|
|
164
|
+
const scoped = lastRejection && lastRejection.status === 403;
|
|
119
165
|
return errorResult(
|
|
120
166
|
new Error(
|
|
121
|
-
`"${target.name || target.id}"
|
|
122
|
-
|
|
167
|
+
`"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
|
|
168
|
+
`${target.role ? ` (내 역할: ${target.role})` : ''}.\n` +
|
|
169
|
+
(scoped
|
|
170
|
+
? '원인: 지금 등록된 키가 다른 워크스페이스 전용 키입니다 (워크스페이스 키는 자기 워크스페이스 밖에서 403).\n'
|
|
171
|
+
: `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
|
|
172
|
+
'조치 (둘 중 하나, 사용자가 직접):\n' +
|
|
173
|
+
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
|
|
174
|
+
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``,
|
|
123
175
|
),
|
|
124
176
|
);
|
|
125
177
|
}
|
|
126
178
|
if (!match) {
|
|
179
|
+
const seenList = visible.length
|
|
180
|
+
? `\n지금 보이는 워크스페이스: ${visible.map((w) => `${w.name || w.slug || ''}(${w.id})`).join(', ')}`
|
|
181
|
+
: '';
|
|
127
182
|
return errorResult(
|
|
128
183
|
new Error(
|
|
129
|
-
|
|
130
|
-
'
|
|
184
|
+
listed === 0
|
|
185
|
+
? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키가 만료/취소되었거나 목록 권한이 없음).\n' +
|
|
186
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 새로 발급해 `rootr config --api-key <키>` 로 등록하세요.'
|
|
187
|
+
: `"${wanted}" 워크스페이스가 내 소속 목록에 없습니다.${seenList}\n` +
|
|
188
|
+
'조치: 주소가 맞는지 확인하고, 다른 계정의 워크스페이스라면 그 계정으로 발급한 키를 ' +
|
|
189
|
+
'`rootr config --add-key <키>` 로 등록하세요.',
|
|
131
190
|
),
|
|
132
191
|
);
|
|
133
192
|
}
|
|
@@ -161,24 +220,79 @@ export function registerWorkspaceTools(server) {
|
|
|
161
220
|
async () => {
|
|
162
221
|
try {
|
|
163
222
|
const hosted = currentClientOverride();
|
|
164
|
-
|
|
223
|
+
// Hosted (remote connector) requests have no ~/.rootr/config.json to
|
|
224
|
+
// read — candidateClients() would return nothing and the tool used to
|
|
225
|
+
// answer "(no workspaces visible to this key)" even though the
|
|
226
|
+
// connector is happily bound to one workspace. Ask the API about the
|
|
227
|
+
// bound workspace directly instead. (A connector token has no userId,
|
|
228
|
+
// so GET /workspaces is not open to it — GET /workspaces/{id} is.)
|
|
229
|
+
if (hosted) {
|
|
230
|
+
let name = '';
|
|
231
|
+
try {
|
|
232
|
+
const ws = await hosted.request(`/workspaces/${encodeURIComponent(hosted.workspace)}`);
|
|
233
|
+
name = ws.name || ws.slug || '';
|
|
234
|
+
} catch {
|
|
235
|
+
// fall through: the id alone is still more useful than nothing
|
|
236
|
+
}
|
|
237
|
+
return textResult(
|
|
238
|
+
`* ${hosted.workspace}\t${name}\n\n` +
|
|
239
|
+
'이 원격 커넥터는 위 워크스페이스 하나에만 연결되어 있습니다 (승인 시 선택). ' +
|
|
240
|
+
'다른 워크스페이스가 필요하면 rootr.io > 설정 > 연동에서 그 워크스페이스로 커넥터를 하나 더 발급하세요.',
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const cfg = loadConfig();
|
|
244
|
+
const active = cfg.workspace;
|
|
245
|
+
// Probing costs one API call per workspace (they count against the
|
|
246
|
+
// daily read quota), so only do it in the case it actually diagnoses:
|
|
247
|
+
// no account key registered, i.e. every credential here is scoped to
|
|
248
|
+
// one workspace and most of the list is unusable.
|
|
249
|
+
const probeReachability = !cfg.apiKey && (cfg.keys || []).length > 0;
|
|
165
250
|
const seen = new Map();
|
|
166
|
-
|
|
251
|
+
const reached = [];
|
|
252
|
+
let failures = 0;
|
|
253
|
+
for (const { client, label } of candidateClients()) {
|
|
167
254
|
let list = [];
|
|
168
255
|
try {
|
|
169
256
|
list = await client.listWorkspaces();
|
|
170
257
|
} catch {
|
|
258
|
+
failures += 1;
|
|
171
259
|
continue; // a revoked/foreign key shouldn't hide the others
|
|
172
260
|
}
|
|
173
261
|
for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
|
|
262
|
+
// Which workspace each key can actually WRITE in is what decides
|
|
263
|
+
// whether rootr_use_workspace will succeed — listing does not.
|
|
264
|
+
for (const w of probeReachability ? list.slice(0, 12) : []) {
|
|
265
|
+
if (reached.includes(w.id)) continue;
|
|
266
|
+
try {
|
|
267
|
+
await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
|
|
268
|
+
reached.push(w.id);
|
|
269
|
+
} catch {
|
|
270
|
+
// scoped key rejected here; another key may still cover it
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
void label;
|
|
174
274
|
}
|
|
175
275
|
const rows = [...seen.values()].map(
|
|
176
|
-
(w) =>
|
|
276
|
+
(w) =>
|
|
277
|
+
`${w.id === active ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}` +
|
|
278
|
+
(!probeReachability || reached.includes(w.id) ? '' : '\t[등록된 키로는 접근 불가]'),
|
|
177
279
|
);
|
|
280
|
+
if (!rows.length) {
|
|
281
|
+
return textResult(
|
|
282
|
+
failures
|
|
283
|
+
? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).\n' +
|
|
284
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.'
|
|
285
|
+
: '이 컴퓨터에 등록된 Rootr 키가 없습니다.\n' +
|
|
286
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const blocked = rows.some((r) => r.includes('접근 불가'));
|
|
178
290
|
return textResult(
|
|
179
|
-
rows.
|
|
180
|
-
|
|
181
|
-
|
|
291
|
+
`${rows.join('\n')}\n\n(* = 현재 워크스페이스, rootr_use_workspace 로 전환)` +
|
|
292
|
+
(blocked
|
|
293
|
+
? '\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다. ' +
|
|
294
|
+
'계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`'
|
|
295
|
+
: ''),
|
|
182
296
|
);
|
|
183
297
|
} catch (err) {
|
|
184
298
|
return errorResult(err);
|
package/lib/resolve.js
CHANGED
|
@@ -1,15 +1,67 @@
|
|
|
1
1
|
import { RootrApiError } from './client.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Web-app route segments that address a single node: rootr.io/{locale?}/w/{ws}/{kind}/{id}
|
|
5
|
+
* (mirrors frontend/src/app/w/[ws]/*). Anything else under /w/{ws} is a
|
|
6
|
+
* workspace-level screen (graph, trash, settings …) with no node to resolve.
|
|
7
|
+
*/
|
|
8
|
+
const NODE_URL_KINDS = new Set(['d', 'db', 'sheet', 'whiteboard', 'log', 'form', 'deck', 'rec', 'issues', 'crm']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a Rootr web URL an agent was handed by a human ("여기 봐줘: https://rootr.io/ko/w/…/d/…").
|
|
12
|
+
* Without this every such paste is treated as a node id and comes back as a
|
|
13
|
+
* bare "Not found", which reads like a permission problem and sends the
|
|
14
|
+
* assistant guessing. Returns null for anything that isn't a Rootr URL.
|
|
15
|
+
*/
|
|
16
|
+
export function parseRootrUrl(target) {
|
|
17
|
+
if (typeof target !== 'string' || !/^https?:\/\//i.test(target)) return null;
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(target);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
25
|
+
const at = segments.indexOf('w');
|
|
26
|
+
if (at === -1 || !segments[at + 1]) return null;
|
|
27
|
+
const kind = segments[at + 2];
|
|
28
|
+
const id = NODE_URL_KINDS.has(kind) ? segments[at + 3] : undefined;
|
|
29
|
+
return { workspaceId: segments[at + 1], nodeId: id, kind: id ? kind : undefined };
|
|
30
|
+
}
|
|
31
|
+
|
|
3
32
|
/**
|
|
4
33
|
* Resolve a CLI/MCP "target" argument to a node id.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
34
|
+
* A full rootr.io URL is unpacked to the node id it points at; a value
|
|
35
|
+
* starting with '/' is a tree path looked up via by-path (requires a
|
|
36
|
+
* workspace); anything else is already an id.
|
|
7
37
|
*
|
|
8
38
|
* Returns { id, existed, path } where existed indicates whether the path
|
|
9
39
|
* resolved to an existing node (always true for id-form targets, since we
|
|
10
40
|
* don't verify those against the API).
|
|
11
41
|
*/
|
|
12
42
|
export async function resolveTarget(client, target) {
|
|
43
|
+
const url = parseRootrUrl(target);
|
|
44
|
+
if (url) {
|
|
45
|
+
if (!url.nodeId) {
|
|
46
|
+
throw new RootrApiError(`이 주소는 문서가 아니라 워크스페이스 화면을 가리킵니다: ${target}`, {
|
|
47
|
+
hint: `해당 워크스페이스로 전환하려면 rootr_use_workspace 에 ${url.workspaceId} 를 넘기세요.`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// The URL carries the workspace it lives in, so a mismatch is knowable
|
|
51
|
+
// BEFORE the API answers 404 — say so instead of letting it look missing.
|
|
52
|
+
if (client.workspace && url.workspaceId !== client.workspace) {
|
|
53
|
+
throw new RootrApiError(
|
|
54
|
+
`이 주소는 다른 워크스페이스(${url.workspaceId})의 노드입니다. 현재 활성 워크스페이스는 ${client.workspace} 입니다.`,
|
|
55
|
+
{
|
|
56
|
+
hint:
|
|
57
|
+
`먼저 rootr_use_workspace 로 ${url.workspaceId} 로 전환하세요. ` +
|
|
58
|
+
'전환이 안 되면 그 워크스페이스에 쓸 수 있는 키가 없는 것입니다 (rootr_workspaces 로 확인).',
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return { id: url.nodeId, existed: true, path: undefined, url };
|
|
63
|
+
}
|
|
64
|
+
|
|
13
65
|
if (!target.startsWith('/')) {
|
|
14
66
|
return { id: target, existed: true, path: undefined };
|
|
15
67
|
}
|
|
@@ -28,3 +80,48 @@ export function requireResolved(resolved) {
|
|
|
28
80
|
}
|
|
29
81
|
return resolved.id;
|
|
30
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a "move destination" argument (used by `rootr mv` and
|
|
86
|
+
* `rootr_move_node`) to { parentId, name }.
|
|
87
|
+
* - a bare id (no leading '/'): move into that folder, keep the current name.
|
|
88
|
+
* - a path ending in '/', or a path that already resolves to a non-DOCUMENT
|
|
89
|
+
* node: move into that folder, keep the current name.
|
|
90
|
+
* - any other '/'-prefixed path: treated as the full destination path
|
|
91
|
+
* (parent + new name), so the same call can rename and move at once.
|
|
92
|
+
* - '/': move to the workspace root.
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveMoveDestination(client, dest) {
|
|
95
|
+
if (!dest.startsWith('/')) {
|
|
96
|
+
return { parentId: dest };
|
|
97
|
+
}
|
|
98
|
+
if (dest === '/') {
|
|
99
|
+
return { parentId: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const trailingSlash = dest.endsWith('/');
|
|
103
|
+
const destPath = trailingSlash ? dest.slice(0, -1) : dest;
|
|
104
|
+
const existing = await client.getNodeByPath(destPath);
|
|
105
|
+
const existingNode = existing && (existing.node || existing);
|
|
106
|
+
if (existingNode && existingNode.type !== 'DOCUMENT') {
|
|
107
|
+
return { parentId: existingNode.id };
|
|
108
|
+
}
|
|
109
|
+
if (trailingSlash) {
|
|
110
|
+
throw new RootrApiError(`대상 폴더를 찾을 수 없습니다: ${destPath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lastSlash = destPath.lastIndexOf('/');
|
|
114
|
+
const parentPath = lastSlash > 0 ? destPath.slice(0, lastSlash) : '';
|
|
115
|
+
const name = destPath.slice(lastSlash + 1);
|
|
116
|
+
if (!name) throw new RootrApiError(`잘못된 대상 경로: ${dest}`);
|
|
117
|
+
|
|
118
|
+
if (!parentPath) {
|
|
119
|
+
return { parentId: null, name };
|
|
120
|
+
}
|
|
121
|
+
const parent = await client.getNodeByPath(parentPath);
|
|
122
|
+
const parentNode = parent && (parent.node || parent);
|
|
123
|
+
if (!parentNode) {
|
|
124
|
+
throw new RootrApiError(`대상 폴더를 찾을 수 없습니다: ${parentPath}`);
|
|
125
|
+
}
|
|
126
|
+
return { parentId: parentNode.id, name };
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rootr-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
@@ -37,5 +37,8 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
39
39
|
"zod": "^3.23.8"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test test/*.test.mjs"
|
|
40
43
|
}
|
|
41
44
|
}
|