gloomberb 0.12.0 → 0.13.2

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.
Files changed (219) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.test.ts +34 -0
  5. package/src/api-client/index.ts +10 -2
  6. package/src/api-client/request.ts +4 -1
  7. package/src/api-client/research-activity.ts +61 -0
  8. package/src/api-client/types.ts +2 -1
  9. package/src/cli/commands/plugins.ts +4 -3
  10. package/src/cli/desktop-pane-shot.ts +79 -10
  11. package/src/cli/pane-functions/capabilities.ts +76 -23
  12. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  13. package/src/cli/pane-functions/cloud-session.ts +38 -0
  14. package/src/cli/pane-functions/dom.test.ts +41 -0
  15. package/src/cli/pane-functions/dom.ts +199 -0
  16. package/src/cli/pane-functions/index.test.ts +13 -2
  17. package/src/cli/pane-functions/index.ts +9 -5
  18. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  19. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  20. package/src/cli/pane-functions/report.test.ts +29 -1
  21. package/src/cli/pane-functions/report.ts +68 -7
  22. package/src/cli/pane-functions/screenshot.ts +195 -42
  23. package/src/cli/restore-plugins.ts +10 -3
  24. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  25. package/src/components/chart/composite/composite-chart.tsx +87 -21
  26. package/src/components/chart/composite/rasterizer.ts +33 -0
  27. package/src/components/chart/composite/renderers.test.ts +51 -4
  28. package/src/components/chart/composite/scene.test.ts +128 -2
  29. package/src/components/chart/composite/scene.ts +92 -16
  30. package/src/components/chart/composite/text-renderer.ts +13 -0
  31. package/src/components/chart/composite/time-scale.ts +30 -10
  32. package/src/components/chart/composite/types.ts +26 -0
  33. package/src/components/command-bar/assist/runtime.ts +38 -30
  34. package/src/components/command-bar/surface/assist.test.tsx +63 -9
  35. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  36. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  37. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  38. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  39. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  40. package/src/data/config/store/index.test.ts +23 -0
  41. package/src/data/config/store/node.ts +7 -1
  42. package/src/data/config/store/normalize.ts +4 -0
  43. package/src/news/types.ts +1 -0
  44. package/src/plugins/builtin/cds/client.test.ts +20 -0
  45. package/src/plugins/builtin/cds/client.ts +13 -3
  46. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  47. package/src/plugins/builtin/cds/headless.ts +113 -0
  48. package/src/plugins/builtin/cds/index.tsx +4 -0
  49. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  50. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  51. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  52. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  53. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  54. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  55. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  56. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  57. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  58. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  59. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  60. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  61. package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
  62. package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
  63. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  64. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  65. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  66. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  67. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  68. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  69. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  70. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  71. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  72. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  73. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  74. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  75. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  76. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  77. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  78. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  79. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  80. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  81. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  82. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  83. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  84. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  85. package/src/plugins/builtin/earnings/headless.ts +162 -0
  86. package/src/plugins/builtin/earnings/index.tsx +2 -0
  87. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  88. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  89. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  90. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  91. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  93. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
  94. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  95. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  96. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  97. package/src/plugins/builtin/holders/client.ts +29 -0
  98. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  99. package/src/plugins/builtin/holders/headless.ts +162 -0
  100. package/src/plugins/builtin/holders/index.tsx +14 -8
  101. package/src/plugins/builtin/holders/pane.tsx +7 -1
  102. package/src/plugins/builtin/insider/client.ts +50 -0
  103. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  104. package/src/plugins/builtin/insider/headless.ts +117 -0
  105. package/src/plugins/builtin/insider/index.tsx +21 -50
  106. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  107. package/src/plugins/builtin/insider/model.ts +72 -0
  108. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  109. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  110. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  111. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  112. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  113. package/src/plugins/builtin/market-movers/client.ts +76 -0
  114. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  115. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  116. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  117. package/src/plugins/builtin/news/headless.test.ts +138 -0
  118. package/src/plugins/builtin/news/headless.ts +190 -0
  119. package/src/plugins/builtin/news/index.tsx +13 -9
  120. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  121. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  122. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  123. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  124. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  125. package/src/plugins/builtin/research/client.ts +12 -0
  126. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  127. package/src/plugins/builtin/research/event-model.ts +245 -0
  128. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  129. package/src/plugins/builtin/research/events-headless.ts +87 -0
  130. package/src/plugins/builtin/research/headless.test.ts +93 -0
  131. package/src/plugins/builtin/research/headless.ts +161 -0
  132. package/src/plugins/builtin/research/index.tsx +32 -18
  133. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  134. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  135. package/src/plugins/builtin/scanner/headless.ts +125 -0
  136. package/src/plugins/builtin/scanner/index.ts +4 -0
  137. package/src/plugins/builtin/sec/client.ts +16 -0
  138. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  139. package/src/plugins/builtin/sec/headless.ts +69 -0
  140. package/src/plugins/builtin/sec/index.tsx +20 -75
  141. package/src/plugins/builtin/sec/model.ts +89 -0
  142. package/src/plugins/builtin/sectors/client.ts +54 -0
  143. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  144. package/src/plugins/builtin/sectors/headless.ts +80 -0
  145. package/src/plugins/builtin/sectors/index.tsx +6 -51
  146. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  147. package/src/plugins/builtin/short-interest/client.ts +5 -2
  148. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  149. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  150. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  151. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  152. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  153. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  154. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  155. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  156. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  157. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  158. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  159. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  160. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  161. package/src/plugins/builtin/volatility/client.ts +16 -3
  162. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  163. package/src/plugins/builtin/volatility/headless.ts +75 -0
  164. package/src/plugins/builtin/volatility/index.tsx +4 -0
  165. package/src/plugins/builtin/world-indices/client.ts +43 -0
  166. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  167. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  168. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  169. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  170. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  171. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  172. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  173. package/src/plugins/bundle.test.ts +58 -1
  174. package/src/plugins/bundle.ts +4 -2
  175. package/src/plugins/catalog-backend.test.ts +26 -0
  176. package/src/plugins/catalog-backend.ts +9 -2
  177. package/src/plugins/catalog-browser.test.ts +0 -13
  178. package/src/plugins/catalog-browser.ts +4 -0
  179. package/src/plugins/catalog-manifest.test.ts +15 -0
  180. package/src/plugins/catalog-manifest.ts +12 -5
  181. package/src/plugins/catalog-ui.ts +0 -6
  182. package/src/plugins/host-contract.ts +9 -0
  183. package/src/plugins/host-modules.ts +12 -0
  184. package/src/plugins/loader.ts +63 -15
  185. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  186. package/src/plugins/prediction-markets/headless.ts +255 -0
  187. package/src/plugins/prediction-markets/index.tsx +2 -0
  188. package/src/plugins/seed.ts +3 -0
  189. package/src/public/broker.ts +6 -0
  190. package/src/remote/schema.test.ts +65 -0
  191. package/src/remote/schema.ts +322 -39
  192. package/src/remote/types.ts +49 -2
  193. package/src/renderers/browser/app-services.ts +3 -0
  194. package/src/renderers/browser/config-host.ts +24 -8
  195. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  196. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  197. package/src/renderers/browser/main.tsx +3 -1
  198. package/src/renderers/browser/research-entry.test.ts +8 -0
  199. package/src/renderers/browser/research-entry.ts +7 -0
  200. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  201. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  202. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  203. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  204. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  205. package/src/sources/gloomberb-cloud/news.ts +1 -0
  206. package/src/sync/core-contributors.test.ts +111 -0
  207. package/src/sync/core-contributors.ts +155 -1
  208. package/src/time-series/resolve.test.ts +118 -1
  209. package/src/time-series/resolve.ts +14 -16
  210. package/src/types/config.ts +2 -0
  211. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  212. package/src/plugins/broker-sync/native-loader.ts +0 -10
  213. package/src/plugins/broker-sync/normalize.ts +0 -308
  214. package/src/plugins/broker-sync/public.ts +0 -122
  215. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  216. package/src/plugins/broker-sync/robinhood.ts +0 -114
  217. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  218. package/src/plugins/broker-sync/simplefin.ts +0 -70
  219. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
