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 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
- 출력: `id\tname` 목록 (계정 키면 소속된 전체 워크스페이스, 워크스페이스 키면 그 워크스페이스만).
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` 도구로 **이름이나 id**를 주면 활성 워크스페이스가
173
- 바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정 키(PAT)를 쓰면
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
- return new RootrClient(cfg);
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 client = makeClient();
306
- const workspaces = await client.listWorkspaces();
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
- console.log(`${w.id}\t${w.name || w.slug || ''}`);
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
  }
@@ -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, saveConfig, keyForWorkspace, upsertKey } from '../config.js';
4
+ import { loadConfig } from '../config.js';
5
5
  import { currentClientOverride } from '../context.js';
6
- import { RootrClient } from '../client.js';
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.string().describe('Workspace id, or its name (case-insensitive)'),
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
- if (currentClientOverride()) {
70
- return errorResult(
71
- new Error(
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
- `"${target.name || target.id}" 워크스페이스에 접근할 수 있는 키가 없습니다. ` +
122
- '그 워크스페이스용 키를 등록하세요: rootr config --add-key <키>',
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
- saveConfig({ workspace: match.workspace.id });
136
- // Remember which key covers this workspace so later calls resolve
137
- // directly instead of probing every key again.
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
- const active = hosted ? hosted.workspace : loadConfig().workspace;
165
- const seen = new Map();
166
- for (const { client } of candidateClients()) {
167
- let list = [];
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
- list = await client.listWorkspaces();
101
+ const ws = await hosted.request(`/workspaces/${encodeURIComponent(hosted.workspace)}`);
102
+ name = ws.name || ws.slug || '';
170
103
  } catch {
171
- continue; // a revoked/foreign key shouldn't hide the others
104
+ // fall through: the id alone is still more useful than nothing
172
105
  }
173
- for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
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 = [...seen.values()].map(
176
- (w) => `${w.id === active ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}`,
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.length
180
- ? `${rows.join('\n')}\n\n(* = current; switch with rootr_use_workspace)`
181
- : '(no workspaces visible to this key)',
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
- * If it starts with '/', treat it as a path and look it up via by-path
6
- * (requires a workspace to be configured). Otherwise treat it as an id already.
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.6.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
  }