opencandle 0.6.0 → 0.8.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.
Files changed (201) hide show
  1. package/README.md +43 -23
  2. package/dist/cli-main.d.ts +1 -0
  3. package/dist/cli-main.js +200 -0
  4. package/dist/cli-main.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +14 -194
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +10 -0
  9. package/dist/config.js +13 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/doctor/cli-command.d.ts +1 -0
  12. package/dist/doctor/cli-command.js +69 -0
  13. package/dist/doctor/cli-command.js.map +1 -0
  14. package/dist/doctor/render.d.ts +2 -0
  15. package/dist/doctor/render.js +32 -0
  16. package/dist/doctor/render.js.map +1 -0
  17. package/dist/doctor/report.d.ts +63 -0
  18. package/dist/doctor/report.js +500 -0
  19. package/dist/doctor/report.js.map +1 -0
  20. package/dist/infra/index.d.ts +0 -1
  21. package/dist/infra/index.js +0 -1
  22. package/dist/infra/index.js.map +1 -1
  23. package/dist/infra/native-dependencies.d.ts +8 -0
  24. package/dist/infra/native-dependencies.js +49 -0
  25. package/dist/infra/native-dependencies.js.map +1 -1
  26. package/dist/infra/node-version.js +2 -5
  27. package/dist/infra/node-version.js.map +1 -1
  28. package/dist/monitor.d.ts +1 -1
  29. package/dist/monitor.js +2 -1
  30. package/dist/monitor.js.map +1 -1
  31. package/dist/onboarding/connect.d.ts +2 -2
  32. package/dist/onboarding/connect.js +10 -3
  33. package/dist/onboarding/connect.js.map +1 -1
  34. package/dist/onboarding/provider-status.d.ts +46 -0
  35. package/dist/onboarding/provider-status.js +256 -0
  36. package/dist/onboarding/provider-status.js.map +1 -0
  37. package/dist/onboarding/providers.d.ts +85 -8
  38. package/dist/onboarding/providers.js +87 -9
  39. package/dist/onboarding/providers.js.map +1 -1
  40. package/dist/onboarding/state.d.ts +1 -0
  41. package/dist/onboarding/state.js +5 -0
  42. package/dist/onboarding/state.js.map +1 -1
  43. package/dist/onboarding/tool-tags.d.ts +12 -1
  44. package/dist/onboarding/tool-tags.js +31 -1
  45. package/dist/onboarding/tool-tags.js.map +1 -1
  46. package/dist/onboarding/validation.d.ts +2 -2
  47. package/dist/onboarding/validation.js.map +1 -1
  48. package/dist/pi/opencandle-extension.js +91 -15
  49. package/dist/pi/opencandle-extension.js.map +1 -1
  50. package/dist/pi/session.d.ts +0 -1
  51. package/dist/pi/session.js +2 -1
  52. package/dist/pi/session.js.map +1 -1
  53. package/dist/pi/tool-adapter.d.ts +4 -1
  54. package/dist/pi/tool-adapter.js +5 -4
  55. package/dist/pi/tool-adapter.js.map +1 -1
  56. package/dist/prompts/context-builder.js +1 -1
  57. package/dist/prompts/policy-cards.js +9 -5
  58. package/dist/prompts/policy-cards.js.map +1 -1
  59. package/dist/prompts/workflow-prompts.js +10 -6
  60. package/dist/prompts/workflow-prompts.js.map +1 -1
  61. package/dist/providers/external-tool-command.d.ts +17 -0
  62. package/dist/providers/external-tool-command.js +114 -0
  63. package/dist/providers/external-tool-command.js.map +1 -0
  64. package/dist/providers/external-tool-error.d.ts +10 -0
  65. package/dist/providers/external-tool-error.js +21 -0
  66. package/dist/providers/external-tool-error.js.map +1 -0
  67. package/dist/providers/reddit-cli.d.ts +32 -0
  68. package/dist/providers/reddit-cli.js +172 -0
  69. package/dist/providers/reddit-cli.js.map +1 -0
  70. package/dist/providers/reddit.d.ts +1 -1
  71. package/dist/providers/reddit.js +7 -35
  72. package/dist/providers/reddit.js.map +1 -1
  73. package/dist/providers/twitter-cli.d.ts +36 -0
  74. package/dist/providers/twitter-cli.js +124 -0
  75. package/dist/providers/twitter-cli.js.map +1 -0
  76. package/dist/providers/twitter.d.ts +0 -8
  77. package/dist/providers/twitter.js +4 -54
  78. package/dist/providers/twitter.js.map +1 -1
  79. package/dist/providers/wrap-provider.js +30 -0
  80. package/dist/providers/wrap-provider.js.map +1 -1
  81. package/dist/providers/yahoo-finance.js +53 -32
  82. package/dist/providers/yahoo-finance.js.map +1 -1
  83. package/dist/routing/classify-intent.js +7 -3
  84. package/dist/routing/classify-intent.js.map +1 -1
  85. package/dist/routing/planning.d.ts +1 -1
  86. package/dist/routing/planning.js +9 -6
  87. package/dist/routing/planning.js.map +1 -1
  88. package/dist/routing/router.js +7 -3
  89. package/dist/routing/router.js.map +1 -1
  90. package/dist/runtime/answer-contracts.d.ts +1 -1
  91. package/dist/runtime/answer-contracts.js +12 -1
  92. package/dist/runtime/answer-contracts.js.map +1 -1
  93. package/dist/runtime/tool-defaults-wrapper.js +6 -2
  94. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  95. package/dist/sentiment/index.d.ts +1 -0
  96. package/dist/sentiment/index.js +1 -0
  97. package/dist/sentiment/index.js.map +1 -1
  98. package/dist/sentiment/insights.d.ts +17 -0
  99. package/dist/sentiment/insights.js +206 -0
  100. package/dist/sentiment/insights.js.map +1 -0
  101. package/dist/sentiment/pipeline.js +13 -1
  102. package/dist/sentiment/pipeline.js.map +1 -1
  103. package/dist/sentiment/scorer.d.ts +2 -0
  104. package/dist/sentiment/scorer.js +10 -1
  105. package/dist/sentiment/scorer.js.map +1 -1
  106. package/dist/sentiment/types.d.ts +2 -0
  107. package/dist/sentiment/types.js.map +1 -1
  108. package/dist/system-prompt.js +3 -7
  109. package/dist/system-prompt.js.map +1 -1
  110. package/dist/tools/index.d.ts +5 -2
  111. package/dist/tools/index.js +8 -8
  112. package/dist/tools/index.js.map +1 -1
  113. package/dist/tools/sentiment/insight-format.d.ts +2 -0
  114. package/dist/tools/sentiment/insight-format.js +36 -0
  115. package/dist/tools/sentiment/insight-format.js.map +1 -0
  116. package/dist/tools/sentiment/query-match.d.ts +3 -0
  117. package/dist/tools/sentiment/query-match.js +113 -0
  118. package/dist/tools/sentiment/query-match.js.map +1 -0
  119. package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
  120. package/dist/tools/sentiment/reddit-sentiment.js +263 -117
  121. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  122. package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
  123. package/dist/tools/sentiment/sentiment-summary.js +217 -201
  124. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  125. package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
  126. package/dist/tools/sentiment/twitter-sentiment.js +187 -64
  127. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  128. package/dist/tools/sentiment/web-sentiment.js +4 -0
  129. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  130. package/dist/types/sentiment.d.ts +52 -0
  131. package/gui/server/http-routes.ts +419 -0
  132. package/gui/server/invoke-tool.ts +17 -3
  133. package/gui/server/model-setup.ts +10 -3
  134. package/gui/server/projector.ts +6 -2
  135. package/gui/server/server.ts +30 -275
  136. package/gui/server/tool-metadata.ts +80 -16
  137. package/gui/server/ws-hub.ts +19 -0
  138. package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +1 -0
  139. package/gui/web/dist/assets/index-Dm4Aom2_.js +69 -0
  140. package/gui/web/dist/assets/{index-2KZtKBmu.css → index-UzZUg3dx.css} +1 -1
  141. package/gui/web/dist/index.html +2 -2
  142. package/package.json +17 -14
  143. package/src/cli-main.ts +233 -0
  144. package/src/cli.ts +14 -226
  145. package/src/config.ts +27 -0
  146. package/src/doctor/cli-command.ts +83 -0
  147. package/src/doctor/render.ts +37 -0
  148. package/src/doctor/report.ts +638 -0
  149. package/src/infra/index.ts +0 -1
  150. package/src/infra/native-dependencies.ts +67 -0
  151. package/src/infra/node-version.ts +2 -5
  152. package/src/monitor.ts +3 -1
  153. package/src/onboarding/connect.ts +20 -4
  154. package/src/onboarding/provider-status.ts +382 -0
  155. package/src/onboarding/providers.ts +148 -18
  156. package/src/onboarding/state.ts +9 -0
  157. package/src/onboarding/tool-tags.ts +45 -2
  158. package/src/onboarding/validation.ts +2 -2
  159. package/src/pi/opencandle-extension.ts +115 -17
  160. package/src/pi/session.ts +2 -1
  161. package/src/pi/tool-adapter.ts +14 -4
  162. package/src/prompts/context-builder.ts +1 -1
  163. package/src/prompts/policy-cards.ts +13 -5
  164. package/src/prompts/workflow-prompts.ts +13 -6
  165. package/src/providers/external-tool-command.ts +164 -0
  166. package/src/providers/external-tool-error.ts +20 -0
  167. package/src/providers/reddit-cli.ts +286 -0
  168. package/src/providers/reddit.ts +7 -63
  169. package/src/providers/twitter-cli.ts +202 -0
  170. package/src/providers/twitter.ts +4 -73
  171. package/src/providers/wrap-provider.ts +34 -0
  172. package/src/providers/yahoo-finance.ts +65 -32
  173. package/src/routing/classify-intent.ts +11 -6
  174. package/src/routing/planning.ts +16 -10
  175. package/src/routing/router.ts +11 -6
  176. package/src/runtime/answer-contracts.ts +23 -2
  177. package/src/runtime/tool-defaults-wrapper.ts +12 -2
  178. package/src/sentiment/index.ts +1 -0
  179. package/src/sentiment/insights.ts +269 -0
  180. package/src/sentiment/pipeline.ts +13 -1
  181. package/src/sentiment/scorer.ts +12 -1
  182. package/src/sentiment/types.ts +3 -0
  183. package/src/system-prompt.ts +3 -7
  184. package/src/tools/index.ts +9 -8
  185. package/src/tools/sentiment/insight-format.ts +50 -0
  186. package/src/tools/sentiment/query-match.ts +117 -0
  187. package/src/tools/sentiment/reddit-sentiment.ts +354 -141
  188. package/src/tools/sentiment/sentiment-summary.ts +283 -237
  189. package/src/tools/sentiment/twitter-sentiment.ts +262 -78
  190. package/src/tools/sentiment/web-sentiment.ts +4 -0
  191. package/src/types/sentiment.ts +59 -0
  192. package/dist/infra/browser.d.ts +0 -35
  193. package/dist/infra/browser.js +0 -105
  194. package/dist/infra/browser.js.map +0 -1
  195. package/dist/tools/interaction/twitter-login.d.ts +0 -8
  196. package/dist/tools/interaction/twitter-login.js +0 -87
  197. package/dist/tools/interaction/twitter-login.js.map +0 -1
  198. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
  199. package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
  200. package/src/infra/browser.ts +0 -113
  201. package/src/tools/interaction/twitter-login.ts +0 -105
