rootr-cli 0.18.0 → 0.20.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.
@@ -3,8 +3,27 @@ import { z } from 'zod';
3
3
  import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { currentClientOverride } from '../context.js';
6
- import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../credentials.js';
6
+ import {
7
+ candidateClients,
8
+ rememberSessionKey,
9
+ rememberWorkspace,
10
+ resolveWorkspaceSwitch,
11
+ surveyWorkspaces,
12
+ } from '../credentials.js';
7
13
  import { parseRootrUrl } from '../resolve.js';
14
+ import { t as tr } from '../i18n.js';
15
+
16
+ /**
17
+ * ISSUE-221 — the workspace this server process was pinned to at launch via the
18
+ * ROOTR_WORKSPACE env var ('' when it was not). Read once, before any switch
19
+ * rewrites process.env: a switch on an env-pinned server only lasts as long as
20
+ * the process does (the MCP client passes the same env again on every restart
21
+ * or reconnect, and env deliberately wins over the config file), so the switch
22
+ * reply has to say where the server will be after a restart — otherwise the
23
+ * next write after a reconnect lands in the pinned workspace while the agent
24
+ * still believes it is in the one it switched to.
25
+ */
26
+ const LAUNCH_WORKSPACE = process.env.ROOTR_WORKSPACE || '';
8
27
 
