rootr-cli 0.2.1 → 0.4.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/LICENSE +21 -0
- package/README.md +94 -4
- package/bin/rootr.js +109 -3
- package/lib/client.js +531 -1
- package/lib/config.js +30 -1
- package/lib/context.js +38 -0
- package/lib/mcp-tools/ask.js +1 -0
- package/lib/mcp-tools/attachments.js +110 -0
- package/lib/mcp-tools/crm.js +711 -0
- package/lib/mcp-tools/databases.js +5 -0
- package/lib/mcp-tools/documents.js +153 -1
- package/lib/mcp-tools/forms.js +206 -0
- package/lib/mcp-tools/issues.js +6 -0
- package/lib/mcp-tools/logs.js +5 -0
- package/lib/mcp-tools/misc.js +2 -0
- package/lib/mcp-tools/presentations.js +8 -0
- package/lib/mcp-tools/recordings.js +426 -0
- package/lib/mcp-tools/shared.js +18 -3
- package/lib/mcp-tools/spreadsheets.js +224 -0
- package/lib/mcp-tools/webhooks.js +3 -0
- package/lib/mcp-tools/whiteboards.js +166 -0
- package/lib/mcp-tools/workspaces.js +148 -5
- package/lib/mcp.js +98 -5
- package/package.json +22 -3
package/lib/client.js
CHANGED
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
* can render consistent, helpful messages.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { currentRateLimitSink } from './context.js';
|
|
10
|
+
|
|
11
|
+
/** Copy the API's X-RateLimit-* headers into the ambient per-call sink, if any. */
|
|
12
|
+
function captureRateLimit(res) {
|
|
13
|
+
const sink = currentRateLimitSink();
|
|
14
|
+
if (!sink) return;
|
|
15
|
+
const limit = Number(res.headers.get('x-ratelimit-limit'));
|
|
16
|
+
const remaining = Number(res.headers.get('x-ratelimit-remaining'));
|
|
17
|
+
if (!Number.isFinite(limit) || !Number.isFinite(remaining)) return;
|
|
18
|
+
sink.limit = limit;
|
|
19
|
+
sink.remaining = remaining;
|
|
20
|
+
sink.window = res.headers.get('x-ratelimit-window') || undefined;
|
|
21
|
+
sink.resetAt = Number(res.headers.get('x-ratelimit-reset')) || undefined;
|
|
22
|
+
sink.upgradeUrl = res.headers.get('x-ratelimit-upgrade-url') || undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
export class RootrApiError extends Error {
|
|
10
26
|
constructor(message, { status, body, hint } = {}) {
|
|
11
27
|
super(message);
|
|
@@ -34,10 +50,13 @@ function hintFor(status, body) {
|
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
export class RootrClient {
|
|
37
|
-
constructor({ apiKey, workspace, baseUrl }) {
|
|
53
|
+
constructor({ apiKey, workspace, baseUrl, defaultHeaders }) {
|
|
38
54
|
this.apiKey = apiKey;
|
|
39
55
|
this.workspace = workspace;
|
|
40
56
|
this.baseUrl = (baseUrl || '').replace(/\/+$/, '');
|
|
57
|
+
/** Sent on every request — the hosted transport forwards Accept-Language
|
|
58
|
+
* here so API error text comes back in the caller's language. */
|
|
59
|
+
this.defaultHeaders = defaultHeaders || {};
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
_requireWorkspace() {
|
|
@@ -72,6 +91,7 @@ export class RootrClient {
|
|
|
72
91
|
const url = opts.url || `${this.baseUrl}${path}`;
|
|
73
92
|
const headers = {
|
|
74
93
|
'x-api-key': this.apiKey,
|
|
94
|
+
...this.defaultHeaders,
|
|
75
95
|
...(opts.headers || {}),
|
|
76
96
|
};
|
|
77
97
|
|
|
@@ -92,6 +112,8 @@ export class RootrClient {
|
|
|
92
112
|
throw new RootrApiError(`네트워크 오류: ${err.message}`, { hint: `요청 URL: ${url}` });
|
|
93
113
|
}
|
|
94
114
|
|
|
115
|
+
captureRateLimit(res);
|
|
116
|
+
|
|
95
117
|
if (opts.returnResponse) {
|
|
96
118
|
if (!res.ok) {
|
|
97
119
|
await this._throwForResponse(res);
|
|
@@ -276,6 +298,47 @@ export class RootrClient {
|
|
|
276
298
|
return this.request(`/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
277
299
|
}
|
|
278
300
|
|
|
301
|
+
// ---- Comments / publishing / duplication (document-level extras) --------
|
|
302
|
+
|
|
303
|
+
/** Comments carry a user identity — needs an account key (PAT) or user session; workspace keys 403. */
|
|
304
|
+
async createComment(documentId, { body, blockId, parentId } = {}) {
|
|
305
|
+
return this.request(`/documents/${encodeURIComponent(documentId)}/comments`, {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
body: { body, blockId, parentId },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async listComments(documentId, { blockId } = {}) {
|
|
312
|
+
return this.request(
|
|
313
|
+
`/documents/${encodeURIComponent(documentId)}/comments${this._qs({ blockId })}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Publish to a public /p/{slug} URL. Returns { slug, ... }. Requires MANAGE on the node. */
|
|
318
|
+
async publishDocument(documentId) {
|
|
319
|
+
return this.request(`/documents/${encodeURIComponent(documentId)}/publish`, { method: 'POST' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Patch public settings, e.g. { isPublic: false } to unpublish. */
|
|
323
|
+
async setDocumentPublic(documentId, settings = {}) {
|
|
324
|
+
return this.request(`/documents/${encodeURIComponent(documentId)}/public`, {
|
|
325
|
+
method: 'PATCH',
|
|
326
|
+
body: settings,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async getDocumentPublicStatus(documentId) {
|
|
331
|
+
return this.request(`/documents/${encodeURIComponent(documentId)}/public-status`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Deep-copy a node (tree) — optionally into another workspace the caller can access. */
|
|
335
|
+
async duplicateNode(nodeId, { deep, targetWorkspaceId } = {}) {
|
|
336
|
+
return this.request(`/nodes/${encodeURIComponent(nodeId)}/duplicate`, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
body: { deep, targetWorkspaceId },
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
279
342
|
// ---- Workspaces (account-key scoped) -------------------------------------
|
|
280
343
|
|
|
281
344
|
/** List workspaces visible to the current key (account key / JWT). Defensive parsing. */
|
|
@@ -501,4 +564,471 @@ export class RootrClient {
|
|
|
501
564
|
{ method: 'DELETE' },
|
|
502
565
|
);
|
|
503
566
|
}
|
|
567
|
+
|
|
568
|
+
// ---- CRM ----------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/** Create a CRM node. pipelines/fields/config optional (server fills sane defaults). */
|
|
571
|
+
async createCrm(workspace, { name, path, parentId, icon, pipelines, fields, config, createParents, autoRename } = {}) {
|
|
572
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/crms`, {
|
|
573
|
+
method: 'POST',
|
|
574
|
+
body: { name, path, parentId, icon, pipelines, fields, config, createParents, autoRename },
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** CRM detail: { id, name, pipelines[{id,name,stages[]}], fields, config, counts, myLevel }. */
|
|
579
|
+
async getCrm(crmId) {
|
|
580
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async updateCrm(crmId, { pipelines, fields, config, icon } = {}) {
|
|
584
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}`, {
|
|
585
|
+
method: 'PATCH',
|
|
586
|
+
body: { pipelines, fields, config, icon },
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Per-pipeline stage value/forecast/win-rate + wonByMonth + open task counts. */
|
|
591
|
+
async getCrmSummary(crmId) {
|
|
592
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/summary`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
_qs(params) {
|
|
596
|
+
const usp = new URLSearchParams();
|
|
597
|
+
for (const [k, v] of Object.entries(params || {})) {
|
|
598
|
+
if (v !== undefined && v !== null && v !== '') usp.set(k, String(v));
|
|
599
|
+
}
|
|
600
|
+
const s = usp.toString();
|
|
601
|
+
return s ? `?${s}` : '';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// -- Companies --
|
|
605
|
+
|
|
606
|
+
async listCrmCompanies(crmId, { q, ownerId, tag, sort, limit, offset } = {}) {
|
|
607
|
+
return this.request(
|
|
608
|
+
`/crms/${encodeURIComponent(crmId)}/companies${this._qs({ q, ownerId, tag, sort, limit, offset })}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async createCrmCompany(crmId, body) {
|
|
613
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/companies`, { method: 'POST', body });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Detail includes contacts/deals/activities/tasks alongside the company. */
|
|
617
|
+
async getCrmCompany(companyId) {
|
|
618
|
+
return this.request(`/crm-companies/${encodeURIComponent(companyId)}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async updateCrmCompany(companyId, patch) {
|
|
622
|
+
return this.request(`/crm-companies/${encodeURIComponent(companyId)}`, { method: 'PATCH', body: patch });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async deleteCrmCompany(companyId) {
|
|
626
|
+
return this.request(`/crm-companies/${encodeURIComponent(companyId)}`, { method: 'DELETE' });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// -- Contacts --
|
|
630
|
+
|
|
631
|
+
async listCrmContacts(crmId, { q, companyId, lifecycleStage, leadStatus, ownerId, tag, sort, limit, offset } = {}) {
|
|
632
|
+
return this.request(
|
|
633
|
+
`/crms/${encodeURIComponent(crmId)}/contacts${this._qs({
|
|
634
|
+
q,
|
|
635
|
+
companyId,
|
|
636
|
+
lifecycleStage,
|
|
637
|
+
leadStatus,
|
|
638
|
+
ownerId,
|
|
639
|
+
tag,
|
|
640
|
+
sort,
|
|
641
|
+
limit,
|
|
642
|
+
offset,
|
|
643
|
+
})}`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async createCrmContact(crmId, body) {
|
|
648
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/contacts`, { method: 'POST', body });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async getCrmContact(contactId) {
|
|
652
|
+
return this.request(`/crm-contacts/${encodeURIComponent(contactId)}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async updateCrmContact(contactId, patch) {
|
|
656
|
+
return this.request(`/crm-contacts/${encodeURIComponent(contactId)}`, { method: 'PATCH', body: patch });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async deleteCrmContact(contactId) {
|
|
660
|
+
return this.request(`/crm-contacts/${encodeURIComponent(contactId)}`, { method: 'DELETE' });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// -- Deals --
|
|
664
|
+
|
|
665
|
+
async listCrmDeals(crmId, { q, pipelineId, stageId, status, ownerId, companyId, contactId, tag, sort, limit, offset } = {}) {
|
|
666
|
+
return this.request(
|
|
667
|
+
`/crms/${encodeURIComponent(crmId)}/deals${this._qs({
|
|
668
|
+
q,
|
|
669
|
+
pipelineId,
|
|
670
|
+
stageId,
|
|
671
|
+
status,
|
|
672
|
+
ownerId,
|
|
673
|
+
companyId,
|
|
674
|
+
contactId,
|
|
675
|
+
tag,
|
|
676
|
+
sort,
|
|
677
|
+
limit,
|
|
678
|
+
offset,
|
|
679
|
+
})}`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async createCrmDeal(crmId, body) {
|
|
684
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/deals`, { method: 'POST', body });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async getCrmDeal(dealId) {
|
|
688
|
+
return this.request(`/crm-deals/${encodeURIComponent(dealId)}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async updateCrmDeal(dealId, patch) {
|
|
692
|
+
return this.request(`/crm-deals/${encodeURIComponent(dealId)}`, { method: 'PATCH', body: patch });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async deleteCrmDeal(dealId) {
|
|
696
|
+
return this.request(`/crm-deals/${encodeURIComponent(dealId)}`, { method: 'DELETE' });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Kanban-style stage move: { stageId, pipelineId?, position? }. */
|
|
700
|
+
async moveCrmDeal(dealId, { stageId, pipelineId, position } = {}) {
|
|
701
|
+
return this.request(`/crm-deals/${encodeURIComponent(dealId)}/move`, {
|
|
702
|
+
method: 'POST',
|
|
703
|
+
body: { stageId, pipelineId, position },
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// -- Activities --
|
|
708
|
+
|
|
709
|
+
async listCrmActivities(crmId, { contactId, companyId, dealId, type, limit, offset } = {}) {
|
|
710
|
+
return this.request(
|
|
711
|
+
`/crms/${encodeURIComponent(crmId)}/activities${this._qs({ contactId, companyId, dealId, type, limit, offset })}`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async createCrmActivity(crmId, body) {
|
|
716
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/activities`, { method: 'POST', body });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// -- Tasks --
|
|
720
|
+
|
|
721
|
+
async listCrmTasks(crmId, { status, assigneeId, due, contactId, companyId, dealId, limit, offset } = {}) {
|
|
722
|
+
return this.request(
|
|
723
|
+
`/crms/${encodeURIComponent(crmId)}/tasks${this._qs({
|
|
724
|
+
status,
|
|
725
|
+
assigneeId,
|
|
726
|
+
due,
|
|
727
|
+
contactId,
|
|
728
|
+
companyId,
|
|
729
|
+
dealId,
|
|
730
|
+
limit,
|
|
731
|
+
offset,
|
|
732
|
+
})}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async createCrmTask(crmId, body) {
|
|
737
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/tasks`, { method: 'POST', body });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async updateCrmTask(taskId, patch) {
|
|
741
|
+
return this.request(`/crm-tasks/${encodeURIComponent(taskId)}`, { method: 'PATCH', body: patch });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// -- Import / export --
|
|
745
|
+
|
|
746
|
+
/** Raw CSV text for entity = contact | company | deal. */
|
|
747
|
+
async exportCrm(crmId, entity) {
|
|
748
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/export${this._qs({ entity })}`, { rawText: true });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async importCrm(crmId, { entity, rows } = {}) {
|
|
752
|
+
return this.request(`/crms/${encodeURIComponent(crmId)}/import`, {
|
|
753
|
+
method: 'POST',
|
|
754
|
+
body: { entity, rows },
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---- Presentations (slide decks) ----------------------------------------
|
|
759
|
+
|
|
760
|
+
async createPresentation(workspaceId, { name, parentId, theme, slides, config, icon } = {}) {
|
|
761
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/presentations`, {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
body: { name, parentId, theme, slides, config, icon },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Full deck: node meta + theme + ordered slides array. */
|
|
768
|
+
async getPresentation(presentationId) {
|
|
769
|
+
return this.request(`/presentations/${encodeURIComponent(presentationId)}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** REC-001 — create an empty RECORDING node (audio is uploaded separately). */
|
|
773
|
+
async createRecording(
|
|
774
|
+
workspaceId,
|
|
775
|
+
{ name, parentId, path, createParents, autoRename, icon, expectedSpeakers } = {},
|
|
776
|
+
) {
|
|
777
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/recordings`, {
|
|
778
|
+
method: 'POST',
|
|
779
|
+
body: { name, parentId, path, createParents, autoRename, icon, expectedSpeakers },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** REC-001 — recording detail: status + diarized transcript + speaker names. */
|
|
784
|
+
async getRecording(recordingId) {
|
|
785
|
+
return this.request(`/recordings/${encodeURIComponent(recordingId)}`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Downloads a recording's audio bytes. Uses the unauthenticated `/raw` route
|
|
790
|
+
* (unguessable attachment id, same tradeoff images already take) so the
|
|
791
|
+
* redirect to the storage bucket isn't sent our API key.
|
|
792
|
+
*/
|
|
793
|
+
async downloadAttachmentBytes(attachmentId) {
|
|
794
|
+
const url = this.attachmentRawUrl(attachmentId);
|
|
795
|
+
const res = await fetch(url);
|
|
796
|
+
if (!res.ok) {
|
|
797
|
+
throw new RootrApiError(`오디오 다운로드 실패 (HTTP ${res.status})`, { status: res.status });
|
|
798
|
+
}
|
|
799
|
+
return Buffer.from(await res.arrayBuffer());
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Merge-patch speakerNames ({"S1":"김대표"}) and/or icon. */
|
|
803
|
+
async updateRecording(recordingId, { speakerNames, expectedSpeakers, icon } = {}) {
|
|
804
|
+
return this.request(`/recordings/${encodeURIComponent(recordingId)}`, {
|
|
805
|
+
method: 'PATCH',
|
|
806
|
+
body: { speakerNames, expectedSpeakers, icon },
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Uploads the raw audio bytes (max 100MB) and kicks off diarized STT —
|
|
812
|
+
* same raw-body pattern as uploadAttachment. Returns the recording with
|
|
813
|
+
* status TRANSCRIBING.
|
|
814
|
+
*/
|
|
815
|
+
async uploadRecordingAudio(recordingId, { filename, mimeType, durationSec, data }) {
|
|
816
|
+
const qs = new URLSearchParams({ filename, mimeType });
|
|
817
|
+
if (durationSec != null) qs.set('durationSec', String(durationSec));
|
|
818
|
+
return this.request(`/recordings/${encodeURIComponent(recordingId)}/audio?${qs.toString()}`, {
|
|
819
|
+
method: 'POST',
|
|
820
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
821
|
+
body: data,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Re-runs speech-to-text on the already-uploaded audio (spends AI credits). */
|
|
826
|
+
async transcribeRecording(recordingId) {
|
|
827
|
+
return this.request(`/recordings/${encodeURIComponent(recordingId)}/transcribe`, {
|
|
828
|
+
method: 'POST',
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Built-in summarization → creates a markdown DOCUMENT node; returns { docId }. */
|
|
833
|
+
async summarizeRecording(recordingId, { locale, format } = {}) {
|
|
834
|
+
return this.request(`/recordings/${encodeURIComponent(recordingId)}/summarize`, {
|
|
835
|
+
method: 'POST',
|
|
836
|
+
body: { locale, format },
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Merge-patch theme/config/icon; `slides` REPLACES the whole array when present. */
|
|
841
|
+
async updatePresentation(presentationId, { theme, slides, config, icon } = {}) {
|
|
842
|
+
return this.request(`/presentations/${encodeURIComponent(presentationId)}`, {
|
|
843
|
+
method: 'PATCH',
|
|
844
|
+
body: { theme, slides, config, icon },
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async appendPresentationSlides(presentationId, slides) {
|
|
849
|
+
return this.request(`/presentations/${encodeURIComponent(presentationId)}/slides`, {
|
|
850
|
+
method: 'POST',
|
|
851
|
+
body: { slides },
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async updatePresentationSlide(presentationId, slideId, slide) {
|
|
856
|
+
return this.request(
|
|
857
|
+
`/presentations/${encodeURIComponent(presentationId)}/slides/${encodeURIComponent(slideId)}`,
|
|
858
|
+
{ method: 'PATCH', body: { slide } },
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** `order` = slide ids in the desired order (must be a permutation of existing ids). */
|
|
863
|
+
async reorderPresentationSlides(presentationId, order) {
|
|
864
|
+
return this.request(`/presentations/${encodeURIComponent(presentationId)}/reorder`, {
|
|
865
|
+
method: 'POST',
|
|
866
|
+
body: { order },
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// ---- Images (deck assets; consume AI credits) ---------------------------
|
|
871
|
+
|
|
872
|
+
async generateImage(workspaceId, { prompt, nodeId, context } = {}) {
|
|
873
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/images/generate`, {
|
|
874
|
+
method: 'POST',
|
|
875
|
+
body: { prompt, nodeId, context },
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async removeImageBackground(workspaceId, { attachmentId, nodeId } = {}) {
|
|
880
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/images/remove-background`, {
|
|
881
|
+
method: 'POST',
|
|
882
|
+
body: { attachmentId, nodeId },
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ---- Attachments (upload local files/images) -----------------------------
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Proxied upload — mirrors the editor's upload path (#13), which streams
|
|
890
|
+
* bytes through the API instead of a presigned S3 PUT (avoids S3 CORS
|
|
891
|
+
* issues from non-browser callers like the CLI/MCP).
|
|
892
|
+
*/
|
|
893
|
+
async uploadAttachment(workspaceId, { filename, mimeType, nodeId, data }) {
|
|
894
|
+
const qs = new URLSearchParams({ workspaceId, filename, mimeType });
|
|
895
|
+
if (nodeId) qs.set('nodeId', nodeId);
|
|
896
|
+
return this.request(`/attachments/upload?${qs.toString()}`, {
|
|
897
|
+
method: 'POST',
|
|
898
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
899
|
+
body: data,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Public, unauthenticated URL an <img> tag (or a published /p/ document) can load directly. */
|
|
904
|
+
attachmentRawUrl(attachmentId) {
|
|
905
|
+
return `${this.baseUrl}/attachments/${encodeURIComponent(attachmentId)}/raw`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ---- Spreadsheets ---------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
async listSpreadsheets(workspace) {
|
|
911
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/spreadsheets`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async createSpreadsheet(workspace, { name, parentId, path, createParents, autoRename, icon, data, config } = {}) {
|
|
915
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/spreadsheets`, {
|
|
916
|
+
method: 'POST',
|
|
917
|
+
body: { name, parentId, path, createParents, autoRename, icon, data, config },
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Full spreadsheet: node meta + sheets[] (each with a sparse `cells` map). */
|
|
922
|
+
async getSpreadsheet(spreadsheetId) {
|
|
923
|
+
return this.request(`/spreadsheets/${encodeURIComponent(spreadsheetId)}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Merge-patch config/icon; `sheets` REPLACES the whole sheets array when present. */
|
|
927
|
+
async updateSpreadsheet(spreadsheetId, { sheets, config, icon } = {}) {
|
|
928
|
+
return this.request(`/spreadsheets/${encodeURIComponent(spreadsheetId)}`, {
|
|
929
|
+
method: 'PATCH',
|
|
930
|
+
body: { sheets, config, icon },
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async createSheet(spreadsheetId, { name, rowCount, colCount } = {}) {
|
|
935
|
+
return this.request(`/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets`, {
|
|
936
|
+
method: 'POST',
|
|
937
|
+
body: { name, rowCount, colCount },
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async patchSheet(spreadsheetId, sheetId, patch) {
|
|
942
|
+
return this.request(
|
|
943
|
+
`/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets/${encodeURIComponent(sheetId)}`,
|
|
944
|
+
{ method: 'PATCH', body: patch },
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async deleteSheet(spreadsheetId, sheetId) {
|
|
949
|
+
return this.request(
|
|
950
|
+
`/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets/${encodeURIComponent(sheetId)}`,
|
|
951
|
+
{ method: 'DELETE' },
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Sparse cell patch: { "A1": { v, f? } | null }. null clears the cell. */
|
|
956
|
+
async patchCells(spreadsheetId, sheetId, cells) {
|
|
957
|
+
return this.request(
|
|
958
|
+
`/spreadsheets/${encodeURIComponent(spreadsheetId)}/sheets/${encodeURIComponent(sheetId)}/cells`,
|
|
959
|
+
{ method: 'PATCH', body: { cells } },
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ---- Whiteboards -----------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
async createWhiteboard(workspace, { name, parentId, path, createParents, autoRename, icon, scene, config } = {}) {
|
|
966
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/whiteboards`, {
|
|
967
|
+
method: 'POST',
|
|
968
|
+
body: { name, parentId, path, createParents, autoRename, icon, scene, config },
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Full board: node meta + scene ({ shapes[], edges[] }). */
|
|
973
|
+
async getWhiteboard(whiteboardId) {
|
|
974
|
+
return this.request(`/whiteboards/${encodeURIComponent(whiteboardId)}`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Merge-patch config/icon; `scene` REPLACES the whole scene when present
|
|
979
|
+
* unless `merge` is true (then shapes/edges are upserted by id).
|
|
980
|
+
*/
|
|
981
|
+
async updateWhiteboard(whiteboardId, { scene, merge, config, icon } = {}) {
|
|
982
|
+
return this.request(`/whiteboards/${encodeURIComponent(whiteboardId)}`, {
|
|
983
|
+
method: 'PATCH',
|
|
984
|
+
body: { scene, merge, config, icon },
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ---- Forms -----------------------------------------------------------------
|
|
989
|
+
|
|
990
|
+
async createForm(workspace, { name, parentId, path, createParents, autoRename, icon, fields, targetDatabaseId, config } = {}) {
|
|
991
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/forms`, {
|
|
992
|
+
method: 'POST',
|
|
993
|
+
body: { name, parentId, path, createParents, autoRename, icon, fields, targetDatabaseId, config },
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Full form: node meta + fields[] + targetDatabaseId + config. */
|
|
998
|
+
async getForm(formId) {
|
|
999
|
+
return this.request(`/forms/${encodeURIComponent(formId)}`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Merge-patch fields/targetDatabaseId/config/icon. */
|
|
1003
|
+
async updateForm(formId, { fields, targetDatabaseId, config, icon } = {}) {
|
|
1004
|
+
return this.request(`/forms/${encodeURIComponent(formId)}`, {
|
|
1005
|
+
method: 'PATCH',
|
|
1006
|
+
body: { fields, targetDatabaseId, config, icon },
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Submitted responses, most recent first. */
|
|
1011
|
+
async listFormResponses(formId) {
|
|
1012
|
+
const data = await this.request(`/forms/${encodeURIComponent(formId)}/responses`);
|
|
1013
|
+
if (data && Array.isArray(data.responses)) return data.responses;
|
|
1014
|
+
return [];
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async createFormResponse(formId, values) {
|
|
1018
|
+
return this.request(`/forms/${encodeURIComponent(formId)}/responses`, {
|
|
1019
|
+
method: 'POST',
|
|
1020
|
+
body: { values },
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Issue a new public share link ({ token, url, ... }) for the form. */
|
|
1025
|
+
async createFormShareLink(formId) {
|
|
1026
|
+
return this.request(`/forms/${encodeURIComponent(formId)}/share`, { method: 'POST' });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async listFormShareLinks(formId) {
|
|
1030
|
+
const data = await this.request(`/forms/${encodeURIComponent(formId)}/share`);
|
|
1031
|
+
if (data && Array.isArray(data.links)) return data.links;
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
504
1034
|
}
|
package/lib/config.js
CHANGED
|
@@ -29,8 +29,37 @@ export function loadConfig() {
|
|
|
29
29
|
const apiKey = process.env.ROOTR_API_KEY || file.apiKey || '';
|
|
30
30
|
const workspace = process.env.ROOTR_WORKSPACE || file.workspace || '';
|
|
31
31
|
const baseUrl = process.env.ROOTR_BASE_URL || file.baseUrl || DEFAULT_BASE_URL;
|
|
32
|
+
// Extra credentials, each optionally bound to the workspace it can reach:
|
|
33
|
+
// [{ apiKey, workspace?, label? }]. Lets one machine hold keys for several
|
|
34
|
+
// workspaces and switch between them without re-running `rootr config`.
|
|
35
|
+
const keys = Array.isArray(file.keys) ? file.keys.filter((k) => k && typeof k.apiKey === 'string') : [];
|
|
32
36
|
|
|
33
|
-
return { apiKey, workspace, baseUrl };
|
|
37
|
+
return { apiKey, workspace, baseUrl, keys };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pick the credential that can reach `workspace`: a key explicitly bound to it
|
|
42
|
+
* wins, otherwise the default key (an account key/PAT reaches every workspace
|
|
43
|
+
* its owner belongs to, so it is the right fallback).
|
|
44
|
+
*/
|
|
45
|
+
export function keyForWorkspace(cfg, workspace) {
|
|
46
|
+
if (workspace) {
|
|
47
|
+
const bound = (cfg.keys || []).find((k) => k.workspace === workspace);
|
|
48
|
+
if (bound) return bound.apiKey;
|
|
49
|
+
}
|
|
50
|
+
return cfg.apiKey;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Add/replace a stored key, keyed by its secret. Returns the saved list. */
|
|
54
|
+
export function upsertKey({ apiKey, workspace, label }) {
|
|
55
|
+
const file = readConfigFile();
|
|
56
|
+
const keys = Array.isArray(file.keys) ? file.keys.slice() : [];
|
|
57
|
+
const at = keys.findIndex((k) => k && k.apiKey === apiKey);
|
|
58
|
+
const entry = { apiKey, ...(workspace ? { workspace } : {}), ...(label ? { label } : {}) };
|
|
59
|
+
if (at >= 0) keys[at] = { ...keys[at], ...entry };
|
|
60
|
+
else keys.push(entry);
|
|
61
|
+
saveConfig({ keys });
|
|
62
|
+
return keys;
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
/**
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lets a hosted (multi-tenant) MCP transport inject a per-request RootrClient
|
|
5
|
+
* so the same tool registration functions used by the local stdio CLI server
|
|
6
|
+
* can be reused without them reading ~/.rootr/config.json or process env
|
|
7
|
+
* (which are single-tenant, CLI-only concepts). CLI usage never sets this,
|
|
8
|
+
* so `makeClient()` falls back to `loadConfig()` unchanged.
|
|
9
|
+
*/
|
|
10
|
+
const storage = new AsyncLocalStorage();
|
|
11
|
+
|
|
12
|
+
export function withClient(client, fn) {
|
|
13
|
+
return storage.run(client, fn);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function currentClientOverride() {
|
|
17
|
+
return storage.getStore();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-tool-call sink for the rate-limit headers the API returns. The MCP layer
|
|
22
|
+
* runs each tool handler inside `withRateLimitSink({})`; `RootrClient.request`
|
|
23
|
+
* writes whatever `X-RateLimit-*` headers it saw into that object, and the MCP
|
|
24
|
+
* layer turns a nearly-exhausted quota into a heads-up appended to the tool
|
|
25
|
+
* result — so the assistant warns the user BEFORE the wall, not after it.
|
|
26
|
+
*
|
|
27
|
+
* Async-local (not module-global) on purpose: the hosted transport serves many
|
|
28
|
+
* workspaces in one process, and one tenant must never see another's quota.
|
|
29
|
+
*/
|
|
30
|
+
const rateLimitStorage = new AsyncLocalStorage();
|
|
31
|
+
|
|
32
|
+
export function withRateLimitSink(sink, fn) {
|
|
33
|
+
return rateLimitStorage.run(sink, fn);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function currentRateLimitSink() {
|
|
37
|
+
return rateLimitStorage.getStore();
|
|
38
|
+
}
|
package/lib/mcp-tools/ask.js
CHANGED
|
@@ -13,6 +13,7 @@ export function registerAskTools(server) {
|
|
|
13
13
|
'root-cause-analysis style questions ("why did X happen", "what changed before Y") that need to reason ' +
|
|
14
14
|
'across linked documents and LOG anomalies rather than a single document. Requires the `ask` scope. ' +
|
|
15
15
|
'Returns the answer text plus citations pointing at the source documents/quotes it drew from.',
|
|
16
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
16
17
|
inputSchema: {
|
|
17
18
|
question: z.string().describe('Natural-language question to ask'),
|
|
18
19
|
workspace: z
|