rootr-cli 0.6.0 → 0.7.1
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 +16 -6
- package/bin/rootr.js +68 -7
- package/lib/client.js +21 -0
- package/lib/credentials.js +169 -0
- package/lib/mcp-tools/documents.js +39 -10
- package/lib/mcp-tools/shared.js +1 -1
- package/lib/mcp-tools/workspaces.js +58 -102
- package/lib/resolve.js +99 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -152,13 +152,16 @@ rootr rm /notes/todo.md --yes # 확인 없이 (스크립트용)
|
|
|
152
152
|
(파이프/CI처럼 입력이 없는 환경에서는 "아니오"로 처리되어 안전합니다).
|
|
153
153
|
`docs:write` 스코프가 필요합니다.
|
|
154
154
|
|
|
155
|
-
### 워크스페이스 목록
|
|
155
|
+
### 워크스페이스 목록 · 전환
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
|
-
rootr ws
|
|
158
|
+
rootr ws # 목록 (현재 워크스페이스에 *, 못 여는 곳은 표시)
|
|
159
|
+
rootr use "영업팀" # 전환 — id · 이름 · rootr.io 주소 아무거나
|
|
159
160
|
```
|
|
160
161
|
|
|
161
|
-
|
|
162
|
+
`rootr ws` 출력은 `id\tname` 목록입니다. 등록된 키가 워크스페이스 전용 키뿐이면 목록에는
|
|
163
|
+
보이지만 열 수 없는 워크스페이스에 `[등록된 키로는 접근 불가]`가 붙습니다 — 목록에 보이는
|
|
164
|
+
것과 접근되는 것은 다릅니다. `rootr use`는 그 워크스페이스를 여는 키를 골라 함께 기억합니다.
|
|
162
165
|
|
|
163
166
|
### 워크스페이스 여러 개 쓰기 (키 등록 + 전환)
|
|
164
167
|
|
|
@@ -169,9 +172,16 @@ rootr config --add-key rootr_xxx --label "영업팀"
|
|
|
169
172
|
rootr config # 등록된 키 목록 확인
|
|
170
173
|
```
|
|
171
174
|
|
|
172
|
-
이후 MCP에서 `rootr_use_workspace` 도구로
|
|
173
|
-
바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정
|
|
174
|
-
소속된 어느 워크스페이스로든 바로 전환됩니다.
|
|
175
|
+
이후 MCP에서 `rootr_use_workspace` 도구로 **이름·id·루터 웹 주소** 중 아무거나 주면 활성
|
|
176
|
+
워크스페이스가 바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정
|
|
177
|
+
키(PAT)를 쓰면 소속된 어느 워크스페이스로든 바로 전환됩니다.
|
|
178
|
+
|
|
179
|
+
워크스페이스 키는 자기 워크스페이스 밖에서는 열리지 않습니다 — `rootr ws`(=`rootr_workspaces`)
|
|
180
|
+
목록에 보여도 접근은 안 되는 경우가 있어, 등록된 키가 워크스페이스 키뿐이면 목록에
|
|
181
|
+
`[등록된 키로는 접근 불가]`를 함께 표시합니다.
|
|
182
|
+
|
|
183
|
+
문서를 가리키는 웹 주소(`https://rootr.io/ko/w/<ws>/d/<doc>`)는 `rootr_read` 등 모든 노드
|
|
184
|
+
인자에 그대로 붙여넣을 수 있습니다. 다른 워크스페이스의 주소면 "먼저 전환하라"고 알려줍니다.
|
|
175
185
|
|
|
176
186
|
> 원격 커넥터(`https://rootr.io/mcp`)는 승인할 때 고른 워크스페이스 하나에만 연결됩니다.
|
|
177
187
|
> 다른 워크스페이스가 필요하면 그 워크스페이스로 커넥터를 새로 발급하세요.
|
package/bin/rootr.js
CHANGED
|
@@ -3,9 +3,10 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, basename, extname } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { loadConfig, saveConfig, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
6
|
+
import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
|
|
7
|
+
import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
|
|
7
8
|
import { RootrClient, RootrApiError } from '../lib/client.js';
|
|
8
|
-
import { resolveTarget, requireResolved } from '../lib/resolve.js';
|
|
9
|
+
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
9
10
|
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -19,9 +20,11 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
19
20
|
rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] 문서 끝(또는 헤딩 아래)에 안전하게 추가
|
|
20
21
|
rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
|
|
21
22
|
rootr search <query> 워크스페이스 검색
|
|
23
|
+
rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
|
|
22
24
|
rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
|
|
23
25
|
rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
|
|
24
|
-
rootr ws 내 워크스페이스 목록 (id\tname)
|
|
26
|
+
rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
|
|
27
|
+
rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
|
|
25
28
|
rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
|
|
26
29
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
27
30
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
|
|
@@ -70,7 +73,10 @@ function readStdin() {
|
|
|
70
73
|
|
|
71
74
|
function makeClient() {
|
|
72
75
|
const cfg = loadConfig();
|
|
73
|
-
|
|
76
|
+
// Several keys can live side by side, each bound to the workspace it opens
|
|
77
|
+
// (config.js keys[]). Use the one that covers the active workspace instead of
|
|
78
|
+
// insisting on a single default key.
|
|
79
|
+
return new RootrClient({ ...cfg, apiKey: keyForWorkspace(cfg, cfg.workspace) });
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
function printErrorAndExit(err) {
|
|
@@ -218,6 +224,21 @@ async function cmdSearch(args) {
|
|
|
218
224
|
}
|
|
219
225
|
}
|
|
220
226
|
|
|
227
|
+
async function cmdMv(args) {
|
|
228
|
+
const source = args._[0];
|
|
229
|
+
const dest = args._[1];
|
|
230
|
+
if (!source || !dest) throw new Error('사용법: rootr mv <path|id> <새 경로|폴더 id>');
|
|
231
|
+
|
|
232
|
+
const client = makeClient();
|
|
233
|
+
const resolved = await resolveTarget(client, source);
|
|
234
|
+
const id = requireResolved(resolved);
|
|
235
|
+
|
|
236
|
+
const { parentId, name } = await resolveMoveDestination(client, dest);
|
|
237
|
+
const updated = await client.updateNode(id, { parentId, name });
|
|
238
|
+
|
|
239
|
+
console.log(`이동했습니다: ${resolved.path || id} → ${updated.path || dest}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
221
242
|
async function cmdRm(args) {
|
|
222
243
|
const target = args._[0];
|
|
223
244
|
if (!target) throw new Error('사용법: rootr rm <path|id> [--yes]');
|
|
@@ -302,11 +323,47 @@ async function cmdAttach(args) {
|
|
|
302
323
|
}
|
|
303
324
|
|
|
304
325
|
async function cmdWs() {
|
|
305
|
-
const
|
|
306
|
-
const
|
|
326
|
+
const cfg = loadConfig();
|
|
327
|
+
const candidates = candidateClients(cfg);
|
|
328
|
+
if (!candidates.length) {
|
|
329
|
+
throw new RootrApiError('이 컴퓨터에 등록된 Rootr 키가 없습니다.', {
|
|
330
|
+
hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// Probing costs an API call per workspace, so only when it diagnoses something:
|
|
334
|
+
// no account key means every key is scoped and most of the list is unusable.
|
|
335
|
+
const probe = !cfg.apiKey && (cfg.keys || []).length > 0;
|
|
336
|
+
const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
|
|
337
|
+
if (!workspaces.length) {
|
|
338
|
+
throw new RootrApiError(
|
|
339
|
+
failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).' : '보이는 워크스페이스가 없습니다.',
|
|
340
|
+
{ hint: 'rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.' },
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
let blocked = false;
|
|
307
344
|
for (const w of workspaces) {
|
|
308
|
-
|
|
345
|
+
const unreachable = probe && !reachable.has(w.id);
|
|
346
|
+
blocked = blocked || unreachable;
|
|
347
|
+
console.log(
|
|
348
|
+
`${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}${unreachable ? '\t[등록된 키로는 접근 불가]' : ''}`,
|
|
349
|
+
);
|
|
309
350
|
}
|
|
351
|
+
if (blocked) {
|
|
352
|
+
console.error(
|
|
353
|
+
'\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다.\n' +
|
|
354
|
+
'계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`',
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function cmdUse(args) {
|
|
360
|
+
const wanted = args._.join(' ').trim();
|
|
361
|
+
if (!wanted) throw new Error('사용법: rootr use <워크스페이스 id | 이름 | rootr.io 주소>');
|
|
362
|
+
const cfg = loadConfig();
|
|
363
|
+
const match = await resolveWorkspaceSwitch(wanted, cfg);
|
|
364
|
+
if (match.error) throw new RootrApiError(match.error);
|
|
365
|
+
rememberWorkspace(match, cfg);
|
|
366
|
+
console.log(`활성 워크스페이스: "${match.workspace.name || match.workspace.slug || ''}" (${match.workspace.id})`);
|
|
310
367
|
}
|
|
311
368
|
|
|
312
369
|
async function cmdAsk(args) {
|
|
@@ -405,6 +462,8 @@ async function main() {
|
|
|
405
462
|
return cmdSearch(args);
|
|
406
463
|
case 'attach':
|
|
407
464
|
return cmdAttach(args);
|
|
465
|
+
case 'mv':
|
|
466
|
+
return cmdMv(args);
|
|
408
467
|
case 'rm':
|
|
409
468
|
return cmdRm(args);
|
|
410
469
|
case 'ws':
|
|
@@ -413,6 +472,8 @@ async function main() {
|
|
|
413
472
|
return cmdAsk(args);
|
|
414
473
|
case 'config':
|
|
415
474
|
return cmdConfig(args);
|
|
475
|
+
case 'use':
|
|
476
|
+
return cmdUse(args);
|
|
416
477
|
case 'mcp':
|
|
417
478
|
return cmdMcp(args);
|
|
418
479
|
default:
|
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. */
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { loadConfig, saveConfig, upsertKey } from './config.js';
|
|
2
|
+
import { RootrClient } from './client.js';
|
|
3
|
+
import { parseRootrUrl } from './resolve.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every credential this machine can use, default key first. An account key
|
|
7
|
+
* (PAT) reaches every workspace its owner belongs to; a workspace key reaches
|
|
8
|
+
* one. Which one applies to a given workspace is discovered by asking the API
|
|
9
|
+
* (listWorkspaces / a workspace-scoped call), never guessed from the key.
|
|
10
|
+
*
|
|
11
|
+
* Shared by the CLI (`rootr ws`, `rootr use`) and the MCP tools so both answer
|
|
12
|
+
* "which workspaces can I actually open?" the same way.
|
|
13
|
+
*/
|
|
14
|
+
export function candidateClients(cfg = loadConfig()) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
if (cfg.apiKey) {
|
|
18
|
+
seen.add(cfg.apiKey);
|
|
19
|
+
out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
|
|
20
|
+
}
|
|
21
|
+
for (const k of cfg.keys || []) {
|
|
22
|
+
if (!k.apiKey || seen.has(k.apiKey)) continue;
|
|
23
|
+
seen.add(k.apiKey);
|
|
24
|
+
out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect every workspace the registered keys can see, and (when asked) which
|
|
31
|
+
* of them a key can actually open — listing is not a capability probe: a
|
|
32
|
+
* workspace key lists all of its owner's workspaces while being rejected by
|
|
33
|
+
* every one but its own.
|
|
34
|
+
*/
|
|
35
|
+
export async function surveyWorkspaces(candidates, { probe = false, probeLimit = 12 } = {}) {
|
|
36
|
+
const seen = new Map();
|
|
37
|
+
const reachable = new Set();
|
|
38
|
+
let listed = 0;
|
|
39
|
+
let failures = 0;
|
|
40
|
+
for (const { client } of candidates) {
|
|
41
|
+
let list = [];
|
|
42
|
+
try {
|
|
43
|
+
list = await client.listWorkspaces();
|
|
44
|
+
listed += 1;
|
|
45
|
+
} catch {
|
|
46
|
+
failures += 1;
|
|
47
|
+
continue; // a revoked/foreign key shouldn't hide the others
|
|
48
|
+
}
|
|
49
|
+
for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
|
|
50
|
+
if (!probe) continue;
|
|
51
|
+
for (const w of list.slice(0, probeLimit)) {
|
|
52
|
+
if (reachable.has(w.id)) continue;
|
|
53
|
+
try {
|
|
54
|
+
await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
|
|
55
|
+
reachable.add(w.id);
|
|
56
|
+
} catch {
|
|
57
|
+
// scoped key rejected here; another key may still cover it
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { workspaces: [...seen.values()], reachable, listed, failures };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve "which workspace did the user mean, and which key opens it?" from an
|
|
66
|
+
* id, a name, or a rootr.io URL. Returns either { workspace, candidate } or a
|
|
67
|
+
* { error } describing the cause AND the next step — a bare "not found" is what
|
|
68
|
+
* used to send agents guessing at invitations for workspaces the user owns.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveWorkspaceSwitch(wantedRaw, cfg = loadConfig()) {
|
|
71
|
+
const url = parseRootrUrl(wantedRaw);
|
|
72
|
+
const wanted = (url ? url.workspaceId : wantedRaw).trim();
|
|
73
|
+
const wantedLower = wanted.toLowerCase();
|
|
74
|
+
const candidates = candidateClients(cfg);
|
|
75
|
+
if (!candidates.length) {
|
|
76
|
+
return {
|
|
77
|
+
error:
|
|
78
|
+
'이 컴퓨터에 등록된 Rootr 키가 없습니다.\n' +
|
|
79
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급받아 `rootr config --api-key <키>` 로 등록하세요.',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 1 — resolve the name/id to a real workspace. /workspaces lists the
|
|
84
|
+
// KEY OWNER's workspaces, so any key can answer this.
|
|
85
|
+
let target = null;
|
|
86
|
+
const visible = [];
|
|
87
|
+
let listed = 0;
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
let list = [];
|
|
90
|
+
try {
|
|
91
|
+
list = await candidate.client.listWorkspaces();
|
|
92
|
+
listed += 1;
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const w of list) if (!visible.some((v) => v.id === w.id)) visible.push(w);
|
|
97
|
+
const hit = list.find(
|
|
98
|
+
(w) => w.id === wanted || (w.name || '').toLowerCase() === wantedLower || (w.slug || '').toLowerCase() === wantedLower,
|
|
99
|
+
);
|
|
100
|
+
if (hit) {
|
|
101
|
+
target = hit;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Step 2 — find a key that can actually WORK in it.
|
|
107
|
+
let usable = null;
|
|
108
|
+
let lastRejection = null;
|
|
109
|
+
if (target) {
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
try {
|
|
112
|
+
await candidate.client.request(`/workspaces/${encodeURIComponent(target.id)}`);
|
|
113
|
+
usable = candidate;
|
|
114
|
+
break;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
lastRejection = err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Listing can be unavailable (a legacy key with no owner attached gets 403 on
|
|
122
|
+
// /workspaces) while that key still opens the very workspace being asked for.
|
|
123
|
+
if (!target) {
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
try {
|
|
126
|
+
const ws = await candidate.client.request(`/workspaces/${encodeURIComponent(wanted)}`);
|
|
127
|
+
if (ws && ws.id) return { workspace: ws, candidate };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
lastRejection = err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (target && usable) return { workspace: target, candidate: usable };
|
|
134
|
+
|
|
135
|
+
if (target) {
|
|
136
|
+
const scoped = lastRejection && lastRejection.status === 403;
|
|
137
|
+
return {
|
|
138
|
+
error:
|
|
139
|
+
`"${target.name || target.id}" (${target.id}) 는 접근 권한 목록에는 있지만, 등록된 키로는 열 수 없습니다` +
|
|
140
|
+
`${target.role ? ` (내 역할: ${target.role})` : ''}.\n` +
|
|
141
|
+
(scoped
|
|
142
|
+
? '원인: 지금 등록된 키가 다른 워크스페이스 전용 키입니다 (워크스페이스 키는 자기 워크스페이스 밖에서 403).\n'
|
|
143
|
+
: `원인: ${lastRejection ? lastRejection.message : '알 수 없음'}\n`) +
|
|
144
|
+
'조치 (둘 중 하나, 사용자가 직접):\n' +
|
|
145
|
+
'1) rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급 → `rootr config --api-key <키>` (모든 워크스페이스에 통함)\n' +
|
|
146
|
+
`2) 그 워크스페이스용 키를 발급 → \`rootr config --add-key <키> --workspace ${target.id}\``,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const seenList = visible.length
|
|
150
|
+
? `\n지금 보이는 워크스페이스: ${visible.map((w) => `${w.name || w.slug || ''}(${w.id})`).join(', ')}`
|
|
151
|
+
: '';
|
|
152
|
+
return {
|
|
153
|
+
error:
|
|
154
|
+
listed === 0
|
|
155
|
+
? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키가 만료/취소되었거나 목록 권한이 없음).\n' +
|
|
156
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 새로 발급해 `rootr config --api-key <키>` 로 등록하세요.'
|
|
157
|
+
: `"${wanted}" 워크스페이스가 내 소속 목록에 없습니다.${seenList}\n` +
|
|
158
|
+
'조치: 주소가 맞는지 확인하고, 다른 계정의 워크스페이스라면 그 계정으로 발급한 키를 ' +
|
|
159
|
+
'`rootr config --add-key <키>` 로 등록하세요.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Persist the switch: remember the workspace and which key covers it. */
|
|
164
|
+
export function rememberWorkspace({ workspace, candidate }, cfg = loadConfig()) {
|
|
165
|
+
saveConfig({ workspace: workspace.id });
|
|
166
|
+
if (candidate && candidate.apiKey !== cfg.apiKey) {
|
|
167
|
+
upsertKey({ apiKey: candidate.apiKey, workspace: workspace.id, label: candidate.label });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -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
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
|
|
4
|
-
import { loadConfig
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
5
|
import { currentClientOverride } from '../context.js';
|
|
6
|
-
import {
|
|
6
|
+
import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../credentials.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
|
|
@@ -25,28 +26,6 @@ function parseTree(tree) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
/**
|
|
29
|
-
* Every credential this machine can use, default key first. An account key
|
|
30
|
-
* (PAT) reaches every workspace its owner belongs to; workspace keys reach one.
|
|
31
|
-
* Which one applies to a given workspace is discovered by asking the API
|
|
32
|
-
* (listWorkspaces), not guessed from the key string.
|
|
33
|
-
*/
|
|
34
|
-
function candidateClients() {
|
|
35
|
-
const cfg = loadConfig();
|
|
36
|
-
const out = [];
|
|
37
|
-
const seen = new Set();
|
|
38
|
-
if (cfg.apiKey) {
|
|
39
|
-
seen.add(cfg.apiKey);
|
|
40
|
-
out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
|
|
41
|
-
}
|
|
42
|
-
for (const k of cfg.keys || []) {
|
|
43
|
-
if (!k.apiKey || seen.has(k.apiKey)) continue;
|
|
44
|
-
seen.add(k.apiKey);
|
|
45
|
-
out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
|
|
46
|
-
}
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
29
|
/** Workspace listing and scaffolding tools: rootr_workspaces, rootr_scaffold_plan, rootr_create_workspace, rootr_scaffold_apply. */
|
|
51
30
|
export function registerWorkspaceTools(server) {
|
|
52
31
|
server.registerTool(
|
|
@@ -61,83 +40,32 @@ export function registerWorkspaceTools(server) {
|
|
|
61
40
|
'(A hosted/remote connector is bound to a single workspace at consent time and cannot switch.)',
|
|
62
41
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
63
42
|
inputSchema: {
|
|
64
|
-
workspace: z
|
|
43
|
+
workspace: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe('Workspace id, its name (case-insensitive), or a rootr.io URL to a page in it'),
|
|
65
46
|
},
|
|
66
47
|
},
|
|
67
48
|
async ({ workspace }) => {
|
|
68
49
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
const wanted = workspace.trim();
|
|
78
|
-
const wantedLower = wanted.toLowerCase();
|
|
79
|
-
const candidates = candidateClients();
|
|
80
|
-
|
|
81
|
-
// Step 1 — resolve the name/id to a real workspace. /workspaces lists
|
|
82
|
-
// the KEY OWNER's workspaces, so any key can answer this.
|
|
83
|
-
let target = null;
|
|
84
|
-
for (const candidate of candidates) {
|
|
85
|
-
let list = [];
|
|
86
|
-
try {
|
|
87
|
-
list = await candidate.client.listWorkspaces();
|
|
88
|
-
} catch {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
const hit = list.find(
|
|
92
|
-
(w) => w.id === wanted || (w.name || '').toLowerCase() === wantedLower || (w.slug || '').toLowerCase() === wantedLower,
|
|
93
|
-
);
|
|
94
|
-
if (hit) {
|
|
95
|
-
target = hit;
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Step 2 — find a key that can actually WORK in it. Listing is not a
|
|
101
|
-
// capability probe: a workspace-scoped key still lists all of its
|
|
102
|
-
// owner's workspaces while being rejected by every one but its own.
|
|
103
|
-
// Only a workspace-scoped call proves the binding.
|
|
104
|
-
let usable = null;
|
|
105
|
-
if (target) {
|
|
106
|
-
for (const candidate of candidates) {
|
|
107
|
-
try {
|
|
108
|
-
await candidate.client.request(`/workspaces/${encodeURIComponent(target.id)}`);
|
|
109
|
-
usable = candidate;
|
|
110
|
-
break;
|
|
111
|
-
} catch {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
50
|
+
const hosted = currentClientOverride();
|
|
51
|
+
if (hosted) {
|
|
52
|
+
const url = parseRootrUrl(workspace);
|
|
53
|
+
const wanted = (url ? url.workspaceId : workspace).trim();
|
|
54
|
+
if (wanted === hosted.workspace) {
|
|
55
|
+
return textResult(`이미 이 워크스페이스(${hosted.workspace})에 연결되어 있습니다. 그대로 진행하세요.`);
|
|
114
56
|
}
|
|
115
|
-
}
|
|
116
|
-
const match = target && usable ? { workspace: target, candidate: usable } : null;
|
|
117
|
-
|
|
118
|
-
if (target && !usable) {
|
|
119
57
|
return errorResult(
|
|
120
58
|
new Error(
|
|
121
|
-
|
|
122
|
-
'그
|
|
123
|
-
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
if (!match) {
|
|
127
|
-
return errorResult(
|
|
128
|
-
new Error(
|
|
129
|
-
`"${wanted}" 워크스페이스를 찾지 못했습니다. rootr_workspaces로 접근 가능한 목록을 확인하세요. ` +
|
|
130
|
-
'목록에 없다면 그 워크스페이스용 키가 등록되지 않은 것입니다 (rootr config --add-key <키>).',
|
|
59
|
+
`이 원격 커넥터는 워크스페이스 ${hosted.workspace} 하나에만 연결되어 있어 ${wanted} 로 전환할 수 없습니다.\n` +
|
|
60
|
+
'조치: rootr.io > 설정 > 연동에서 그 워크스페이스로 커넥터를 하나 더 발급한 뒤 다시 시도하세요. ' +
|
|
61
|
+
'(사용자가 직접 해야 하는 작업입니다.)',
|
|
131
62
|
),
|
|
132
63
|
);
|
|
133
64
|
}
|
|
134
65
|
const cfg = loadConfig();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (match.candidate.apiKey !== cfg.apiKey) {
|
|
139
|
-
upsertKey({ apiKey: match.candidate.apiKey, workspace: match.workspace.id, label: match.candidate.label });
|
|
140
|
-
}
|
|
66
|
+
const match = await resolveWorkspaceSwitch(workspace, cfg);
|
|
67
|
+
if (match.error) return errorResult(new Error(match.error));
|
|
68
|
+
rememberWorkspace(match, cfg);
|
|
141
69
|
const name = match.workspace.name || match.workspace.slug || '';
|
|
142
70
|
return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
|
|
143
71
|
} catch (err) {
|
|
@@ -161,24 +89,52 @@ export function registerWorkspaceTools(server) {
|
|
|
161
89
|
async () => {
|
|
162
90
|
try {
|
|
163
91
|
const hosted = currentClientOverride();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
92
|
+
// Hosted (remote connector) requests have no ~/.rootr/config.json to
|
|
93
|
+
// read — candidateClients() would return nothing and the tool used to
|
|
94
|
+
// answer "(no workspaces visible to this key)" even though the
|
|
95
|
+
// connector is happily bound to one workspace. Ask the API about the
|
|
96
|
+
// bound workspace directly instead. (A connector token has no userId,
|
|
97
|
+
// so GET /workspaces is not open to it — GET /workspaces/{id} is.)
|
|
98
|
+
if (hosted) {
|
|
99
|
+
let name = '';
|
|
168
100
|
try {
|
|
169
|
-
|
|
101
|
+
const ws = await hosted.request(`/workspaces/${encodeURIComponent(hosted.workspace)}`);
|
|
102
|
+
name = ws.name || ws.slug || '';
|
|
170
103
|
} catch {
|
|
171
|
-
|
|
104
|
+
// fall through: the id alone is still more useful than nothing
|
|
172
105
|
}
|
|
173
|
-
|
|
106
|
+
return textResult(
|
|
107
|
+
`* ${hosted.workspace}\t${name}\n\n` +
|
|
108
|
+
'이 원격 커넥터는 위 워크스페이스 하나에만 연결되어 있습니다 (승인 시 선택). ' +
|
|
109
|
+
'다른 워크스페이스가 필요하면 rootr.io > 설정 > 연동에서 그 워크스페이스로 커넥터를 하나 더 발급하세요.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const cfg = loadConfig();
|
|
113
|
+
const candidates = candidateClients(cfg);
|
|
114
|
+
// Probing costs one API call per workspace (they count against the
|
|
115
|
+
// daily read quota), so only do it in the case it actually diagnoses:
|
|
116
|
+
// no account key registered, i.e. every credential here is scoped to
|
|
117
|
+
// one workspace and most of the list is unusable.
|
|
118
|
+
const probe = !cfg.apiKey && (cfg.keys || []).length > 0;
|
|
119
|
+
const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
|
|
120
|
+
if (!workspaces.length) {
|
|
121
|
+
return textResult(
|
|
122
|
+
(failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).\n' : '이 컴퓨터에 등록된 Rootr 키가 없습니다.\n') +
|
|
123
|
+
'조치: rootr.io > 설정 > API 키에서 계정 키(PAT)를 발급해 `rootr config --api-key <키>` 로 등록하세요.',
|
|
124
|
+
);
|
|
174
125
|
}
|
|
175
|
-
const rows =
|
|
176
|
-
(w) =>
|
|
126
|
+
const rows = workspaces.map(
|
|
127
|
+
(w) =>
|
|
128
|
+
`${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}` +
|
|
129
|
+
(!probe || reachable.has(w.id) ? '' : '\t[등록된 키로는 접근 불가]'),
|
|
177
130
|
);
|
|
131
|
+
const blocked = probe && workspaces.some((w) => !reachable.has(w.id));
|
|
178
132
|
return textResult(
|
|
179
|
-
rows.
|
|
180
|
-
|
|
181
|
-
|
|
133
|
+
`${rows.join('\n')}\n\n(* = 현재 워크스페이스, rootr_use_workspace 로 전환)` +
|
|
134
|
+
(blocked
|
|
135
|
+
? '\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다. ' +
|
|
136
|
+
'계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`'
|
|
137
|
+
: ''),
|
|
182
138
|
);
|
|
183
139
|
} catch (err) {
|
|
184
140
|
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.1",
|
|
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
|
}
|