rootr-cli 0.14.1 → 0.17.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.
@@ -0,0 +1,243 @@
1
+ import { z } from 'zod';
2
+
3
+ import {
4
+ jsonResult,
5
+ errorResult,
6
+ makeClient,
7
+ requireWorkspace,
8
+ resolveNodeId,
9
+ resolveOptionalNodeId,
10
+ NODE_TARGET_HINT,
11
+ NODE_URL_HINT,
12
+ } from './shared.js';
13
+
14
+ /**
15
+ * PAGE (web page) tools.
16
+ *
17
+ * A Rootr page is an ordered list of typed blocks — that is the safety model:
18
+ * a published page cannot carry a script or a phishing overlay. The agent's job
19
+ * is to CHOOSE BLOCKS. The one escape hatch is the `custom` block, whose free
20
+ * HTML/CSS renders inside a locked-down sandboxed iframe (no scripts, opaque
21
+ * origin, no network), so even bold CSS stays inside its own box.
22
+ *
23
+ * The guide below is repeated (shortened) on every tool, so an agent that only
24
+ * sees one of these tools still knows the block vocabulary.
25
+ */
26
+ const BLOCK_GUIDE =
27
+ 'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
28
+ 'You assemble a page from these 15 block types:\n' +
29
+ '· hero {title, subtitle?, align?:"left"|"center", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner\n' +
30
+ '· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
31
+ '· features {columns?:1|2|3, items:[{title, body, icon?(emoji)}]} — "what it does" grid\n' +
32
+ '· gallery {images:[{attachmentId, caption?}]} — images must be workspace attachments (no external image URLs)\n' +
33
+ '· pricing {plans:[{name, price, period?, features:[string], highlight?, buttonLabel?, target?}]}\n' +
34
+ '· testimonials {items:[{quote, name, affiliation?}]}\n' +
35
+ '· faq {items:[{q, a}]}\n' +
36
+ '· timeline {items:[{date, title, body?}]}\n' +
37
+ '· cta {title, buttonLabel, target, body?} — closing call to action\n' +
38
+ '· footer {links:[{label, target}], contact?, copyright?}\n' +
39
+ '· liveStats {cards:[{label, icon?, source:{nodeId(DATABASE/LOG node), agg:"count"|"sum"|"avg"|"min"|"max", column?}}]} — numbers read LIVE from a table\n' +
40
+ '· liveTable {source:{nodeId}, columns?:[name], rowLimit?(1-200), orderBy?:{column, dir:"asc"|"desc"}}\n' +
41
+ '· liveChart {source:{nodeId}, chart:"bar"|"line"|"donut", labelColumn, valueColumn, agg:"count"|"sum"|"avg"}\n' +
42
+ '· form {formNodeId(FORM node), headline?, submitLabel?} — visitors submit right on the page\n' +
43
+ '· custom {html, height?(40-4000, default 260)} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
44
+ '(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
45
+ 'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
46
+ 'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS.\n' +
47
+ 'LIVE/FORM BLOCKS: `source.nodeId` / `formNodeId` must be a node in the SAME workspace, and `column`/`labelColumn`/' +
48
+ '`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
49
+ 'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
50
+ 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
51
+ 'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
52
+ 'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
53
+ 'not published — so link to nodes rather than pasting addresses.\n' +
54
+ 'CONFIG: {theme, accentColor?, navEnabled?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
55
+ 'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent. ' +
56
+ 'Every published page carries the Rootr signature at the bottom.\n' +
57
+ 'Block ids are optional — omit them and they are generated. Keep the ids you get back if you want to edit one block later.';
58
+
59
+ /** PAGE (block-assembled web page) node tools: create/read/update/append + live values. */
60
+ export function registerPageTools(server) {
61
+ server.registerTool(
62
+ 'rootr_create_page',
63
+ {
64
+ title: 'Create a Rootr page (block-assembled web page)',
65
+ description:
66
+ 'Create a new PAGE node in a Rootr (루터) workspace — a web page assembled from safe typed blocks, which ' +
67
+ 'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
68
+ 'for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
69
+ 'Omit `blocks` to start from a hero/text/footer skeleton. ' +
70
+ BLOCK_GUIDE +
71
+ ' ' +
72
+ NODE_URL_HINT +
73
+ ' (that is the editor address; use rootr_publish_node for the public address.)',
74
+ annotations: { readOnlyHint: false, destructiveHint: false },
75
+ inputSchema: {
76
+ name: z.string().optional().describe('Page name (also the browser tab title of the published page)'),
77
+ parentId: z.string().optional().describe(`Parent folder/node id${NODE_TARGET_HINT}`),
78
+ path: z.string().optional().describe('Full path including the name, e.g. "/마케팅/가을 행사" — wins over parentId+name'),
79
+ createParents: z.boolean().optional().describe('Create missing parent folders in `path`'),
80
+ autoRename: z.boolean().optional().describe('On a name clash, append " (2)" instead of failing with 409'),
81
+ icon: z.string().optional().describe('Emoji icon for the page node'),
82
+ blocks: z.array(z.record(z.unknown())).optional().describe('Ordered blocks — see the block vocabulary in this description'),
83
+ config: z
84
+ .record(z.unknown())
85
+ .optional()
86
+ .describe('{theme, accentColor?, navEnabled?} — theme: classic|minimal|warm|pastel|editorial|mono|dark|midnight'),
87
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
88
+ },
89
+ },
90
+ async ({ name, parentId, path, createParents, autoRename, icon, blocks, config, workspace }) => {
91
+ try {
92
+ const ws = requireWorkspace(workspace);
93
+ const client = makeClient(ws);
94
+ parentId = await resolveOptionalNodeId(client, parentId);
95
+ return jsonResult(
96
+ await client.createPage(ws, { name, parentId, path, createParents, autoRename, icon, blocks, config }),
97
+ );
98
+ } catch (err) {
99
+ return errorResult(err);
100
+ }
101
+ },
102
+ );
103
+
104
+ server.registerTool(
105
+ 'rootr_read_page',
106
+ {
107
+ title: 'Read a Rootr page',
108
+ description:
109
+ 'Read a Rootr (루터) PAGE node — its full blocks array and config. Read this before rootr_update_page or ' +
110
+ 'rootr_update_page_block so you know the current block ids, order and contents.',
111
+ annotations: { readOnlyHint: true, destructiveHint: false },
112
+ inputSchema: {
113
+ pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
114
+ },
115
+ },
116
+ async ({ pageId }) => {
117
+ try {
118
+ const client = makeClient();
119
+ pageId = await resolveNodeId(client, pageId);
120
+ return jsonResult(await client.getPage(pageId));
121
+ } catch (err) {
122
+ return errorResult(err);
123
+ }
124
+ },
125
+ );
126
+
127
+ server.registerTool(
128
+ 'rootr_append_page_blocks',
129
+ {
130
+ title: 'Append blocks to a Rootr page (preferred way to add sections)',
131
+ description:
132
+ 'Append one or more blocks to the END of a Rootr (루터) PAGE, leaving existing blocks untouched. ' +
133
+ 'PREFER this over rootr_update_page when you are only adding sections — update replaces the whole page. ' +
134
+ BLOCK_GUIDE,
135
+ annotations: { readOnlyHint: false, destructiveHint: false },
136
+ inputSchema: {
137
+ pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
138
+ blocks: z.array(z.record(z.unknown())).describe('Blocks to append, in order'),
139
+ },
140
+ },
141
+ async ({ pageId, blocks }) => {
142
+ try {
143
+ const client = makeClient();
144
+ pageId = await resolveNodeId(client, pageId);
145
+ return jsonResult(await client.appendPageBlocks(pageId, blocks));
146
+ } catch (err) {
147
+ return errorResult(err);
148
+ }
149
+ },
150
+ );
151
+
152
+ server.registerTool(
153
+ 'rootr_update_page',
154
+ {
155
+ title: 'Update a Rootr page (blocks/theme)',
156
+ description:
157
+ 'Update a Rootr (루터) PAGE. Passing `blocks` REPLACES THE WHOLE PAGE — call rootr_read_page first and ' +
158
+ 'resend every block you want to keep, or use rootr_append_page_blocks / rootr_update_page_block for ' +
159
+ 'incremental edits. Passing only `config` changes the theme/accent without touching content. ' +
160
+ BLOCK_GUIDE,
161
+ annotations: { readOnlyHint: false, destructiveHint: false },
162
+ inputSchema: {
163
+ pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
164
+ blocks: z
165
+ .array(z.record(z.unknown()))
166
+ .optional()
167
+ .describe('COMPLETE blocks array to set — any block you omit is deleted'),
168
+ config: z.record(z.unknown()).optional().describe('{theme, accentColor?, navEnabled?}'),
169
+ },
170
+ },
171
+ async ({ pageId, blocks, config }) => {
172
+ try {
173
+ const client = makeClient();
174
+ pageId = await resolveNodeId(client, pageId);
175
+ return jsonResult(await client.updatePage(pageId, { blocks, config }));
176
+ } catch (err) {
177
+ return errorResult(err);
178
+ }
179
+ },
180
+ );
181
+
182
+ server.registerTool(
183
+ 'rootr_update_page_block',
184
+ {
185
+ title: 'Edit one block of a Rootr page',
186
+ description:
187
+ 'Replace the props of a SINGLE block on a Rootr (루터) PAGE, leaving every other block alone — the safe way ' +
188
+ 'to fix one section (a headline, a price, a live table source). The block keeps its id and position. ' +
189
+ 'Get block ids from rootr_read_page. ' +
190
+ BLOCK_GUIDE,
191
+ annotations: { readOnlyHint: false, destructiveHint: false },
192
+ inputSchema: {
193
+ pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
194
+ blockId: z.string().describe('Block id, from rootr_read_page'),
195
+ props: z.record(z.unknown()).describe('COMPLETE props object for this block type (replaces the old props)'),
196
+ type: z.string().optional().describe('Change the block type too (rare) — props must then match the new type'),
197
+ },
198
+ },
199
+ async ({ pageId, blockId, props, type }) => {
200
+ try {
201
+ const client = makeClient();
202
+ pageId = await resolveNodeId(client, pageId);
203
+ const page = await client.getPage(pageId);
204
+ const blocks = Array.isArray(page.blocks) ? page.blocks : [];
205
+ const idx = blocks.findIndex((b) => b && b.id === blockId);
206
+ if (idx === -1) {
207
+ // 있는 id를 알려 준다 — 못 찾았다고만 하면 다음 시도도 똑같이 틀린다.
208
+ const ids = blocks.map((b) => `${b.id}(${b.type})`).join(', ') || '(none)';
209
+ return errorResult(new Error(`block "${blockId}" not found on this page. Blocks: ${ids}`));
210
+ }
211
+ const next = blocks.map((b, i) => (i === idx ? { ...b, ...(type ? { type } : {}), props } : b));
212
+ return jsonResult(await client.updatePage(pageId, { blocks: next }));
213
+ } catch (err) {
214
+ return errorResult(err);
215
+ }
216
+ },
217
+ );
218
+
219
+ server.registerTool(
220
+ 'rootr_read_page_live_data',
221
+ {
222
+ title: 'Read what a page\'s live blocks currently show',
223
+ description:
224
+ 'Read the values a Rootr (루터) PAGE\'s live blocks (liveStats/liveTable/liveChart) are showing right now — ' +
225
+ 'the aggregates, rows and chart points computed from their source tables. Use it to check that a live block ' +
226
+ 'points at the right table and column: an empty result usually means the source is missing, is in a PRIVATE ' +
227
+ 'subtree, or the column name does not match.',
228
+ annotations: { readOnlyHint: true, destructiveHint: false },
229
+ inputSchema: {
230
+ pageId: z.string().describe(`PAGE node id${NODE_TARGET_HINT}`),
231
+ },
232
+ },
233
+ async ({ pageId }) => {
234
+ try {
235
+ const client = makeClient();
236
+ pageId = await resolveNodeId(client, pageId);
237
+ return jsonResult(await client.getPageLiveData(pageId));
238
+ } catch (err) {
239
+ return errorResult(err);
240
+ }
241
+ },
242
+ );
243
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  /**
6
6
  * Authoring guide, repeated (shortened) across every tool description so an
@@ -123,7 +123,9 @@ export function registerPresentationTools(server) {
123
123
  description:
124
124
  'Create a new PRESENTATION node (a slide deck) in a Rootr (루터) workspace, optionally seeded with an ' +
125
125
  'initial theme and slides. ' +
126
- AUTHORING_GUIDE,
126
+ AUTHORING_GUIDE +
127
+ ' ' +
128
+ NODE_URL_HINT,
127
129
  annotations: { readOnlyHint: false, destructiveHint: false },
128
130
  inputSchema: {
129
131
  name: z.string().optional().describe('Deck name/title'),
@@ -4,7 +4,7 @@ import { basename, extname, join, isAbsolute } from 'node:path';
4
4
 
5
5
  import { z } from 'zod';
6
6
 
7
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
7
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
8
8
  import { RootrApiError } from '../client.js';
9
9
 
10
10
  /** 100MB — the backend's hard limit on a single audio upload. */
@@ -68,7 +68,8 @@ export function registerRecordingTools(server) {
68
68
  'speech-to-text; nothing is transcribed until you call it) → poll rootr_read_recording until status is ' +
69
69
  'READY → read the transcript (and optionally name the speakers with rootr_rename_recording_speakers) → ' +
70
70
  'write the minutes yourself with rootr_write, or call rootr_summarize_recording to use Rootr\'s built-in ' +
71
- 'summarizer.',
71
+ 'summarizer. ' +
72
+ NODE_URL_HINT,
72
73
  annotations: { readOnlyHint: false, destructiveHint: false },
73
74
  inputSchema: {
74
75
  name: z.string().optional().describe('Recording name/title, e.g. "2026-07-21 주간회의"'),
@@ -208,6 +209,7 @@ export function registerRecordingTools(server) {
208
209
  'UPLOADED = audio stored but no transcript yet — call rootr_transcribe_recording to create one; ' +
209
210
  'TRANSCRIBING = speech-to-text still running (poll again later); ' +
210
211
  'READY = transcript available; FAILED = last transcription errored (see `error`).',
212
+ annotations: { readOnlyHint: true, destructiveHint: false },
211
213
  inputSchema: {
212
214
  recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
213
215
  },
@@ -242,6 +244,7 @@ export function registerRecordingTools(server) {
242
244
  'Assign display names to the diarized speaker labels of a Rootr (루터) RECORDING node, e.g. ' +
243
245
  '{"S1":"김대표","S2":"이팀장"}. Merge-patch: only the keys you send change. The names apply to the ' +
244
246
  'transcript everywhere (web UI, rootr_read_recording, knowledge graph).',
247
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
245
248
  inputSchema: {
246
249
  recordingId: z.string().describe(`RECORDING node id${NODE_TARGET_HINT}`),
247
250
  speakerNames: z
@@ -10,6 +10,15 @@ import { resolveTarget, requireResolved } from '../resolve.js';
10
10
  */
11
11
  export const NODE_TARGET_HINT = ', or its path starting with "/", or a full rootr.io URL';
12
12
 
13
+ /**
14
+ * ISSUE-103 — every node create/read tool's result carries `url` (the address
15
+ * to hand a human) and `routeSegment` (the URL segment, in case `url` is
16
+ * missing). Never guess the segment yourself: DOCUMENT/db/log/sheet/whiteboard/
17
+ * form/issues/deck/crm/rec are all different, and SIGNED reuses DOCUMENT's 'd'.
18
+ */
19
+ export const NODE_URL_HINT =
20
+ 'The result includes `url` — the address to open this node at. Use it as-is; never guess or construct it.';
21
+
13
22
  /**
14
23
  * Resolve a path-or-id argument to a node id. Paths go through the by-path
15
24
  * endpoint; anything else is already an id and is passed through untouched.
@@ -9,6 +9,7 @@ import {
9
9
  resolveNodeId,
10
10
  resolveOptionalNodeId,
11
11
  NODE_TARGET_HINT,
12
+ NODE_URL_HINT,
12
13
  } from './shared.js';
13
14
 
14
15
  /**
@@ -77,7 +78,9 @@ export function registerSignedDocTools(server) {
77
78
  '문서, 연구노트, 점검 기록, 회의 확인서. ' +
78
79
  'Flow: create (body + approval line) → edit the body freely with rootr_write/rootr_append → ' +
79
80
  'rootr_request_signing_code → ask the human for the 6-digit code → rootr_submit_signed_document ' +
80
- '(freezes + first signature) → each remaining signer does request-code + rootr_sign_document.',
81
+ '(freezes + first signature) → each remaining signer does request-code + rootr_sign_document. ' +
82
+ NODE_URL_HINT +
83
+ ' (SIGNED opens on the DOCUMENT screen, not a dedicated one.)',
81
84
  annotations: { readOnlyHint: false, destructiveHint: false },
82
85
  inputSchema: {
83
86
  name: z.string().optional().describe('문서 이름, 예: "2026-08-25 실험 기록"'),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  const CELL_VALUE = z
6
6
  .union([z.string(), z.number(), z.boolean(), z.null()])
@@ -28,7 +28,9 @@ export function registerSpreadsheetTools(server) {
28
28
  'Create a new SPREADSHEET node (a grid with formulas) in a Rootr (루터) workspace. Use SPREADSHEET when ' +
29
29
  'content needs calculation (budgets, models, estimates) — for plain tracked lists use DATABASE instead. ' +
30
30
  'Defaults to a single empty "Sheet1" if no `data` is given. ' +
31
- CELLS_GUIDE,
31
+ CELLS_GUIDE +
32
+ ' ' +
33
+ NODE_URL_HINT,
32
34
  annotations: { readOnlyHint: false, destructiveHint: false },
33
35
  inputSchema: {
34
36
  name: z.string().optional().describe('Spreadsheet name/title'),
@@ -60,7 +62,8 @@ export function registerSpreadsheetTools(server) {
60
62
  {
61
63
  title: 'Read a Rootr spreadsheet',
62
64
  description:
63
- 'Read a Rootr (루터) SPREADSHEET node — its sheets, each with a sparse `cells` map and dimensions. Call ' +
65
+ 'Read a Rootr (루터) SPREADSHEET node — returns { data: { sheets: [...] }, ... }, where each sheet has ' +
66
+ 'a sparse `cells` map and dimensions (sheets are under `data.sheets`, not top-level `sheets`). Call ' +
64
67
  'this before rootr_update_spreadsheet / rootr_patch_spreadsheet_cells so you know the current sheetIds ' +
65
68
  'and cell contents.',
66
69
  annotations: { readOnlyHint: true, destructiveHint: false },
@@ -88,7 +91,8 @@ export function registerSpreadsheetTools(server) {
88
91
  'Merge-patch a Rootr (루터) SPREADSHEET: update config/icon, or REPLACE THE WHOLE sheets array (if you ' +
89
92
  'pass `sheets`, it replaces every existing sheet — call rootr_read_spreadsheet first and resend sheets ' +
90
93
  'you want to keep, or prefer rootr_create_sheet / rootr_patch_spreadsheet_cells for incremental changes).',
91
- annotations: { readOnlyHint: false, destructiveHint: false },
94
+ // `sheets`를 주면 시트 배열 전체를 갈아끼운다 — 안 보낸 시트는 사라진다.
95
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
92
96
  inputSchema: {
93
97
  spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
94
98
  sheets: z
@@ -132,7 +132,8 @@ export function registerTemplateTools(server) {
132
132
  'Change a Rootr (루터) template — its name, emoji, description or `spec`. Editing a builtin transparently ' +
133
133
  'creates a workspace copy, so the builtin itself is never damaged. Use this for "change the standard format" ' +
134
134
  'requests instead of saving a second near-identical template.',
135
- annotations: { readOnlyHint: false, destructiveHint: false },
135
+ // `spec`을 주면 템플릿 정의를 통째로 덮어쓴다(버전 기록 없음).
136
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
136
137
  inputSchema: {
137
138
  templateId: z.string().describe('Template id from rootr_list_templates'),
138
139
  name: z.string().optional().describe('New name'),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT, resolveOptionalNodeId } from './shared.js';
4
4
 
5
5
  const shapeShape = z
6
6
  .object({
@@ -63,7 +63,9 @@ export function registerWhiteboardTools(server) {
63
63
  'Create a new WHITEBOARD node (a freeform visual canvas) in a Rootr (루터) workspace, optionally seeded ' +
64
64
  'with an initial scene. Use WHITEBOARD for flow diagrams, brainstorming, journey maps — not for anything ' +
65
65
  'that needs typed/trackable rows (use DATABASE) or calculation (use SPREADSHEET). ' +
66
- AUTHORING_GUIDE,
66
+ AUTHORING_GUIDE +
67
+ ' ' +
68
+ NODE_URL_HINT,
67
69
  annotations: { readOnlyHint: false, destructiveHint: false },
68
70
  inputSchema: {
69
71
  name: z.string().optional().describe('Whiteboard name/title'),
@@ -139,7 +141,8 @@ export function registerWhiteboardTools(server) {
139
141
  'content). merge=false/omitted REPLACES the whole scene — call rootr_read_whiteboard first and resend ' +
140
142
  'anything you want to keep. ' +
141
143
  AUTHORING_GUIDE,
142
- annotations: { readOnlyHint: false, destructiveHint: false },
144
+ // merge=false 면 장면 전체를 갈아끼운다 — 사람이 손으로 그린 것까지 날아간다.
145
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
143
146
  inputSchema: {
144
147
  whiteboardId: z.string().describe(`WHITEBOARD node id${NODE_TARGET_HINT}`),
145
148
  scene: z
@@ -118,12 +118,12 @@ export function registerWorkspaceTools(server) {
118
118
  }
119
119
  const cfg = loadConfig();
120
120
  const candidates = candidateClients(cfg);
121
- // Probing costs one API call per workspace (they count against the
122
- // daily read quota), so only do it in the case it actually diagnoses:
123
- // no account key registered, i.e. every credential here is scoped to
124
- // one workspace and most of the list is unusable.
121
+ // Probe only matters as a fallback for servers that predate the
122
+ // `accessible` field on GET /workspaces — surveyWorkspaces() reads
123
+ // that field for free when present, so this no longer costs extra
124
+ // API calls in the normal case.
125
125
  const probe = !cfg.apiKey && (cfg.keys || []).length > 0;
126
- const { workspaces, reachable, failures } = await surveyWorkspaces(candidates, { probe });
126
+ const { workspaces, reachable, failures, probed } = await surveyWorkspaces(candidates, { probe });
127
127
  if (!workspaces.length) {
128
128
  return textResult(
129
129
  (failures ? '등록된 키로 워크스페이스 목록을 조회하지 못했습니다 (키 만료/취소 가능성).\n' : '이 컴퓨터에 등록된 Rootr 키가 없습니다.\n') +
@@ -133,13 +133,14 @@ export function registerWorkspaceTools(server) {
133
133
  const rows = workspaces.map(
134
134
  (w) =>
135
135
  `${w.id === cfg.workspace ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}` +
136
- (!probe || reachable.has(w.id) ? '' : '\t[등록된 키로는 접근 불가]'),
136
+ (!probed || reachable.has(w.id) ? '' : '\t[등록된 키로는 접근 불가]'),
137
137
  );
138
- const blocked = probe && workspaces.some((w) => !reachable.has(w.id));
138
+ const blocked = probed && workspaces.some((w) => !reachable.has(w.id));
139
139
  return textResult(
140
140
  `${rows.join('\n')}\n\n(* = 현재 워크스페이스, rootr_use_workspace 로 전환)` +
141
141
  (blocked
142
142
  ? '\n"접근 불가" = 내 소속 워크스페이스지만 등록된 키가 워크스페이스 전용이라 못 엽니다. ' +
143
+ '쓰기 전에 이 목록에서 "접근 불가"가 없는 워크스페이스만 고르세요. ' +
143
144
  '계정 키(PAT)를 등록하면 전부 열립니다: rootr.io > 설정 > API 키 → `rootr config --api-key <키>`'
144
145
  : ''),
145
146
  );
package/lib/mcp.js CHANGED
@@ -13,6 +13,7 @@ import { registerMiscTools } from './mcp-tools/misc.js';
13
13
  import { registerPublicTools } from './mcp-tools/public.js';
14
14
  import { registerCrmTools } from './mcp-tools/crm.js';
15
15
  import { registerPresentationTools } from './mcp-tools/presentations.js';
16
+ import { registerPageTools } from './mcp-tools/pages.js';
16
17
  import { registerSpreadsheetTools } from './mcp-tools/spreadsheets.js';
17
18
  import { registerWhiteboardTools } from './mcp-tools/whiteboards.js';
18
19
  import { registerFormTools } from './mcp-tools/forms.js';
@@ -261,6 +262,7 @@ export function buildMcpServer({ name, version }) {
261
262
  registerPublicTools(server);
262
263
  registerCrmTools(server);
263
264
  registerPresentationTools(server);
265
+ registerPageTools(server);
264
266
  registerSpreadsheetTools(server);
265
267
  registerWhiteboardTools(server);
266
268
  registerFormTools(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.14.1",
3
+ "version": "0.17.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": {