rootr-cli 0.16.0 → 0.17.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.
package/bin/rootr.js CHANGED
@@ -91,7 +91,7 @@ const USAGE = `Rootr CLI v${pkg.version}
91
91
  rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
92
92
  rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
93
93
  rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
94
- rootr mcp MCP stdio 서버 구동 (도구 151종 — README 참고)
94
+ rootr mcp MCP stdio 서버 구동 (도구 167종 — README 참고)
95
95
  rootr --help 도움말
96
96
  rootr --version 버전 출력
97
97
 
package/lib/client.js CHANGED
@@ -1407,7 +1407,7 @@ export class RootrClient {
1407
1407
  });
1408
1408
  }
1409
1409
 
1410
- /** Full spreadsheet: node meta + sheets[] (each with a sparse `cells` map). */
1410
+ /** Full spreadsheet: node meta + data.sheets[] (each with a sparse `cells` map). */
1411
1411
  async getSpreadsheet(spreadsheetId) {
1412
1412
  return this.request(`/spreadsheets/${encodeURIComponent(spreadsheetId)}`);
1413
1413
  }
@@ -27,16 +27,22 @@ export function candidateClients(cfg = loadConfig()) {
27
27
  }
28
28
 
29
29
  /**
30
- * Collect every workspace the registered keys can see, and (when asked) which
31
- * of them a key can actually open — listing is not a capability probe: a
32
- * workspace key lists all of its owner's workspaces while being rejected by
33
- * every one but its own.
30
+ * Collect every workspace the registered keys can see, and which of them a
31
+ * key can actually open — listing is not a capability probe: a workspace key
32
+ * lists all of its owner's workspaces while being rejected by every one but
33
+ * its own. `GET /workspaces` now tags each entry with `accessible` (computed
34
+ * server-side from the calling key's own scope), so this reads straight off
35
+ * that field instead of firing one extra request per workspace. Older
36
+ * servers that predate the field fall back to the old per-workspace probe
37
+ * when `probe` is requested.
34
38
  */
35
39
  export async function surveyWorkspaces(candidates, { probe = false, probeLimit = 12 } = {}) {
36
40
  const seen = new Map();
37
41
  const reachable = new Set();
38
42
  let listed = 0;
39
43
  let failures = 0;
44
+ let sawAccessibleField = false;
45
+ const lists = [];
40
46
  for (const { client } of candidates) {
41
47
  let list = [];
42
48
  try {
@@ -46,19 +52,29 @@ export async function surveyWorkspaces(candidates, { probe = false, probeLimit =
46
52
  failures += 1;
47
53
  continue; // a revoked/foreign key shouldn't hide the others
48
54
  }
49
- for (const w of list) if (!seen.has(w.id)) seen.set(w.id, w);
50
- if (!probe) continue;
51
- for (const w of list.slice(0, probeLimit)) {
52
- if (reachable.has(w.id)) continue;
53
- try {
54
- await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
55
- reachable.add(w.id);
56
- } catch {
57
- // scoped key rejected here; another key may still cover it
55
+ lists.push({ client, list });
56
+ for (const w of list) {
57
+ if (!seen.has(w.id)) seen.set(w.id, w);
58
+ if (typeof w.accessible === 'boolean') {
59
+ sawAccessibleField = true;
60
+ if (w.accessible) reachable.add(w.id);
61
+ }
62
+ }
63
+ }
64
+ if (!sawAccessibleField && probe) {
65
+ for (const { client, list } of lists) {
66
+ for (const w of list.slice(0, probeLimit)) {
67
+ if (reachable.has(w.id)) continue;
68
+ try {
69
+ await client.request(`/workspaces/${encodeURIComponent(w.id)}`);
70
+ reachable.add(w.id);
71
+ } catch {
72
+ // scoped key rejected here; another key may still cover it
73
+ }
58
74
  }
59
75
  }
60
76
  }
61
- return { workspaces: [...seen.values()], reachable, listed, failures };
77
+ return { workspaces: [...seen.values()], reachable, listed, failures, probed: sawAccessibleField || probe };
62
78
  }
63
79
 
64
80
  /**
@@ -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
  * Shared context repeated (shortened) across tool descriptions so an agent
@@ -56,7 +56,9 @@ export function registerCrmTools(server) {
56
56
  'activities and tasks all live inside it. Optionally seed custom `pipelines` (name + ordered stages, each ' +
57
57
  'with an optional win probability/color) and/or custom company/contact/deal `fields`; sane defaults are ' +
58
58
  'used if omitted. ' +
59
- CRM_CONTEXT,
59
+ CRM_CONTEXT +
60
+ ' ' +
61
+ NODE_URL_HINT,
60
62
  annotations: { readOnlyHint: false, destructiveHint: false },
61
63
  inputSchema: {
62
64
  name: z.string().optional().describe('CRM node name'),
@@ -8,6 +8,7 @@ import {
8
8
  resolveNodeId,
9
9
  resolveOptionalNodeId,
10
10
  NODE_TARGET_HINT,
11
+ NODE_URL_HINT,
11
12
  } from './shared.js';
12
13
 
13
14
  /** Human text for the `databaseId` param — accepts a path just like the document tools. */
@@ -139,7 +140,8 @@ export function registerDatabaseTools(server) {
139
140
  'COST NOTE: by default, every row added to this table (now or later) queues that table for graph extraction ' +
140
141
  '(embeddings + LLM) — fine for a handful of rows, expensive for a bulk-loaded roster/log table. Pass ' +
141
142
  'graphSync:false for tables you are about to bulk-load; flip it back with ' +
142
- '`rootr_update_node({ graphSyncOff: true|false })` any time after.',
143
+ '`rootr_update_node({ graphSyncOff: true|false })` any time after. ' +
144
+ NODE_URL_HINT,
143
145
  annotations: { readOnlyHint: false, destructiveHint: false },
144
146
  inputSchema: {
145
147
  name: z.string().describe('Table name, e.g. "업무 목록"'),
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
 
3
3
  import { RootrApiError } from '../client.js';
4
4
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../resolve.js';
5
- import { textResult, jsonResult, errorResult, makeClient, NODE_TARGET_HINT } from './shared.js';
5
+ import { textResult, jsonResult, errorResult, makeClient, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
6
6
 
7
7
  /** Registers the original 6 document tools: list/read/append/edit/write/search. */
8
8
  export function registerDocumentTools(server) {
@@ -147,7 +147,8 @@ export function registerDocumentTools(server) {
147
147
  description:
148
148
  'Replace the ENTIRE content of a Rootr document, or create a new document at a path if it does not exist. ' +
149
149
  'This is DESTRUCTIVE — it discards whatever is currently there. Read the document first and pass its etag ' +
150
- 'as ifMatch to avoid clobbering concurrent changes. Prefer rootr_append or rootr_edit for incremental changes.',
150
+ 'as ifMatch to avoid clobbering concurrent changes. Prefer rootr_append or rootr_edit for incremental changes. ' +
151
+ `When this creates a new document, the result text includes its address (same idea as: ${NODE_URL_HINT}).`,
151
152
  annotations: { readOnlyHint: false, destructiveHint: true },
152
153
  inputSchema: {
153
154
  target: z.string().describe('Document path (starting with "/", created if missing) or node id, or a full rootr.io URL'),
@@ -174,7 +175,8 @@ export function registerDocumentTools(server) {
174
175
  content,
175
176
  });
176
177
  const id = created.id || (created.node && created.node.id);
177
- return textResult(`created ${resolved.path} (id ${id})`);
178
+ const url = created.url || (created.node && created.node.url);
179
+ return textResult(`created ${resolved.path} (id ${id})${url ? ` — ${url}` : ''}`);
178
180
  }
179
181
  const { etag } = await client.putDocument(resolved.id, content, { ifMatch });
180
182
  return textResult(`wrote (etag ${etag || 'n/a'})`);
@@ -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 fieldShape = z
6
6
  .object({
@@ -88,7 +88,9 @@ export function registerFormTools(server) {
88
88
  'Create a new FORM node (fillable intake) in a Rootr (루터) workspace, optionally seeded with field ' +
89
89
  'definitions. Use FORM for surveys/request queues collected from people outside the tree — set ' +
90
90
  '`targetDatabaseId` to also mirror each response into a DATABASE\'s rows. ' +
91
- FIELDS_GUIDE,
91
+ FIELDS_GUIDE +
92
+ ' ' +
93
+ NODE_URL_HINT,
92
94
  annotations: { readOnlyHint: false, destructiveHint: false },
93
95
  inputSchema: {
94
96
  name: z.string().optional().describe('Form name/title'),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
 
3
- import { textResult, jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
3
+ import { textResult, jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
4
4
 
5
5
  /** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
6
6
  export function registerIssueTools(server) {
@@ -10,7 +10,8 @@ export function registerIssueTools(server) {
10
10
  title: 'Create an issue tracker in a Rootr workspace',
11
11
  description:
12
12
  'Create a new GitHub-style issue tracker document in a Rootr (루터) workspace. Returns the tracker with ' +
13
- 'its id, plus its default labels/types — use rootr_create_issue afterward to add issues to it.',
13
+ 'its id, plus its default labels/types — use rootr_create_issue afterward to add issues to it. ' +
14
+ NODE_URL_HINT,
14
15
  annotations: { readOnlyHint: false, destructiveHint: false },
15
16
  inputSchema: {
16
17
  name: z.string().describe('Name of the issue tracker'),
@@ -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
  /** LOG datastore tools: create/update-fields/add-entries/query-entries/stats. */
6
6
  export function registerLogTools(server) {
@@ -12,7 +12,8 @@ export function registerLogTools(server) {
12
12
  'Create a new typed LOG store (a structured, queryable table-like node) in a Rootr (루터) workspace. ' +
13
13
  'Each field has a name/type; type is one of string|int|float|bool|datetime|json|enum|level|relation. ' +
14
14
  'Relation fields additionally need target (a node id to relate to) and optional many/relation flags. ' +
15
- 'Use rootr_add_log_entries afterward to insert rows, and rootr_query_log_entries / rootr_log_stats to read them back.',
15
+ 'Use rootr_add_log_entries afterward to insert rows, and rootr_query_log_entries / rootr_log_stats to read them back. ' +
16
+ NODE_URL_HINT,
16
17
  annotations: { readOnlyHint: false, destructiveHint: false },
17
18
  inputSchema: {
18
19
  name: z.string().describe('Name of the LOG store'),
@@ -8,6 +8,7 @@ import {
8
8
  resolveNodeId,
9
9
  resolveOptionalNodeId,
10
10
  NODE_TARGET_HINT,
11
+ NODE_URL_HINT,
11
12
  } from './shared.js';
12
13
 
13
14
  /**
@@ -66,7 +67,10 @@ export function registerPageTools(server) {
66
67
  'can then be published to the web at a public address (see rootr_publish_node). Use this when someone asks ' +
67
68
  'for a landing page, an announcement page, an event page, a product/pricing page or a signup page. ' +
68
69
  'Omit `blocks` to start from a hero/text/footer skeleton. ' +
69
- BLOCK_GUIDE,
70
+ BLOCK_GUIDE +
71
+ ' ' +
72
+ NODE_URL_HINT +
73
+ ' (that is the editor address; use rootr_publish_node for the public address.)',
70
74
  annotations: { readOnlyHint: false, destructiveHint: false },
71
75
  inputSchema: {
72
76
  name: z.string().optional().describe('Page name (also the browser tab title of the published page)'),
@@ -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 주간회의"'),
@@ -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 },
@@ -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'),
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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": {