search-mcp-rotator 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -1,18 +1,66 @@
1
1
  # Search MCP Rotator
2
2
 
3
- ![Search MCP Rotator](./poster.png)
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/sahilchouksey/search-mcp-rotator/main/poster.png" alt="Search MCP Rotator" width="100%" />
5
+ </p>
4
6
 
5
- A local MCP (Model Context Protocol) proxy server that provides transparent API key rotation for search providers. When one key is exhausted, rate-limited, or returns any server-side error, the proxy automatically rotates to the next available key and retries the request transparently, without the MCP client knowing a rotation occurred.
7
+ A local MCP proxy that transparently rotates API keys across search providers. When a key is rate-limited, exhausted, or errors, it automatically switches to the next key and retries — invisible to the MCP client.
6
8
 
7
- ## Features
9
+ ---
10
+
11
+ ## Quick Start
12
+
13
+ ### Step 1 — Add your API keys
14
+
15
+ Run the interactive setup CLI to select providers and enter your keys:
16
+
17
+ ```bash
18
+ npx search-mcp-rotator --setup
19
+ ```
20
+
21
+ The wizard walks through each provider, lets you paste multiple keys at once (comma-separated), and writes config to `~/.config/search-mcp-rotator/config.json` automatically.
22
+
23
+ > Press `Ctrl+O` on any provider screen to open its API key dashboard in your browser.
24
+
25
+ ### Step 2 — Add to your MCP client
26
+
27
+ #### OpenCode — `~/.config/opencode/opencode.json`
28
+
29
+ ```json
30
+ {
31
+ "mcp": {
32
+ "exa": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=exa"], "enabled": true },
33
+ "firecrawl": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=firecrawl"], "enabled": true },
34
+ "tavily": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=tavily"], "enabled": true },
35
+ "linkup": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=linkup"], "enabled": true },
36
+ "brightdata": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=brightdata"], "enabled": true },
37
+ "olostep": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=olostep"], "enabled": true },
38
+ "dappier": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=dappier"], "enabled": true },
39
+ "parallel": { "type": "local", "command": ["npx", "-y", "search-mcp-rotator", "--provider=parallel"], "enabled": true }
40
+ }
41
+ }
42
+ ```
43
+
44
+ #### Pi agent — `~/.pi/agent/mcp.json`
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "exa": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=exa"], "lifecycle": "lazy" },
50
+ "firecrawl": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=firecrawl"], "lifecycle": "lazy" },
51
+ "tavily": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=tavily"], "lifecycle": "lazy" },
52
+ "linkup": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=linkup"], "lifecycle": "lazy" },
53
+ "brightdata": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=brightdata"], "lifecycle": "lazy" },
54
+ "olostep": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=olostep"], "lifecycle": "lazy" },
55
+ "dappier": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=dappier"], "lifecycle": "lazy" },
56
+ "parallel": { "command": "npx", "args": ["-y", "search-mcp-rotator", "--provider=parallel"], "lifecycle": "lazy" }
57
+ }
58
+ }
59
+ ```
60
+
61
+ > Only include providers you configured in Step 1. No `--config` flag needed — reads from `~/.config/search-mcp-rotator/config.json` automatically. Restart your client after editing.
8
62
 
9
- - **Transparent Key Rotation**: Automatic failover between API keys
10
- - **Multi-Provider Support**: Supports 9 search providers with different auth patterns
11
- - **Flexible Rotation Strategies**: Round-robin, priority, and random selection
12
- - **Circuit Breaker Pattern**: Prevents cascading failures
13
- - **Configurable Cooldowns**: Smart recovery timing per provider
14
- - **Strategy-as-Tool-Argument**: LLMs can choose rotation strategy per request
15
- - **Comprehensive Error Detection**: Provider-specific exhaustion patterns
63
+ ---
16
64
 
17
65
  ## Supported Providers
18
66
 
@@ -21,22 +69,32 @@ A local MCP (Model Context Protocol) proxy server that provides transparent API
21
69
  | **Exa** | Bearer header | Neural/semantic web search, code search |
22
70
  | **Firecrawl** | Bearer header | Web scraping, deep crawl, structured extraction |
23
71
  | **Linkup** | Bearer header | Real-time web search, source-cited answers |
24
- | **Bright Data** | Bearer header | 40+ scraping tools, Google SERP, Amazon |
72
+ | **Bright Data** | Bearer header | 40+ scraping tools, Google SERP |
25
73
  | **Olostep** | Bearer header | Search + extract + AI answers with citations |
26
74
  | **Tavily** | Query param | Real-time web search, extract, map, crawl |
27
75
  | **Dappier** | Query param | Real-time news, finance, sports, weather |
28
76
  | **Parallel** | Custom header | Highest-accuracy general web search |
29
77
 
30
- ## Installation
78
+ ---
31
79
 
32
- ```bash
33
- npm install
34
- npm run build
35
- ```
80
+ ## Features
81
+
82
+ - **Transparent Key Rotation** — automatic failover between API keys
83
+ - **Multi-Provider Support** — 8 providers, different auth patterns handled automatically
84
+ - **Flexible Rotation Strategies** — round-robin, priority, random per tool call
85
+ - **Circuit Breaker** — prevents cascading failures
86
+ - **Configurable Cooldowns** — smart recovery timing per provider
87
+ - **Bulk Key Entry** — paste comma-separated keys during setup
36
88
 
37
- ## Configuration
89
+ ---
38
90
 
39
- Copy `config.example.json` to `config.json` and add your API keys:
91
+ ## Manual Configuration
92
+
93
+ Prefer editing config directly instead of `--setup`? Copy the example and add your keys:
94
+
95
+ ```bash
96
+ cp config.example.json ~/.config/search-mcp-rotator/config.json
97
+ ```
40
98
 
41
99
  ```json