@@ -15,3 +15,70 @@ export function getNativeDependencyErrorMessage(
15
15
  `Run \`npm rebuild ${dependencyName}\` or reinstall dependencies under the active Node with \`npm install\`.`
16
16
  );
17
17
  }
18
+
19
+ export async function rebuildNativeDependency(dependencyName: string): Promise<void> {
20
+ const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
21
+ const { spawn } = await import("node:child_process");
22
+
23
+ await new Promise<void>((resolve, reject) => {
24
+ const child = spawn(npmCommand, ["rebuild", dependencyName], {
25
+ stdio: "inherit",
26
+ });
27
+
28
+ child.on("error", reject);
29
+ child.on("exit", (code) => {
30
+ if (code === 0) {
31
+ resolve();
32
+ return;
33
+ }
34
+
35
+ reject(new Error(`npm rebuild ${dependencyName} failed with exit code ${code}.`));
36
+ });
37
+ });
38
+ }
39
+
40
+ export async function ensureNativeDependency({
41
+ dependencyName,
42
+ load,
43
+ rebuild = rebuildNativeDependency,
44
+ log = console.error,
45
+ }: {
46
+ dependencyName: string;
47
+ load: () => Promise<void>;
48
+ rebuild?: (dependencyName: string) => Promise<void>;
49
+ log?: (message: string) => void;
50
+ }): Promise<void> {
51
+ try {
52
+ await load();
53
+ return;
54
+ } catch (error) {
55
+ const message = getNativeDependencyErrorMessage(error, dependencyName);
56
+ if (!message) throw error;
57
+
58
+ log(`${message}\nAttempting \`npm rebuild ${dependencyName}\` before continuing...`);
59
+ }
60
+
61
+ await rebuild(dependencyName);
62
+
63
+ try {
64
+ await load();
65
+ } catch (error) {
66
+ const message = getNativeDependencyErrorMessage(error, dependencyName);
67
+ if (!message) throw error;
68
+
69
+ throw new Error(
70
+ `${message}\nAutomatic rebuild did not repair the native binding. Run \`npm install\` under Node ${process.versions.node} and retry.`,
71
+ );
72
+ }
73
+ }
74
+
75
+ export async function ensureOpenCandleNativeDependencies(): Promise<void> {
76
+ await ensureNativeDependency({
77
+ dependencyName: "better-sqlite3",
78
+ async load() {
79
+ const { default: Database } = await import("better-sqlite3");
80
+ const db = new Database(":memory:");
81
+ db.close();
82
+ },
83
+ });
84
+ }
@@ -1,12 +1,11 @@
1
- const SUPPORTED_NODE_RANGE = "20.19+, 22.12+, or 24.x-26.x";
1
+ const SUPPORTED_NODE_RANGE = "22.19+ or 24.x-26.x";
2
2
 
