kalshi-trading-bot-cli 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/assets/kalshi-flow-light.png +0 -0
  4. package/assets/screenshot.png +0 -0
  5. package/env.example +43 -0
  6. package/kalshi-flow-light.png +0 -0
  7. package/package.json +66 -0
  8. package/src/agent/agent.ts +249 -0
  9. package/src/agent/channels.ts +53 -0
  10. package/src/agent/index.ts +29 -0
  11. package/src/agent/prompts.ts +171 -0
  12. package/src/agent/run-context.ts +23 -0
  13. package/src/agent/scratchpad.ts +465 -0
  14. package/src/agent/token-counter.ts +33 -0
  15. package/src/agent/tool-executor.ts +166 -0
  16. package/src/agent/types.ts +221 -0
  17. package/src/audit/index.ts +25 -0
  18. package/src/audit/reader.ts +43 -0
  19. package/src/audit/trail.ts +29 -0
  20. package/src/audit/types.ts +133 -0
  21. package/src/backtest/discovery.ts +170 -0
  22. package/src/backtest/fetcher.ts +247 -0
  23. package/src/backtest/metrics.ts +165 -0
  24. package/src/backtest/renderer.ts +196 -0
  25. package/src/backtest/types.ts +45 -0
  26. package/src/cli.ts +943 -0
  27. package/src/commands/alerts.ts +48 -0
  28. package/src/commands/analyze.ts +662 -0
  29. package/src/commands/backtest.ts +276 -0
  30. package/src/commands/clear-cache.ts +24 -0
  31. package/src/commands/config.ts +107 -0
  32. package/src/commands/dispatch.ts +473 -0
  33. package/src/commands/edge.ts +62 -0
  34. package/src/commands/formatters.ts +339 -0
  35. package/src/commands/help.ts +263 -0
  36. package/src/commands/helpers.ts +48 -0
  37. package/src/commands/index.ts +287 -0
  38. package/src/commands/json.ts +43 -0
  39. package/src/commands/parse-args.ts +229 -0
  40. package/src/commands/portfolio.ts +236 -0
  41. package/src/commands/review.ts +176 -0
  42. package/src/commands/scan-formatters.ts +98 -0
  43. package/src/commands/scan.ts +38 -0
  44. package/src/commands/search-edge.ts +139 -0
  45. package/src/commands/status.ts +70 -0
  46. package/src/commands/themes.ts +117 -0
  47. package/src/commands/watch.ts +295 -0
  48. package/src/components/answer-box.ts +57 -0
  49. package/src/components/approval-prompt.ts +34 -0
  50. package/src/components/browse-list.ts +134 -0
  51. package/src/components/chat-log.ts +291 -0
  52. package/src/components/custom-editor.ts +18 -0
  53. package/src/components/debug-panel.ts +52 -0
  54. package/src/components/index.ts +17 -0
  55. package/src/components/intro.ts +92 -0
  56. package/src/components/select-list.ts +155 -0
  57. package/src/components/tool-event.ts +127 -0
  58. package/src/components/user-query.ts +18 -0
  59. package/src/components/working-indicator.ts +87 -0
  60. package/src/controllers/agent-runner.ts +283 -0
  61. package/src/controllers/browse.ts +1013 -0
  62. package/src/controllers/index.ts +7 -0
  63. package/src/controllers/input-history.ts +76 -0
  64. package/src/controllers/model-selection.ts +244 -0
  65. package/src/db/alerts.ts +77 -0
  66. package/src/db/edge.ts +105 -0
  67. package/src/db/event-index.ts +323 -0
  68. package/src/db/events.ts +41 -0
  69. package/src/db/index.ts +60 -0
  70. package/src/db/octagon-cache.ts +118 -0
  71. package/src/db/positions.ts +71 -0
  72. package/src/db/risk.ts +51 -0
  73. package/src/db/schema.ts +227 -0
  74. package/src/db/themes.ts +34 -0
  75. package/src/db/trades.ts +50 -0
  76. package/src/eval/brier.ts +90 -0
  77. package/src/eval/index.ts +4 -0
  78. package/src/eval/performance.ts +87 -0
  79. package/src/gateway/access-control.ts +253 -0
  80. package/src/gateway/agent-runner.ts +75 -0
  81. package/src/gateway/alerts/formatter.ts +90 -0
  82. package/src/gateway/alerts/index.ts +4 -0
  83. package/src/gateway/alerts/router.ts +32 -0
  84. package/src/gateway/alerts/terminal.ts +16 -0
  85. package/src/gateway/alerts/types.ts +13 -0
  86. package/src/gateway/channels/index.ts +9 -0
  87. package/src/gateway/channels/manager.ts +153 -0
  88. package/src/gateway/channels/types.ts +48 -0
  89. package/src/gateway/channels/whatsapp/README.md +234 -0
  90. package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
  91. package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
  92. package/src/gateway/channels/whatsapp/error.ts +122 -0
  93. package/src/gateway/channels/whatsapp/inbound.ts +326 -0
  94. package/src/gateway/channels/whatsapp/index.ts +5 -0
  95. package/src/gateway/channels/whatsapp/lid.ts +56 -0
  96. package/src/gateway/channels/whatsapp/logger.ts +25 -0
  97. package/src/gateway/channels/whatsapp/login.ts +94 -0
  98. package/src/gateway/channels/whatsapp/outbound.ts +119 -0
  99. package/src/gateway/channels/whatsapp/plugin.ts +54 -0
  100. package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
  101. package/src/gateway/channels/whatsapp/runtime.ts +122 -0
  102. package/src/gateway/channels/whatsapp/session.ts +89 -0
  103. package/src/gateway/channels/whatsapp/types.ts +32 -0
  104. package/src/gateway/commands/handler.ts +64 -0
  105. package/src/gateway/commands/index.ts +7 -0
  106. package/src/gateway/commands/parser.ts +29 -0
  107. package/src/gateway/commands/wa-formatters.ts +92 -0
  108. package/src/gateway/config.ts +244 -0
  109. package/src/gateway/extension-points.ts +17 -0
  110. package/src/gateway/gateway.ts +301 -0
  111. package/src/gateway/group/history-buffer.ts +75 -0
  112. package/src/gateway/group/index.ts +8 -0
  113. package/src/gateway/group/member-tracker.ts +60 -0
  114. package/src/gateway/group/mention-detection.ts +42 -0
  115. package/src/gateway/heartbeat/index.ts +8 -0
  116. package/src/gateway/heartbeat/prompt.ts +73 -0
  117. package/src/gateway/heartbeat/runner.ts +200 -0
  118. package/src/gateway/heartbeat/suppression.ts +74 -0
  119. package/src/gateway/index.ts +138 -0
  120. package/src/gateway/routing/resolve-route.ts +119 -0
  121. package/src/gateway/sessions/store.ts +65 -0
  122. package/src/gateway/types.ts +11 -0
  123. package/src/gateway/utils.ts +82 -0
  124. package/src/index.tsx +30 -0
  125. package/src/model/llm.ts +247 -0
  126. package/src/providers.ts +94 -0
  127. package/src/risk/circuit-breaker.ts +113 -0
  128. package/src/risk/correlation.ts +40 -0
  129. package/src/risk/gate.ts +125 -0
  130. package/src/risk/index.ts +10 -0
  131. package/src/risk/kelly.ts +230 -0
  132. package/src/scan/alerter.ts +64 -0
  133. package/src/scan/edge-computer.ts +164 -0
  134. package/src/scan/invoker.ts +199 -0
  135. package/src/scan/loop.ts +184 -0
  136. package/src/scan/octagon-client.ts +627 -0
  137. package/src/scan/octagon-events-api.ts +105 -0
  138. package/src/scan/octagon-prefetch.ts +172 -0
  139. package/src/scan/theme-resolver.ts +179 -0
  140. package/src/scan/types.ts +62 -0
  141. package/src/scan/watchdog.ts +126 -0
  142. package/src/setup/wizard.ts +659 -0
  143. package/src/theme.ts +67 -0
  144. package/src/tools/fetch/cache.ts +95 -0
  145. package/src/tools/fetch/external-content.ts +200 -0
  146. package/src/tools/fetch/index.ts +1 -0
  147. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  148. package/src/tools/fetch/web-fetch.ts +419 -0
  149. package/src/tools/index.ts +10 -0
  150. package/src/tools/kalshi/api.ts +251 -0
  151. package/src/tools/kalshi/dlq.ts +35 -0
  152. package/src/tools/kalshi/events.ts +84 -0
  153. package/src/tools/kalshi/exchange.ts +24 -0
  154. package/src/tools/kalshi/historical.ts +89 -0
  155. package/src/tools/kalshi/index.ts +11 -0
  156. package/src/tools/kalshi/kalshi-search.ts +437 -0
  157. package/src/tools/kalshi/kalshi-trade.ts +102 -0
  158. package/src/tools/kalshi/markets.ts +76 -0
  159. package/src/tools/kalshi/portfolio.ts +100 -0
  160. package/src/tools/kalshi/search-index.ts +198 -0
  161. package/src/tools/kalshi/series.ts +16 -0
  162. package/src/tools/kalshi/trading.ts +115 -0
  163. package/src/tools/kalshi/types.ts +199 -0
  164. package/src/tools/registry.ts +160 -0
  165. package/src/tools/search/index.ts +25 -0
  166. package/src/tools/search/tavily.ts +35 -0
  167. package/src/tools/types.ts +53 -0
  168. package/src/tools/v2/edge-query.ts +135 -0
  169. package/src/tools/v2/octagon-report.ts +112 -0
  170. package/src/tools/v2/portfolio-query.ts +79 -0
  171. package/src/tools/v2/portfolio-review.ts +59 -0
  172. package/src/tools/v2/risk-status.ts +94 -0
  173. package/src/tools/v2/scan.ts +78 -0
  174. package/src/types/qrcode-terminal.d.ts +7 -0
  175. package/src/types/whiskeysockets-baileys.d.ts +41 -0
  176. package/src/types.ts +22 -0
  177. package/src/utils/ai-message.ts +26 -0
  178. package/src/utils/bot-config.ts +219 -0
  179. package/src/utils/cache.ts +195 -0
  180. package/src/utils/config.ts +113 -0
  181. package/src/utils/env.ts +111 -0
  182. package/src/utils/errors.ts +313 -0
  183. package/src/utils/history-context.ts +32 -0
  184. package/src/utils/in-memory-chat-history.ts +268 -0
  185. package/src/utils/index.ts +28 -0
  186. package/src/utils/input-key-handlers.ts +64 -0
  187. package/src/utils/logger.ts +67 -0
  188. package/src/utils/long-term-chat-history.ts +138 -0
  189. package/src/utils/markdown-table.ts +227 -0
  190. package/src/utils/model.ts +70 -0
  191. package/src/utils/ollama.ts +37 -0
  192. package/src/utils/paths.ts +12 -0
  193. package/src/utils/progress-channel.ts +84 -0
  194. package/src/utils/telemetry.ts +103 -0
  195. package/src/utils/text-navigation.ts +81 -0
  196. package/src/utils/thinking-verbs.ts +18 -0
  197. package/src/utils/tokens.ts +36 -0
  198. package/src/utils/tool-description.ts +61 -0
