rootr-cli 0.18.0 → 0.19.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.
@@ -19,7 +19,12 @@ const ACCESS_HINT =
19
19
  '`access` can see it and everything under it) plus `access: [{ subjectId: "<userId>", level: "READ"|"WRITE"|' +
20
20
  '"MANAGE" }]` — that pair is how "make a folder these three people can see" happens in one call instead of ' +
21
21
  '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).';
22
+ 'lock) and `database.notifications` (who to notify on row changes). A database property may carry ' +
23
+ '`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
24
+ 'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
25
+ 'A DOCUMENT\'s `content` may embed a live ' +
26
+ 'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
27
+ 'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
23
28
 
24
29
  const treeArg = (desc) => z.array(z.record(z.any())).or(z.string()).describe(desc + ACCESS_HINT);
25
30
 
@@ -73,6 +78,34 @@ export function registerWorkspaceTools(server) {
73
78
  const match = await resolveWorkspaceSwitch(workspace, cfg);
74
79
  if (match.error) return errorResult(new Error(match.error));
75
80
  rememberWorkspace(match, cfg);
81
+ // ISSUE-172 — rememberWorkspace() only writes ~/.rootr/config.json.
82
+ // loadConfig()/keyForWorkspace() both give ROOTR_WORKSPACE/ROOTR_API_KEY
83
+ // env vars priority over that file (deliberately — see config.js
84
+ // ENV-WINS comment), so on a server launched with those env vars set
85
+ // (the common case for MCP client configs that pass env: {...}) every
86
+ // later tool call kept reading the OLD workspace/key from the
87
+ // process's environment no matter what got written to disk. The tool
88
+ // still replied with a success message, so the switch looked like it
89
+ // worked while nothing after it actually moved. Fix: mutate this
90
+ // process's env too, so the very next loadConfig() call (same
91
+ // process, same request loop) picks up the switch immediately.
92
+ if (process.env.ROOTR_WORKSPACE) process.env.ROOTR_WORKSPACE = match.workspace.id;
93
+ if (process.env.ROOTR_API_KEY && match.candidate && match.candidate.apiKey !== process.env.ROOTR_API_KEY) {
94
+ process.env.ROOTR_API_KEY = match.candidate.apiKey;
95
+ }
96
+ // Verify the switch actually took before reporting success — never
97
+ // report a switch that didn't actually move the active workspace.
98
+ const after = loadConfig();
99
+ if (after.workspace !== match.workspace.id) {
100
+ return errorResult(
101
+ new Error(
102
+ `워크스페이스를 "${match.workspace.id}" 로 바꾸지 못했습니다 — 이 서버 프로세스가 ROOTR_WORKSPACE 환경변수로 ` +
103
+ '고정되어 있어 전환이 적용되지 않습니다.\n' +
104
+ '조치: MCP 서버를 실행하는 환경설정에서 ROOTR_WORKSPACE 환경변수를 제거하거나 원하는 워크스페이스 id로 바꾼 뒤 ' +
105
+ '서버를 재시작하세요.',
106
+ ),
107
+ );
108
+ }
76
109
  const name = match.workspace.name || match.workspace.slug || '';
77
110
  return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
78
111
  } catch (err) {
@@ -190,79 +223,99 @@ export function registerWorkspaceTools(server) {
190
223
  },
191
224
  );
