rootr-cli 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
4
+
5
+ const CELL_VALUE = z
6
+ .union([z.string(), z.number(), z.boolean(), z.null()])
7
+ .describe('Raw/display value; null clears the cell when patching');
8
+
9
+ const cellShape = z
10
+ .object({
11
+ v: CELL_VALUE.optional(),
12
+ f: z.string().optional().describe('Formula source, e.g. "=SUM(A1:A3)" (leading "=" included)'),
13
+ })
14
+ .describe('One cell: v (value) and/or f (formula). Formulas are recalculated server-side.');
15
+
16
+ const CELLS_GUIDE =
17
+ 'Cells are addressed A1-style ("A1", "B12", ...) in a sparse map: { "A1": { v: 42 }, "B1": { f: "=A1*2" } }. ' +
18
+ 'Set a cell to `null` to clear it. Read the sheet first (rootr_read_spreadsheet) so you know current dimensions ' +
19
+ 'and existing content before patching — patchCells only touches the keys you send, everything else is untouched.';
20
+
21
+ /** SPREADSHEET node tools: create/read/update the grid, manage sheets, patch cells. */
22
+ export function registerSpreadsheetTools(server) {
23
+ server.registerTool(
24
+ 'rootr_create_spreadsheet',
25
+ {
26
+ title: 'Create a Rootr spreadsheet',
27
+ description:
28
+ 'Create a new SPREADSHEET node (a grid with formulas) in a Rootr (루터) workspace. Use SPREADSHEET when ' +
29
+ 'content needs calculation (budgets, models, estimates) — for plain tracked lists use DATABASE instead. ' +
30
+ 'Defaults to a single empty "Sheet1" if no `data` is given. ' +
31
+ CELLS_GUIDE,
32
+ annotations: { readOnlyHint: false, destructiveHint: false },
33
+ inputSchema: {
34
+ name: z.string().optional().describe('Spreadsheet name/title'),
35
+ parentId: z.string().optional().describe(`Parent folder/node id${NODE_TARGET_HINT}`),
36
+ icon: z.string().optional().describe('Emoji icon for the node'),
37
+ data: z
38
+ .object({ sheets: z.array(z.record(z.unknown())).optional() })
39
+ .optional()
40
+ .describe('Initial data payload; defaults to a single empty "Sheet1"'),
41
+ config: z.record(z.unknown()).optional().describe('Free-form spreadsheet config object'),
42
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
43
+ },
44
+ },
45
+ async ({ name, parentId, icon, data, config, workspace }) => {
46
+ try {
47
+ const ws = requireWorkspace(workspace);
48
+ const client = makeClient(ws);
49
+ parentId = await resolveOptionalNodeId(client, parentId);
50
+ const result = await client.createSpreadsheet(ws, { name, parentId, icon, data, config });
51
+ return jsonResult(result);
52
+ } catch (err) {
53
+ return errorResult(err);
54
+ }
55
+ },
56
+ );
57
+
58
+ server.registerTool(
59
+ 'rootr_read_spreadsheet',
60
+ {
61
+ title: 'Read a Rootr spreadsheet',
62
+ description:
63
+ 'Read a Rootr (루터) SPREADSHEET node — its sheets, each with a sparse `cells` map and dimensions. Call ' +
64
+ 'this before rootr_update_spreadsheet / rootr_patch_spreadsheet_cells so you know the current sheetIds ' +
65
+ 'and cell contents.',
66
+ annotations: { readOnlyHint: true, destructiveHint: false },
67
+ inputSchema: {
68
+ spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
69
+ },
70
+ },
71
+ async ({ spreadsheetId }) => {
72
+ try {
73
+ const client = makeClient();
74
+ spreadsheetId = await resolveNodeId(client, spreadsheetId);
75
+ const result = await client.getSpreadsheet(spreadsheetId);
76
+ return jsonResult(result);
77
+ } catch (err) {
78
+ return errorResult(err);
79
+ }
80
+ },
81
+ );
82
+
83
+ server.registerTool(
84
+ 'rootr_update_spreadsheet',
85
+ {
86
+ title: 'Update a Rootr spreadsheet (config/icon, or replace all sheets)',
87
+ description:
88
+ 'Merge-patch a Rootr (루터) SPREADSHEET: update config/icon, or REPLACE THE WHOLE sheets array (if you ' +
89
+ 'pass `sheets`, it replaces every existing sheet — call rootr_read_spreadsheet first and resend sheets ' +
90
+ 'you want to keep, or prefer rootr_create_sheet / rootr_patch_spreadsheet_cells for incremental changes).',
91
+ annotations: { readOnlyHint: false, destructiveHint: false },
92
+ inputSchema: {
93
+ spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
94
+ sheets: z
95
+ .array(z.record(z.unknown()))
96
+ .optional()
97
+ .describe('COMPLETE sheets array to set — omitted existing sheets will be dropped'),
98
+ config: z.record(z.unknown()).optional().describe('Free-form spreadsheet config object'),
99
+ icon: z.string().optional().describe('Emoji icon for the node'),
100
+ },
101
+ },
102
+ async ({ spreadsheetId, sheets, config, icon }) => {
103
+ try {
104
+ const client = makeClient();
105
+ spreadsheetId = await resolveNodeId(client, spreadsheetId);
106
+ const result = await client.updateSpreadsheet(spreadsheetId, { sheets, config, icon });
107
+ return jsonResult(result);
108
+ } catch (err) {
109
+ return errorResult(err);
110
+ }
111
+ },
112
+ );
113
+
114
+ server.registerTool(
115
+ 'rootr_create_sheet',
116
+ {
117
+ title: 'Add a sheet (tab) to a Rootr spreadsheet',
118
+ description: 'Add a new empty sheet (tab) to an existing Rootr (루터) SPREADSHEET, without touching other sheets.',
119
+ annotations: { readOnlyHint: false, destructiveHint: false },
120
+ inputSchema: {
121
+ spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
122
+ name: z.string().optional().describe('Sheet name, e.g. "Q1 Budget"'),
123
+ rowCount: z.number().int().min(1).max(10_000).optional().describe('Row count (default 1000)'),
124
+ colCount: z.number().int().min(1).max(500).optional().describe('Column count (default 52)'),
125
+ },
126
+ },
127
+ async ({ spreadsheetId, name, rowCount, colCount }) => {
128
+ try {
129
+ const client = makeClient();
130
+ spreadsheetId = await resolveNodeId(client, spreadsheetId);
131
+ const result = await client.createSheet(spreadsheetId, { name, rowCount, colCount });
132
+ return jsonResult(result);
133
+ } catch (err) {
134
+ return errorResult(err);
135
+ }
136
+ },
137
+ );
138
+
139
+ server.registerTool(
140
+ 'rootr_update_sheet',
141
+ {
142
+ title: 'Rename/resize ONE sheet of a Rootr spreadsheet',
143
+ description:
144
+ 'Merge-patch a single sheet\'s name/dimensions/row-col sizing by sheetId, without touching its cells. ' +
145
+ 'Call rootr_read_spreadsheet first to get the exact sheetId.',
146
+ annotations: { readOnlyHint: false, destructiveHint: false },
147
+ inputSchema: {
148
+ spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
149
+ sheetId: z.string().describe('Sheet id to update'),
150
+ name: z.string().optional().describe('New sheet name'),
151
+ rowCount: z.number().int().min(1).max(10_000).optional(),
152
+ colCount: z.number().int().min(1).max(500).optional(),
153
+ rowSizes: z.record(z.number()).optional().describe('Per-row heights (px), keyed by 0-based row index string'),
154
+ colSizes: z.record(z.number()).optional().describe('Per-column widths (px), keyed by 0-based col index string'),
155
+ merges: z
156
+ .array(z.object({ r1: z.number(), c1: z.number(), r2: z.number(), c2: z.number() }))
157
+ .optional()
158
+ .describe('Merged-cell ranges (0-indexed, inclusive, anchor = top-left)'),
159
+ },
160
+ },
161
+ async ({ spreadsheetId, sheetId, name, rowCount, colCount, rowSizes, colSizes, merges }) => {
162
+ try {
163
+ const client = makeClient();
164
+ spreadsheetId = await resolveNodeId(client, spreadsheetId);
165
+ const result = await client.patchSheet(spreadsheetId, sheetId, {
166
+ name,
167
+ rowCount,
168
+ colCount,
169
+ rowSizes,
170
+ colSizes,
171
+ merges,
172
+ });
173
+ return jsonResult(result);
174
+ } catch (err) {
175
+ return errorResult(err);
176
+ }
177
+ },
178
+ );
179
+
180
+ server.registerTool(
181
+ 'rootr_delete_sheet',
182
+ {
183
+ title: 'Delete a sheet (tab) from a Rootr spreadsheet',
184
+ description: 'Delete one sheet (tab) from a Rootr (루터) SPREADSHEET by sheetId. Not recoverable — read first to confirm.',
185
+ annotations: { readOnlyHint: false, destructiveHint: true },
186
+ inputSchema: {
187
+ spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
188
+ sheetId: z.string().describe('Sheet id to delete'),
189
+ },
190
+ },
191
+ async ({ spreadsheetId, sheetId }) => {
192
+ try {
193
+ const client = makeClient();
194
+ spreadsheetId = await resolveNodeId(client, spreadsheetId);
195
+ await client.deleteSheet(spreadsheetId, sheetId);
196
+ return jsonResult({ deleted: sheetId });
197
+ } catch (err) {
198
+ return errorResult(err);
199
+ }
200
+ },
201
+ );
202
+
203
+ server.registerTool(
204
+ 'rootr_patch_spreadsheet_cells',
205
+ {
206
+ title: 'Write cells into a Rootr spreadsheet sheet (preferred way to fill in data)',
207
+ description:
208
+ 'Sparse-patch cells of ONE sheet in a Rootr (루터) SPREADSHEET, without touching the rest of the sheet. ' +
209
+ 'PREFER this over rootr_update_spreadsheet when you are only filling in / editing cell values or formulas. ' +
210
+ CELLS_GUIDE,
211
+ annotations: { readOnlyHint: false, destructiveHint: false },
212
+ inputSchema: {
213
+ spreadsheetId: z.string().describe(`SPREADSHEET node id${NODE_TARGET_HINT}`),
214
+ sheetId: z.string().describe('Sheet id to patch'),
215
+ cells: z
216
+ .record(cellShape.nullable())
217
+ .describe('Map of A1-style ref -> cell (or null to clear), e.g. { "A1": { "v": 42 }, "B1": { "f": "=A1*2" } }'),
218
+ },
219
+ },
220
+ async ({ spreadsheetId, sheetId, cells }) => {
221
+ try {
222
+ const client = makeClient();
223
+ spreadsheetId = await resolveNodeId(client, spreadsheetId);
224
+ const result = await client.patchCells(spreadsheetId, sheetId, cells);
225
+ return jsonResult(result);
226
+ } catch (err) {
227
+ return errorResult(err);
228
+ }
229
+ },
230
+ );
231
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveOptionalNodeId, NODE_TARGET_HINT } from './shared.js';
4
4
 