@@ -0,0 +1,659 @@
1
+ import { existsSync } from 'fs';
2
+ import { config } from 'dotenv';
3
+ import { ApiKeyInputComponent, createProviderSelector } from '../components/index.js';
4
+ import { VimSelectList } from '../components/select-list.js';
5
+ import { selectListTheme, theme } from '../theme.js';
6
+ import { checkApiKeyExists, saveApiKeyToEnv, ENV_PATH } from '../utils/env.js';
7
+ import { callKalshiApi } from '../tools/kalshi/api.js';
8
+ import { loadBotConfig, saveBotConfig } from '../utils/bot-config.js';
9
+ import { appPath } from '../utils/paths.js';
10
+ import type { SelectItem } from '@mariozechner/pi-tui';
11
+
12
+ export type WizardState =
13
+ | 'welcome'
14
+ | 'kalshi_api_key'
15
+ | 'kalshi_private_key'
16
+ | 'octagon_api_key'
17
+ | 'llm_provider_select'
18
+ | 'llm_api_key'
19
+ | 'testing'
20
+ | 'complete';
21
+
22
+ interface TestResult {
23
+ name: string;
24
+ status: 'pending' | 'ok' | 'fail' | 'skip';
25
+ message?: string;
26
+ }
27
+
28
+ export class SetupWizardController {
29
+ private wizardState: WizardState = 'welcome';
30
+ private collectedKeys: Record<string, string> = {};
31
+ private originalEnvValues: Record<string, string | undefined> = {};
32
+ private testResults: TestResult[] = [];
33
+ private configWritten = false;
34
+ private selectedProvider: string | null = null;
35
+ private readonly onComplete: () => void;
36
+ private readonly onChange: () => void;
37
+ private active = false;
38
+ private stepError: string | null = null;
39
+
40
+ // Reusable UI components for the current step
41
+ private currentInput: ApiKeyInputComponent | null = null;
42
+ private currentSelector: VimSelectList | null = null;
43
+
44
+ constructor(onChange: () => void, onComplete: () => void) {
45
+ this.onChange = onChange;
46
+ this.onComplete = onComplete;
47
+ }
48
+
49
+ get state(): WizardState {
50
+ return this.wizardState;
51
+ }
52
+
53
+ get isActive(): boolean {
54
+ return this.active;
55
+ }
56
+
57
+ start() {
58
+ this.active = true;
59
+ this.wizardState = 'welcome';
60
+ this.collectedKeys = {};
61
+ this.originalEnvValues = {};
62
+ this.testResults = [];
63
+ this.configWritten = false;
64
+ this.selectedProvider = null;
65
+ this.currentInput = null;
66
+ this.currentSelector = null;
67
+ this.onChange();
68
+ }
69
+
70
+ cancel() {
71
+ this.restoreStagedEnv();
72
+ this.active = false;
73
+ this.wizardState = 'welcome';
74
+ this.currentInput = null;
75
+ this.currentSelector = null;
76
+ this.onChange();
77
+ }
78
+
79
+ /** Snapshot and stage an env var — records original value for cancel/restore */
80
+ private stageEnv(key: string, value: string) {
81
+ if (!(key in this.originalEnvValues)) {
82
+ this.originalEnvValues[key] = process.env[key];
83
+ }
84
+ this.collectedKeys[key] = value;
85
+ process.env[key] = value;
86
+ }
87
+
88
+ /** Restore all staged env vars to their original values and clear collected keys */
89
+ private restoreStagedEnv() {
90
+ for (const key of Object.keys(this.collectedKeys)) {
91
+ const original = this.originalEnvValues[key];
92
+ if (original !== undefined) {
93
+ process.env[key] = original;
94
+ } else {
95
+ delete process.env[key];
96
+ }
97
+ }
98
+ this.collectedKeys = {};
99
+ this.originalEnvValues = {};
100
+ }
101
+
102
+ // --- Rendering info for cli.ts ---
103
+
104
+ getTitle(): string {
105
+ switch (this.wizardState) {
106
+ case 'welcome':
107
+ return 'Welcome to Kalshi Trading Bot CLI';
108
+ case 'kalshi_api_key':
109
+ return 'Step 1/5: Kalshi API Key';
110
+ case 'kalshi_private_key':
111
+ return 'Step 2/5: Kalshi Private Key';
112
+ case 'octagon_api_key':
113
+ return 'Step 3/5: Octagon API Key';
114
+ case 'llm_provider_select':
115
+ return 'Step 4/5: LLM Provider';
116
+ case 'llm_api_key':
117
+ return `Step 5/5: ${this.selectedProvider ?? 'LLM'} API Key`;
118
+ case 'testing':
119
+ return 'Testing connections...';
120
+ case 'complete':
121
+ return "You're all set!";
122
+ }
123
+ }
124
+
125
+ getDescription(): string {
126
+ switch (this.wizardState) {
127
+ case 'welcome':
128
+ return "Let's get you set up. This takes ~2 minutes.\nYou'll need your Kalshi API credentials and at least one LLM API key.";
129
+ case 'kalshi_api_key':
130
+ return 'Paste your Kalshi API key below.\nGet one at: https://kalshi.com/account/api';
131
+ case 'kalshi_private_key': {
132
+ let desc = 'Paste your Kalshi private key below.\nCopy it from the Kalshi API key creation screen.\nYou can also enter a path to a .pem file.';
133
+ if (this.stepError) desc += `\n\n${this.stepError}`;
134
+ return desc;
135
+ }
136
+ case 'octagon_api_key':
137
+ return 'Paste your Octagon API key (recommended for deep research).\nGet one at: https://app.octagonai.co\nLeave empty and press Enter to skip.';
138
+ case 'llm_provider_select':
139
+ return 'Select your LLM provider. You can change this later with /model.';
140
+ case 'llm_api_key':
141
+ return `Paste your ${this.selectedProvider ?? 'LLM'} API key below.`;
142
+ case 'testing':
143
+ return '';
144
+ case 'complete':
145
+ return this.configWritten
146
+ ? 'All keys saved to .env. Default thresholds written to config.json.'
147
+ : 'All keys saved to .env. Type /help to get started.';
148
+ }
149
+ }
150
+
151
+ getFooter(): string {
152
+ switch (this.wizardState) {
153
+ case 'welcome':
154
+ return 'Enter to continue';
155
+ case 'kalshi_api_key':
156
+ case 'kalshi_private_key':
157
+ case 'octagon_api_key':
158
+ case 'llm_api_key':
159
+ return 'Enter to confirm · Esc to cancel setup';
160
+ case 'llm_provider_select':
161
+ return 'Enter to confirm · Esc to cancel setup';
162
+ case 'testing':
163
+ return '';
164
+ case 'complete':
165
+ if (this.testResults.some((r) => r.status === 'fail')) {
166
+ return 'R to restart wizard · Enter to continue anyway';
167
+ }
168
+ return 'Press Enter to continue';
169
+ }
170
+ }
171
+
172
+ /** Returns the component that should receive focus, or null for text-only states */
173
+ getFocusTarget(): ApiKeyInputComponent | VimSelectList | null {
174
+ if (this.wizardState === 'llm_provider_select' && this.currentSelector) {
175
+ return this.currentSelector;
176
+ }
177
+ if (this.currentInput) {
178
+ return this.currentInput;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /** Returns extra body lines for states without an interactive component */
184
+ getBodyLines(): string[] {
185
+ if (this.wizardState === 'testing') {
186
+ return this.testResults.map((r) => {
187
+ const icon =
188
+ r.status === 'ok' ? theme.success(' OK') :
189
+ r.status === 'fail' ? theme.error(' FAIL') :
190
+ r.status === 'skip' ? theme.muted(' --') :
191
+ theme.muted(' ...');
192
+ const msg = r.message ? theme.muted(` ${r.message}`) : '';
193
+ return `${icon} ${r.name}${msg}`;
194
+ });
195
+ }
196
+ if (this.wizardState === 'complete') {
197
+ const lines = this.testResults.map((r) => {
198
+ const icon = r.status === 'ok' ? theme.success(' OK') : r.status === 'skip' ? theme.muted(' --') : theme.error(' FAIL');
199
+ const msg = r.message ? theme.muted(` ${r.message}`) : '';
200
+ return `${icon} ${r.name}${msg}`;
201
+ });
202
+ if (this.configWritten) {
203
+ lines.push('');
204
+ lines.push(theme.muted(' Default thresholds (to customize, run the command shown):'));
205
+ lines.push(` min_edge_threshold = 5% ${theme.muted('e.g. bun start config risk.min_edge_threshold 0.10')}`);
206
+ lines.push(` kelly_multiplier = 0.5 ${theme.muted('e.g. bun start config risk.kelly_multiplier 0.25')}`);
207
+ lines.push(` max_position_pct = 10% ${theme.muted('e.g. bun start config risk.max_position_pct 0.05')}`);
208
+ lines.push(` daily_loss_limit = $200 ${theme.muted('e.g. bun start config risk.daily_loss_limit 100')}`);
209
+ lines.push(` max_positions = 10 ${theme.muted('e.g. bun start config risk.max_positions 5')}`);
210
+ lines.push('');
211
+ lines.push(theme.muted(' Run "bun start config" to see all settings.'));
212
+ }
213
+ return lines;
214
+ }
215
+ return [];
216
+ }
217
+
218
+ /** Create the input/selector component for the current step (called by cli.ts during render) */
219
+ ensureComponent(): ApiKeyInputComponent | VimSelectList | null {
220
+ switch (this.wizardState) {
221
+ case 'kalshi_api_key': {
222
+ if (!this.currentInput) {
223
+ const input = new ApiKeyInputComponent(true);
224
+ input.onSubmit = (value) => this.handleApiKeySubmit('KALSHI_API_KEY', value, 'kalshi_private_key');
225
+ input.onCancel = () => this.cancel();
226
+ this.currentInput = input;
227
+ }
228
+ return this.currentInput;
229
+ }
230
+ case 'kalshi_private_key': {
231
+ if (!this.currentInput) {
232
+ const input = new ApiKeyInputComponent(true); // Masked — it's a private key
233
+ input.onSubmit = (value) => this.handlePrivateKeySubmit(value);
234
+ input.onCancel = () => this.cancel();
235
+ this.currentInput = input;
236
+ }
237
+ return this.currentInput;
238
+ }
239
+ case 'octagon_api_key': {
240
+ if (!this.currentInput) {
241
+ const input = new ApiKeyInputComponent(true);
242
+ input.onSubmit = (value) => this.handleOptionalKeySubmit('OCTAGON_API_KEY', value, 'llm_provider_select');
243
+ input.onCancel = () => this.cancel();
244
+ this.currentInput = input;
245
+ }
246
+ return this.currentInput;
247
+ }
248
+ case 'llm_provider_select': {
249
+ if (!this.currentSelector) {
250
+ const items: SelectItem[] = [
251
+ { value: 'openai', label: '1. OpenAI' },
252
+ { value: 'anthropic', label: '2. Anthropic' },
253
+ { value: 'google', label: '3. Google' },
254
+ { value: 'xai', label: '4. xAI' },
255
+ { value: 'deepseek', label: '5. DeepSeek' },
256
+ { value: 'openrouter', label: '6. OpenRouter' },
257
+ { value: 'ollama', label: '7. Ollama (local, no key needed)' },
258
+ { value: 'skip', label: '8. Skip (set up later with /model)' },
259
+ ];
260
+ const list = new VimSelectList(items, 10, selectListTheme);
261
+ list.onSelect = (item) => this.handleProviderSelect(item.value);
262
+ list.onCancel = () => this.cancel();
263
+ this.currentSelector = list;
264
+ }
265
+ return this.currentSelector;
266
+ }
267
+ case 'llm_api_key': {
268
+ if (!this.currentInput) {
269
+ const input = new ApiKeyInputComponent(true);
270
+ input.onSubmit = (value) => this.handleLlmApiKeySubmit(value);
271
+ input.onCancel = () => this.cancel();
272
+ this.currentInput = input;
273
+ }
274
+ return this.currentInput;
275
+ }
276
+ default:
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /** Handle keyboard input for non-component states (welcome, testing, complete) */
282
+ handleInput(keyData: string): void {
283
+ if (keyData === '\r') {
284
+ if (this.wizardState === 'welcome') {
285
+ this.transition('kalshi_api_key');
286
+ return;
287
+ }
288
+ if (this.wizardState === 'complete') {
289
+ const failed = this.flushKeysToEnv();
290
+ if (failed.length > 0) {
291
+ this.testResults.push(...failed.map((k) => ({ name: `Save ${k}`, status: 'fail' as const, message: 'Failed to write to .env' })));
292
+ this.onChange();
293
+ return;
294
+ }
295
+ this.active = false;
296
+ this.onComplete();
297
+ return;
298
+ }
299
+ }
300
+ if ((keyData === 'r' || keyData === 'R') && this.wizardState === 'complete') {
301
+ if (this.testResults.some((r) => r.status === 'fail')) {
302
+ this.restoreStagedEnv();
303
+ this.testResults = [];
304
+ this.transition('kalshi_api_key');
305
+ return;
306
+ }
307
+ }
308
+ if (keyData === '\u001b') {
309
+ // Esc
310
+ if (this.wizardState === 'welcome' || this.wizardState === 'complete') {
311
+ if (this.wizardState === 'complete') {
312
+ const failed = this.flushKeysToEnv();
313
+ if (failed.length > 0) {
314
+ this.testResults.push(...failed.map((k) => ({ name: `Save ${k}`, status: 'fail' as const, message: 'Failed to write to .env' })));
315
+ this.onChange();
316
+ return;
317
+ }
318
+ this.active = false;
319
+ this.onComplete();
320
+ } else {
321
+ this.cancel();
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ /** Persist all collected keys to .env — called only when the user confirms completion.
328
+ * Returns list of keys that failed to persist (empty on full success). */
329
+ private flushKeysToEnv(): string[] {
330
+ const failed: string[] = [];
331
+ for (const [key, value] of Object.entries(this.collectedKeys)) {
332
+ if (!saveApiKeyToEnv(key, value)) {
333
+ failed.push(key);
334
+ }
335
+ }
336
+ return failed;
337
+ }
338
+
339
+ // --- Internal state transitions ---
340
+
341
+ private transition(next: WizardState) {
342
+ this.wizardState = next;
343
+ this.stepError = null;
344
+ this.currentInput = null;
345
+ this.currentSelector = null;
346
+ this.onChange();
347
+ }
348
+
349
+ private handleApiKeySubmit(envName: string, value: string | null, nextState: WizardState) {
350
+ if (!value) {
351
+ // Required key — don't advance
352
+ return;
353
+ }
354
+ this.stageEnv(envName, value);
355
+ this.transition(nextState);
356
+ }
357
+
358
+ private handlePrivateKeySubmit(value: string | null) {
359
+ if (!value) return; // Required
360
+
361
+ const trimmed = value.trim();
362
+
363
+ // Check if it's a file path
364
+ if (trimmed.endsWith('.pem') || trimmed.startsWith('/') || trimmed.startsWith('~') || trimmed.startsWith('.')) {
365
+ // Expand ~ to home
366
+ const expanded = trimmed.startsWith('~')
367
+ ? trimmed.replace('~', process.env.HOME ?? '')
368
+ : trimmed;
369
+
370
+ if (!existsSync(expanded)) {
371
+ this.stepError = `File not found: ${expanded}`;
372
+ this.onChange();
373
+ return;
374
+ }
375
+ this.stageEnv('KALSHI_PRIVATE_KEY_FILE', expanded);
376
+ } else {
377
+ // Raw PEM content pasted — the single-line input strips newlines,
378
+ // so reconstruct PEM structure: header, base64 body in 64-char lines, footer
379
+ let pem = trimmed;
380
+ const pemHeaderRe = /^(-----BEGIN [A-Z ]+-----)(.*?)(-----END [A-Z ]+-----)$/;
381
+ const match = pem.match(pemHeaderRe);
382
+ if (!match) {
383
+ this.stepError = 'Invalid private key. Expected PEM format starting with -----BEGIN RSA PRIVATE KEY-----';
384
+ this.onChange();
385
+ return;
386
+ }
387
+ if (match) {
388
+ const header = match[1];
389
+ const body = match[2].replace(/\s+/g, '');
390
+ const footer = match[3];
391
+ // Split base64 body into 64-character lines (standard PEM format)
392
+ const bodyLines: string[] = [];
393
+ for (let i = 0; i < body.length; i += 64) {
394
+ bodyLines.push(body.slice(i, i + 64));
395
+ }
396
+ pem = [header, ...bodyLines, footer].join('\n');
397
+ }
398
+ // Encode newlines for .env compatibility — dotenv expands \n in double-quoted values
399
+ const encoded = `"${pem.replace(/\n/g, '\\n')}"`;
400
+ this.collectedKeys['KALSHI_PRIVATE_KEY'] = encoded;
401
+ // Store actual PEM (with real newlines) in process.env so API clients can use it directly
402
+ if (!(('KALSHI_PRIVATE_KEY') in this.originalEnvValues)) {
403
+ this.originalEnvValues['KALSHI_PRIVATE_KEY'] = process.env['KALSHI_PRIVATE_KEY'];
404
+ }
405
+ process.env['KALSHI_PRIVATE_KEY'] = pem;
406
+ }
407
+
408
+ // Only set KALSHI_USE_DEMO default if not already configured
409
+ if (!process.env.KALSHI_USE_DEMO) {
410
+ this.stageEnv('KALSHI_USE_DEMO', 'false');
411
+ }
412
+ this.transition('octagon_api_key');
413
+ }
414
+
415
+ private handleOptionalKeySubmit(envName: string, value: string | null, nextState: WizardState) {
416
+ if (value) {
417
+ this.stageEnv(envName, value);
418
+ }
419
+ this.transition(nextState);
420
+ }
421
+
422
+ private readonly providerEnvMap: Record<string, string> = {
423
+ openai: 'OPENAI_API_KEY',
424
+ anthropic: 'ANTHROPIC_API_KEY',
425
+ google: 'GOOGLE_API_KEY',
426
+ xai: 'XAI_API_KEY',
427
+ deepseek: 'DEEPSEEK_API_KEY',
428
+ openrouter: 'OPENROUTER_API_KEY',
429
+ moonshot: 'MOONSHOT_API_KEY',
430
+ };
431
+
432
+ private handleProviderSelect(providerId: string) {
433
+ if (providerId === 'skip') {
434
+ this.selectedProvider = null;
435
+ this.runTests().catch((err) => {
436
+ this.testResults = [{ name: 'Setup error', status: 'fail', message: String(err) }];
437
+ this.wizardState = 'complete';
438
+ this.onChange();
439
+ });
440
+ return;
441
+ }
442
+ if (providerId === 'ollama') {
443
+ // Ollama runs locally — no API key needed, but track the selection
444
+ this.selectedProvider = 'ollama';
445
+ this.runTests().catch((err) => {
446
+ this.testResults = [{ name: 'Setup error', status: 'fail', message: String(err) }];
447
+ this.wizardState = 'complete';
448
+ this.onChange();
449
+ });
450
+ return;
451
+ }
452
+ this.selectedProvider = providerId;
453
+ this.transition('llm_api_key');
454
+ }
455
+
456
+ private handleLlmApiKeySubmit(value: string | null) {
457
+ if (!value || !value.trim()) {
458
+ // Empty submission — treat as skip
459
+ this.selectedProvider = null;
460
+ this.runTests().catch((err) => {
461
+ this.testResults = [{ name: 'Setup error', status: 'fail', message: String(err) }];
462
+ this.wizardState = 'complete';
463
+ this.onChange();
464
+ });
465
+ return;
466
+ }
467
+ if (this.selectedProvider) {
468
+ const envName = this.providerEnvMap[this.selectedProvider];
469
+ if (envName) {
470
+ this.stageEnv(envName, value);
471
+ }
472
+ }
473
+ this.runTests().catch((err) => {
474
+ this.testResults = [{ name: 'Setup error', status: 'fail', message: String(err) }];
475
+ this.wizardState = 'complete';
476
+ this.onChange();
477
+ });
478
+ }
479
+
480
+ /** Map provider id → base URL for /models endpoint test */
481
+ private readonly providerBaseUrlMap: Record<string, string> = {
482
+ openai: 'https://api.openai.com/v1',
483
+ xai: 'https://api.x.ai/v1',
484
+ openrouter: 'https://openrouter.ai/api/v1',
485
+ moonshot: 'https://api.moonshot.cn/v1',
486
+ deepseek: 'https://api.deepseek.com',
487
+ };
488
+
489
+ /** Test an API key by hitting a lightweight endpoint */
490
+ private async testBearerKey(baseUrl: string, apiKey: string): Promise<void> {
491
+ const res = await fetch(`${baseUrl}/models`, {
492
+ headers: { Authorization: `Bearer ${apiKey}` },
493
+ signal: AbortSignal.timeout(10_000),
494
+ });
495
+ if (!res.ok) {
496
+ const text = await res.text().catch(() => '');
497
+ throw new Error(`${res.status} ${text.slice(0, 80)}`);
498
+ }
499
+ }
500
+
501
+ private async runTests() {
502
+ this.testResults = [
503
+ { name: 'Kalshi API', status: 'pending' },
504
+ { name: 'Octagon API', status: 'pending' },
505
+ { name: 'LLM API', status: 'pending' },
506
+ ];
507
+ this.transition('testing');
508
+
509
+ // Reload env from .env (non-overwriting so staged process.env values are preserved)
510
+ config({ path: ENV_PATH, quiet: true });
511
+
512
+ // Test Kalshi
513
+ try {
514
+ await callKalshiApi('GET', '/exchange/status');
515
+ this.testResults[0] = { name: 'Kalshi API', status: 'ok', message: 'Connected' };
516
+ } catch (err) {
517
+ const msg = err instanceof Error ? err.message : String(err);
518
+ this.testResults[0] = { name: 'Kalshi API', status: 'fail', message: msg.slice(0, 60) };
519
+ }
520
+ this.onChange();
521
+
522
+ // Test Octagon
523
+ const octagonKey = process.env.OCTAGON_API_KEY;
524
+ if (octagonKey) {
525
+ try {
526
+ const octagonBase = process.env.OCTAGON_BASE_URL ?? 'https://api-gateway.octagonagents.com/v1';
527
+ const res = await fetch(`${octagonBase}/models`, {
528
+ headers: { Authorization: `Bearer ${octagonKey}` },
529
+ signal: AbortSignal.timeout(10_000),
530
+ });
531
+ if (res.ok || res.status === 404) {
532
+ // 404 is fine — key is valid, endpoint just doesn't exist
533
+ this.testResults[1] = { name: 'Octagon API', status: 'ok', message: 'Connected' };
534
+ } else if (res.status === 401 || res.status === 403) {
535
+ this.testResults[1] = { name: 'Octagon API', status: 'fail', message: 'Invalid API key' };
536
+ } else {
537
+ this.testResults[1] = { name: 'Octagon API', status: 'fail', message: `HTTP ${res.status}` };
538
+ }
539
+ } catch (err) {
540
+ const msg = err instanceof Error ? err.message : String(err);
541
+ this.testResults[1] = { name: 'Octagon API', status: 'fail', message: msg.slice(0, 60) };
542
+ }
543
+ } else {
544
+ this.testResults[1] = { name: 'Octagon API', status: 'skip', message: 'Skipped (set later in .env)' };
545
+ }
546
+ this.onChange();
547
+
548
+ // Test LLM
549
+ if (this.selectedProvider === 'ollama') {
550
+ try {
551
+ const ollamaBase = process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434';
552
+ const res = await fetch(`${ollamaBase}/api/tags`, {
553
+ signal: AbortSignal.timeout(5_000),
554
+ });
555
+ if (res.ok) {
556
+ this.testResults[2] = { name: 'LLM API', status: 'ok', message: 'Ollama connected' };
557
+ } else {
558
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: `Ollama returned ${res.status}` };
559
+ }
560
+ } catch {
561
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Ollama not reachable at localhost:11434' };
562
+ }
563
+ } else if (this.selectedProvider === 'anthropic') {
564
+ // Anthropic doesn't have a /models endpoint — test with a minimal messages call
565
+ const apiKey = process.env.ANTHROPIC_API_KEY;
566
+ if (apiKey) {
567
+ try {
568
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
569
+ method: 'POST',
570
+ headers: {
571
+ 'x-api-key': apiKey,
572
+ 'anthropic-version': '2023-06-01',
573
+ 'Content-Type': 'application/json',
574
+ },
575
+ body: JSON.stringify({
576
+ model: process.env.ANTHROPIC_TEST_MODEL ?? 'claude-haiku-4-5-20251001',
577
+ max_tokens: 1,
578
+ messages: [{ role: 'user', content: 'hi' }],
579
+ }),
580
+ signal: AbortSignal.timeout(10_000),
581
+ });
582
+ if (res.ok) {
583
+ this.testResults[2] = { name: 'LLM API', status: 'ok', message: 'Anthropic connected' };
584
+ } else if (res.status === 401) {
585
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Invalid API key' };
586
+ } else {
587
+ // 400, 429, etc. still means the key authenticated
588
+ this.testResults[2] = { name: 'LLM API', status: 'ok', message: 'Anthropic key valid' };
589
+ }
590
+ } catch (err) {
591
+ const msg = err instanceof Error ? err.message : String(err);
592
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: msg.slice(0, 60) };
593
+ }
594
+ } else {
595
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Key not found' };
596
+ }
597
+ } else if (this.selectedProvider === 'google') {
598
+ // Google Gemini uses API key as query param
599
+ const apiKey = process.env.GOOGLE_API_KEY;
600
+ if (apiKey) {
601
+ try {
602
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`, {
603
+ signal: AbortSignal.timeout(10_000),
604
+ });
605
+ if (res.ok) {
606
+ this.testResults[2] = { name: 'LLM API', status: 'ok', message: 'Google connected' };
607
+ } else if (res.status === 400 || res.status === 403) {
608
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Invalid API key' };
609
+ } else {
610
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: `HTTP ${res.status}` };
611
+ }
612
+ } catch (err) {
613
+ const msg = err instanceof Error ? err.message : String(err);
614
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: msg.slice(0, 60) };
615
+ }
616
+ } else {
617
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Key not found' };
618
+ }
619
+ } else if (this.selectedProvider) {
620
+ // OpenAI-compatible providers: openai, xai, openrouter, moonshot, deepseek
621
+ const envName = this.providerEnvMap[this.selectedProvider];
622
+ const apiKey = envName ? process.env[envName] : undefined;
623
+ const baseUrl = this.providerBaseUrlMap[this.selectedProvider];
624
+ if (apiKey && baseUrl) {
625
+ try {
626
+ await this.testBearerKey(baseUrl, apiKey);
627
+ this.testResults[2] = { name: 'LLM API', status: 'ok', message: `${this.selectedProvider} connected` };
628
+ } catch (err) {
629
+ const msg = err instanceof Error ? err.message : String(err);
630
+ if (msg.includes('401') || msg.includes('403')) {
631
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Invalid API key' };
632
+ } else {
633
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: msg.slice(0, 60) };
634
+ }
635
+ }
636
+ } else {
637
+ this.testResults[2] = { name: 'LLM API', status: 'fail', message: 'Key not found' };
638
+ }
639
+ } else {
640
+ this.testResults[2] = { name: 'LLM API', status: 'skip', message: 'Skipped (use /model to set up)' };
641
+ }
642
+ this.onChange();
643
+
644
+ // Small delay so user can see results
645
+ await new Promise((r) => setTimeout(r, 800));
646
+
647
+ // Write default config.json if it doesn't exist yet
648
+ if (!existsSync(appPath('config.json'))) {
649
+ const defaults = loadBotConfig(); // returns DEFAULTS when no file exists
650
+ this.configWritten = saveBotConfig(defaults);
651
+ if (!this.configWritten) {
652
+ this.testResults.push({ name: 'Write config.json', status: 'fail', message: `Could not write to ${appPath('config.json')}` });
653
+ }
654
+ }
655
+
656
+ this.wizardState = 'complete';
657
+ this.onChange();
658
+ }
659
+ }