@@ -1,122 +0,0 @@
1
- import type { BrokerAdapter } from "../../types/broker";
2
- import type { GloomPlugin } from "../../types/plugin";
3
- import { normalizePublicSnapshot, type BrokerPortfolioSnapshot } from "./normalize";
4
-
5
- const PUBLIC_API_URL = "https://api.public.com";
6
-
7
- type FetchLike = typeof fetch;
8
-
9
- function publicError(status: number, fallback: string): Error {
10
- if (status === 400 || status === 401 || status === 403) {
11
- return new Error("Public rejected the API secret. Create a new secret in Public API settings.");
12
- }
13
- return new Error(fallback);
14
- }
15
-
16
- async function readJson(response: Response, fallback: string): Promise<unknown> {
17
- if (!response.ok) throw publicError(response.status, `${fallback} Status: ${response.status}.`);
18
- try {
19
- return await response.json();
20
- } catch {
21
- throw new Error(`${fallback} Public returned an invalid response.`);
22
- }
23
- }
24
-
25
- async function publicRequest(
26
- path: string,
27
- init: RequestInit,
28
- fetchImpl: FetchLike,
29
- timeoutMs = 20_000,
30
- ): Promise<Response> {
31
- const controller = new AbortController();
32
- const timer = setTimeout(() => controller.abort(), timeoutMs);
33
- try {
34
- return await fetchImpl(`${PUBLIC_API_URL}${path}`, { ...init, signal: controller.signal });
35
- } catch (error) {
36
- if (error instanceof Error && error.name === "AbortError") {
37
- throw new Error("Public took too long to respond.");
38
- }
39
- throw new Error(`Gloomberb could not reach Public. ${error instanceof Error ? error.message : ""}`.trim());
40
- } finally {
41
- clearTimeout(timer);
42
- }
43
- }
44
-
45
- export async function loadPublicPortfolio(apiSecret: string, fetchImpl: FetchLike = fetch): Promise<BrokerPortfolioSnapshot> {
46
- const secret = apiSecret.trim();
47
- if (!secret || secret.length > 2_000 || /[\u0000-\u0020\u007f]/.test(secret)) {
48
- throw new Error("Enter the API secret exactly as Public generated it.");
49
- }
50
-
51
- const tokenPayload = await readJson(await publicRequest(
52
- "/userapiauthservice/personal/access-tokens",
53
- {
54
- method: "POST",
55
- headers: { Accept: "application/json", "Content-Type": "application/json" },
56
- body: JSON.stringify({ secret, validityInMinutes: 5 }),
57
- },
58
- fetchImpl,
59
- ), "Public could not create an access token.") as { accessToken?: unknown };
60
- if (typeof tokenPayload.accessToken !== "string" || !tokenPayload.accessToken) {
61
- throw new Error("Public did not return an access token.");
62
- }
63
- const headers = { Accept: "application/json", Authorization: `Bearer ${tokenPayload.accessToken}` };
64
- const accountsPayload = await readJson(await publicRequest(
65
- "/userapigateway/trading/account",
66
- { headers },
67
- fetchImpl,
68
- ), "Public could not load accounts.");
69
- const accountValues = (accountsPayload as { accounts?: unknown }).accounts;
70
- if (!Array.isArray(accountValues)) throw new Error("Public returned an invalid account list.");
71
-
72
- const portfolioPayloads = await Promise.all(accountValues.map(async (value) => {
73
- const accountId = value && typeof value === "object" && typeof (value as { accountId?: unknown }).accountId === "string"
74
- ? (value as { accountId: string }).accountId
75
- : "";
76
- if (!accountId) throw new Error("Public returned an account without an ID.");
77
- return readJson(await publicRequest(
78
- `/userapigateway/trading/${encodeURIComponent(accountId)}/portfolio/v2`,
79
- { headers },
80
- fetchImpl,
81
- ), `Public could not load account ${accountId}.`);
82
- }));
83
-
84
- return normalizePublicSnapshot(accountsPayload, portfolioPayloads);
85
- }
86
-
87
- export const publicBroker: BrokerAdapter = {
88
- id: "public",
89
- name: "Public",
90
- configSchema: [{
91
- key: "apiSecret",
92
- label: "API Secret",
93
- type: "password",
94
- required: true,
95
- placeholder: "Secret from Public API settings",
96
- }],
97
-
98
- async validate(instance) {
99
- return typeof instance.config.apiSecret === "string" && instance.config.apiSecret.trim().length > 0;
100
- },
101
-
102
- async importPositions(instance) {
103
- return (await loadPublicPortfolio(String(instance.config.apiSecret ?? ""))).positions;
104
- },
105
-
106
- async importPortfolioSnapshot(instance) {
107
- return loadPublicPortfolio(String(instance.config.apiSecret ?? ""));
108
- },
109
-
110
- async listAccounts(instance) {
111
- return (await loadPublicPortfolio(String(instance.config.apiSecret ?? ""))).accounts;
112
- },
113
- };
114
-
115
- export const publicPlugin: GloomPlugin = {
116
- id: "public",
117
- name: "Public",
118
- version: "1.0.0",
119
- description: "Read-only account and position sync for Public.",
120
- toggleable: true,
121
- broker: publicBroker,
122
- };
@@ -1,367 +0,0 @@
1
- import { createServer, type Server } from "node:http";
2
- import { randomBytes } from "node:crypto";
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
- import {
5
- UnauthorizedError,
6
- type OAuthClientProvider,
7
- type OAuthDiscoveryState,
8
- } from "@modelcontextprotocol/sdk/client/auth.js";
9
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10
- import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
11
- import type { BrokerAdapter, BrokerConnectionStatus } from "../../types/broker";
12
- import type { BrokerInstanceConfig } from "../../types/config";
13
- import type { GloomPlugin } from "../../types/plugin";
14
- import { normalizeRobinhoodSnapshot, type BrokerPortfolioSnapshot } from "./normalize";
15
-
16
- const ROBINHOOD_MCP_URL = "https://agent.robinhood.com/mcp/trading";
17
- export const ROBINHOOD_POSITION_TOOLS = Object.freeze(["get_accounts", "get_equity_positions"]);
18
-
19
- interface ListedRobinhoodTool {
20
- name: string;
21
- inputSchema?: unknown;
22
- annotations?: { readOnlyHint?: boolean };
23
- }
24
-
25
- export function requireRobinhoodPositionTools(tools: ListedRobinhoodTool[]): Map<string, ListedRobinhoodTool> {
26
- const available = new Map(tools.map((tool) => [tool.name, tool]));
27
- for (const toolName of ROBINHOOD_POSITION_TOOLS) {
28
- const tool = available.get(toolName);
29
- if (!tool || tool.annotations?.readOnlyHint === false) {
30
- throw new Error(`Robinhood did not expose the read-only ${toolName} tool.`);
31
- }
32
- }
33
- return new Map(ROBINHOOD_POSITION_TOOLS.map((toolName) => [toolName, available.get(toolName)!]));
34
- }
35
-
36
- interface RobinhoodOAuthData {
37
- clientInformation?: OAuthClientInformationMixed;
38
- tokens?: OAuthTokens;
39
- codeVerifier?: string;
40
- discoveryState?: OAuthDiscoveryState;
41
- }
42
-
43
- const pendingOAuth = new Map<string, RobinhoodOAuthData>();
44
- const statuses = new Map<string, BrokerConnectionStatus>();
45
- const statusListeners = new Map<string, Set<() => void>>();
46
-
47
- function setStatus(instanceId: string, state: BrokerConnectionStatus["state"], message?: string): void {
48
- statuses.set(instanceId, { state, message, mode: "oauth", updatedAt: Date.now() });
49
- for (const listener of statusListeners.get(instanceId) ?? []) listener();
50
- }
51
-
52
- function cloneOAuth(value: unknown): RobinhoodOAuthData {
53
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
54
- return structuredClone(value) as RobinhoodOAuthData;
55
- }
56
-
57
- async function openExternal(url: string): Promise<void> {
58
- const command = process.platform === "darwin"
59
- ? ["open", url]
60
- : process.platform === "win32"
61
- ? ["cmd", "/c", "start", "", url]
62
- : ["xdg-open", url];
63
- const processHandle = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
64
- const exitCode = await processHandle.exited;
65
- if (exitCode !== 0) throw new Error("Gloomberb could not open the Robinhood sign-in page.");
66
- }
67
-
68
- class RobinhoodOAuthProvider implements OAuthClientProvider {
69
- constructor(
70
- readonly redirectUrl: string,
71
- private readonly oauth: RobinhoodOAuthData,
72
- private readonly oauthState: string,
73
- ) {}
74
-
75
- get clientMetadata() {
76
- return {
77
- client_name: "Gloomberb Robinhood Position Sync",
78
- redirect_uris: [this.redirectUrl],
79
- grant_types: ["authorization_code", "refresh_token"],
80
- response_types: ["code"],
81
- token_endpoint_auth_method: "client_secret_post",
82
- };
83
- }
84
-
85
- state() { return this.oauthState; }
86
- clientInformation() { return this.oauth.clientInformation; }
87
- saveClientInformation(value: OAuthClientInformationMixed) { this.oauth.clientInformation = value; }
88
- tokens() { return this.oauth.tokens; }
89
- saveTokens(value: OAuthTokens) { this.oauth.tokens = value; }
90
- async redirectToAuthorization(url: URL) { await openExternal(url.toString()); }
91
- saveCodeVerifier(value: string) { this.oauth.codeVerifier = value; }
92
- codeVerifier() {
93
- if (!this.oauth.codeVerifier) throw new Error("The Robinhood OAuth verifier is missing.");
94
- return this.oauth.codeVerifier;
95
- }
96
- saveDiscoveryState(value: OAuthDiscoveryState) { this.oauth.discoveryState = value; }
97
- discoveryState() { return this.oauth.discoveryState; }
98
- invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery") {
99
- if (scope === "all" || scope === "client") delete this.oauth.clientInformation;
100
- if (scope === "all" || scope === "tokens") delete this.oauth.tokens;
101
- if (scope === "all" || scope === "verifier") delete this.oauth.codeVerifier;
102
- if (scope === "all" || scope === "discovery") delete this.oauth.discoveryState;
103
- }
104
- }
105
-
106
- interface OAuthCallback {
107
- redirectUrl: string;
108
- code: Promise<string>;
109
- close(): Promise<void>;
110
- }
111
-
112
- async function startOAuthCallback(expectedState: string): Promise<OAuthCallback> {
113
- let resolveCode!: (code: string) => void;
114
- let rejectCode!: (error: Error) => void;
115
- let settled = false;
116
- const code = new Promise<string>((resolve, reject) => {
117
- resolveCode = resolve;
118
- rejectCode = reject;
119
- });
120
- let server: Server;
121
- const finish = (action: () => void) => {
122
- if (settled) return;
123
- settled = true;
124
- action();
125
- };
126
- server = createServer((request, response) => {
127
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
128
- if (url.pathname !== "/callback") {
129
- response.writeHead(404).end("Not found.");
130
- return;
131
- }
132
- const error = url.searchParams.get("error");
133
- const state = url.searchParams.get("state");
134
- const authorizationCode = url.searchParams.get("code");
135
- if (error) {
136
- response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
137
- response.end("Robinhood sign-in failed. Return to Gloomberb.");
138
- finish(() => rejectCode(new Error(`Robinhood sign-in failed: ${error}.`)));
139
- return;
140
- }
141
- if (!authorizationCode || state !== expectedState) {
142
- response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
143
- response.end("Gloomberb could not verify this Robinhood sign-in.");
144
- finish(() => rejectCode(new Error("Gloomberb could not verify the Robinhood sign-in response.")));
145
- return;
146
- }
147
- response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
148
- response.end("Robinhood is connected. You can close this page and return to Gloomberb.");
149
- finish(() => resolveCode(authorizationCode));
150
- });
151
- await new Promise<void>((resolve, reject) => {
152
- server.once("error", reject);
153
- server.listen(0, "127.0.0.1", resolve);
154
- });
155
- const address = server.address();
156
- if (!address || typeof address === "string") {
157
- server.close();
158
- throw new Error("Gloomberb could not start the Robinhood sign-in callback.");
159
- }
160
- const timeout = setTimeout(() => {
161
- finish(() => rejectCode(new Error("Robinhood sign-in timed out.")));
162
- }, 5 * 60_000);
163
- return {
164
- redirectUrl: `http://127.0.0.1:${address.port}/callback`,
165
- code,
166
- close: async () => {
167
- clearTimeout(timeout);
168
- await new Promise<void>((resolve) => server.close(() => resolve()));
169
- },
170
- };
171
- }
172
-
173
- async function connectMcp(provider: RobinhoodOAuthProvider, callback: OAuthCallback): Promise<{
174
- client: Client;
175
- transport: StreamableHTTPClientTransport;
176
- }> {
177
- const makeConnection = async () => {
178
- const transport = new StreamableHTTPClientTransport(new URL(ROBINHOOD_MCP_URL), { authProvider: provider });
179
- const client = new Client({ name: "gloomberb-robinhood-sync", version: "1.0.0" }, { capabilities: {} });
180
- await client.connect(transport);
181
- return { client, transport };
182
- };
183
- const transport = new StreamableHTTPClientTransport(new URL(ROBINHOOD_MCP_URL), { authProvider: provider });
184
- const client = new Client({ name: "gloomberb-robinhood-sync", version: "1.0.0" }, { capabilities: {} });
185
- try {
186
- await client.connect(transport);
187
- return { client, transport };
188
- } catch (error) {
189
- if (!(error instanceof UnauthorizedError)) {
190
- await client.close().catch(() => {});
191
- throw error;
192
- }
193
- const authorizationCode = await callback.code;
194
- await transport.finishAuth(authorizationCode);
195
- await client.close().catch(() => {});
196
- return makeConnection();
197
- }
198
- }
199
-
200
- function toolPayload(result: unknown): unknown {
201
- const value = result as {
202
- isError?: boolean;
203
- content?: Array<{ type?: string; text?: string }>;
204
- structuredContent?: unknown;
205
- };
206
- const text = value.content
207
- ?.filter((block) => block.type === "text" && typeof block.text === "string")
208
- .map((block) => block.text)
209
- .join("\n")
210
- .trim();
211
- if (value.isError) throw new Error(text || "Robinhood returned an error.");
212
- if (!text) return value.structuredContent ?? result;
213
- const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
214
- try {
215
- return JSON.parse(cleaned);
216
- } catch {
217
- throw new Error("Robinhood returned an invalid tool response.");
218
- }
219
- }
220
-
221
- function accountIds(payload: unknown): string[] {
222
- const ids = new Set<string>();
223
- const visit = (value: unknown) => {
224
- if (!value || typeof value !== "object") return;
225
- if (Array.isArray(value)) {
226
- for (const item of value) visit(item);
227
- return;
228
- }
229
- const item = value as Record<string, unknown>;
230
- for (const key of ["accountId", "account_id", "accountNumber", "account_number"]) {
231
- if (typeof item[key] === "string" && item[key]) ids.add(item[key]);
232
- }
233
- for (const child of Object.values(item)) visit(child);
234
- };
235
- visit(payload);
236
- return [...ids];
237
- }
238
-
239
- function positionArguments(inputSchema: unknown, accountId: string): Record<string, string> {
240
- if (!inputSchema || typeof inputSchema !== "object") return {};
241
- const properties = (inputSchema as { properties?: unknown }).properties;
242
- if (!properties || typeof properties !== "object" || Array.isArray(properties)) return {};
243
- const accountKey = Object.keys(properties).find((key) => /account/i.test(key));
244
- return accountKey ? { [accountKey]: accountId } : {};
245
- }
246
-
247
- export async function loadRobinhoodPortfolio(instance: BrokerInstanceConfig): Promise<BrokerPortfolioSnapshot> {
248
- setStatus(instance.id, "connecting", "Waiting for Robinhood");
249
- const oauth = cloneOAuth(pendingOAuth.get(instance.id) ?? instance.config.oauth);
250
- const oauthState = randomBytes(24).toString("hex");
251
- const callback = await startOAuthCallback(oauthState);
252
- const provider = new RobinhoodOAuthProvider(callback.redirectUrl, oauth, oauthState);
253
- let connection: Awaited<ReturnType<typeof connectMcp>> | null = null;
254
- try {
255
- connection = await connectMcp(provider, callback);
256
- const listed = await connection.client.listTools();
257
- pendingOAuth.set(instance.id, oauth);
258
- const tools = requireRobinhoodPositionTools(listed.tools);
259
- const accountsPayload = toolPayload(await connection.client.callTool({ name: "get_accounts", arguments: {} }));
260
- const ids = accountIds(accountsPayload);
261
- const positionTool = tools.get("get_equity_positions")!;
262
- const positionPayloads = ids.length > 0
263
- ? await Promise.all(ids.map((accountId) => connection!.client.callTool({
264
- name: "get_equity_positions",
265
- arguments: positionArguments(positionTool.inputSchema, accountId),
266
- }).then(toolPayload)))
267
- : [toolPayload(await connection.client.callTool({ name: "get_equity_positions", arguments: {} }))];
268
- const snapshot = normalizeRobinhoodSnapshot(accountsPayload, positionPayloads);
269
- setStatus(instance.id, "connected", "Read-only OAuth connection");
270
- return snapshot;
271
- } catch (error) {
272
- const message = error instanceof Error ? error.message : "Robinhood sync failed.";
273
- setStatus(instance.id, "error", message);
274
- throw new Error(`Gloomberb could not sync Robinhood. ${message}`);
275
- } finally {
276
- await connection?.client.close().catch(() => {});
277
- await callback.close();
278
- }
279
- }
280
-
281
- export const robinhoodBroker: BrokerAdapter = {
282
- id: "robinhood",
283
- name: "Robinhood",
284
- configSchema: [{
285
- key: "connectionMode",
286
- label: "Connection",
287
- type: "select",
288
- required: true,
289
- defaultValue: "oauth",
290
- options: [{
291
- label: "Robinhood sign-in (read-only sync)",
292
- value: "oauth",
293
- description: "Gloomberb opens Robinhood in your browser.",
294
- }],
295
- }],
296
-
297
- async validate(instance) {
298
- return instance.config.connectionMode === "oauth";
299
- },
300
-
301
- async importPositions(instance) {
302
- return (await loadRobinhoodPortfolio(instance)).positions;
303
- },
304
-
305
- async importPortfolioSnapshot(instance) {
306
- return loadRobinhoodPortfolio(instance);
307
- },
308
-
309
- async listAccounts(instance) {
310
- return (await loadRobinhoodPortfolio(instance)).accounts;
311
- },
312
-
313
- async connect(instance) {
314
- await loadRobinhoodPortfolio(instance);
315
- },
316
-
317
- async disconnect(instance) {
318
- pendingOAuth.delete(instance.id);
319
- setStatus(instance.id, "disconnected");
320
- },
321
-
322
- getStatus(instance) {
323
- return statuses.get(instance.id) ?? {
324
- state: instance.config.oauth ? "connected" : "disconnected",
325
- message: instance.config.oauth ? "Read-only OAuth connection" : "Sign in during the first sync",
326
- mode: "oauth",
327
- updatedAt: 0,
328
- };
329
- },
330
-
331
- subscribeStatus(instance, listener) {
332
- const listeners = statusListeners.get(instance.id) ?? new Set();
333
- listeners.add(listener);
334
- statusListeners.set(instance.id, listeners);
335
- return () => {
336
- listeners.delete(listener);
337
- if (listeners.size === 0) statusListeners.delete(instance.id);
338
- };
339
- },
340
-
341
- getPersistedConfigUpdate(instance) {
342
- const oauth = pendingOAuth.get(instance.id);
343
- if (!oauth) return null;
344
- pendingOAuth.delete(instance.id);
345
- return { connectionMode: "oauth", oauth };
346
- },
347
-
348
- toConfigValues() {
349
- return { connectionMode: "oauth" };
350
- },
351
-
352
- fromConfigValues(_values, previous) {
353
- return {
354
- connectionMode: "oauth",
355
- ...(previous?.config.oauth ? { oauth: previous.config.oauth } : {}),
356
- };
357
- },
358
- };
359
-
360
- export const robinhoodPlugin: GloomPlugin = {
361
- id: "robinhood",
362
- name: "Robinhood",
363
- version: "1.0.0",
364
- description: "Read-only account and position sync through Robinhood Trading MCP.",
365
- toggleable: true,
366
- broker: robinhoodBroker,
367
- };
@@ -1,114 +0,0 @@
1
- import type { BrokerAdapter, BrokerConnectionStatus } from "../../types/broker";
2
- import type { BrokerInstanceConfig } from "../../types/config";
3
- import type { GloomPlugin } from "../../types/plugin";
4
- import type { BrokerPortfolioSnapshot } from "./normalize";
5
- import { loadRobinhoodNativeModule } from "./native-loader";
6
-
7
- const statuses = new Map<string, BrokerConnectionStatus>();
8
- const statusListeners = new Map<string, Set<() => void>>();
9
-
10
- function setStatus(instanceId: string, state: BrokerConnectionStatus["state"], message?: string): void {
11
- statuses.set(instanceId, { state, message, mode: "oauth", updatedAt: Date.now() });
12
- for (const listener of statusListeners.get(instanceId) ?? []) listener();
13
- }
14
-
15
- async function loadRobinhoodPortfolio(instance: BrokerInstanceConfig): Promise<BrokerPortfolioSnapshot> {
16
- setStatus(instance.id, "connecting", "Waiting for Robinhood");
17
- try {
18
- const module = await loadRobinhoodNativeModule();
19
- const snapshot = await module.loadRobinhoodPortfolio(instance);
20
- setStatus(instance.id, "connected", "Read-only OAuth connection");
21
- return snapshot;
22
- } catch (error) {
23
- const message = error instanceof Error ? error.message : "Robinhood sync failed.";
24
- setStatus(instance.id, "error", message);
25
- throw error;
26
- }
27
- }
28
-
29
- export const robinhoodBroker: BrokerAdapter = {
30
- id: "robinhood",
31
- name: "Robinhood",
32
- configSchema: [{
33
- key: "connectionMode",
34
- label: "Connection",
35
- type: "select",
36
- required: true,
37
- defaultValue: "oauth",
38
- options: [{
39
- label: "Robinhood sign-in (read-only sync)",
40
- value: "oauth",
41
- description: "Gloomberb opens Robinhood in your browser.",
42
- }],
43
- }],
44
-
45
- async validate(instance) {
46
- return instance.config.connectionMode === "oauth";
47
- },
48
-
49
- async importPositions(instance) {
50
- return (await loadRobinhoodPortfolio(instance)).positions;
51
- },
52
-
53
- async importPortfolioSnapshot(instance) {
54
- return loadRobinhoodPortfolio(instance);
55
- },
56
-
57
- async listAccounts(instance) {
58
- return (await loadRobinhoodPortfolio(instance)).accounts;
59
- },
60
-
61
- async connect(instance) {
62
- await loadRobinhoodPortfolio(instance);
63
- },
64
-
65
- async disconnect(instance) {
66
- const module = await loadRobinhoodNativeModule();
67
- await module.robinhoodBroker.disconnect?.(instance);
68
- setStatus(instance.id, "disconnected");
69
- },
70
-
71
- getStatus(instance) {
72
- return statuses.get(instance.id) ?? {
73
- state: instance.config.oauth ? "connected" : "disconnected",
74
- message: instance.config.oauth ? "Read-only OAuth connection" : "Sign in during the first sync",
75
- mode: "oauth",
76
- updatedAt: 0,
77
- };
78
- },
79
-
80
- subscribeStatus(instance, listener) {
81
- const listeners = statusListeners.get(instance.id) ?? new Set();
82
- listeners.add(listener);
83
- statusListeners.set(instance.id, listeners);
84
- return () => {
85
- listeners.delete(listener);
86
- if (listeners.size === 0) statusListeners.delete(instance.id);
87
- };
88
- },
89
-
90
- async getPersistedConfigUpdate(instance) {
91
- const module = await loadRobinhoodNativeModule();
92
- return module.robinhoodBroker.getPersistedConfigUpdate?.(instance) ?? null;
93
- },
94
-
95
- toConfigValues() {
96
- return { connectionMode: "oauth" };
97
- },
98
-
99
- fromConfigValues(_values, previous) {
100
- return {
101
- connectionMode: "oauth",
102
- ...(previous?.config.oauth ? { oauth: previous.config.oauth } : {}),
103
- };
104
- },
105
- };
106
-
107
- export const robinhoodPlugin: GloomPlugin = {
108
- id: "robinhood",
109
- name: "Robinhood",
110
- version: "1.0.0",
111
- description: "Read-only account and position sync through Robinhood Trading MCP.",
112
- toggleable: true,
113
- broker: robinhoodBroker,
114
- };