5
5
  const VALID_EVENTS = [
6
6
  'document.created',
@@ -23,6 +23,7 @@ export function registerWebhookTools(server) {
23
23
  description:
24
24
  'List webhooks configured on a Rootr (루터) workspace. Secrets are not re-shown here (they only appear ' +
25
25
  'once, at creation time).',
26
+ annotations: { readOnlyHint: true, destructiveHint: false },
26
27
  inputSchema: {
27
28
  workspace: z
28
29
  .string()
@@ -54,11 +55,12 @@ export function registerWebhookTools(server) {
54
55
  'shown again. LOOP GUARD: deliveries carry the actor as data.actorId ("apikey:<key-id>" for API writes) — ' +
55
56
  'a consumer MUST skip deliveries caused by its own key before reacting, or an agent that writes on ' +
56
57
  'notification will trigger itself forever.',
58
+ annotations: { readOnlyHint: false, destructiveHint: false },
57
59
  inputSchema: {
58
60
  url: z.string().describe('HTTPS endpoint to receive webhook POSTs'),
59
61
  events: z.array(z.string()).describe(`Event names to subscribe to, from: ${VALID_EVENTS.join(', ')}`),
60
62
  name: z.string().optional().describe('Human-readable name for the webhook'),
61
- scopeNodeId: z.string().optional().describe('Restrict to this node and its descendants'),
63
+ scopeNodeId: z.string().optional().describe(`Node id${NODE_TARGET_HINT} — restrict to this node and its descendants`),
62
64
  filterTags: z.array(z.string()).optional().describe('Only fire for nodes carrying one of these tags'),
63
65
  debounceSeconds: z.number().optional().describe('Trailing debounce window in seconds (server-capped)'),
64
66
  workspace: z
@@ -71,6 +73,7 @@ export function registerWebhookTools(server) {
71
73
  try {
72
74
  const ws = requireWorkspace(workspace);
73
75
  const client = makeClient(ws);
76
+ scopeNodeId = await resolveOptionalNodeId(client, scopeNodeId);
74
77
  const result = await client.createWebhook(ws, {
75
78
  name,
76
79
  url,
@@ -93,6 +96,7 @@ export function registerWebhookTools(server) {
93
96
  {
94
97
  title: 'Delete a webhook',
95
98
  description: 'Delete a webhook from a Rootr (루터) workspace. This cannot be undone.',
99
+ annotations: { readOnlyHint: false, destructiveHint: true },
96
100
  inputSchema: {
97
101
  webhookId: z.string().describe('Webhook id to delete'),
98
102
  workspace: z
@@ -0,0 +1,169 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, resolveOptionalNodeId } from './shared.js';
4
+
5
+ const shapeShape = z
6
+ .object({
7
+ id: z.string().describe('Unique shape id, referenced by edges\' from/to'),
8
+ type: z.string().describe('e.g. "rect" | "ellipse" | "text" | "sticky" | "arrow"'),
9
+ x: z.number().describe('X position'),
10
+ y: z.number().describe('Y position'),
11
+ w: z.number().describe('Width'),
12
+ h: z.number().describe('Height'),
13
+ text: z.string().optional().describe('Label/body text — text spine, put facts here'),
14
+ style: z.record(z.unknown()).optional().describe('Free-form style hints (color, fill, etc.)'),
15
+ })
16
+ .describe('A whiteboard shape (node of the diagram).');
17
+
18
+ const edgeShape = z
19
+ .object({
20
+ id: z.string().describe('Unique edge id'),
21
+ from: z.string().describe('Source shape id'),
22
+ to: z.string().describe('Target shape id'),
23
+ label: z.string().optional().describe('Edge label — text spine'),
24
+ fromHandle: z.string().optional().describe('Connection point on the `from` shape, e.g. "b" (bottom)'),
25
+ toHandle: z.string().optional().describe('Connection point on the `to` shape'),
26
+ })
27
+ .describe('A whiteboard edge (connection between two shapes).');
28
+
29
+ const sceneShape = z
30
+ .object({
31
+ shapes: z.array(shapeShape).optional(),
32
+ edges: z.array(edgeShape).optional(),
33
+ })
34
+ .describe('Simple scene: { shapes[], edges[] } — converted server-side to Excalidraw elements.');
35
+
36
+ // Raw Excalidraw-native scene — full-fidelity access to EVERY element kind
37
+ // (freehand strokes, images, frames, …), not just the simple shapes/edges view.
38
+ const excalidrawSceneShape = z
39
+ .object({
40
+ format: z.literal('excalidraw'),
41
+ elements: z.array(z.record(z.unknown())).describe('Excalidraw elements (raw, as returned by mode:"full")'),
42
+ appState: z.record(z.unknown()).optional(),
43
+ files: z.record(z.unknown()).optional().describe('Image blobs keyed by fileId ({ mimeType, dataURL })'),
44
+ })
45
+ .describe('Raw Excalidraw scene: { format:"excalidraw", elements[], appState?, files? }.');
46
+
47
+ const AUTHORING_GUIDE =
48
+ 'Authoring guide: author in { shapes: [{id,type,x,y,w,h,text?,style?}], edges: [{id,from,to,label?}] } — shapes ' +
49
+ 'are the nodes of a diagram (types: rect | ellipse | diamond | sticky | text), edges connect them by id and render ' +
50
+ 'as arrows (with arrowheads and optional labels). style supports {color:"<stroke css color>", background:"<fill>"}. ' +
51
+ 'The board itself is an Excalidraw canvas (humans also draw freehand/pen strokes and paste images on it — those ' +
52
+ 'survive merge updates but are not expressible in shapes/edges). Meaning must live in shape/edge TEXT (text/label) ' +
53
+ '— that is what gets indexed into the knowledge graph; x/y/w/h and style are visual-only layout. Read the board ' +
54
+ 'first (rootr_read_whiteboard) before updating so you know the existing shape ids edges refer to.';
55
+
56
+ /** WHITEBOARD node tools: create/read/update the freeform diagram scene. */
57
+ export function registerWhiteboardTools(server) {
58
+ server.registerTool(
59
+ 'rootr_create_whiteboard',
60
+ {
61
+ title: 'Create a Rootr whiteboard',
62
+ description:
63
+ 'Create a new WHITEBOARD node (a freeform visual canvas) in a Rootr (루터) workspace, optionally seeded ' +
64
+ 'with an initial scene. Use WHITEBOARD for flow diagrams, brainstorming, journey maps — not for anything ' +
65
+ 'that needs typed/trackable rows (use DATABASE) or calculation (use SPREADSHEET). ' +
66
+ AUTHORING_GUIDE,
67
+ annotations: { readOnlyHint: false, destructiveHint: false },
68
+ inputSchema: {
69
+ name: z.string().optional().describe('Whiteboard name/title'),
70
+ parentId: z.string().optional().describe(`Parent folder/node id${NODE_TARGET_HINT}`),
71
+ icon: z.string().optional().describe('Emoji icon for the node'),
72
+ scene: z
73
+ .union([sceneShape, excalidrawSceneShape])
74
+ .optional()
75
+ .describe('Initial scene; defaults to an empty board. Simple {shapes,edges} or raw Excalidraw format.'),
76
+ config: z.record(z.unknown()).optional().describe('Free-form whiteboard config object'),
77
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
78
+ },
79
+ },
80
+ async ({ name, parentId, icon, scene, config, workspace }) => {
81
+ try {
82
+ const ws = requireWorkspace(workspace);
83
+ const client = makeClient(ws);
84
+ parentId = await resolveOptionalNodeId(client, parentId);
85
+ const result = await client.createWhiteboard(ws, { name, parentId, icon, scene, config });
86
+ return jsonResult(result);
87
+ } catch (err) {
88
+ return errorResult(err);
89
+ }
90
+ },
91
+ );
92
+
93
+ server.registerTool(
94
+ 'rootr_read_whiteboard',
95
+ {
96
+ title: 'Read a Rootr whiteboard',
97
+ description:
98
+ 'Read a Rootr (루터) WHITEBOARD node. mode:"summary" (default) returns the compact {shapes[], edges[]} ' +
99
+ 'view plus counts of content it cannot express (freehand strokes, images). mode:"full" additionally ' +
100
+ 'returns EVERY raw Excalidraw element (freehand/image/frame/…, verbose) — use it when you need ' +
101
+ 'full-fidelity access or plan a raw-format update. Call this before rootr_update_whiteboard so you know ' +
102
+ 'the current ids.',
103
+ annotations: { readOnlyHint: true, destructiveHint: false },
104
+ inputSchema: {
105
+ whiteboardId: z.string().describe(`WHITEBOARD node id${NODE_TARGET_HINT}`),
106
+ mode: z.enum(['summary', 'full']).optional().describe('"summary" (default) or "full" (raw elements too)'),
107
+ },
108
+ },
109
+ async ({ whiteboardId, mode }) => {
110
+ try {
111
+ const client = makeClient();
112
+ whiteboardId = await resolveNodeId(client, whiteboardId);
113
+ const result = await client.getWhiteboard(whiteboardId);
114
+ const { scene, ...rest } = result ?? {};
115
+ if (mode !== 'full') return jsonResult(rest);
116
+ // Full fidelity, but image blobs are replaced with metadata — a single
117
+ // dataURL can be megabytes and would swamp the model context.
118
+ const files = Object.fromEntries(
119
+ Object.entries(scene?.files ?? {}).map(([id, f]) => [
120
+ id,
121
+ { mimeType: f?.mimeType, dataURLBytes: typeof f?.dataURL === 'string' ? f.dataURL.length : 0 },
122
+ ]),
123
+ );
124
+ return jsonResult({ ...rest, scene: { ...scene, files } });
125
+ } catch (err) {
126
+ return errorResult(err);
127
+ }
128
+ },
129
+ );
130
+
131
+ server.registerTool(
132
+ 'rootr_update_whiteboard',
133
+ {
134
+ title: 'Update a Rootr whiteboard (replace or merge the scene, or config/icon)',
135
+ description:
136
+ 'Update a Rootr (루터) WHITEBOARD: update config/icon, or set the scene. merge=true upserts the passed ' +
137
+ 'shapes/edges by id onto the existing board and leaves everything else untouched (including freehand ' +
138
+ 'drawings/images people made in the editor — PREFER merge=true when editing a board that has existing ' +
139
+ 'content). merge=false/omitted REPLACES the whole scene — call rootr_read_whiteboard first and resend ' +
140
+ 'anything you want to keep. ' +
141
+ AUTHORING_GUIDE,
142
+ annotations: { readOnlyHint: false, destructiveHint: false },
143
+ inputSchema: {
144
+ whiteboardId: z.string().describe(`WHITEBOARD node id${NODE_TARGET_HINT}`),
145
+ scene: z
146
+ .union([sceneShape, excalidrawSceneShape])
147
+ .optional()
148
+ .describe('Scene to set (complete, unless merge=true). Simple {shapes,edges} or raw Excalidraw format.'),
149
+ merge: z
150
+ .boolean()
151
+ .optional()
152
+ .describe('true = upsert shapes/edges by id onto the existing board; false/omitted = replace the whole scene'),
153
+ config: z.record(z.unknown()).optional().describe('Free-form whiteboard config object'),
154
+ icon: z.string().optional().describe('Emoji icon for the node'),
155
+ },
156
+ },
157
+ async ({ whiteboardId, scene, merge, config, icon }) => {
158
+ try {
159
+ const client = makeClient();
160
+ whiteboardId = await resolveNodeId(client, whiteboardId);
161
+ const result = await client.updateWhiteboard(whiteboardId, { scene, merge, config, icon });
162
+ const { scene: _scene, ...rest } = result ?? {};
163
+ return jsonResult(rest);
164
+ } catch (err) {
165
+ return errorResult(err);
166
+ }
167
+ },
168
+ );
169
+ }
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  import { textResult, jsonResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
+ import { loadConfig, saveConfig, keyForWorkspace, upsertKey } from '../config.js';
5
+ import { currentClientOverride } from '../context.js';
6
+ import { RootrClient } from '../client.js';
4
7
 
5
8
  /**
6
9
  * Scaffold `tree` argument schema. Declared as an ARRAY of node objects so MCP
@@ -22,8 +25,127 @@ function parseTree(tree) {
22
25
  }
23
26
  }
24
27
 
28
+ /**
29
+ * Every credential this machine can use, default key first. An account key
30
+ * (PAT) reaches every workspace its owner belongs to; workspace keys reach one.
31
+ * Which one applies to a given workspace is discovered by asking the API
32
+ * (listWorkspaces), not guessed from the key string.
33
+ */
34
+ function candidateClients() {
35
+ const cfg = loadConfig();
36
+ const out = [];
37
+ const seen = new Set();
38
+ if (cfg.apiKey) {
39
+ seen.add(cfg.apiKey);
40
+ out.push({ apiKey: cfg.apiKey, label: 'default', client: new RootrClient({ ...cfg, apiKey: cfg.apiKey }) });
41
+ }
42
+ for (const k of cfg.keys || []) {
43
+ if (!k.apiKey || seen.has(k.apiKey)) continue;
44
+ seen.add(k.apiKey);
45
+ out.push({ apiKey: k.apiKey, label: k.label || k.workspace || 'key', client: new RootrClient({ ...cfg, apiKey: k.apiKey }) });
46
+ }
47
+ return out;
48
+ }
49
+
25
50
  /** Workspace listing and scaffolding tools: rootr_workspaces, rootr_scaffold_plan, rootr_create_workspace, rootr_scaffold_apply. */
26
51
  export function registerWorkspaceTools(server) {
52
+ server.registerTool(
53
+ 'rootr_use_workspace',
54
+ {
55
+ title: 'Switch the active Rootr workspace',
56
+ description:
57
+ 'Switch which Rootr (루터) workspace every later tool call works on, by id OR by name — no ' +
58
+ 'reconfiguration, no restart. With an account key (PAT) you can switch to any workspace you belong ' +
59
+ 'to; if separate workspace keys are configured, the one that covers the target is selected and ' +
60
+ 'remembered automatically. Call rootr_workspaces first if you need to see what is available. ' +
61
+ '(A hosted/remote connector is bound to a single workspace at consent time and cannot switch.)',
62
+ annotations: { readOnlyHint: false, destructiveHint: false },
63
+ inputSchema: {
64
+ workspace: z.string().describe('Workspace id, or its name (case-insensitive)'),
65
+ },
66
+ },
67
+ async ({ workspace }) => {
68
+ try {
69
+ if (currentClientOverride()) {
70
+ return errorResult(
71
+ new Error(
72
+ '이 원격 연결은 승인 시 지정한 워크스페이스 하나에만 연결됩니다. ' +
73
+ '다른 워크스페이스를 쓰려면 설정>연동에서 그 워크스페이스로 커넥터를 새로 발급하세요.',
74
+ ),
75
+ );
76
+ }
77
+ const wanted = workspace.trim();
78
+ const wantedLower = wanted.toLowerCase();
79
+ const candidates = candidateClients();
80
+
81
+ // Step 1 — resolve the name/id to a real workspace. /workspaces lists
82
+ // the KEY OWNER's workspaces, so any key can answer this.
83
+ let target = null;
84
+ for (const candidate of candidates) {
85
+ let list = [];
86
+ try {
87
+ list = await candidate.client.listWorkspaces();
88
+ } catch {
89
+ continue;
90
+ }
91
+ const hit = list.find(
92
+ (w) => w.id === wanted || (w.name || '').toLowerCase() === wantedLower || (w.slug || '').toLowerCase() === wantedLower,
93
+ );
94
+ if (hit) {
95
+ target = hit;
96
+ break;
97
+ }
98
+ }
99
+
100
+ // Step 2 — find a key that can actually WORK in it. Listing is not a
101
+ // capability probe: a workspace-scoped key still lists all of its
102
+ // owner's workspaces while being rejected by every one but its own.
103
+ // Only a workspace-scoped call proves the binding.
104
+ let usable = null;
105
+ if (target) {
106
+ for (const candidate of candidates) {
107
+ try {
108
+ await candidate.client.request(`/workspaces/${encodeURIComponent(target.id)}`);
109
+ usable = candidate;
110
+ break;
111
+ } catch {
112
+ continue;
113
+ }
114
+ }
115
+ }
116
+ const match = target && usable ? { workspace: target, candidate: usable } : null;
117
+
118
+ if (target && !usable) {
119
+ return errorResult(
120
+ new Error(
121
+ `"${target.name || target.id}" 워크스페이스에 접근할 수 있는 키가 없습니다. ` +
122
+ '그 워크스페이스용 키를 등록하세요: rootr config --add-key <키>',
123
+ ),
124
+ );
125
+ }
126
+ if (!match) {
127
+ return errorResult(
128
+ new Error(
129
+ `"${wanted}" 워크스페이스를 찾지 못했습니다. rootr_workspaces로 접근 가능한 목록을 확인하세요. ` +
130
+ '목록에 없다면 그 워크스페이스용 키가 등록되지 않은 것입니다 (rootr config --add-key <키>).',
131
+ ),
132
+ );
133
+ }
134
+ const cfg = loadConfig();
135
+ saveConfig({ workspace: match.workspace.id });
136
+ // Remember which key covers this workspace so later calls resolve
137
+ // directly instead of probing every key again.
138
+ if (match.candidate.apiKey !== cfg.apiKey) {
139
+ upsertKey({ apiKey: match.candidate.apiKey, workspace: match.workspace.id, label: match.candidate.label });
140
+ }
141
+ const name = match.workspace.name || match.workspace.slug || '';
142
+ return textResult(`활성 워크스페이스를 "${name}" (${match.workspace.id}) 로 바꿨습니다. 이후 도구 호출은 여기에 적용됩니다.`);
143
+ } catch (err) {
144
+ return errorResult(err);
145
+ }
146
+ },
147
+ );
148
+
27
149
  server.registerTool(
28
150
  'rootr_workspaces',
29
151
  {
@@ -31,15 +153,33 @@ export function registerWorkspaceTools(server) {
31
153
  description:
32
154
  'List the Rootr (루터) workspaces the current API key can see. Works with either key type: with an ' +
33
155
  'account key (PAT) this lists all workspaces you belong to; a workspace key only sees the workspace ' +
34
- "it's scoped to. Use this to find a workspace id before calling other workspace-scoped tools.",
156
+ "it's scoped to. Use this to find a workspace id before calling other workspace-scoped tools, then " +
157
+ 'switch to one with rootr_use_workspace (no reconfiguration needed).',
158
+ annotations: { readOnlyHint: true, destructiveHint: false },
35
159
  inputSchema: {},
36
160
  },
37
161
  async () => {
38
162
  try {
39
- const client = makeClient();
40
- const workspaces = await client.listWorkspaces();
41
- const rows = workspaces.map((w) => `${w.id}\t${w.name || w.slug || ''}`);
42
- return textResult(rows.length ? rows.join('\n') : '(no workspaces visible to this key)');
163
+ const hosted = currentClientOverride();
164
+ const active = hosted ? hosted.workspace : loadConfig().workspace;
165
+ const seen = new Map();
166
+ for (const { client } of candidateClients()) {
167
+ let list = [];
168
+ try {
169
+ list = await client.listWorkspaces();
170
+ } catch {
171
+ continue; // a revoked/foreign key shouldn't hide the others
172
+ }
173
+ for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
174
+ }
175
+ const rows = [...seen.values()].map(
176
+ (w) => `${w.id === active ? '* ' : ' '}${w.id}\t${w.name || w.slug || ''}`,
177
+ );
178
+ return textResult(
179
+ rows.length
180
+ ? `${rows.join('\n')}\n\n(* = current; switch with rootr_use_workspace)`
181
+ : '(no workspaces visible to this key)',
182
+ );
43
183
  } catch (err) {
44
184
  return errorResult(err);
45
185
  }
@@ -58,6 +198,7 @@ export function registerWorkspaceTools(server) {
58
198
  'GENERATE your own questions from those frameworks, ask the user, and only design the tree AFTER their ' +
59
199
  'answers — every answer must visibly shape the result. Never build straight from a one-line intent. ' +
60
200
  'No workspace needed; create with rootr_create_workspace or rootr_scaffold_apply afterwards.',
201
+ annotations: { readOnlyHint: true, destructiveHint: false },
61
202
  inputSchema: {
62
203
  intent: z.string().describe('Natural-language description of what the workspace/tree should be for'),
63
204
  },
@@ -87,6 +228,7 @@ export function registerWorkspaceTools(server) {
87
228
  'the scaffold schema in llms.txt (max 200 nodes). AFTER creating: if your runtime can receive HTTP ' +
88
229
  'callbacks, offer the user to register a webhook on the new tree (rootr_create_webhook with scopeNodeId) ' +
89
230
  'so changes flow back to you — skip your own actorId to avoid self-triggering.',
231
+ annotations: { readOnlyHint: false, destructiveHint: false },
90
232
  inputSchema: {
91
233
  name: z.string().describe('Name for the new workspace'),
92
234
  intent: z.string().optional().describe('Original natural-language intent, for reference/regeneration'),
@@ -115,6 +257,7 @@ export function registerWorkspaceTools(server) {
115
257
  'bar as rootr_create_workspace — real building blocks with icons/views/seed content, not thin markdown). ' +
116
258
  'AFTER creating: if your runtime can receive HTTP callbacks, offer a webhook on the new subtree ' +
117
259
  '(rootr_create_webhook with scopeNodeId) so changes flow back to you — skip your own actorId.',
260
+ annotations: { readOnlyHint: false, destructiveHint: false },
118
261
  inputSchema: {
119
262
  tree: treeArg('Scaffold tree — an ARRAY of node objects to create (see llms.txt tree schema)'),
120
263
  rootPath: z.string().optional().describe('Existing path to root the new tree under, e.g. "/projects"'),