9
28
  /**
10
29
  * Scaffold `tree` argument schema. Declared as an ARRAY of node objects so MCP
@@ -19,7 +38,12 @@ const ACCESS_HINT =
19
38
  '`access` can see it and everything under it) plus `access: [{ subjectId: "<userId>", level: "READ"|"WRITE"|' +
20
39
  '"MANAGE" }]` — that pair is how "make a folder these three people can see" happens in one call instead of ' +
21
40
  'leaving the locking to the user afterwards. A DATABASE node may also carry `database.permissions` (칸/행 ' +
22
- 'lock) and `database.notifications` (who to notify on row changes).';
41
+ 'lock) and `database.notifications` (who to notify on row changes). A database property may carry ' +
42
+ '`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
43
+ 'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
44
+ 'A DOCUMENT\'s `content` may embed a live ' +
45
+ 'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
46
+ 'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
23
47
 
24
48
  const treeArg = (desc) => z.array(z.record(z.any())).or(z.string()).describe(desc + ACCESS_HINT);
25
49
 
@@ -44,6 +68,8 @@ export function registerWorkspaceTools(server) {
44
68
  'reconfiguration, no restart. With an account key (PAT) you can switch to any workspace you belong ' +
45
69
  'to; if separate workspace keys are configured, the one that covers the target is selected and ' +
46
70
  'remembered automatically. Call rootr_workspaces first if you need to see what is available. ' +
71
+ 'If the server was started with the ROOTR_WORKSPACE env var, the switch lasts until the server ' +
72
+ 'restarts (the reply says so) — after a restart/reconnect, check rootr_workspaces again. ' +
47
73
  '(A hosted/remote connector is bound to a single workspace at consent time and cannot switch.)',
48
74
  annotations: { readOnlyHint: false, destructiveHint: false },
49
75
  inputSchema: {
@@ -73,8 +99,55 @@ export function registerWorkspaceTools(server) {
73
99
  const match = await resolveWorkspaceSwitch(workspace, cfg);
74
100
  if (match.error) return errorResult(new Error(match.error));
75
101
  rememberWorkspace(match, cfg);
102
+ // ISSUE-172 — rememberWorkspace() only writes ~/.rootr/config.json.
103
+ // loadConfig()/keyForWorkspace() both give ROOTR_WORKSPACE/ROOTR_API_KEY
104
+ // env vars priority over that file (deliberately — see config.js
105
+ // ENV-WINS comment), so on a server launched with those env vars set
106
+ // (the common case for MCP client configs that pass env: {...}) every
107
+ // later tool call kept reading the OLD workspace/key from the
108
+ // process's environment no matter what got written to disk. The tool
109
+ // still replied with a success message, so the switch looked like it
110
+ // worked while nothing after it actually moved. Fix: mutate this
111
+ // process's env too, so the very next loadConfig() call (same
112
+ // process, same request loop) picks up the switch immediately.
113
+ if (process.env.ROOTR_WORKSPACE) process.env.ROOTR_WORKSPACE = match.workspace.id;
114
+ if (process.env.ROOTR_API_KEY && match.candidate && match.candidate.apiKey !== process.env.ROOTR_API_KEY) {
115
+ // ISSUE-221 — the key being replaced may exist nowhere but in this
116
+ // process's environment; keep it so switching back still works.
117
+ rememberSessionKey(process.env.ROOTR_API_KEY);
118
+ process.env.ROOTR_API_KEY = match.candidate.apiKey;
119
+ }
120
+ // Verify the switch actually took before reporting success — never
121
+ // report a switch that didn't actually move the active workspace.
122
+ const after = loadConfig();
123
+ if (after.workspace !== match.workspace.id) {
124
+ return errorResult(
125
+ new Error(
126
+ `워크스페이스를 "${match.workspace.id}" 로 바꾸지 못했습니다 — 이 서버 프로세스가 ROOTR_WORKSPACE 환경변수로 ` +
127
+ '고정되어 있어 전환이 적용되지 않습니다.\n' +
128
+ '조치: MCP 서버를 실행하는 환경설정에서 ROOTR_WORKSPACE 환경변수를 제거하거나 원하는 워크스페이스 id로 바꾼 뒤 ' +
129
+ '서버를 재시작하세요.',
130
+ ),
131
+ );
132
+ }
76
133
  const name = match.workspace.name || match.workspace.slug || '';
77
- return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
134
+ // ISSUE-221 — say how long the switch lasts when the server is env-pinned.
135
+ const pinnedNote =
136
+ LAUNCH_WORKSPACE && LAUNCH_WORKSPACE !== match.workspace.id
137
+ ? tr(
138
+ `\n주의: 이 MCP 서버는 ROOTR_WORKSPACE 환경변수(${LAUNCH_WORKSPACE})로 고정되어 시작됐습니다. 이 전환은 서버가 ` +
139
+ `켜져 있는 동안만 유효하고, 서버가 재시작(재연결)되면 ${LAUNCH_WORKSPACE} 로 돌아갑니다 — 재시작 뒤에는 쓰기 전에 ` +
140
+ 'rootr_workspaces 로 현재 워크스페이스를 다시 확인하세요. 계속 이 워크스페이스를 쓰려면 MCP 서버 설정의 ' +
141
+ `ROOTR_WORKSPACE 를 ${match.workspace.id} 로 바꾸거나 지우세요 (사용자가 직접).`,
142
+ `\nNote: this MCP server was started pinned to ROOTR_WORKSPACE=${LAUNCH_WORKSPACE}. The switch lasts only while ` +
143
+ `the server keeps running; after a restart/reconnect it is back on ${LAUNCH_WORKSPACE} — check rootr_workspaces ` +
144
+ 'again before writing. To stay on this workspace, change (or remove) ROOTR_WORKSPACE in the MCP server ' +
145
+ `config to ${match.workspace.id} (a step for the user).`,
146
+ )
147
+ : '';
148
+ return textResult(
149
+ `활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.${pinnedNote}`,
150
+ );
78
151
  } catch (err) {
79
152
  return errorResult(err);
80
153
  }
@@ -190,79 +263,99 @@ export function registerWorkspaceTools(server) {
190
263
  },
191
264
  );
192
265
 
193
- server.registerTool(
194
- 'rootr_create_workspace',
195
- {
196
- title: 'Create a brand-new Rootr workspace from a scaffold tree',
197
- description:
198
- 'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
199
- 'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
200
- 'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
201
- 'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
202
- 'response carries the same `review` back, so a low score here means you shipped something thin. ' +
203
- 'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
204
- 'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
205
- 'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
206
- 'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
207
- 'callbacks, offer the user to register a webhook on the new tree (rootr_create_webhook with scopeNodeId) ' +
208
- 'so changes flow back to you — skip your own actorId to avoid self-triggering.',
209
- annotations: { readOnlyHint: false, destructiveHint: false },
210
- inputSchema: {
211
- name: z.string().describe('Name for the new workspace'),
212
- intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
213
- tree: treeArg('Scaffold tree — an ARRAY of node objects to populate the new workspace with (see llms.txt tree schema)'),
266
+ // A hosted/remote connector (ChatGPT, Claude web, etc.) is bound to exactly
267
+ // one workspace chosen at OAuth consent time and its token can never carry
268
+ // `workspaces:create` (see MCP_PERMISSION_SCOPES in mcp-oauth.service.ts —
269
+ // deliberately excluded there). Advertising these two tools anyway meant a
270
+ // hosted session always saw rootr_create_workspace/rootr_delete_workspace in
271
+ // tools/list but every call 403'd with "missing scope: workspaces:create" —
272
+ // the tool looked broken with no way to know why. Only the local stdio CLI
273
+ // (PAT-backed, no client override) can ever satisfy that scope, so skip
274
+ // registering them entirely for hosted sessions.
275
+ if (!currentClientOverride()) {
276
+ server.registerTool(
277
+ 'rootr_create_workspace',
278
+ {
279
+ title: 'Create a brand-new Rootr workspace from a scaffold tree',
280
+ description:
281
+ 'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
282
+ 'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
283
+ 'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
284
+ 'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
285
+ 'response carries the same `review` back, so a low score here means you shipped something thin. ' +
286
+ 'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
287
+ 'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
288
+ 'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
289
+ 'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
290
+ 'callbacks, offer the user to register a webhook on the new tree (rootr_create_webhook with scopeNodeId) ' +
291
+ 'so changes flow back to you — skip your own actorId to avoid self-triggering.',
292
+ annotations: { readOnlyHint: false, destructiveHint: false },
293
+ inputSchema: {
294
+ name: z.string().describe('Name for the new workspace'),
295
+ intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
296
+ tree: treeArg('Scaffold tree — an ARRAY of node objects to populate the new workspace with (see llms.txt tree schema)'),
297
+ mode: z
298
+ .enum(['docs', 'minimal'])
299
+ .optional()
300
+ .describe(
301
+ 'Lower the review bar when the system-building rules do not apply: "docs" for a document-only ' +
302
+ 'tree with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s ' +
303
+ 'shared ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for ' +
304
+ 'normal multi-table workspaces.',
305
+ ),
306
+ },
214
307
  },
215
- },
216
- async ({ name, intent, tree }) => {
217
- try {
218
- const client = makeClient();
219
- const result = await client.createWorkspaceScaffold({ name, intent, tree: parseTree(tree) });
220
- return jsonResult(result);
221
- } catch (err) {
222
- return errorResult(err);
223
- }
224
- },
225
- );
308
+ async ({ name, intent, tree, mode }) => {
309
+ try {
310
+ const client = makeClient();
311
+ const result = await client.createWorkspaceScaffold({ name, intent, tree: parseTree(tree), mode });
312
+ return jsonResult(result);
313
+ } catch (err) {
314
+ return errorResult(err);
315
+ }
316
+ },
317
+ );
226
318
 
227
- /**
228
- * WS-CLEANUP — **짓다 실패해서 남은 빈 워크스페이스를 에이전트가 치울 수 있게.**
229
- *
230
- * 스캐폴딩이 중간에 실패하면 껍데기만 남는데 지우는 문이 없었다. 무료 한도가
231
- * 5개라 몇 번 실패하면 사용자가 바로 막히고, 그 잔해를 사람이 화면에서 하나씩
232
- * 지워야 했다(2026-08-30 실측: 치과 3개·아파트 4개).
233
- *
234
- * 이름을 그대로 다시 적게 한다 — 서버가 요구하는 안전장치이자, 모델이 엉뚱한
235
- * 워크스페이스를 지목했을 때 걸리는 그물이다.
236
- */
237
- server.registerTool(
238
- 'rootr_delete_workspace',
239
- {
240
- title: 'Delete a workspace you created (cleanup after a failed scaffold)',
241
- description:
242
- 'Permanently delete a Rootr (루터) workspace. Intended for cleaning up EMPTY leftovers from a scaffold ' +
243
- 'run that failed halfway — the free plan allows only a handful of workspaces, so abandoned shells block ' +
244
- 'the user from trying again. Requires OWNER on that workspace, and you must pass `confirmName` exactly ' +
245
- 'matching the workspace name (call rootr_workspaces first to read it). THIS CANNOT BE UNDONE: everything ' +
246
- 'inside is removed. Never call this on a workspace that holds the user\'s real work — if you did not ' +
247
- 'create it in this session, ask the user first.',
248
- annotations: { readOnlyHint: false, destructiveHint: true },
249
- inputSchema: {
250
- workspaceId: z.string().describe('Id of the workspace to delete'),
251
- confirmName: z
252
- .string()
253
- .describe('The workspace name, typed again exactly — the server rejects the call if it does not match'),
319
+ /**
320
+ * WS-CLEANUP — **짓다 실패해서 남은 빈 워크스페이스를 에이전트가 치울 수 있게.**
321
+ *
322
+ * 스캐폴딩이 중간에 실패하면 껍데기만 남는데 지우는 문이 없었다. 무료 한도가
323
+ * 5개라 몇 번 실패하면 사용자가 바로 막히고, 그 잔해를 사람이 화면에서 하나씩
324
+ * 지워야 했다(2026-08-30 실측: 치과 3개·아파트 4개).
325
+ *
326
+ * 이름을 그대로 다시 적게 한다 — 서버가 요구하는 안전장치이자, 모델이 엉뚱한
327
+ * 워크스페이스를 지목했을 때 걸리는 그물이다.
328
+ */
329
+ server.registerTool(
330
+ 'rootr_delete_workspace',
331
+ {
332
+ title: 'Delete a workspace you created (cleanup after a failed scaffold)',
333
+ description:
334
+ 'Permanently delete a Rootr (루터) workspace. Intended for cleaning up EMPTY leftovers from a scaffold ' +
335
+ 'run that failed halfway — the free plan allows only a handful of workspaces, so abandoned shells block ' +
336
+ 'the user from trying again. Requires OWNER on that workspace, and you must pass `confirmName` exactly ' +
337
+ 'matching the workspace name (call rootr_workspaces first to read it). THIS CANNOT BE UNDONE: everything ' +
338
+ 'inside is removed. Never call this on a workspace that holds the user\'s real work — if you did not ' +
339
+ 'create it in this session, ask the user first.',
340
+ annotations: { readOnlyHint: false, destructiveHint: true },
341
+ inputSchema: {
342
+ workspaceId: z.string().describe('Id of the workspace to delete'),
343
+ confirmName: z
344
+ .string()
345
+ .describe('The workspace name, typed again exactly — the server rejects the call if it does not match'),
346
+ },
254
347
  },
255
- },
256
- async ({ workspaceId, confirmName }) => {
257
- try {
258
- const client = makeClient();
259
- await client.deleteWorkspace({ workspaceId, confirmName });
260
- return jsonResult({ deleted: true, workspaceId });
261
- } catch (err) {
262
- return errorResult(err);
263
- }
264
- },
265
- );
348
+ async ({ workspaceId, confirmName }) => {
349
+ try {
350
+ const client = makeClient();
351
+ await client.deleteWorkspace({ workspaceId, confirmName });
352
+ return jsonResult({ deleted: true, workspaceId });
353
+ } catch (err) {
354
+ return errorResult(err);
355
+ }
356
+ },
357
+ );
358
+ }
266
359
 