192
225
 
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)'),
226
+ // A hosted/remote connector (ChatGPT, Claude web, etc.) is bound to exactly
227
+ // one workspace chosen at OAuth consent time and its token can never carry
228
+ // `workspaces:create` (see MCP_PERMISSION_SCOPES in mcp-oauth.service.ts —
229
+ // deliberately excluded there). Advertising these two tools anyway meant a
230
+ // hosted session always saw rootr_create_workspace/rootr_delete_workspace in
231
+ // tools/list but every call 403'd with "missing scope: workspaces:create" —
232
+ // the tool looked broken with no way to know why. Only the local stdio CLI
233
+ // (PAT-backed, no client override) can ever satisfy that scope, so skip
234
+ // registering them entirely for hosted sessions.
235
+ if (!currentClientOverride()) {
236
+ server.registerTool(
237
+ 'rootr_create_workspace',
238
+ {
239
+ title: 'Create a brand-new Rootr workspace from a scaffold tree',
240
+ description:
241
+ 'Create an entirely new Rootr (루터) workspace pre-populated with the given document tree. ' +
242
+ 'REQUIRES an account key (PAT) with the workspaces:create scope — a workspace-scoped key cannot do this. ' +
243
+ 'PRECONDITION: rootr_scaffold_plan first, ask the user its 3-10 clarifying questions, design the tree from ' +
244
+ 'their answers, then run rootr_scaffold_review and fix everything it flags at `error` level. The ' +
245
+ 'response carries the same `review` back, so a low score here means you shipped something thin. ' +
246
+ 'Build a real tool, not a pile of thin markdown files: mix DATABASE (board/' +
247
+ 'timeline views, seed rows), SPREADSHEET (formulas), FORM, ISSUE_TRACKER and rich multi-section DOCUMENTs ' +
248
+ 'where they fit, and set an emoji `icon` on folders/databases/key documents. The `tree` shape follows ' +
249
+ 'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
250
+ 'callbacks, offer the user to register a webhook on the new tree (rootr_create_webhook with scopeNodeId) ' +
251
+ 'so changes flow back to you — skip your own actorId to avoid self-triggering.',
252
+ annotations: { readOnlyHint: false, destructiveHint: false },
253
+ inputSchema: {
254
+ name: z.string().describe('Name for the new workspace'),
255
+ intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
256
+ tree: treeArg('Scaffold tree — an ARRAY of node objects to populate the new workspace with (see llms.txt tree schema)'),
257
+ mode: z
258
+ .enum(['docs', 'minimal'])
259
+ .optional()
260
+ .describe(
261
+ 'Lower the review bar when the system-building rules do not apply: "docs" for a document-only ' +
262
+ 'tree with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s ' +
263
+ 'shared ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for ' +
264
+ 'normal multi-table workspaces.',
265
+ ),
266
+ },
214
267
  },
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
- );
268
+ async ({ name, intent, tree, mode }) => {
269
+ try {
270
+ const client = makeClient();
271
+ const result = await client.createWorkspaceScaffold({ name, intent, tree: parseTree(tree), mode });
272
+ return jsonResult(result);
273
+ } catch (err) {
274
+ return errorResult(err);
275
+ }
276
+ },
277
+ );
226
278
 
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'),
279
+ /**
280
+ * WS-CLEANUP — **짓다 실패해서 남은 빈 워크스페이스를 에이전트가 치울 수 있게.**
281
+ *
282
+ * 스캐폴딩이 중간에 실패하면 껍데기만 남는데 지우는 문이 없었다. 무료 한도가
283
+ * 5개라 몇 번 실패하면 사용자가 바로 막히고, 그 잔해를 사람이 화면에서 하나씩
284
+ * 지워야 했다(2026-08-30 실측: 치과 3개·아파트 4개).
285
+ *
286
+ * 이름을 그대로 다시 적게 한다 — 서버가 요구하는 안전장치이자, 모델이 엉뚱한
287
+ * 워크스페이스를 지목했을 때 걸리는 그물이다.
288
+ */
289
+ server.registerTool(
290
+ 'rootr_delete_workspace',
291
+ {
292
+ title: 'Delete a workspace you created (cleanup after a failed scaffold)',
293
+ description:
294
+ 'Permanently delete a Rootr (루터) workspace. Intended for cleaning up EMPTY leftovers from a scaffold ' +
295
+ 'run that failed halfway — the free plan allows only a handful of workspaces, so abandoned shells block ' +
296
+ 'the user from trying again. Requires OWNER on that workspace, and you must pass `confirmName` exactly ' +
297
+ 'matching the workspace name (call rootr_workspaces first to read it). THIS CANNOT BE UNDONE: everything ' +
298
+ 'inside is removed. Never call this on a workspace that holds the user\'s real work — if you did not ' +
299
+ 'create it in this session, ask the user first.',
300
+ annotations: { readOnlyHint: false, destructiveHint: true },
301
+ inputSchema: {
302
+ workspaceId: z.string().describe('Id of the workspace to delete'),
303
+ confirmName: z
304
+ .string()
305
+ .describe('The workspace name, typed again exactly — the server rejects the call if it does not match'),
306
+ },
254
307
  },
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
- );
308
+ async ({ workspaceId, confirmName }) => {
309
+ try {
310
+ const client = makeClient();
311
+ await client.deleteWorkspace({ workspaceId, confirmName });
312
+ return jsonResult({ deleted: true, workspaceId });
313
+ } catch (err) {
314
+ return errorResult(err);
315
+ }
316
+ },
317
+ );
318
+ }
266
319
 
