rootr-cli 0.6.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 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` 도구로 **이름이나 id**를 주면 활성 워크스페이스가
173
- 바뀌고, 그 워크스페이스를 담당하는 키가 자동으로 선택·기억됩니다. 계정 키(PAT)를 쓰면
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
  > 다른 워크스페이스가 필요하면 그 워크스페이스로 커넥터를 새로 발급하세요.
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. */
@@ -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
@@ -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.string().describe('Workspace id, or its name (case-insensitive)'),
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
- const match = target && usable ? { workspace: target, candidate: usable } : null;
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
- '그 워크스페이스용 키를 등록하세요: rootr config --add-key <키>',
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
- `"${wanted}" 워크스페이스를 찾지 못했습니다. rootr_workspaces로 접근 가능한 목록을 확인하세요. ` +
130
- '목록에 없다면 그 워크스페이스용 키가 등록되지 않은 것입니다 (rootr config --add-key <키>).',
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
- const active = hosted ? hosted.workspace : loadConfig().workspace;
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
- for (const { client } of candidateClients()) {
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) => `${w.id === active ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}`,
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.length
180
- ? `${rows.join('\n')}\n\n(* = current; switch with rootr_use_workspace)`
181
- : '(no workspaces visible to this key)',
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
- * 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.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
  }