267
360
  server.registerTool(
268
361
  'rootr_scaffold_review',
@@ -280,12 +373,21 @@ export function registerWorkspaceTools(server) {
280
373
  annotations: { readOnlyHint: true, destructiveHint: false },
281
374
  inputSchema: {
282
375
  tree: treeArg('The scaffold tree you designed — an ARRAY of node objects, same shape you would pass to rootr_scaffold_apply'),
376
+ mode: z
377
+ .enum(['docs', 'minimal'])
378
+ .optional()
379
+ .describe(
380
+ 'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
381
+ 'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
382
+ 'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
383
+ 'multi-table workspaces.',
384
+ ),
283
385
  },
284
386
  },
285
- async ({ tree }) => {
387
+ async ({ tree, mode }) => {
286
388
  try {
287
389
  const client = makeClient();
288
- const result = await client.scaffoldReview(parseTree(tree));
390
+ const result = await client.scaffoldReview(parseTree(tree), mode);
289
391
  return jsonResult(result);
290
392
  } catch (err) {
291
393
  return errorResult(err);
@@ -309,13 +411,20 @@ export function registerWorkspaceTools(server) {
309
411
  inputSchema: {
310
412
  rootPath: z.string().optional().describe('Grade only this subtree, e.g. "/동네 빵집 운영"'),
311
413
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
414
+ mode: z
415
+ .enum(['docs', 'minimal'])
416
+ .optional()
417
+ .describe(
418
+ 'Same meaning as on rootr_scaffold_review: "docs" for a document-only subtree, "minimal" for a ' +
419
+ 'small personal/few-person subtree where relations/guide/forms/rollups are overkill.',
420
+ ),
312
421
  },
313
422
  },
314
- async ({ rootPath, workspace }) => {
423
+ async ({ rootPath, workspace, mode }) => {
315
424
  try {
316
425
  const ws = requireWorkspace(workspace);
317
426
  const client = makeClient(ws);
318
- return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath));
427
+ return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath, mode));
319
428
  } catch (err) {
320
429
  return errorResult(err);
321
430
  }
@@ -373,13 +482,22 @@ export function registerWorkspaceTools(server) {
373
482
  .string()
374
483
  .optional()
375
484
  .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
485
+ mode: z
486
+ .enum(['docs', 'minimal'])
487
+ .optional()
488
+ .describe(
489
+ 'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
490
+ 'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
491
+ 'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
492
+ 'multi-table workspaces.',
493
+ ),
376
494
  },
377
495
  },
378
- async ({ tree, rootPath, workspace }) => {
496
+ async ({ tree, rootPath, workspace, mode }) => {
379
497
  try {
380
498
  const ws = requireWorkspace(workspace);
381
499
  const client = makeClient(ws);
382
- const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree) });
500
+ const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree), mode });
383
501
  return jsonResult(result);