42
100
  {
@@ -54,124 +112,66 @@ Copy `config.example.json` to `config.json` and add your API keys:
54
112
  }
55
113
  ```
56
114
 
57
- ### Environment Variable Overrides
58
-
59
- Keys can be overridden via environment variables:
115
+ Keys can also be set via environment variables:
60
116
 
61
117
  ```bash
62
118
  export EXA_KEYS="key1,key2,key3"
63
119
  export FIRECRAWL_KEYS="key1,key2"
64
120
  ```
65
121
 
66
- ## Usage
67
-
68
- Start a rotator for a specific provider:
69
-
70
- ```bash
71
- # Using built binary
72
- node dist/index.js --provider=exa --config=config.json
73
-
74
- # Or using npm script
75
- npm run dev -- --provider=exa --config=config.json
76
- ```
77
-
78
- ## OpenCode Integration
79
-
80
- Register each rotator as a local MCP server in `~/.config/opencode/opencode.json`:
81
-
82
- ```json
83
- {
84
- "mcpServers": {
85
- "exa-rotator": {
86
- "type": "local",
87
- "command": "node",
88
- "args": [
89
- "/path/to/search-mcp-rotator/dist/index.js",
90
- "--provider=exa"
91
- ],
92
- "env": {
93
- "MCP_ROTATOR_CONFIG": "/path/to/config.json"
94
- },
95
- "enabled": true
96
- }
97
- }
98
- }
99
- ```
122
+ ---
100
123
 
101
124
  ## Rotation Strategies
102
125
 
103
126
  Every tool call accepts an optional `strategy` parameter:
104
127
 
105
- - **`round-robin`** (default): Distribute requests evenly across keys
106
- - **`priority`**: Always use first healthy key, fallback to others
107
- - **`random`**: Random key selection to avoid patterns
128
+ | Strategy | Behavior |
129
+ |----------|----------|
130
+ | `round-robin` (default) | Distribute requests evenly across keys |
131
+ | `priority` | Always use first healthy key, fallback to others |
132
+ | `random` | Random key selection |
108
133
 
109
- ```typescript
110
- // Example tool call with strategy override
111
- {
112
- "query": "AI news",
113
- "strategy": "priority" // Optional: overrides provider default
114
- }
115
- ```
134
+ ---
116
135
 
117
136
  ## Architecture
118
137
 
119
138
  ```
