brownian-code 2026.2.10

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/bin/brownian +25 -0
  4. package/env.example +21 -0
  5. package/package.json +87 -0
  6. package/src/agent/agent.test.ts +414 -0
  7. package/src/agent/agent.ts +385 -0
  8. package/src/agent/index.ts +27 -0
  9. package/src/agent/prompts.ts +271 -0
  10. package/src/agent/scratchpad.test.ts +482 -0
  11. package/src/agent/scratchpad.ts +526 -0
  12. package/src/agent/token-counter.test.ts +59 -0
  13. package/src/agent/token-counter.ts +33 -0
  14. package/src/agent/types.ts +137 -0
  15. package/src/cli.tsx +385 -0
  16. package/src/commands/builtin.test.ts +271 -0
  17. package/src/commands/builtin.ts +200 -0
  18. package/src/commands/registry.test.ts +188 -0
  19. package/src/commands/registry.ts +111 -0
  20. package/src/commands/types.ts +64 -0
  21. package/src/components/AgentEventView.tsx +487 -0
  22. package/src/components/AnswerBox.tsx +81 -0
  23. package/src/components/ApiKeyPrompt.tsx +75 -0
  24. package/src/components/CommandMenu.test.tsx +64 -0
  25. package/src/components/CommandMenu.tsx +38 -0
  26. package/src/components/CursorText.tsx +43 -0
  27. package/src/components/DebugPanel.tsx +48 -0
  28. package/src/components/ErrorBox.test.tsx +58 -0
  29. package/src/components/ErrorBox.tsx +26 -0
  30. package/src/components/HelpView.test.tsx +70 -0
  31. package/src/components/HelpView.tsx +61 -0
  32. package/src/components/HistoryItemView.tsx +108 -0
  33. package/src/components/Input.tsx +193 -0
  34. package/src/components/Intro.test.tsx +59 -0
  35. package/src/components/Intro.tsx +35 -0
  36. package/src/components/ModelSelector.tsx +288 -0
  37. package/src/components/StatusBar.test.tsx +78 -0
  38. package/src/components/StatusBar.tsx +56 -0
  39. package/src/components/WorkingIndicator.tsx +133 -0
  40. package/src/components/index.ts +23 -0
  41. package/src/e2e/agent-flow.test.ts +378 -0
  42. package/src/evals/components/EvalApp.tsx +206 -0
  43. package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
  44. package/src/evals/components/EvalProgress.tsx +33 -0
  45. package/src/evals/components/EvalRecentResults.tsx +63 -0
  46. package/src/evals/components/EvalStats.tsx +49 -0
  47. package/src/evals/components/index.ts +5 -0
  48. package/src/evals/dataset/crypto_agent.csv +16 -0
  49. package/src/evals/run.ts +355 -0
  50. package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
  51. package/src/gateway/channels/whatsapp/inbound.ts +86 -0
  52. package/src/gateway/channels/whatsapp/login.ts +28 -0
  53. package/src/gateway/channels/whatsapp/outbound.ts +27 -0
  54. package/src/gateway/channels/whatsapp/session.ts +69 -0
  55. package/src/gateway/config.ts +81 -0
  56. package/src/gateway/index.ts +62 -0
  57. package/src/hooks/useAgentRunner.ts +317 -0
  58. package/src/hooks/useDebugLogs.ts +22 -0
  59. package/src/hooks/useInputHistory.ts +106 -0
  60. package/src/hooks/useModelSelection.ts +249 -0
  61. package/src/hooks/useTextBuffer.test.ts +121 -0
  62. package/src/hooks/useTextBuffer.ts +97 -0
  63. package/src/index.tsx +74 -0
  64. package/src/mcp/cache.ts +205 -0
  65. package/src/mcp/client.test.ts +126 -0
  66. package/src/mcp/client.ts +145 -0
  67. package/src/mcp/index.ts +2 -0
  68. package/src/model/llm.test.ts +158 -0
  69. package/src/model/llm.ts +233 -0
  70. package/src/providers.ts +94 -0
  71. package/src/skills/index.ts +17 -0
  72. package/src/skills/loader.ts +73 -0
  73. package/src/skills/registry.ts +125 -0
  74. package/src/skills/types.ts +31 -0
  75. package/src/test-utils/mocks.ts +110 -0
  76. package/src/theme.ts +21 -0
  77. package/src/tools/browser/browser.ts +357 -0
  78. package/src/tools/browser/index.ts +1 -0
  79. package/src/tools/crypto/hive-tools.ts +171 -0
  80. package/src/tools/crypto/index.ts +1 -0
  81. package/src/tools/descriptions/browser.ts +105 -0
  82. package/src/tools/descriptions/crypto-search.ts +58 -0
  83. package/src/tools/descriptions/index.ts +8 -0
  84. package/src/tools/descriptions/web-fetch.ts +44 -0
  85. package/src/tools/descriptions/web-search.ts +26 -0
  86. package/src/tools/fetch/cache.ts +95 -0
  87. package/src/tools/fetch/external-content.ts +200 -0
  88. package/src/tools/fetch/index.ts +1 -0
  89. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  90. package/src/tools/fetch/web-fetch.ts +371 -0
  91. package/src/tools/index.ts +12 -0
  92. package/src/tools/registry.ts +130 -0
  93. package/src/tools/search/exa.ts +43 -0
  94. package/src/tools/search/index.ts +2 -0
  95. package/src/tools/search/tavily.ts +35 -0
  96. package/src/tools/skill.ts +62 -0
  97. package/src/tools/types.ts +53 -0
  98. package/src/utils/ai-message.ts +26 -0
  99. package/src/utils/config.ts +54 -0
  100. package/src/utils/cost-calculator.test.ts +101 -0
  101. package/src/utils/cost-calculator.ts +74 -0
  102. package/src/utils/env.ts +101 -0
  103. package/src/utils/error-classifier.test.ts +146 -0
  104. package/src/utils/error-classifier.ts +91 -0
  105. package/src/utils/in-memory-chat-history.test.ts +291 -0
  106. package/src/utils/in-memory-chat-history.ts +224 -0
  107. package/src/utils/index.ts +19 -0
  108. package/src/utils/input-key-handlers.test.ts +155 -0
  109. package/src/utils/input-key-handlers.ts +64 -0
  110. package/src/utils/logger.ts +67 -0
  111. package/src/utils/long-term-chat-history.ts +138 -0
  112. package/src/utils/markdown-table.ts +227 -0
  113. package/src/utils/ollama.ts +37 -0
  114. package/src/utils/progress-channel.ts +84 -0
  115. package/src/utils/text-navigation.test.ts +222 -0
  116. package/src/utils/text-navigation.ts +81 -0
  117. package/src/utils/thinking-verbs.ts +29 -0
  118. package/src/utils/tokens.test.ts +163 -0
  119. package/src/utils/tokens.ts +67 -0
  120. package/src/utils/tool-description.ts +88 -0