384
502
  } catch (err) {
385
503
  return errorResult(err);
package/lib/mcp.js CHANGED
@@ -11,6 +11,8 @@ import { registerTemplateTools } from './mcp-tools/templates.js';
11
11
  import { registerWebhookTools } from './mcp-tools/webhooks.js';
12
12
  import { registerMiscTools } from './mcp-tools/misc.js';
13
13
  import { registerPublicTools } from './mcp-tools/public.js';
14
+ // BOOKING — 공개한 표를 예약 페이지로 쓰는 도구 + 구글 캘린더 연결.
15
+ import { registerBookingTools } from './mcp-tools/booking.js';
14
16
  import { registerCrmTools } from './mcp-tools/crm.js';
15
17
  import { registerPresentationTools } from './mcp-tools/presentations.js';
16
18
  import { registerPageTools } from './mcp-tools/pages.js';
@@ -272,6 +274,7 @@ export function buildMcpServer({ name, version }) {
272
274
  registerWebhookTools(server);
273
275
  registerMiscTools(server);
274
276
  registerPublicTools(server);
277
+ registerBookingTools(server);
275
278
  registerCrmTools(server);
276
279
  registerPresentationTools(server);
277
280
  registerPageTools(server);
package/lib/resolve.js CHANGED
@@ -30,6 +30,26 @@ export function parseRootrUrl(target) {
30
30
  return { workspaceId: segments[at + 1], nodeId: id, kind: id ? kind : undefined };
31
31
  }
32
32
 
33
+ /**
34
+ * Parse a published public document URL: `rootr.io/p/{slug}` (optionally
35
+ * locale-prefixed, e.g. `/ko/p/{slug}`). This is the "share this doc"
36
+ * address — it carries no workspace id, unlike `/w/{ws}/...` URLs, so it
37
+ * needs its own recognizer. Returns null for anything else.
38
+ */
39
+ export function parsePublicSlugUrl(target) {
40
+ if (typeof target !== 'string' || !/^https?:\/\//i.test(target)) return null;
41
+ let url;
42
+ try {
43
+ url = new URL(target);
44
+ } catch {
45
+ return null;
46
+ }
47
+ const segments = url.pathname.split('/').filter(Boolean);
48
+ const at = segments.indexOf('p');
49
+ if (at === -1 || !segments[at + 1]) return null;
50
+ return { slug: segments[at + 1] };
51
+ }
52
+
33
53
  /**
34
54
  * Resolve a CLI/MCP "target" argument to a node id.
35
55
  * A full rootr.io URL is unpacked to the node id it points at; a value
@@ -78,6 +98,27 @@ export async function resolveTarget(client, target) {
78
98
  return { id: url.nodeId, existed: true, path: undefined, url };
79
99
  }
80
100
 
101
+ const publicUrl = parsePublicSlugUrl(target);
102
+ if (publicUrl) {
103
+ if (!client.workspace) {
104
+ throw new RootrApiError(
105
+ tr(
106
+ `이 주소는 공개 문서 주소(/p/${publicUrl.slug})입니다. 먼저 rootr_use_workspace 로 워크스페이스를 선택하세요.`,
107
+ `This is a public document URL (/p/${publicUrl.slug}). Select a workspace with rootr_use_workspace first.`,
108
+ ),
109
+ );
110
+ }
111
+ const node = await client.getNodeBySlug(publicUrl.slug);
112
+ if (!node) {
113
+ // ISSUE-127 — 못 찾은 공개 주소는 "새로 만들 경로"가 아니다. 예전에는 path 에 주소를 그대로 실어, rootr_write 가
114
+ // `/https:/rootr.io/p/<slug>` 라는 폴더 줄기와 헛문서를 만들었다(격리 dev 실측). path 는 비우고 주소는 target 으로만 남긴다.
115
+ // publicSlug 는 rootr_read 가 공개 발행본으로 되받을 때만 쓴다(쓰기 도구는 안 본다).
116
+ return { id: undefined, existed: false, path: undefined, target, publicSlug: publicUrl.slug };
117
+ }
118
+ const id = node.id || (node.node && node.node.id);
119
+ return { id, existed: true, path: undefined, node: node.node || node };
120
+ }
121
+
81
122
  if (!target.startsWith('/')) {
82
123
  return { id: target, existed: true, path: undefined };
83
124
  }
@@ -92,7 +133,8 @@ export async function resolveTarget(client, target) {
92
133
 
93
134
  export function requireResolved(resolved) {
94
135
  if (!resolved.existed || !resolved.id) {
95
- throw new RootrApiError(tr(`경로를 찾을 수 없습니다: ${resolved.path}`, `Path not found: ${resolved.path}`));
136
+ const what = resolved.path ?? resolved.target;
137
+ throw new RootrApiError(tr(`경로를 찾을 수 없습니다: ${what}`, `Path not found: ${what}`));
96
138
  }
97
139
  return resolved.id;
98
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.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": {