267
320
  server.registerTool(
268
321
  'rootr_scaffold_review',
@@ -280,12 +333,21 @@ export function registerWorkspaceTools(server) {
280
333
  annotations: { readOnlyHint: true, destructiveHint: false },
281
334
  inputSchema: {
282
335
  tree: treeArg('The scaffold tree you designed — an ARRAY of node objects, same shape you would pass to rootr_scaffold_apply'),
336
+ mode: z
337
+ .enum(['docs', 'minimal'])
338
+ .optional()
339
+ .describe(
340
+ 'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
341
+ 'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
342
+ 'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
343
+ 'multi-table workspaces.',
344
+ ),
283
345
  },
284
346
  },
285
- async ({ tree }) => {
347
+ async ({ tree, mode }) => {
286
348
  try {
287
349
  const client = makeClient();
288
- const result = await client.scaffoldReview(parseTree(tree));
350
+ const result = await client.scaffoldReview(parseTree(tree), mode);
289
351
  return jsonResult(result);
290
352
  } catch (err) {
291
353
  return errorResult(err);
@@ -309,13 +371,20 @@ export function registerWorkspaceTools(server) {
309
371
  inputSchema: {
310
372
  rootPath: z.string().optional().describe('Grade only this subtree, e.g. "/동네 빵집 운영"'),
311
373
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
374
+ mode: z
375
+ .enum(['docs', 'minimal'])
376
+ .optional()
377
+ .describe(
378
+ 'Same meaning as on rootr_scaffold_review: "docs" for a document-only subtree, "minimal" for a ' +
379
+ 'small personal/few-person subtree where relations/guide/forms/rollups are overkill.',
380
+ ),
312
381
  },
313
382
  },
314
- async ({ rootPath, workspace }) => {
383
+ async ({ rootPath, workspace, mode }) => {
315
384
  try {
316
385
  const ws = requireWorkspace(workspace);
317
386
  const client = makeClient(ws);
318
- return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath));
387
+ return jsonResult(await client.scaffoldReviewBuilt(ws, rootPath, mode));
319
388
  } catch (err) {
320
389
  return errorResult(err);
321
390
  }
@@ -373,13 +442,22 @@ export function registerWorkspaceTools(server) {
373
442
  .string()
374
443
  .optional()
375
444
  .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
445
+ mode: z
446
+ .enum(['docs', 'minimal'])
447
+ .optional()
448
+ .describe(
449
+ 'Lower the review bar when the system-building rules do not apply: "docs" for a document-only tree ' +
450
+ 'with no tables at all, "minimal" for a small personal/few-person tree (e.g. a couple\'s shared ' +
451
+ 'ledger) where relations, a how-to-use guide, forms, and rollups are overkill. Omit for normal ' +
452
+ 'multi-table workspaces.',
453
+ ),
376
454
  },
377
455
  },
378
- async ({ tree, rootPath, workspace }) => {
456
+ async ({ tree, rootPath, workspace, mode }) => {
379
457
  try {
380
458
  const ws = requireWorkspace(workspace);
381
459
  const client = makeClient(ws);
382
- const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree) });
460
+ const result = await client.scaffoldApply(ws, { rootPath, tree: parseTree(tree), mode });
383
461
  return jsonResult(result);
384
462
  } catch (err) {
385
463
  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.19.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": {