@@ -0,0 +1,249 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import { getSetting, setSetting } from '../utils/config.js';
3
+ import { getProviderDisplayName, checkApiKeyExistsForProvider, saveApiKeyForProvider } from '../utils/env.js';
4
+ import { getModelsForProvider, getDefaultModelForProvider, type Model } from '../components/ModelSelector.js';
5
+ import { getOllamaModels } from '../utils/ollama.js';
6
+ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from '../model/llm.js';
7
+ import { InMemoryChatHistory } from '../utils/in-memory-chat-history.js';
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ const SELECTION_STATES = ['provider_select', 'model_select', 'model_input', 'api_key_confirm', 'api_key_input'] as const;
14
+ type SelectionState = typeof SELECTION_STATES[number];
15
+ type AppState = 'idle' | SelectionState;
16
+
17
+ export interface ModelSelectionState {
18
+ appState: AppState;
19
+ pendingProvider: string | null;
20
+ pendingModels: Model[];
21
+ }
22
+
23
+ export interface UseModelSelectionResult {
24
+ // Current state
25
+ selectionState: ModelSelectionState;
26
+ provider: string;
27
+ model: string;
28
+ inMemoryChatHistoryRef: React.RefObject<InMemoryChatHistory>;
29
+
30
+ // Actions
31
+ startSelection: () => void;
32
+ cancelSelection: () => void;
33
+ handleProviderSelect: (providerId: string | null) => Promise<void>;
34
+ handleModelSelect: (modelId: string | null) => void;
35
+ handleModelInputSubmit: (modelName: string | null) => void;
36
+ handleApiKeyConfirm: (wantsToSet: boolean) => void;
37
+ handleApiKeySubmit: (apiKey: string | null) => void;
38
+
39
+ // Helpers
40
+ isInSelectionFlow: () => boolean;
41
+ }
42
+
43
+ // ============================================================================
44
+ // Helper
45
+ // ============================================================================
46
+
47
+ function isSelectionState(state: AppState): state is SelectionState {
48
+ return (SELECTION_STATES as readonly string[]).includes(state);
49
+ }
50
+
51
+ // ============================================================================
52
+ // Hook
53
+ // ============================================================================
54
+
55
+ export function useModelSelection(
56
+ onError: (message: string) => void
57
+ ): UseModelSelectionResult {
58
+ // Provider and model state (persisted)
59
+ const [provider, setProvider] = useState(() => getSetting('provider', DEFAULT_PROVIDER));
60
+ const [model, setModel] = useState(() => {
61
+ const savedModel = getSetting('modelId', null) as string | null;
62
+ const savedProvider = getSetting('provider', DEFAULT_PROVIDER) as string;
63
+ if (savedModel) {
64
+ return savedModel;
65
+ }
66
+ return getDefaultModelForProvider(savedProvider) || DEFAULT_MODEL;
67
+ });
68
+
69
+ // Selection flow state
70
+ const [appState, setAppState] = useState<AppState>('idle');
71
+ const [pendingProvider, setPendingProvider] = useState<string | null>(null);
72
+ const [pendingModels, setPendingModels] = useState<Model[]>([]);
73
+ const [pendingSelectedModelId, setPendingSelectedModelId] = useState<string | null>(null);
74
+
75
+ // Message history ref - shared with agent runner
76
+ const inMemoryChatHistoryRef = useRef<InMemoryChatHistory>(new InMemoryChatHistory(model));
77
+
78
+ // Helper to complete a model switch (DRY pattern)
79
+ const completeModelSwitch = useCallback((newProvider: string, newModelId: string) => {
80
+ setProvider(newProvider);
81
+ setModel(newModelId);
82
+ setSetting('provider', newProvider);
83
+ setSetting('modelId', newModelId);
84
+ inMemoryChatHistoryRef.current.setModel(newModelId);
85
+ setPendingProvider(null);
86
+ setPendingModels([]);
87
+ setPendingSelectedModelId(null);
88
+ setAppState('idle');
89
+ }, []);
90
+
91
+ // Reset pending state
92
+ const resetPendingState = useCallback(() => {
93
+ setPendingProvider(null);
94
+ setPendingModels([]);
95
+ setPendingSelectedModelId(null);
96
+ setAppState('idle');
97
+ }, []);
98
+
99
+ // Start selection flow
100
+ const startSelection = useCallback(() => {
101
+ setAppState('provider_select');
102
+ }, []);
103
+
104
+ // Cancel selection flow
105
+ const cancelSelection = useCallback(() => {
106
+ resetPendingState();
107
+ }, [resetPendingState]);
108
+
109
+ // Check if in selection flow
110
+ const isInSelectionFlow = useCallback(() => {
111
+ return isSelectionState(appState);
112
+ }, [appState]);
113
+
114
+ // Provider selection handler
115
+ const handleProviderSelect = useCallback(async (providerId: string | null) => {
116
+ if (providerId) {
117
+ setPendingProvider(providerId);
118
+
119
+ // OpenRouter uses free-text model input instead of a list
120
+ if (providerId === 'openrouter') {
121
+ setPendingModels([]);
122
+ setAppState('model_input');
123
+ } else if (providerId === 'ollama') {
124
+ // Fetch models from local Ollama API and convert to Model objects
125
+ const ollamaModelIds = await getOllamaModels();
126
+ const ollamaModels: Model[] = ollamaModelIds.map((id) => ({ id, displayName: id }));
127
+ setPendingModels(ollamaModels);
128
+ setAppState('model_select');
129
+ } else {
130
+ setPendingModels(getModelsForProvider(providerId));
131
+ setAppState('model_select');
132
+ }
133
+ } else {
134
+ setAppState('idle');
135
+ }
136
+ }, []);
137
+
138
+ // Model selection handler (for list-based selection)
139
+ const handleModelSelect = useCallback((modelId: string | null) => {
140
+ if (!modelId || !pendingProvider) {
141
+ // User cancelled - go back to provider select
142
+ setPendingProvider(null);
143
+ setPendingModels([]);
144
+ setPendingSelectedModelId(null);
145
+ setAppState('provider_select');
146
+ return;
147
+ }
148
+
149
+ // For Ollama, skip API key flow entirely
150
+ if (pendingProvider === 'ollama') {
151
+ const fullModelId = `ollama:${modelId}`;
152
+ completeModelSwitch(pendingProvider, fullModelId);
153
+ return;
154
+ }
155
+
156
+ // For cloud providers, check API key
157
+ if (checkApiKeyExistsForProvider(pendingProvider)) {
158
+ completeModelSwitch(pendingProvider, modelId);
159
+ } else {
160
+ // Need to get API key - store the selected model temporarily
161
+ setPendingSelectedModelId(modelId);
162
+ setAppState('api_key_confirm');
163
+ }
164
+ }, [pendingProvider, completeModelSwitch]);
165
+
166
+ // Model input handler (for free-text input like OpenRouter)
167
+ const handleModelInputSubmit = useCallback((modelName: string | null) => {
168
+ if (!modelName || !pendingProvider) {
169
+ // User cancelled - go back to provider select
170
+ setPendingProvider(null);
171
+ setPendingModels([]);
172
+ setPendingSelectedModelId(null);
173
+ setAppState('provider_select');
174
+ return;
175
+ }
176
+
177
+ // Store with provider prefix (e.g., openrouter:anthropic/claude-3.5-sonnet)
178
+ const fullModelId = `${pendingProvider}:${modelName}`;
179
+
180
+ // Check API key for the provider
181
+ if (checkApiKeyExistsForProvider(pendingProvider)) {
182
+ completeModelSwitch(pendingProvider, fullModelId);
183
+ } else {
184
+ // Need to get API key - store the selected model temporarily
185
+ setPendingSelectedModelId(fullModelId);
186
+ setAppState('api_key_confirm');
187
+ }
188
+ }, [pendingProvider, completeModelSwitch]);
189
+
190
+ // API key confirmation handler
191
+ const handleApiKeyConfirm = useCallback((wantsToSet: boolean) => {
192
+ if (wantsToSet) {
193
+ setAppState('api_key_input');
194
+ } else {
195
+ // Check if existing key is available
196
+ if (pendingProvider && pendingSelectedModelId && checkApiKeyExistsForProvider(pendingProvider)) {
197
+ completeModelSwitch(pendingProvider, pendingSelectedModelId);
198
+ } else {
199
+ onError(`Cannot use ${pendingProvider ? getProviderDisplayName(pendingProvider) : 'provider'} without an API key.`);
200
+ resetPendingState();
201
+ }
202
+ }
203
+ }, [pendingProvider, pendingSelectedModelId, completeModelSwitch, resetPendingState, onError]);
204
+
205
+ // API key submit handler
206
+ const handleApiKeySubmit = useCallback((apiKey: string | null) => {
207
+ // Guard: ensure we have a selected model
208
+ if (!pendingSelectedModelId) {
209
+ onError('No model selected.');
210
+ resetPendingState();
211
+ return;
212
+ }
213
+
214
+ if (apiKey && pendingProvider) {
215
+ const saved = saveApiKeyForProvider(pendingProvider, apiKey);
216
+ if (saved) {
217
+ completeModelSwitch(pendingProvider, pendingSelectedModelId);
218
+ } else {
219
+ onError('Failed to save API key.');
220
+ resetPendingState();
221
+ }
222
+ } else if (!apiKey && pendingProvider && checkApiKeyExistsForProvider(pendingProvider)) {
223
+ // Cancelled but existing key available
224
+ completeModelSwitch(pendingProvider, pendingSelectedModelId);
225
+ } else {
226
+ onError('API key not set. Provider unchanged.');
227
+ resetPendingState();
228
+ }
229
+ }, [pendingProvider, pendingSelectedModelId, completeModelSwitch, resetPendingState, onError]);
230
+
231
+ return {
232
+ selectionState: {
233
+ appState,
234
+ pendingProvider,
235
+ pendingModels,
236
+ },
237
+ provider,
238
+ model,
239
+ inMemoryChatHistoryRef,
240
+ startSelection,
241
+ cancelSelection,
242
+ handleProviderSelect,
243
+ handleModelSelect,
244
+ handleModelInputSubmit,
245
+ handleApiKeyConfirm,
246
+ handleApiKeySubmit,
247
+ isInSelectionFlow,
248
+ };
249
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import React, { useState, useCallback } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { render, cleanup } from 'ink-testing-library';
5
+ import { useTextBuffer } from './useTextBuffer.js';
6
+
7
+ afterEach(cleanup);
8
+
9
+ /**
10
+ * Test component that drives useTextBuffer through stdin keypresses.
11
+ * Instead of calling actions directly (which don't trigger re-renders in test),
12
+ * we test via the same mechanism the real Input uses: useInput-like patterns.
13
+ *
14
+ * For unit testing of ref-based hook, we use a command-driven approach:
15
+ * The component reads a "command" prop and executes it on mount/update.
16
+ */
17
+ function TestBuffer({ command }: { command: string }) {
18
+ const { text, cursorPosition, actions } = useTextBuffer();
19
+
20
+ // Execute command on each render with a new command value
21
+ const [lastCmd, setLastCmd] = React.useState('');
22
+ if (command !== lastCmd) {
23
+ setLastCmd(command);
24
+ const parts = command.split(':');
25
+ const op = parts[0];
26
+ const arg = parts.slice(1).join(':');
27
+
28
+ switch (op) {
29
+ case 'insert': actions.insert(arg); break;
30
+ case 'delete': actions.deleteBackward(); break;
31
+ case 'deleteWord': actions.deleteWordBackward(); break;
32
+ case 'move': actions.moveCursor(parseInt(arg)); break;
33
+ case 'clear': actions.clear(); break;
34
+ case 'set': actions.setValue(arg); break;
35
+ case 'setStart': actions.setValue(arg, false); break;
36
+ }
37
+ }
38
+
39
+ return React.createElement(Text, null, `[${text}|${cursorPosition}]`);
40
+ }
41
+
42
+ // Since useTextBuffer uses refs + forceRender, we need to give each test
43
+ // a unique command string to trigger the state-in-render pattern.
44
+ // We'll use rerender() to drive state changes.
45
+
46
+ describe('useTextBuffer - insert', () => {
47
+ test('inserts text', () => {
48
+ const { lastFrame, rerender } = render(
49
+ React.createElement(TestBuffer, { command: '' })
50
+ );
51
+ rerender(React.createElement(TestBuffer, { command: 'insert:hello' }));
52
+ expect(lastFrame()).toBe('[hello|5]');
53
+ });
54
+
55
+ test('inserts multi-character text', () => {
56
+ const { lastFrame, rerender } = render(
57
+ React.createElement(TestBuffer, { command: '' })
58
+ );
59
+ rerender(React.createElement(TestBuffer, { command: 'insert:abc' }));
60
+ expect(lastFrame()).toBe('[abc|3]');
61
+ });
62
+ });
63
+
64
+ describe('useTextBuffer - setValue', () => {
65
+ test('sets value with cursor at end', () => {
66
+ const { lastFrame, rerender } = render(
67
+ React.createElement(TestBuffer, { command: '' })
68
+ );
69
+ rerender(React.createElement(TestBuffer, { command: 'set:hello world' }));
70
+ expect(lastFrame()).toBe('[hello world|11]');
71
+ });
72
+
73
+ test('sets value with cursor at start', () => {
74
+ const { lastFrame, rerender } = render(
75
+ React.createElement(TestBuffer, { command: '' })
76
+ );
77
+ rerender(React.createElement(TestBuffer, { command: 'setStart:hello world' }));
78
+ expect(lastFrame()).toBe('[hello world|0]');
79
+ });
80
+ });
81
+
82
+ describe('useTextBuffer - clear', () => {
83
+ test('clears text and resets cursor', () => {
84
+ const { lastFrame, rerender } = render(
85
+ React.createElement(TestBuffer, { command: '' })
86
+ );
87
+ rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
88
+ expect(lastFrame()).toBe('[hello|5]');
89
+ rerender(React.createElement(TestBuffer, { command: 'clear' }));
90
+ expect(lastFrame()).toBe('[|0]');
91
+ });
92
+ });
93
+
94
+ describe('useTextBuffer - moveCursor', () => {
95
+ test('moves to valid position', () => {
96
+ const { lastFrame, rerender } = render(
97
+ React.createElement(TestBuffer, { command: '' })
98
+ );
99
+ rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
100
+ rerender(React.createElement(TestBuffer, { command: 'move:2' }));
101
+ expect(lastFrame()).toBe('[hello|2]');
102
+ });
103
+
104
+ test('clamps below 0', () => {
105
+ const { lastFrame, rerender } = render(
106
+ React.createElement(TestBuffer, { command: '' })
107
+ );
108
+ rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
109
+ rerender(React.createElement(TestBuffer, { command: 'move:-5' }));
110
+ expect(lastFrame()).toBe('[hello|0]');
111
+ });
112
+
113
+ test('clamps above text length', () => {
114
+ const { lastFrame, rerender } = render(
115
+ React.createElement(TestBuffer, { command: '' })
116
+ );
117
+ rerender(React.createElement(TestBuffer, { command: 'set:hello' }));
118
+ rerender(React.createElement(TestBuffer, { command: 'move:100' }));
119
+ expect(lastFrame()).toBe('[hello|5]');
120
+ });
121
+ });
@@ -0,0 +1,97 @@
1
+ import { useRef, useState, useCallback } from 'react';
2
+ import { findPrevWordStart } from '../utils/text-navigation.js';
3
+
4
+ export interface TextBufferActions {
5
+ /** Insert text at the current cursor position */
6
+ insert: (text: string) => void;
7
+ /** Delete the character before the cursor */
8
+ deleteBackward: () => void;
9
+ /** Delete from cursor back to start of previous word */
10
+ deleteWordBackward: () => void;
11
+ /** Move cursor to an absolute position (clamped to valid range) */
12
+ moveCursor: (position: number) => void;
13
+ /** Clear the buffer and reset cursor to 0 */
14
+ clear: () => void;
15
+ /** Set the buffer value and optionally position cursor at end */
16
+ setValue: (value: string, cursorAtEnd?: boolean) => void;
17
+ }
18
+
19
+ export interface UseTextBufferResult {
20
+ /** Current text content */
21
+ text: string;
22
+ /** Current cursor position (0-indexed) */
23
+ cursorPosition: number;
24
+ /** Actions to manipulate the buffer */
25
+ actions: TextBufferActions;
26
+ }
27
+
28
+ /**
29
+ * Hook for managing a text buffer with cursor position.
30
+ * Uses refs internally to avoid React state race conditions with fast typing,
31
+ * while still triggering re-renders when content changes.
32
+ */
33
+ export function useTextBuffer(): UseTextBufferResult {
34
+ // Use refs to avoid race conditions with fast typing
35
+ const buffer = useRef('');
36
+ const cursorPos = useRef(0);
37
+ const [, forceRender] = useState(0);
38
+
39
+ const rerender = useCallback(() => forceRender(x => x + 1), []);
40
+
41
+ const actions: TextBufferActions = {
42
+ insert: (input: string) => {
43
+ // Normalize line endings but preserve newlines for multi-line input
44
+ const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
45
+ buffer.current =
46
+ buffer.current.slice(0, cursorPos.current) +
47
+ normalized +
48
+ buffer.current.slice(cursorPos.current);
49
+ cursorPos.current += normalized.length;
50
+ rerender();
51
+ },
52
+
53
+ deleteBackward: () => {
54
+ if (cursorPos.current > 0) {
55
+ buffer.current =
56
+ buffer.current.slice(0, cursorPos.current - 1) +
57
+ buffer.current.slice(cursorPos.current);
58
+ cursorPos.current--;
59
+ rerender();
60
+ }
61
+ },
62
+
63
+ deleteWordBackward: () => {
64
+ if (cursorPos.current > 0) {
65
+ const wordStart = findPrevWordStart(buffer.current, cursorPos.current);
66
+ buffer.current =
67
+ buffer.current.slice(0, wordStart) +
68
+ buffer.current.slice(cursorPos.current);
69
+ cursorPos.current = wordStart;
70
+ rerender();
71
+ }
72
+ },
73
+
74
+ moveCursor: (position: number) => {
75
+ cursorPos.current = Math.max(0, Math.min(buffer.current.length, position));
76
+ rerender();
77
+ },
78
+
79
+ clear: () => {
80
+ buffer.current = '';
81
+ cursorPos.current = 0;
82
+ rerender();
83
+ },
84
+
85
+ setValue: (value: string, cursorAtEnd = true) => {
86
+ buffer.current = value;
87
+ cursorPos.current = cursorAtEnd ? value.length : 0;
88
+ rerender();
89
+ },
90
+ };
91
+
92
+ return {
93
+ text: buffer.current,
94
+ cursorPosition: cursorPos.current,
95
+ actions,
96
+ };
97
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirnamePreflight = dirname(fileURLToPath(import.meta.url));
7
+ const projectRoot = join(__dirnamePreflight, '..');
8
+
9
+ // Pre-flight: check dependencies are installed
10
+ if (!existsSync(join(projectRoot, 'node_modules', 'react'))) {
11
+ console.error('Dependencies not installed. Run: bun install');
12
+ process.exit(1);
13
+ }
14
+
15
+ // Pre-flight: warn if no .env file
16
+ if (!existsSync(join(projectRoot, '.env'))) {
17
+ console.warn('No .env file found. Copy the template: cp env.example .env');
18
+ }
19
+
20
+ import React from 'react';
21
+ import { render } from 'ink';
22
+ import { config } from 'dotenv';
23
+ import { CLI } from './cli.js';
24
+
25
+ // Handle --version / -v
26
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'));
29
+ console.log(`brownian-code v${pkg.version}`);
30
+ } catch {
31
+ console.log('brownian-code');
32
+ }
33
+ process.exit(0);
34
+ }
35
+
36
+ // Handle --help / -h
37
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
38
+ console.log(`
39
+ Brownian Code - Autonomous crypto research agent
40
+ Think Claude Code, but built for crypto research.
41
+
42
+ Usage:
43
+ brownian Start interactive session
44
+ brownian --version Show version
45
+ brownian --help Show this help
46
+
47
+ Environment:
48
+ Set API keys in .env or enter them interactively.
49
+ See env.example for all supported variables.
50
+
51
+ Commands (inside the app):
52
+ /help Show commands & keyboard shortcuts
53
+ /model Switch model/provider
54
+ /clear Clear conversation history
55
+ /cost Show session token/cost stats
56
+ /compact Summarize & clear old context
57
+ /history Show past queries
58
+ /export Export conversation to file
59
+ /debug Toggle debug panel
60
+ /verbose Toggle verbose tool output
61
+ /shortcuts Show keyboard shortcuts
62
+
63
+ More info: https://brownian.xyz
64
+ `);
65
+ process.exit(0);
66
+ }
67
+
68
+ // Load environment variables
69
+ config({ quiet: true });
70
+
71
+ // Render the CLI app and wait for it to exit
72
+ // This keeps the process alive until the user exits
73
+ const { waitUntilExit } = render(<CLI />);
74
+ await waitUntilExit();