rootr-cli 0.1.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.
package/lib/client.js ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Thin REST client for the Rootr API.
3
+ *
4
+ * All requests are authenticated with the `x-api-key` header. Errors from
5
+ * the API are normalized into RootrApiError so callers (CLI or MCP tools)
6
+ * can render consistent, helpful messages.
7
+ */
8
+
9
+ export class RootrApiError extends Error {
10
+ constructor(message, { status, body, hint } = {}) {
11
+ super(message);
12
+ this.name = 'RootrApiError';
13
+ this.status = status;
14
+ this.body = body;
15
+ this.hint = hint;
16
+ }
17
+ }
18
+
19
+ function hintFor(status, body) {
20
+ if (status === 401 || status === 403) {
21
+ return (
22
+ 'API 키 또는 워크스페이스 스코프를 확인하세요 (rootr config로 현재 설정 확인). ' +
23
+ '워크스페이스 생성(rootr_create_workspace)은 계정 키(PAT, workspaces:create 스코프)가 필요합니다 — ' +
24
+ '워크스페이스 키(docs:read/docs:write/graph:read/ask/webhooks:manage 스코프)로는 할 수 없습니다.'
25
+ );
26
+ }
27
+ if (status === 412) {
28
+ return '문서가 그 사이 변경되었습니다: 다시 읽고 재시도하세요.';
29
+ }
30
+ if (status === 409) {
31
+ return body && (body.message || body.error) ? undefined : '충돌이 발생했습니다.';
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ export class RootrClient {
37
+ constructor({ apiKey, workspace, baseUrl }) {
38
+ this.apiKey = apiKey;
39
+ this.workspace = workspace;
40
+ this.baseUrl = (baseUrl || '').replace(/\/+$/, '');
41
+ }
42
+
43
+ _requireWorkspace() {
44
+ if (!this.workspace) {
45
+ throw new RootrApiError('워크스페이스가 설정되지 않았습니다.', {
46
+ hint: 'rootr config --workspace <id> 로 설정하거나 ROOTR_WORKSPACE 환경변수를 지정하세요.',
47
+ });
48
+ }
49
+ }
50
+
51
+ _requireApiKey() {
52
+ if (!this.apiKey) {
53
+ throw new RootrApiError('API 키가 설정되지 않았습니다.', {
54
+ hint: 'rootr config --api-key <key> 로 설정하거나 ROOTR_API_KEY 환경변수를 지정하세요.',
55
+ });
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Low-level request helper.
61
+ * @param {string} path - path beginning with '/', appended to baseUrl
62
+ * @param {object} opts
63
+ * @param {string} [opts.method]
64
+ * @param {object} [opts.headers]
65
+ * @param {any} [opts.body] - if an object, JSON-stringified and Content-Type set
66
+ * @param {boolean} [opts.rawText] - if true, return response text instead of parsed JSON
67
+ * @param {boolean} [opts.returnResponse] - if true, return the raw Response (for header access)
68
+ */
69
+ async request(path, opts = {}) {
70
+ this._requireApiKey();
71
+
72
+ const url = opts.url || `${this.baseUrl}${path}`;
73
+ const headers = {
74
+ 'x-api-key': this.apiKey,
75
+ ...(opts.headers || {}),
76
+ };
77
+
78
+ let body = opts.body;
79
+ if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
80
+ headers['Content-Type'] = headers['Content-Type'] || 'application/json';
81
+ body = JSON.stringify(body);
82
+ }
83
+
84
+ let res;
85
+ try {
86
+ res = await fetch(url, {
87
+ method: opts.method || 'GET',
88
+ headers,
89
+ body,
90
+ });
91
+ } catch (err) {
92
+ throw new RootrApiError(`네트워크 오류: ${err.message}`, { hint: `요청 URL: ${url}` });
93
+ }
94
+
95
+ if (opts.returnResponse) {
96
+ if (!res.ok) {
97
+ await this._throwForResponse(res);
98
+ }
99
+ return res;
100
+ }
101
+
102
+ if (!res.ok) {
103
+ await this._throwForResponse(res);
104
+ }
105
+
106
+ if (opts.rawText) {
107
+ return res.text();
108
+ }
109
+
110
+ const text = await res.text();
111
+ if (!text) return null;
112
+ try {
113
+ return JSON.parse(text);
114
+ } catch {
115
+ return text;
116
+ }
117
+ }
118
+
119
+ async _throwForResponse(res) {
120
+ let bodyText = '';
121
+ let bodyJson;
122
+ try {
123
+ bodyText = await res.text();
124
+ bodyJson = bodyText ? JSON.parse(bodyText) : undefined;
125
+ } catch {
126
+ // body wasn't JSON; keep raw text
127
+ }
128
+
129
+ const message =
130
+ (bodyJson && (bodyJson.message || bodyJson.error)) ||
131
+ bodyText ||
132
+ `HTTP ${res.status} ${res.statusText}`;
133
+
134
+ throw new RootrApiError(message, {
135
+ status: res.status,
136
+ body: bodyJson || bodyText,
137
+ hint: hintFor(res.status, bodyJson),
138
+ });
139
+ }
140
+
141
+ // ---- Tree / navigation -------------------------------------------------
142
+
143
+ async getTree() {
144
+ this._requireWorkspace();
145
+ const data = await this.request(`/workspaces/${encodeURIComponent(this.workspace)}/tree`);
146
+ if (Array.isArray(data)) return data;
147
+ if (data && Array.isArray(data.nodes)) return data.nodes;
148
+ if (data && Array.isArray(data.tree)) return data.tree;
149
+ return [];
150
+ }
151
+
152
+ async getNodeByPath(path) {
153
+ this._requireWorkspace();
154
+ try {
155
+ const data = await this.request(
156
+ `/workspaces/${encodeURIComponent(this.workspace)}/nodes/by-path?path=${encodeURIComponent(path)}`,
157
+ );
158
+ return data;
159
+ } catch (err) {
160
+ if (err instanceof RootrApiError && err.status === 404) return null;
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ async createNode({ type = 'DOCUMENT', path, createParents = true, autoRename = false, content }) {
166
+ this._requireWorkspace();
167
+ return this.request(`/workspaces/${encodeURIComponent(this.workspace)}/nodes`, {
168
+ method: 'POST',
169
+ body: { type, path, createParents, autoRename, content },
170
+ });
171
+ }
172
+
173
+ async search(query) {
174
+ this._requireWorkspace();
175
+ const data = await this.request(
176
+ `/workspaces/${encodeURIComponent(this.workspace)}/search?q=${encodeURIComponent(query)}`,
177
+ );
178
+ if (Array.isArray(data)) return data;
179
+ if (data && Array.isArray(data.results)) return data.results;
180
+ return [];
181
+ }
182
+
183
+ // ---- Documents ----------------------------------------------------------
184
+
185
+ /** Fetch raw markdown content for a document id. Returns { content, etag }. */
186
+ async getDocumentMarkdown(id) {
187
+ const res = await this.request(`/documents/${encodeURIComponent(id)}`, {
188
+ headers: { Accept: 'text/markdown' },
189
+ returnResponse: true,
190
+ });
191
+ const content = await res.text();
192
+ const etag = res.headers.get('etag') || res.headers.get('ETag') || undefined;
193
+ return { content, etag };
194
+ }
195
+
196
+ /** Fetch the full JSON representation of a document. */
197
+ async getDocumentJson(id) {
198
+ const res = await this.request(`/documents/${encodeURIComponent(id)}`, {
199
+ headers: { Accept: 'application/json' },
200
+ returnResponse: true,
201
+ });
202
+ const text = await res.text();
203
+ const etag = res.headers.get('etag') || res.headers.get('ETag') || undefined;
204
+ let json;
205
+ try {
206
+ json = text ? JSON.parse(text) : {};
207
+ } catch {
208
+ json = { content: text };
209
+ }
210
+ if (etag && json && json.etag === undefined) json.etag = etag;
211
+ return json;
212
+ }
213
+
214
+ /** Replace document content entirely. */
215
+ async putDocument(id, content, { ifMatch } = {}) {
216
+ const headers = {};
217
+ if (ifMatch) headers['If-Match'] = ifMatch;
218
+ const res = await this.request(`/documents/${encodeURIComponent(id)}`, {
219
+ method: 'PUT',
220
+ headers,
221
+ body: { content },
222
+ returnResponse: true,
223
+ });
224
+ const etag = res.headers.get('etag') || res.headers.get('ETag') || undefined;
225
+ return { etag };
226
+ }
227
+
228
+ /** Safe, conflict-free append. */
229
+ async appendDocument(id, { content, underHeading, createIfMissing } = {}) {
230
+ const res = await this.request(`/documents/${encodeURIComponent(id)}/append`, {
231
+ method: 'POST',
232
+ body: { content, underHeading, createIfMissing },
233
+ returnResponse: true,
234
+ });
235
+ const etag = res.headers.get('etag') || res.headers.get('ETag') || undefined;
236
+ let json;
237
+ try {
238
+ const text = await res.text();
239
+ json = text ? JSON.parse(text) : {};
240
+ } catch {
241
+ json = {};
242
+ }
243
+ return { ...json, etag: json.etag || etag };
244
+ }
245
+
246
+ /** Section-mode patch: { op, heading?, content, createIfMissing? } */
247
+ async patchDocumentSection(id, { op, heading, content, createIfMissing, ifMatch } = {}) {
248
+ const headers = {};
249
+ if (ifMatch) headers['If-Match'] = ifMatch;
250
+ return this.request(`/documents/${encodeURIComponent(id)}`, {
251
+ method: 'PATCH',
252
+ headers,
253
+ body: { op, heading, content, createIfMissing, ...(ifMatch ? { ifMatch } : {}) },
254
+ });
255
+ }
256
+
257
+ /** Anchor-edit mode patch: { find, replace, replaceAll? } */
258
+ async patchDocumentAnchor(id, { find, replace, replaceAll, ifMatch } = {}) {
259
+ const headers = {};
260
+ if (ifMatch) headers['If-Match'] = ifMatch;
261
+ return this.request(`/documents/${encodeURIComponent(id)}`, {
262
+ method: 'PATCH',
263
+ headers,
264
+ body: { find, replace, replaceAll, ...(ifMatch ? { ifMatch } : {}) },
265
+ });
266
+ }
267
+
268
+ /** List document versions. */
269
+ async getDocumentVersions(id, { limit } = {}) {
270
+ const qs = limit ? `?limit=${encodeURIComponent(limit)}` : '';
271
+ return this.request(`/documents/${encodeURIComponent(id)}/versions${qs}`);
272
+ }
273
+
274
+ /** Move a node to the trash (recoverable). */
275
+ async deleteNode(id) {
276
+ return this.request(`/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' });
277
+ }
278
+
279
+ // ---- Workspaces (account-key scoped) -------------------------------------
280
+
281
+ /** List workspaces visible to the current key (account key / JWT). Defensive parsing. */
282
+ async listWorkspaces() {
283
+ const data = await this.request('/workspaces');
284
+ if (Array.isArray(data)) return data;
285
+ if (data && Array.isArray(data.workspaces)) return data.workspaces;
286
+ if (data && Array.isArray(data.items)) return data.items;
287
+ return [];
288
+ }
289
+
290
+ // ---- Scaffolding ----------------------------------------------------------
291
+
292
+ /** Plan a scaffold tree from a natural-language intent. No workspace required. */
293
+ async scaffoldPlan(intent) {
294
+ return this.request('/scaffold/plan', { method: 'POST', body: { intent } });
295
+ }
296
+
297
+ /** Create a brand-new workspace from a scaffold tree. Requires an account key (PAT) with workspaces:create. */
298
+ async createWorkspaceScaffold({ name, intent, tree }) {
299
+ return this.request('/scaffold/workspace', {
300
+ method: 'POST',
301
+ body: { name, intent, tree },
302
+ });
303
+ }
304
+
305
+ /** Apply a scaffold tree to an existing workspace. */
306
+ async scaffoldApply(workspace, { rootPath, tree } = {}) {
307
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/scaffold/apply`, {
308
+ method: 'POST',
309
+ body: { rootPath, tree },
310
+ });
311
+ }
312
+
313
+ // ---- LOG datastores ---------------------------------------------------
314
+
315
+ /** Create a typed LOG store node. fields: [{name,type,...}] (relation fields carry target/many/relation). */
316
+ async createLogStore(workspace, { name, fields, parentId } = {}) {
317
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/logs`, {
318
+ method: 'POST',
319
+ body: { name, fields, parentId },
320
+ });
321
+ }
322
+
323
+ async getLog(id) {
324
+ return this.request(`/logs/${encodeURIComponent(id)}`);
325
+ }
326
+
327
+ /** Replace the ENTIRE field set of a LOG store — resend every field you want to keep. */
328
+ async updateLogFields(id, fields) {
329
+ return this.request(`/logs/${encodeURIComponent(id)}`, {
330
+ method: 'PATCH',
331
+ body: { fields },
332
+ });
333
+ }
334
+
335
+ async addLogEntries(id, entries) {
336
+ return this.request(`/logs/${encodeURIComponent(id)}/entries`, {
337
+ method: 'POST',
338
+ body: { entries },
339
+ });
340
+ }
341
+
342
+ async queryLogEntries(id, { from, to, source, level, anomalyOnly, limit, order } = {}) {
343
+ const params = new URLSearchParams();
344
+ if (from) params.set('from', from);
345
+ if (to) params.set('to', to);
346
+ if (source) params.set('source', source);
347
+ if (level) params.set('level', level);
348
+ if (anomalyOnly !== undefined) params.set('anomalyOnly', String(anomalyOnly));
349
+ if (limit !== undefined) params.set('limit', String(limit));
350
+ if (order) params.set('order', order);
351
+ const qs = params.toString();
352
+ return this.request(`/logs/${encodeURIComponent(id)}/entries${qs ? `?${qs}` : ''}`);
353
+ }
354
+
355
+ async logStats(id, { groupBy, metric, field, from, to } = {}) {
356
+ const params = new URLSearchParams();
357
+ if (groupBy) params.set('groupBy', groupBy);
358
+ if (metric) params.set('metric', metric);
359
+ if (field) params.set('field', field);
360
+ if (from) params.set('from', from);
361
+ if (to) params.set('to', to);
362
+ const qs = params.toString();
363
+ return this.request(`/logs/${encodeURIComponent(id)}/stats${qs ? `?${qs}` : ''}`);
364
+ }
365
+
366
+ // ---- ask (GraphRAG RCA over GraphQL) --------------------------------------
367
+
368
+ /**
369
+ * Ask a natural-language question against a workspace's knowledge graph.
370
+ * Uses the GraphQL endpoint (baseUrl with trailing /v1 swapped for /graphql),
371
+ * not the REST /v1 base. Requires the `ask` scope.
372
+ */
373
+ async ask(workspaceId, question) {
374
+ this._requireApiKey();
375
+ const graphqlUrl = `${this.baseUrl.replace(/\/v1\/?$/, '')}/graphql`;
376
+ const query =
377
+ 'query($ws:String!,$q:String!){ask(workspaceId:$ws,question:$q){text citations{documentPath quote}}}';
378
+ const data = await this.request(null, {
379
+ url: graphqlUrl,
380
+ method: 'POST',
381
+ body: { query, variables: { ws: workspaceId, q: question } },
382
+ });
383
+ if (data && Array.isArray(data.errors) && data.errors.length) {
384
+ const msg = data.errors.map((e) => e.message || String(e)).join('; ');
385
+ throw new RootrApiError(`ask 오류: ${msg}`, { body: data.errors });
386
+ }
387
+ return (data && data.data && data.data.ask) || { text: '', citations: [] };
388
+ }
389
+
390
+ // ---- Issues ---------------------------------------------------------------
391
+
392
+ async createIssueTracker(workspace, name) {
393
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/issue-trackers`, {
394
+ method: 'POST',
395
+ body: { name },
396
+ });
397
+ }
398
+
399
+ async getIssueTracker(trackerId) {
400
+ return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}`);
401
+ }
402
+
403
+ async listIssues(trackerId, { state, label, type, assigneeId, q } = {}) {
404
+ const params = new URLSearchParams();
405
+ if (state) params.set('state', state);
406
+ if (label) params.set('label', label);
407
+ if (type) params.set('type', type);
408
+ if (assigneeId) params.set('assigneeId', assigneeId);
409
+ if (q) params.set('q', q);
410
+ const qs = params.toString();
411
+ return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}/issues${qs ? `?${qs}` : ''}`);
412
+ }
413
+
414
+ async createIssue(trackerId, { title, body, labels, type, assigneeId, parentIssueId } = {}) {
415
+ return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}/issues`, {
416
+ method: 'POST',
417
+ body: { title, body, labels, type, assigneeId, parentIssueId },
418
+ });
419
+ }
420
+
421
+ async getIssue(issueId) {
422
+ return this.request(`/issues/${encodeURIComponent(issueId)}`);
423
+ }
424
+
425
+ async updateIssue(issueId, patch) {
426
+ return this.request(`/issues/${encodeURIComponent(issueId)}`, {
427
+ method: 'PATCH',
428
+ body: patch,
429
+ });
430
+ }
431
+
432
+ /** Comment on an issue — issues live as blocks inside the tracker document. */
433
+ async commentIssue(trackerId, issueId, body) {
434
+ return this.request(`/documents/${encodeURIComponent(trackerId)}/comments`, {
435
+ method: 'POST',
436
+ body: { body, blockId: issueId },
437
+ });
438
+ }
439
+
440
+ // ---- Webhooks ---------------------------------------------------------
441
+
442
+ async listWebhooks(workspace) {
443
+ const data = await this.request(`/workspaces/${encodeURIComponent(workspace)}/webhooks`);
444
+ if (Array.isArray(data)) return data;
445
+ if (data && Array.isArray(data.webhooks)) return data.webhooks;
446
+ return [];
447
+ }
448
+
449
+ async createWebhook(workspace, { name, url, events, scopeNodeId, filterTags, debounceSeconds } = {}) {
450
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/webhooks`, {
451
+ method: 'POST',
452
+ body: { name, url, events, scopeNodeId, filterTags, debounceSeconds },
453
+ });
454
+ }
455
+
456
+ async updateWebhook(workspace, webhookId, patch) {
457
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/webhooks/${encodeURIComponent(webhookId)}`, {
458
+ method: 'PATCH',
459
+ body: patch,
460
+ });
461
+ }
462
+
463
+ async deleteWebhook(workspace, webhookId) {
464
+ return this.request(`/workspaces/${encodeURIComponent(workspace)}/webhooks/${encodeURIComponent(webhookId)}`, {
465
+ method: 'DELETE',
466
+ });
467
+ }
468
+ }
package/lib/config.js ADDED
@@ -0,0 +1,71 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.rootr');
6
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
7
+
8
+ const DEFAULT_BASE_URL = 'https://rootr.io/api/v1';
9
+
10
+ /**
11
+ * Read the on-disk config file. Returns {} if it doesn't exist or is invalid.
12
+ */
13
+ function readConfigFile() {
14
+ if (!existsSync(CONFIG_PATH)) return {};
15
+ try {
16
+ const raw = readFileSync(CONFIG_PATH, 'utf8');
17
+ return JSON.parse(raw);
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Resolve effective configuration: env vars take priority over the config file.
25
+ */
26
+ export function loadConfig() {
27
+ const file = readConfigFile();
28
+
29
+ const apiKey = process.env.ROOTR_API_KEY || file.apiKey || '';
30
+ const workspace = process.env.ROOTR_WORKSPACE || file.workspace || '';
31
+ const baseUrl = process.env.ROOTR_BASE_URL || file.baseUrl || DEFAULT_BASE_URL;
32
+
33
+ return { apiKey, workspace, baseUrl };
34
+ }
35
+
36
+ /**
37
+ * Persist config to ~/.rootr/config.json (chmod 600). Merges with existing file contents.
38
+ */
39
+ export function saveConfig(partial) {
40
+ const existing = readConfigFile();
41
+ const next = { ...existing, ...partial };
42
+
43
+ // Drop undefined values so we don't write "apiKey": undefined
44
+ for (const key of Object.keys(next)) {
45
+ if (next[key] === undefined) delete next[key];
46
+ }
47
+
48
+ if (!existsSync(CONFIG_DIR)) {
49
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
50
+ }
51
+
52
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', {
53
+ mode: 0o600,
54
+ });
55
+
56
+ try {
57
+ // Ensure permissions are correct even if the file already existed with looser perms.
58
+ chmodSync(CONFIG_PATH, 0o600);
59
+ } catch {
60
+ // best effort; not fatal on platforms without chmod support
61
+ }
62
+
63
+ return next;
64
+ }
65
+
66
+ export function maskKey(key) {
67
+ if (!key) return '(not set)';
68
+ return key.slice(0, 12) + '...';
69
+ }
70
+
71
+ export { CONFIG_PATH, DEFAULT_BASE_URL };
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+
3
+ import { textResult, errorResult, makeClient, requireWorkspace } from './shared.js';
4
+
5
+ /** rootr_ask — GraphRAG root-cause-analysis question answering. */
6
+ export function registerAskTools(server) {
7
+ server.registerTool(
8
+ 'rootr_ask',
9
+ {
10
+ title: 'Ask a question over the Rootr knowledge graph (RCA)',
11
+ description:
12
+ 'Ask a natural-language question against a Rootr (루터) workspace\'s knowledge graph (GraphRAG). Good for ' +
13
+ 'root-cause-analysis style questions ("why did X happen", "what changed before Y") that need to reason ' +
14
+ 'across linked documents and LOG anomalies rather than a single document. Requires the `ask` scope. ' +
15
+ 'Returns the answer text plus citations pointing at the source documents/quotes it drew from.',
16
+ inputSchema: {
17
+ question: z.string().describe('Natural-language question to ask'),
18
+ workspace: z
19
+ .string()
20
+ .optional()
21
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
22
+ },
23
+ },
24
+ async ({ question, workspace }) => {
25
+ try {
26
+ const ws = requireWorkspace(workspace);
27
+ const client = makeClient(ws);
28
+ const result = await client.ask(ws, question);
29
+ const citations = Array.isArray(result.citations) ? result.citations : [];
30
+ const citationLines = citations.map((c) => `- ${c.documentPath}: ${c.quote}`);
31
+ const text = [
32
+ result.text || '(no answer text)',
33
+ '',
34
+ citations.length ? 'Citations:' : '(no citations)',
35
+ ...citationLines,
36
+ ].join('\n');
37
+ return textResult(text);
38
+ } catch (err) {
39
+ return errorResult(err);
40
+ }
41
+ },
42
+ );
43
+ }