3
3
  function isSupportedNodeVersion(version: string): boolean {
4
4
  const [majorRaw, minorRaw] = version.split(".");
5
5
  const major = Number(majorRaw);
6
6
  const minor = Number(minorRaw);
7
7
 
8
- if (major === 20) return minor >= 19;
9
- if (major === 22) return minor >= 12;
8
+ if (major === 22) return minor >= 19;
10
9
  return major >= 24 && major < 27;
11
10
  }
12
11
 
@@ -22,5 +21,3 @@ export function assertSupportedNodeVersion(version?: string): void {
22
21
  const message = getUnsupportedNodeVersionMessage(version);
23
22
  if (message) throw new Error(message);
24
23
  }
25
-
26
- assertSupportedNodeVersion();
package/src/monitor.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import "./infra/node-version.js";
3
2
  import { parseArgs } from "node:util";
4
3
  import { loadEnv } from "./config.js";
4
+ import { assertSupportedNodeVersion } from "./infra/node-version.js";
5
5
  import { runLocalAutomationHeartbeat } from "./market-state/local-automation-service.js";
6
6
  import { MarketStateService } from "./market-state/service.js";
7
7
  import { initDefaultDatabase } from "./memory/sqlite.js";
8
8
 
9
+ assertSupportedNodeVersion();
10
+
9
11
  const DEFAULT_MONITOR_INTERVAL_MS = 60_000;