120
- OpenCode (MCP client)
139
+ MCP Client (OpenCode / Pi / etc.)
121
140
  │ stdio transport
122
141
 
123
142
  ┌─────────────────────────┐
124
- │ Search MCP Rotator Proxy
125
- (local stdio server) │
143
+ │ Search MCP Rotator
144
+ (local stdio proxy) │
126
145
  │ │
127
- ┌─────────────────┐
128
- │ │ KeyPool │ │
129
- │ [key1, key2,
130
- │ │ key3, key4] │ │
131
- │ │ activeIdx = 0 │ │
132
- │ │ degraded: Map │ │
133
- │ └─────────────────┘ │
146
+ KeyPool [k1, k2, k3]
147
+ activeIdx = 0
148
+ degraded: Map
134
149
  │ │
135
- │ On tool call: │
136
150
  │ 1. pick active key │
137
151
  │ 2. inject into request │
138
152
  │ 3. forward upstream │
139
153
  │ 4. on rate-limit: │
140
154
  │ mark degraded │
141
- │ rotate key
142
- │ retry same call │
155
+ │ rotate key, retry
143
156
  └─────────────────────────┘
144
- │ HTTP (Streamable HTTP transport)
157
+ │ HTTP (Streamable HTTP)
145
158
 
146
159
  Remote MCP Provider
147
- (Exa / Firecrawl / etc.)
148
160
  ```
149
161
 
150
- ## Error Detection
151
-
152
- The rotator detects exhaustion signals specific to each provider:
153
-
154
- - **HTTP Status Codes**: 401, 402, 429, etc.
155
- - **JSON-RPC Error Codes**: Provider-specific codes
156
- - **Message Patterns**: "rate limit", "quota", "credits exhausted"
157
- - **Special Cases**: Provider-specific error formats
162
+ ---
158
163
 
159
164
  ## Development
160
165
 
161
166
  ```bash
162
- # Install dependencies
163
167
  npm install
164
-
165
- # Development with auto-reload
166
- npm run dev -- --provider=exa
167
-
168
- # Type checking
169
- npm run typecheck
170
-
171
- # Build for production
172
168
  npm run build
