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 +98 -98
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +360 -0
- package/package.json +4 -37
- package/src/auth-injector.ts +57 -0
- package/src/config.ts +74 -0
- package/src/detector.ts +361 -0
- package/src/index.ts +93 -0
- package/src/key-pool.ts +272 -0
- package/src/logger.ts +56 -0
- package/src/proxy.ts +270 -0
- package/src/setup.tsx +417 -0
- package/src/types.ts +66 -0
- package/tsconfig.json +15 -0
package/README.md
CHANGED
|
@@ -1,18 +1,66 @@
|
|
|
1
1
|
# Search MCP Rotator
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
78
|
+
---
|
|
31
79
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
89
|
+
---
|
|
38
90
|
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
MCP Client (OpenCode / Pi / etc.)
|
|
121
140
|
│ stdio transport
|
|
122
141
|
▼
|
|
123
142
|
┌─────────────────────────┐
|
|
124
|
-
│ Search MCP Rotator
|
|
125
|
-
│
|
|
143
|
+
│ Search MCP Rotator │
|
|
144
|
+
│ (local stdio proxy) │
|
|
126
145
|
│ │
|
|
127
|
-
│
|
|
128
|
-
│ │
|
|
129
|
-
│
|
|
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
|
|
157
|
+
│ HTTP (Streamable HTTP)
|
|
145
158
|
▼
|
|
146
159
|
Remote MCP Provider
|
|
147
|
-
(Exa / Firecrawl / etc.)
|
|
148
160
|
```
|
|
149
161
|
|
|
150
|
-
|
|
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
|
package/dist/setup.d.ts
ADDED
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
|
+
}
|