10
12
  const MIN_MONITOR_INTERVAL_MS = 5_000;
11
13
  const MONITOR_OWNER_ID = `monitor:${process.pid}`;
@@ -25,7 +25,13 @@ import {
25
25
  saveFileConfig,
26
26
  } from "../config.js";
27
27
  import { openInBrowser } from "../infra/open-url.js";
28
- import { getCredentialSource, getProvider, type ProviderId } from "./providers.js";
28
+ import {
29
+ type ApiKeyProviderId,
30
+ getCredentialSource,
31
+ getProvider,
32
+ isApiKeyProvider,
33
+ type ProviderId,
34
+ } from "./providers.js";
29
35
  import { loadOnboardingState, markProviderCompleted, saveOnboardingState } from "./state.js";
30
36
  import { validateCredential } from "./validation.js";
31
37
 
@@ -64,8 +70,11 @@ function writeNested(
64
70
  * provider-setup form. Validation is the caller's responsibility — both
65
71
  * call sites run `validateCredential` before invoking this helper.
66
72
  */
67
- export function persistProviderCredential(providerId: ProviderId, key: string): void {
73
+ export function persistProviderCredential(providerId: ApiKeyProviderId, key: string): void {
68
74
  const descriptor = getProvider(providerId);
75
+ if (!isApiKeyProvider(descriptor)) {
76
+ throw new Error(`Provider ${providerId} does not accept API keys`);
77
+ }
69
78
  const existing = loadFileConfig() as unknown as Record<string, unknown>;
70
79
  const updated = writeNested(existing, descriptor.configPath, key) as OpenCandleFileConfig;
71
80
  saveFileConfig(updated);
@@ -88,6 +97,13 @@ export async function runProviderConnect(
88
97
  providerId: ProviderId,
89
98
  ): Promise<ConnectResult> {
90
99
  const descriptor = getProvider(providerId);
100
+ if (!isApiKeyProvider(descriptor)) {
101
+ ctx.ui.notify(
102
+ `${descriptor.displayName} is not configured with an API key. Run opencandle doctor for setup status.`,
103
+ "warning",
104
+ );
105
+ return { status: "cancelled" };
106
+ }
91
107
 
92
108
  // Env-precedence check.
93
109
  if (getCredentialSource(providerId) === "env") {
@@ -124,7 +140,7 @@ export async function runProviderConnect(
124
140
  // short-circuit here without writing anything; transient failures warn
125
141
  // but proceed to persist so users don't get stuck on a provider outage.
126
142
  ctx.ui.notify(`Verifying your ${descriptor.displayName} key...`, "info");
127
- const validation = await validateCredential(providerId, trimmed);
143
+ const validation = await validateCredential(descriptor.id, trimmed);
128
144
 
129
145
  if (validation.status === "invalid") {
130
146
  const statusHint =
@@ -153,7 +169,7 @@ export async function runProviderConnect(
153
169
  // Fall through to persist.
154
170
  }
155
171
 
156
- persistProviderCredential(providerId, trimmed);
172
+ persistProviderCredential(descriptor.id, trimmed);
157
173
 
158
174
  ctx.ui.notify(`${descriptor.displayName} connected. Your key has been saved.`, "info");
159
175
 
@@ -0,0 +1,382 @@
1
+ import {
2
+ type ExternalToolCommandResult,
3
+ runExternalToolCommand,
4
+ } from "../providers/external-tool-command.js";
5
+ import type { ProviderId } from "./providers.js";
6
+ import {
7
+ getCredentialSource,
8
+ getProvider,
9
+ isApiKeyProvider,
10
+ isExternalToolProvider,
11
+ isPublicHttpProvider,
12
+ } from "./providers.js";
13
+ import { loadOnboardingState } from "./state.js";
14
+
15
+ const STATUS_TTL_MS = 60_000;
16
+ const COMMAND_TIMEOUT_MS = 5_000;
17
+ const PUBLIC_HTTP_TIMEOUT_MS = 3_000;
18
+ const MAX_OUTPUT_CHARS = 32_000;
19
+
20
+ export type CommandResult = ExternalToolCommandResult;
21
+
22
+ export type CommandRunner = (
23
+ command: string,
24
+ args: readonly string[],
25
+ options?: { timeoutMs: number },
26
+ ) => Promise<CommandResult>;
27
+
28
+ interface ProviderStatusBase {
29
+ readonly providerId: ProviderId;
30
+ readonly kind: "api-key" | "external-tool" | "public-http";
31
+ readonly state: string;
32
+ readonly checkedAt: string;
33
+ readonly cacheHit: boolean;
34
+ readonly message?: string;
35
+ }
36
+
37
+ export interface ApiKeyProviderStatus extends ProviderStatusBase {
38
+ readonly kind: "api-key";
39
+ readonly state: "configured" | "missing";
40
+ readonly credentialSource: "env" | "file" | "absent";
41
+ }
42
+
43
+ export interface ExternalToolProviderStatus extends ProviderStatusBase {
44
+ readonly kind: "external-tool";
45
+ readonly mode: "install" | "session";
46
+ readonly state:
47
+ | "installed"
48
+ | "missing"
49
+ | "session_ok"
50
+ | "session_missing"
51
+ | "session_stale"
52
+ | "skipped"
53
+ | "error";
54
+ readonly installCmd?: string;
55
+ }
56
+
57
+ export interface PublicHttpProviderStatus extends ProviderStatusBase {
58
+ readonly kind: "public-http";
59
+ readonly state: "reachable" | "unreachable" | "error";
60
+ readonly statusCode?: number;
61
+ }
62
+
63
+ export type ProviderStatus =
64
+ | ApiKeyProviderStatus
65
+ | ExternalToolProviderStatus
66
+ | PublicHttpProviderStatus;
67
+
68
+ export interface ProbeProviderStatusOptions {
69
+ readonly mode?: "install" | "session";
70
+ readonly force?: boolean;
71
+ readonly respectSkipped?: boolean;
72
+ readonly commandRunner?: CommandRunner;
73
+ readonly fetchImpl?: typeof fetch;
74
+ readonly now?: Date;
75
+ }
76
+
77
+ interface CachedStatus {
78
+ readonly expiresAt: number;
79
+ readonly status: ProviderStatus;
80
+ }
81
+
82
+ const statusCache = new Map<string, CachedStatus>();
83
+
84
+ export function clearProviderStatusCache(): void {
85
+ statusCache.clear();
86
+ }
87
+
88
+ export async function probeProviderStatus(
89
+ providerId: ProviderId,
90
+ options: ProbeProviderStatusOptions = {},
91
+ ): Promise<ProviderStatus> {
92
+ const provider = getProvider(providerId);
93
+ const mode = isExternalToolProvider(provider) ? (options.mode ?? "install") : "default";
94
+ const cacheKey = `${providerId}:${mode}`;
95
+ const now = options.now ?? new Date();
96
+ const skippedByPreference =
97
+ isExternalToolProvider(provider) &&
98
+ loadOnboardingState().providers[providerId]?.status === "never_ask";
99
+
100
+ if (skippedByPreference && (!options.force || options.respectSkipped)) {
101
+ return {
102
+ providerId,
103
+ kind: "external-tool",
104
+ mode: mode as "install" | "session",
105
+ state: "skipped",
106
+ installCmd: provider.installCmd,
107
+ message: `Skipped by user preference; run opencandle doctor --enable ${providerId} to re-enable.`,
108
+ checkedAt: now.toISOString(),
109
+ cacheHit: false,
110
+ };
111
+ }
112
+
113
+ if (!options.force) {
114
+ const cached = statusCache.get(cacheKey);
115
+ if (cached && cached.expiresAt > now.getTime()) {
116
+ return { ...cached.status, cacheHit: true };
117
+ }
118
+ }
119
+
120
+ let status: ProviderStatus;
121
+ if (isApiKeyProvider(provider)) {
122
+ const source = getCredentialSource(provider.id);
123
+ status = {
124
+ providerId,
125
+ kind: "api-key",
126
+ state: source === "absent" ? "missing" : "configured",
127
+ credentialSource: source,
128
+ checkedAt: now.toISOString(),
129
+ cacheHit: false,
130
+ };
131
+ } else if (isExternalToolProvider(provider)) {
132
+ status = await probeExternalTool(providerId, mode as "install" | "session", {
133
+ now,
134
+ commandRunner: options.commandRunner ?? runCommand,
135
+ });
136
+ } else if (isPublicHttpProvider(provider)) {
137
+ status = await probePublicHttp(providerId, {
138
+ now,
139
+ fetchImpl: options.fetchImpl ?? fetch,
140
+ });
141
+ } else {
142
+ const exhaustive: never = provider;
143
+ throw new Error(`Unsupported provider kind: ${JSON.stringify(exhaustive)}`);
144
+ }
145
+
146
+ statusCache.set(cacheKey, { status, expiresAt: now.getTime() + STATUS_TTL_MS });
147
+ return status;
148
+ }
149
+
150
+ export async function probeAllProviderStatuses(
151
+ options: Omit<ProbeProviderStatusOptions, "mode"> = {},
152
+ ): Promise<ProviderStatus[]> {
153
+ const { listAllProviders } = await import("./providers.js");
154
+ const statuses: ProviderStatus[] = [];
155
+ for (const provider of listAllProviders()) {
156
+ statuses.push(await probeProviderStatus(provider.id, options));
157
+ }
158
+ return statuses;
159
+ }
160
+
161
+ export function formatProviderStatus(status: ProviderStatus): string {
162
+ const provider = getProvider(status.providerId);
163
+ if (status.kind === "api-key") {
164
+ return `${provider.displayName}: ${status.state} (${status.credentialSource})`;
165
+ }
166
+ if (status.kind === "external-tool") {
167
+ const suffix = status.message ? ` - ${status.message}` : "";
168
+ return `${provider.displayName}: ${status.state} (${status.mode})${suffix}`;
169
+ }
170
+ const http = status.statusCode === undefined ? "" : ` HTTP ${status.statusCode}`;
171
+ const suffix = status.message ? ` - ${status.message}` : "";
172
+ return `${provider.displayName}: ${status.state}${http}${suffix}`;
173
+ }
174
+
175
+ async function probeExternalTool(
176
+ providerId: ProviderId,
177
+ mode: "install" | "session",
178
+ options: { now: Date; commandRunner: CommandRunner },
179
+ ): Promise<ExternalToolProviderStatus> {
180
+ const provider = getProvider(providerId);
181
+ if (!isExternalToolProvider(provider)) {
182
+ throw new Error(`Provider ${providerId} is not an external tool`);
183
+ }
184
+
185
+ const args =
186
+ mode === "install"
187
+ ? ["--version"]
188
+ : (provider.sessionProbeArgs ?? ["feed", "--max", "1", "--json"]);
189
+ try {
190
+ const result = await options.commandRunner(provider.binary, args, {
191
+ timeoutMs: COMMAND_TIMEOUT_MS,
192
+ });
193
+ const output = redactSensitiveOutput(`${result.stderr}\n${result.stdout}`.trim());
194
+
195
+ if (mode === "install") {
196
+ return {
197
+ providerId,
198
+ kind: "external-tool",
199
+ mode,
200
+ state: result.code === 0 ? "installed" : "error",
201
+ installCmd: provider.installCmd,
202
+ message: result.code === 0 ? undefined : output,
203
+ checkedAt: options.now.toISOString(),
204
+ cacheHit: false,
205
+ };
206
+ }
207
+
208
+ if (result.code === 0) {
209
+ const parsed = parseCliEnvelope(result.stdout, provider.binary);
210
+ const session = classifySessionEnvelope(providerId, parsed);
211
+ return {
212
+ providerId,
213
+ kind: "external-tool",
214
+ mode,
215
+ state: session.state,
216
+ installCmd: provider.installCmd,
217
+ message: session.message,
218
+ checkedAt: options.now.toISOString(),
219
+ cacheHit: false,
220
+ };
221
+ }
222
+
223
+ return {
224
+ providerId,
225
+ kind: "external-tool",
226
+ mode,
227
+ state: classifySessionFailure(output),
228
+ installCmd: provider.installCmd,
229
+ message: output,
230
+ checkedAt: options.now.toISOString(),
231
+ cacheHit: false,
232
+ };
233
+ } catch (err) {
234
+ const nodeError = err as NodeJS.ErrnoException;
235
+ if (nodeError.code === "ENOENT") {
236
+ return {
237
+ providerId,
238
+ kind: "external-tool",
239
+ mode,
240
+ state: "missing",
241
+ installCmd: provider.installCmd,
242
+ checkedAt: options.now.toISOString(),
243
+ cacheHit: false,
244
+ };
245
+ }
246
+ return {
247
+ providerId,
248
+ kind: "external-tool",
249
+ mode,
250
+ state: "error",
251
+ installCmd: provider.installCmd,
252
+ message: redactSensitiveOutput(err instanceof Error ? err.message : String(err)),
253
+ checkedAt: options.now.toISOString(),
254
+ cacheHit: false,
255
+ };
256
+ }
257
+ }
258
+
259
+ async function probePublicHttp(
260
+ providerId: ProviderId,
261
+ options: { now: Date; fetchImpl: typeof fetch },
262
+ ): Promise<PublicHttpProviderStatus> {
263
+ const provider = getProvider(providerId);
264
+ if (!isPublicHttpProvider(provider)) {
265
+ throw new Error(`Provider ${providerId} is not a public HTTP provider`);
266
+ }
267
+
268
+ try {
269
+ const response = await options.fetchImpl(provider.probeUrl, {
270
+ method: "GET",
271
+ signal: AbortSignal.timeout(PUBLIC_HTTP_TIMEOUT_MS),
272
+ });
273
+ return {
274
+ providerId,
275
+ kind: "public-http",
276
+ state: response.ok ? "reachable" : "unreachable",
277
+ statusCode: response.status,
278
+ checkedAt: options.now.toISOString(),
279
+ cacheHit: false,
280
+ };
281
+ } catch (err) {
282
+ return {
283
+ providerId,
284
+ kind: "public-http",
285
+ state: "error",
286
+ message: err instanceof Error ? err.message : String(err),
287
+ checkedAt: options.now.toISOString(),
288
+ cacheHit: false,
289
+ };
290
+ }
291
+ }
292
+
293
+ function parseCliEnvelope(
294
+ stdout: string,
295
+ binary: string,
296
+ ): { ok: boolean; message?: string; data?: unknown } {
297
+ try {
298
+ const parsed = JSON.parse(stdout) as {
299
+ ok?: unknown;
300
+ data?: unknown;
301
+ error?: { message?: unknown };
302
+ };
303
+ return {
304
+ ok: parsed.ok === true,
305
+ data: parsed.data,
306
+ message:
307
+ typeof parsed.error?.message === "string"
308
+ ? redactSensitiveOutput(parsed.error.message)
309
+ : undefined,
310
+ };
311
+ } catch {
312
+ return { ok: false, message: `${binary} returned non-JSON output` };
313
+ }
314
+ }
315
+
316
+ function classifySessionEnvelope(
317
+ providerId: ProviderId,
318
+ parsed: { ok: boolean; message?: string; data?: unknown },
319
+ ): { state: ExternalToolProviderStatus["state"]; message?: string } {
320
+ if (!parsed.ok) {
321
+ return {
322
+ state: classifySessionFailure(parsed.message),
323
+ message: parsed.message,
324
+ };
325
+ }
326
+
327
+ if (providerId === "reddit" && isRecord(parsed.data) && parsed.data.authenticated === false) {
328
+ return {
329
+ state: "session_missing",
330
+ message: "Reddit is not authenticated. Run rdt login.",
331
+ };
332
+ }
333
+
334
+ return { state: "session_ok" };
335
+ }
336
+
337
+ function isRecord(value: unknown): value is Record<string, unknown> {
338
+ return typeof value === "object" && value !== null;
339
+ }
340
+
341
+ function classifySessionFailure(message: string | undefined): ExternalToolProviderStatus["state"] {
342
+ const lower = (message ?? "").toLowerCase();
343
+ if (
344
+ lower.includes("no twitter cookies") ||
345
+ lower.includes("no reddit cookies") ||
346
+ lower.includes("not authenticated") ||
347
+ lower.includes("no cookies")
348
+ ) {
349
+ return "session_missing";
350
+ }
351
+ if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("expired")) {
352
+ return "session_stale";
353
+ }
354
+ return "error";
355
+ }
356
+
357
+ export function redactSensitiveOutput(input: string): string {
358
+ const xCookieNames =
359
+ "auth_token|ct0|twid|kdt|guest_id|guest_id_ads|guest_id_marketing|personalization_id";
360
+ const genericSecretName =
361
+ "[a-z0-9_]*(?:session|token)[a-z0-9_]*|[a-z0-9_]+cookie[a-z0-9_]*|[a-z0-9_]*cookie[a-z0-9_]+";
362
+ return input
363
+ .slice(0, MAX_OUTPUT_CHARS)
364
+ .replace(/\b(cookie|set-cookie)\s*:\s*[^\r\n]+/gi, "$1: [redacted]")
365
+ .replace(new RegExp(`\\b(${xCookieNames})\\b\\s*[:=]\\s*[^;\\s,)]+`, "gi"), "$1=[redacted]")
366
+ .replace(new RegExp(`\\b(${xCookieNames})=([^;\\s,)]+)`, "gi"), "$1=[redacted]")
367
+ .replace(
368
+ new RegExp(`\\b(${genericSecretName})\\b\\s*[:=]\\s*[^;\\s,)]+`, "gi"),
369
+ "$1=[redacted]",
370
+ )
371
+ .replace(new RegExp(`\\b(${genericSecretName})=([^;\\s,)]+)`, "gi"), "$1=[redacted]")
372
+ .replace(
373
+ /(?:~|\/Users\/[^/\s]+|\/home\/[^/\s]+)?\/\.config\/rdt-cli\/credential\.json/g,
374
+ "[redacted-credential-path]",
375
+ );
376
+ }
377
+
378
+ export const runCommand: CommandRunner = (command, args, options) =>
379
+ runExternalToolCommand(command, args, {
380
+ timeoutMs: options?.timeoutMs ?? COMMAND_TIMEOUT_MS,
381
+ maxOutputChars: MAX_OUTPUT_CHARS,
382
+ });