169
+ npm run dev -- --provider=exa # watch mode
170
+ npm run typecheck
173
171
  ```
174
172
 
173
+ ---
174
+
175
175
  ## License
176
176
 
177
- MIT
177
+ MIT
@@ -0,0 +1,3 @@
1
+ export declare const DEFAULT_CONFIG_DIR: string;
2
+ export declare const DEFAULT_CONFIG_PATH: string;
3
+ export declare function runSetup(outputPath?: string): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,360 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import { render, Box, Text, useInput, useApp } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+ import { exec } from 'child_process';
8
+ // ─── Universal config path ────────────────────────────────────────────────────
9
+ export const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.config', 'search-mcp-rotator');
10
+ export const DEFAULT_CONFIG_PATH = path.join(DEFAULT_CONFIG_DIR, 'config.json');
11
+ // ─── Providers ────────────────────────────────────────────────────────────────
12
+ const PROVIDERS = [
13
+ { id: 'exa', name: 'Exa', description: 'Neural/semantic web search, code search', url: 'https://mcp.exa.ai/mcp', authPattern: 'bearer', cooldownMs: 60000, keyHint: 'exa.ai → Dashboard → API Keys', keyUrl: 'https://exa.ai' },
14
+ { id: 'firecrawl', name: 'Firecrawl', description: 'Web scraping, deep crawl, structured extraction', url: 'https://mcp.firecrawl.dev/mcp', authPattern: 'bearer', cooldownMs: 60000, keyHint: 'firecrawl.dev → Dashboard (prefix: fc-)', keyUrl: 'https://firecrawl.dev' },
15
+ { id: 'tavily', name: 'Tavily', description: 'Real-time web search, extract, map, crawl', url: 'https://mcp.tavily.com/mcp/', authPattern: 'queryparam', cooldownMs: 60000, keyHint: 'app.tavily.com → API Keys (prefix: tvly-)', keyUrl: 'https://tavily.com', queryParamName: 'tavilyApiKey' },
16
+ { id: 'linkup', name: 'Linkup', description: 'Real-time web search, source-cited answers', url: 'https://mcp.linkup.so/mcp', authPattern: 'bearer', cooldownMs: 60000, keyHint: 'app.linkup.so → API Keys', keyUrl: 'https://linkup.so' },
17
+ { id: 'brightdata', name: 'Bright Data', description: 'Google SERP + 40 scraping tools (5k free/mo)', url: 'https://mcp.brightdata.com/mcp', authPattern: 'bearer', cooldownMs: 60000, keyHint: 'brightdata.com → Settings → API Token', keyUrl: 'https://brightdata.com' },
18
+ { id: 'olostep', name: 'Olostep', description: 'Search + extract + AI answers with citations', url: 'https://mcp.olostep.com/mcp', authPattern: 'bearer', cooldownMs: 300000, keyHint: 'olostep.com → Dashboard → API Keys', keyUrl: 'https://olostep.com' },
19
+ { id: 'dappier', name: 'Dappier', description: 'Real-time news, finance, sports, weather', url: 'https://mcp.dappier.com/mcp', authPattern: 'queryparam', cooldownMs: 300000, keyHint: 'dappier.com → Dashboard → API Keys', keyUrl: 'https://dappier.com', queryParamName: 'apiKey' },
20
+ { id: 'parallel', name: 'Parallel', description: 'Highest-accuracy general web search', url: 'https://search.parallel.ai/mcp', authPattern: 'customheader', cooldownMs: 60000, keyHint: 'platform.parallel.ai → API Keys', keyUrl: 'https://parallel.ai', headerName: 'x-api-key' },
21
+ ];
22
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
23
+ function openUrl(url) {
24
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
25
+ exec(`${cmd} "${url}"`, () => { });
26
+ }
27
+ function buildEntry(p, keys) {
28
+ const e = { enabled: true, url: p.url, authPattern: p.authPattern, keys: [...keys], strategy: 'round-robin', cooldownMs: p.cooldownMs };
29
+ if (p.authPattern === 'queryparam')
30
+ e.queryParamName = p.queryParamName;
31
+ if (p.authPattern === 'customheader')
32
+ e.headerName = p.headerName;
33
+ return e;
34
+ }
35
+ function syncConfig(configPath, configs) {
36
+ if (!Object.keys(configs).length)
37
+ return;
38
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
39
+ fs.writeFileSync(configPath, JSON.stringify({ logLevel: 'info', providers: configs }, null, 2));
40
+ }
41
+ // ─── Divider ─────────────────────────────────────────────────────────────────
42
+ const Divider = () => (React.createElement(Box, { marginY: 0 },
43
+ React.createElement(Text, { dimColor: true }, '─'.repeat(60))));
44
+ // ─── Screen: Provider Checklist ───────────────────────────────────────────────
45
+ const ProviderSelect = ({ onConfirm }) => {
46
+ const [cursor, setCursor] = useState(0);
47
+ const [selected, setSelected] = useState(new Set());
48
+ useInput((input, key) => {
49
+ if (key.upArrow)
50
+ setCursor(c => Math.max(0, c - 1));
51
+ if (key.downArrow)
52
+ setCursor(c => Math.min(PROVIDERS.length - 1, c + 1));
53
+ if (input === ' ') {
54
+ setSelected(s => {
55
+ const next = new Set(s);
56
+ const id = PROVIDERS[cursor].id;
57
+ next.has(id) ? next.delete(id) : next.add(id);
58
+ return next;
59
+ });
60
+ }
61
+ if (input === 'a')
62
+ setSelected(new Set(PROVIDERS.map(p => p.id)));
63
+ if (input === 'i')
64
+ setSelected(s => new Set(PROVIDERS.map(p => p.id).filter(id => !s.has(id))));
65
+ if (key.return) {
66
+ if (selected.size > 0)
67
+ onConfirm(PROVIDERS.map(p => p.id).filter(id => selected.has(id)));
68
+ }
69
+ });
70
+ return (React.createElement(Box, { flexDirection: "column", gap: 0 },
71
+ React.createElement(Divider, null),
72
+ React.createElement(Box, { marginBottom: 1 }),
73
+ PROVIDERS.map((p, i) => (React.createElement(Box, { key: p.id },
74
+ React.createElement(Text, { color: "cyan" },
75
+ i === cursor ? '›' : ' ',
76
+ " "),
77
+ React.createElement(Text, { color: selected.has(p.id) ? 'green' : 'white', bold: selected.has(p.id) }, selected.has(p.id) ? '[x] ' : '[ ] '),
78
+ React.createElement(Text, { bold: i === cursor }, p.name.padEnd(14)),
79
+ React.createElement(Text, { dimColor: true }, p.description)))),
80
+ React.createElement(Box, { marginTop: 1 },
81
+ React.createElement(Text, { dimColor: true }, "\u2191\u2193 navigate Space select a all i invert Enter confirm"))));
82
+ };
83
+ // ─── Screen: Selected summary ─────────────────────────────────────────────────
84
+ const SelectedSummary = ({ ids }) => (React.createElement(Box, { flexDirection: "column" },
85
+ React.createElement(Divider, null),
86
+ React.createElement(Box, { marginBottom: 1 }),
87
+ React.createElement(Text, { bold: true }, " Selected providers:"),
88
+ React.createElement(Box, { marginBottom: 1 }),
89
+ ids.map(id => {
90
+ const p = PROVIDERS.find(x => x.id === id);
91
+ return (React.createElement(Box, { key: id },
92
+ React.createElement(Text, { color: "green" }, " [x] "),
93
+ React.createElement(Text, { bold: true }, p.name.padEnd(14)),
94
+ React.createElement(Text, { dimColor: true }, p.description)));
95
+ }),
96
+ React.createElement(Box, { marginBottom: 1 })));
97
+ // ─── Screen: Key Entry (one provider at a time) ───────────────────────────────
98
+ const KeyEntryView = ({ provider, providerIndex, totalProviders, existingKeys, prevProvider, inputMode, modeLocked, onKeyAdded, onUndo, onBack, onSkip, onModeToggle }) => {
99
+ const [value, setValue] = useState('');
100
+ const [urlOpened, setUrlOpened] = useState(false);
101
+ const [message, setMessage] = useState('');
102
+ const keyNum = existingKeys.length + 1;
103
+ const isFirstKey = keyNum === 1;
104
+ // Allow mode toggle on Key 1 with no keys for THIS provider —
105
+ // even if mode is globally locked by another provider's entry
106
+ const canToggle = isFirstKey && existingKeys.length === 0;
107
+ useInput((input, key) => {
108
+ // Ctrl+O — open provider site
109
+ if (key.ctrl && input === 'o') {
110
+ openUrl(provider.keyUrl);
111
+ setUrlOpened(true);
112
+ }
113
+ // Tab — toggle mode only on Key 1 before lock, clear any \t ink-text-input added
114
+ if (key.tab && canToggle) {
115
+ setValue('');
116
+ onModeToggle();
117
+ }
118
+ });
119
+ const handleSubmit = useCallback((val) => {
120
+ const trimmed = val.trim();
121
+ setValue('');
122
+ setMessage('');
123
+ // Back navigation
124
+ if (trimmed === 'b' || trimmed === 'back') {
125
+ if (isFirstKey && prevProvider) {
126
+ onBack();
127
+ return;
128
+ }
129
+ if (keyNum > 1) {
130
+ onUndo();
131
+ return;
132
+ }
133
+ }
134
+ // Empty — skip or finish
135
+ if (!trimmed) {
136
+ if (isFirstKey && existingKeys.length === 0) {
137
+ onSkip();
138
+ return;
139
+ }
140
+ onSkip();
141
+ return;
142
+ }
143
+ // Always split by comma — if multiple keys pasted at any point, parse them all
144
+ const newKeys = trimmed.includes(',')
145
+ ? trimmed.split(',').map(k => k.trim()).filter(Boolean)
146
+ : [trimmed];
147
+ if (newKeys.length > 0) {
148
+ setMessage(`✓ ${newKeys.length > 1 ? newKeys.length + ' keys' : 'Key ' + keyNum} saved`);
149
+ onKeyAdded(newKeys);
150
+ // Multiple keys pasted at once — all provided, advance to next provider
151
+ if (newKeys.length > 1) {
152
+ onSkip();
153
+ }
154
+ }
155
+ }, [value, isFirstKey, keyNum, existingKeys, inputMode, modeLocked, prevProvider]);
156
+ // Build hint
157
+ let hint = '';
158
+ if (isFirstKey) {
159
+ const backHint = prevProvider ? ` b = ← ${prevProvider.name}` : '';
160
+ hint = inputMode === 'bulk' ? `comma-separated${backHint}` : `blank to skip${backHint}`;
161
+ }
162
+ else {
163
+ hint = `blank to finish • b = undo key ${keyNum - 1} • comma-sep for multiple`;
164
+ }
165
+ return (React.createElement(Box, { flexDirection: "column" },
166
+ React.createElement(Divider, null),
167
+ React.createElement(Box, { marginBottom: 1 }),
168
+ React.createElement(Box, null,
169
+ React.createElement(Text, { bold: true, color: "cyan" },
170
+ " [ ",
171
+ provider.name,
172
+ " ] "),
173
+ React.createElement(Text, { dimColor: true },
174
+ "(",
175
+ providerIndex + 1,
176
+ "/",
177
+ totalProviders,
178
+ ")")),
179
+ React.createElement(Text, { dimColor: true },
180
+ " ",
181
+ provider.description),
182
+ React.createElement(Text, { dimColor: true },
183
+ " Key: ",
184
+ provider.keyHint),
185
+ React.createElement(Text, { dimColor: true },
186
+ " ",
187
+ urlOpened ? `↗ Opened ${provider.keyUrl}` : `Ctrl+O — open ${provider.keyUrl}`),
188
+ React.createElement(Box, null,
189
+ React.createElement(Text, { dimColor: true }, " Mode: "),
190
+ React.createElement(Text, { bold: true, color: inputMode === 'bulk' ? 'yellow' : 'white' }, inputMode === 'single' ? 'Single' : 'Bulk'),
191
+ canToggle
192
+ ? React.createElement(Text, { dimColor: true },
193
+ " Tab \u2192 ",
194
+ inputMode === 'single' ? 'Bulk' : 'Single')
195
+ : modeLocked
196
+ ? React.createElement(Text, { dimColor: true }, " (locked)")
197
+ : null),
198
+ React.createElement(Box, { marginBottom: 1 }),
199
+ existingKeys.map((k, i) => (React.createElement(Box, { key: i },
200
+ React.createElement(Text, { color: "green" },
201
+ " \u2713 Key ",
202
+ i + 1,
203
+ " "),
204
+ React.createElement(Text, { dimColor: true },
205
+ k.slice(0, 8),
206
+ '*'.repeat(8))))),
207
+ React.createElement(Box, null,
208
+ React.createElement(Text, null,
209
+ " ",
210
+ isFirstKey && inputMode === 'bulk' ? 'Keys' : `API Key ${keyNum}`,
211
+ " "),
212
+ React.createElement(Text, { dimColor: true },
213
+ "(",
214
+ hint,
215
+ "): "),
216
+ React.createElement(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })),
217
+ message && React.createElement(Box, { marginTop: 0 },
218
+ React.createElement(Text, { color: "green" },
219
+ " ",
220
+ message))));
221
+ };
222
+ // ─── Screen: Done ─────────────────────────────────────────────────────────────
223
+ const DoneScreen = ({ configs, configPath }) => {
224
+ const { exit } = useApp();
225
+ useInput((_, key) => { if (key.return || key.escape)
226
+ exit(); });
227
+ return (React.createElement(Box, { flexDirection: "column" },
228
+ React.createElement(Divider, null),
229
+ React.createElement(Box, { marginBottom: 1 }),
230
+ React.createElement(Text, { color: "green", bold: true },
231
+ " \u2713 Config saved to ",
232
+ configPath),
233
+ React.createElement(Box, { marginBottom: 1 }),
234
+ React.createElement(Divider, null),
235
+ React.createElement(Box, { marginBottom: 1 }),
236
+ React.createElement(Text, { bold: true }, " Add to ~/.config/opencode/opencode.json:"),
237
+ React.createElement(Box, { marginBottom: 1 }),
238
+ React.createElement(Text, { dimColor: true },
239
+ " ",
240
+ ' "mcp": {'),
241
+ Object.keys(configs).map(id => (React.createElement(Box, { key: id, flexDirection: "column" },
242
+ React.createElement(Text, { dimColor: true },
243
+ ' ',
244
+ React.createElement(Text, { color: "white" },
245
+ "\"",
246
+ id,
247
+ "\""),
248
+ React.createElement(Text, { dimColor: true },
249
+ ": ",
250
+ '{')),
251
+ React.createElement(Text, { dimColor: true }, ' "type": "local",'),
252
+ React.createElement(Text, { dimColor: true },
253
+ ' "command": ["npx", "-y", "search-mcp-rotator", "--provider=',
254
+ id,
255
+ '"]},'),
256
+ React.createElement(Text, { dimColor: true }, ' "enabled": true'),
257
+ React.createElement(Text, { dimColor: true }, ' },')))),
258
+ React.createElement(Text, { dimColor: true }, ' }'),
259
+ React.createElement(Box, { marginBottom: 1 }),
260
+ React.createElement(Divider, null),
261
+ React.createElement(Box, { marginBottom: 1 }),
262
+ React.createElement(Text, { color: "green", bold: true }, " Setup complete! Restart OpenCode to activate."),
263
+ React.createElement(Text, { dimColor: true }, " Press Enter to exit.")));
264
+ };
265
+ const App = ({ configPath }) => {
266
+ const { exit } = useApp();
267
+ const [screen, setScreen] = useState('select');
268
+ const [selectedIds, setSelectedIds] = useState([]);
269
+ const [providerIndex, setProviderIndex] = useState(0);
270
+ const [providerConfigs, setProviderConfigs] = useState({});
271
+ const [inputMode, setInputMode] = useState('single');
272
+ const [modeLocked, setModeLocked] = useState(false);
273
+ // Ctrl+C global handler
274
+ useInput((_, key) => {
275
+ if (key.ctrl && _.toLowerCase() === 'c')
276
+ exit();
277
+ });
278
+ // ── Provider checklist confirmed ────────────────────────────────────────────
279
+ const handleSelectConfirm = useCallback((ids) => {
280
+ setSelectedIds(ids);
281
+ setProviderIndex(0);
282
+ setScreen('keys');
283
+ }, []);
284
+ // ── Key added for current provider ──────────────────────────────────────────
285
+ const handleKeyAdded = useCallback((newKeys) => {
286
+ setProviderConfigs(prev => {
287
+ const id = selectedIds[providerIndex];
288
+ const provider = PROVIDERS.find(p => p.id === id);
289
+ const existingKeys = prev[id]?.keys ?? [];
290
+ const merged = [...existingKeys, ...newKeys];
291
+ const updated = { ...prev, [id]: buildEntry(provider, merged) };
292
+ syncConfig(configPath, updated);
293
+ return updated;
294
+ });
295
+ if (!modeLocked)
296
+ setModeLocked(true);
297
+ }, [selectedIds, providerIndex, modeLocked, configPath]);
298
+ // ── Undo last key ──────────────────────────────────────────────────────────
299
+ const handleUndo = useCallback(() => {
300
+ setProviderConfigs(prev => {
301
+ const id = selectedIds[providerIndex];
302
+ const provider = PROVIDERS.find(p => p.id === id);
303
+ const keys = [...(prev[id]?.keys ?? [])];
304
+ keys.pop();
305
+ const updated = { ...prev };
306
+ if (keys.length > 0)
307
+ updated[id] = buildEntry(provider, keys);
308
+ else
309
+ delete updated[id];
310
+ syncConfig(configPath, updated);
311
+ return updated;
312
+ });
313
+ }, [selectedIds, providerIndex, configPath]);
314
+ // ── Go back to previous provider ────────────────────────────────────────────
315
+ const handleBack = useCallback(() => {
316
+ setProviderIndex(i => Math.max(0, i - 1));
317
+ }, []);
318
+ // ── Skip / finish current provider → advance ─────────────────────────────
319
+ const handleSkip = useCallback(() => {
320
+ if (providerIndex + 1 >= selectedIds.length) {
321
+ setScreen('done');
322
+ }
323
+ else {
324
+ setProviderIndex(i => i + 1);
325
+ }
326
+ }, [providerIndex, selectedIds]);
327
+ // ── Mode toggle ────────────────────────────────────────────────────────────
328
+ // canToggle in KeyEntryView is the gatekeeper — no double-check needed here
329
+ const handleModeToggle = useCallback(() => {
330
+ setInputMode(m => m === 'single' ? 'bulk' : 'single');
331
+ }, []);
332
+ // ─── Render ─────────────────────────────────────────────────────────────────
333
+ if (screen === 'select') {
334
+ return (React.createElement(Box, { flexDirection: "column" },
335
+ React.createElement(Box, { marginBottom: 1 }),
336
+ React.createElement(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 2 },
337
+ React.createElement(Text, { bold: true, color: "cyan" }, "Search MCP Rotator \u2014 Setup")),
338
+ React.createElement(Text, { dimColor: true },
339
+ " Config: ",
340
+ configPath),
341
+ React.createElement(ProviderSelect, { onConfirm: handleSelectConfirm })));
342
+ }
343
+ if (screen === 'keys') {
344
+ const id = selectedIds[providerIndex];
345
+ const provider = PROVIDERS.find(p => p.id === id);
346
+ const prevId = providerIndex > 0 ? selectedIds[providerIndex - 1] : null;
347
+ const prev = prevId ? PROVIDERS.find(p => p.id === prevId) : null;
348
+ const existingKeys = providerConfigs[id]?.keys ?? [];
349
+ return (React.createElement(Box, { flexDirection: "column" },
350
+ React.createElement(SelectedSummary, { ids: selectedIds }),
351
+ React.createElement(KeyEntryView, { provider: provider, providerIndex: providerIndex, totalProviders: selectedIds.length, existingKeys: existingKeys, prevProvider: prev, inputMode: inputMode, modeLocked: modeLocked, onKeyAdded: handleKeyAdded, onUndo: handleUndo, onBack: handleBack, onSkip: handleSkip, onModeToggle: handleModeToggle })));
352
+ }
353
+ return React.createElement(DoneScreen, { configs: providerConfigs, configPath: configPath });
354
+ };
355
+ // ─── Entry ────────────────────────────────────────────────────────────────────
356
+ export async function runSetup(outputPath) {
357
+ const configPath = outputPath ?? DEFAULT_CONFIG_PATH;
358
+ const { waitUntilExit } = render(React.createElement(App, { configPath: configPath }));
359
+ await waitUntilExit();
360
+ }