explorbot 0.5.7 → 0.5.8
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/bin/explorbot-cli.ts +2 -0
- package/dist/bin/explorbot-cli.js +2 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/explore-command.js +7 -0
- package/dist/src/commands/init-command.d.ts +1 -0
- package/dist/src/commands/init-command.js +87 -36
- package/dist/src/components/InitWizard.d.ts +5 -1
- package/dist/src/components/InitWizard.js +29 -11
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +29 -7
- package/docs/basics/getting-started.md +23 -10
- package/docs/basics/providers.md +2 -0
- package/docs/reference/commands.md +5 -2
- package/docs/reference/configuration.md +1 -0
- package/package.json +1 -1
- package/src/commands/explore-command.ts +8 -0
- package/src/commands/init-command.ts +105 -38
- package/src/components/InitWizard.tsx +43 -11
- package/src/config.ts +32 -7
package/bin/explorbot-cli.ts
CHANGED
|
@@ -502,6 +502,7 @@ program
|
|
|
502
502
|
.option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
|
|
503
503
|
.option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
|
|
504
504
|
.option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
|
|
505
|
+
.option('--url <url>', 'Web application URL written to web.url in the local config')
|
|
505
506
|
.action(async (options) => {
|
|
506
507
|
try {
|
|
507
508
|
const { runInit } = await import('../src/commands/init-command.js');
|
|
@@ -512,6 +513,7 @@ program
|
|
|
512
513
|
global: options.global,
|
|
513
514
|
provider: options.provider,
|
|
514
515
|
apiKey: options.apiKey,
|
|
516
|
+
url: options.url,
|
|
515
517
|
});
|
|
516
518
|
} catch (error) {
|
|
517
519
|
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
@@ -452,6 +452,7 @@ program
|
|
|
452
452
|
.option('-g, --global', 'Configure explorbot in ~/.explorbot to run from anywhere')
|
|
453
453
|
.option('--provider <name>', `AI provider for the global config: ${Object.keys(PROVIDERS).join(', ')}`)
|
|
454
454
|
.option('--api-key <key>', 'API key stored in ~/.explorbot/.env')
|
|
455
|
+
.option('--url <url>', 'Web application URL written to web.url in the local config')
|
|
455
456
|
.action(async (options) => {
|
|
456
457
|
try {
|
|
457
458
|
const { runInit } = await import('../src/commands/init-command.js');
|
|
@@ -462,6 +463,7 @@ program
|
|
|
462
463
|
global: options.global,
|
|
463
464
|
provider: options.provider,
|
|
464
465
|
apiKey: options.apiKey,
|
|
466
|
+
url: options.url,
|
|
465
467
|
});
|
|
466
468
|
}
|
|
467
469
|
catch (error) {
|
package/dist/package.json
CHANGED
|
@@ -576,6 +576,13 @@ export class ExploreCommand extends BaseCommand {
|
|
|
576
576
|
commands,
|
|
577
577
|
});
|
|
578
578
|
}
|
|
579
|
+
sections.push({
|
|
580
|
+
label: 'Explore more',
|
|
581
|
+
commands: [
|
|
582
|
+
{ label: 'Whole site', command: `${cli} explore /` },
|
|
583
|
+
{ label: 'Docs', command: 'https://testomat.ai/docs/explorbot' },
|
|
584
|
+
],
|
|
585
|
+
});
|
|
579
586
|
printNextSteps(sections);
|
|
580
587
|
}
|
|
581
588
|
isLimitReached() {
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
+
import figureSet from 'figures';
|
|
4
5
|
import { ConfigParser, PROVIDERS, missingModelRoles } from "../config.js";
|
|
5
6
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
|
|
6
7
|
import { getCliName } from "../utils/cli-name.js";
|
|
7
8
|
import { log, tag } from '../utils/logger.js';
|
|
8
|
-
import { relativeToCwd } from "../utils/next-steps.js";
|
|
9
|
-
function defaultConfigTemplate(provider) {
|
|
9
|
+
import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
|
|
10
|
+
function defaultConfigTemplate(provider, url) {
|
|
11
|
+
const appUrl = url || 'http://<your-app-host-here>';
|
|
10
12
|
return `// 'provider/model-id' uses a bundled provider.
|
|
11
13
|
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
12
|
-
// https://
|
|
14
|
+
// https://testomat.ai/docs/explorbot
|
|
13
15
|
|
|
14
16
|
const config = {
|
|
15
17
|
web: {
|
|
16
18
|
// use application host without path prefix (e.g., http://localhost:3000)
|
|
17
|
-
url: '
|
|
19
|
+
url: '${appUrl}',
|
|
18
20
|
},
|
|
19
21
|
|
|
20
22
|
ai: {
|
|
21
|
-
${
|
|
23
|
+
// every role (model, visionModel, agenticModel) takes the recommended ${provider} models bundled with this version
|
|
24
|
+
// override a single role as 'provider/model-id':
|
|
25
|
+
// model: '${provider}/<model-id>',
|
|
26
|
+
recommendedModels: '${provider}',
|
|
22
27
|
},
|
|
23
28
|
|
|
24
29
|
reporter: {
|
|
@@ -35,9 +40,10 @@ export default config;
|
|
|
35
40
|
`;
|
|
36
41
|
}
|
|
37
42
|
export function envTemplate(provider) {
|
|
43
|
+
const { keyUrl } = PROVIDERS[provider];
|
|
38
44
|
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
39
45
|
if (name === provider)
|
|
40
|
-
return
|
|
46
|
+
return `# Create a key at ${keyUrl} and paste it after '='\n${envKey}=`;
|
|
41
47
|
return `# ${envKey}=`;
|
|
42
48
|
});
|
|
43
49
|
return `# AI provider API keys
|
|
@@ -64,10 +70,10 @@ export async function runInit(options) {
|
|
|
64
70
|
const choice = await renderInitWizard('choose');
|
|
65
71
|
if (choice !== 'local')
|
|
66
72
|
return;
|
|
67
|
-
const
|
|
68
|
-
if (!
|
|
73
|
+
const setup = await renderLocalSetupWizard();
|
|
74
|
+
if (!setup)
|
|
69
75
|
return;
|
|
70
|
-
runInitCommand({ ...options,
|
|
76
|
+
runInitCommand({ ...options, ...setup });
|
|
71
77
|
}
|
|
72
78
|
export function writeGlobalConfig(provider, apiKey) {
|
|
73
79
|
if (!PROVIDERS[provider]) {
|
|
@@ -75,21 +81,25 @@ export function writeGlobalConfig(provider, apiKey) {
|
|
|
75
81
|
}
|
|
76
82
|
mkdirSync(globalDir(), { recursive: true });
|
|
77
83
|
writeFileSync(globalConfigPath(), globalConfigTemplate(provider), 'utf8');
|
|
78
|
-
log(
|
|
84
|
+
tag('success').log(`${figureSet.tick} Created global config: ${globalConfigPath()}`);
|
|
79
85
|
const envKey = PROVIDERS[provider].envKey;
|
|
80
86
|
writeEnvKey(envKey, apiKey || '');
|
|
81
|
-
log(
|
|
87
|
+
tag('success').log(`${figureSet.tick} Stored ${envKey} in ${globalEnvPath()}`);
|
|
82
88
|
const missing = missingModelRoles(provider);
|
|
83
89
|
if (missing.length) {
|
|
84
90
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
|
|
85
91
|
}
|
|
86
92
|
if (!apiKey && !process.env[envKey]) {
|
|
87
|
-
|
|
93
|
+
printApiKeySteps(provider, globalEnvPath());
|
|
88
94
|
}
|
|
89
95
|
log('');
|
|
90
|
-
log('
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
log(`${figureSet.star} What's next:`);
|
|
97
|
+
printInitNextSteps({
|
|
98
|
+
siteCommands: [
|
|
99
|
+
{ label: 'Any site', command: `${getCliName()} explore https://your-app.example.com` },
|
|
100
|
+
{ label: 'Saved sites', command: `${getCliName()} sites` },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
93
103
|
}
|
|
94
104
|
export function runInitCommand(options) {
|
|
95
105
|
const provider = options.provider || 'openrouter';
|
|
@@ -125,34 +135,38 @@ export function runInitCommand(options) {
|
|
|
125
135
|
log('Use --force to overwrite existing file');
|
|
126
136
|
process.exit(1);
|
|
127
137
|
}
|
|
128
|
-
writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
|
|
129
|
-
log(
|
|
138
|
+
writeFileSync(outPath, defaultConfigTemplate(provider, options.url), 'utf8');
|
|
139
|
+
tag('success').log(`${figureSet.tick} Created config file: ${relativeToCwd(outPath)}`);
|
|
130
140
|
const envPath = resolve(process.cwd(), '.env');
|
|
131
141
|
if (!existsSync(envPath)) {
|
|
132
142
|
writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
|
|
133
|
-
log(
|
|
143
|
+
tag('success').log(`${figureSet.tick} Created env file: ${relativeToCwd(envPath)}`);
|
|
134
144
|
}
|
|
135
145
|
else {
|
|
136
146
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
137
147
|
}
|
|
148
|
+
const envKey = PROVIDERS[provider].envKey;
|
|
149
|
+
if (options.apiKey) {
|
|
150
|
+
writeEnvKey(envKey, options.apiKey, envPath);
|
|
151
|
+
tag('success').log(`${figureSet.tick} Stored ${envKey} in ${relativeToCwd(envPath)}`);
|
|
152
|
+
}
|
|
153
|
+
if (!options.apiKey && !process.env[envKey]) {
|
|
154
|
+
printApiKeySteps(provider, relativeToCwd(envPath));
|
|
155
|
+
}
|
|
156
|
+
if (!options.url) {
|
|
157
|
+
tag('warning').log(`Set your application URL in web.url: ${relativeToCwd(outPath)}`);
|
|
158
|
+
}
|
|
138
159
|
const missing = missingModelRoles(provider);
|
|
139
160
|
if (missing.length) {
|
|
140
161
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
141
162
|
}
|
|
142
|
-
log('');
|
|
143
|
-
log('Next steps:');
|
|
144
|
-
log('1. Configure AI provider in .env');
|
|
145
|
-
log('2. Set AI models config file');
|
|
146
|
-
log('3. Set web application URL in the config file');
|
|
147
|
-
log('4. Add initial knowledge (how to authorize to the application, etc.)');
|
|
148
|
-
tag('substep').log(chalk.yellow(`${getCliName()} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
149
|
-
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
150
|
-
log('5. Launch application on a relative URL');
|
|
151
|
-
tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
|
|
152
163
|
if (!existsSync('./output')) {
|
|
153
164
|
mkdirSync('./output', { recursive: true });
|
|
154
|
-
log(
|
|
165
|
+
tag('success').log(`${figureSet.tick} Created directory: output`);
|
|
155
166
|
}
|
|
167
|
+
log('');
|
|
168
|
+
log(`${figureSet.star} What's next:`);
|
|
169
|
+
printInitNextSteps({ url: options.url });
|
|
156
170
|
}
|
|
157
171
|
catch (error) {
|
|
158
172
|
log('Failed to create config file:', error);
|
|
@@ -198,12 +212,12 @@ async function renderInitWizard(mode) {
|
|
|
198
212
|
}), { exitOnCtrlC: false, patchConsole: false });
|
|
199
213
|
});
|
|
200
214
|
}
|
|
201
|
-
async function
|
|
215
|
+
async function renderLocalSetupWizard() {
|
|
202
216
|
const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
|
|
203
217
|
return new Promise((resolve) => {
|
|
204
|
-
const finish = (
|
|
218
|
+
const finish = (setup) => {
|
|
205
219
|
unmount();
|
|
206
|
-
resolve(
|
|
220
|
+
resolve(setup);
|
|
207
221
|
};
|
|
208
222
|
const { unmount } = render(React.createElement(InitWizard, {
|
|
209
223
|
mode: 'local',
|
|
@@ -211,7 +225,7 @@ async function renderLocalProviderWizard() {
|
|
|
211
225
|
onLocal: () => finish(null),
|
|
212
226
|
onComplete: () => finish(null),
|
|
213
227
|
onCancel: () => finish(null),
|
|
214
|
-
|
|
228
|
+
onLocalSetup: (setup) => finish(setup),
|
|
215
229
|
}), { exitOnCtrlC: false, patchConsole: false });
|
|
216
230
|
});
|
|
217
231
|
}
|
|
@@ -238,7 +252,7 @@ function globalConfigTemplate(provider) {
|
|
|
238
252
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
239
253
|
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
240
254
|
// Model ids are snapshotted from the recommendations of this Explorbot version.
|
|
241
|
-
// https://
|
|
255
|
+
// https://testomat.ai/docs/explorbot
|
|
242
256
|
const config = {
|
|
243
257
|
ai: {
|
|
244
258
|
${modelLines(provider)}
|
|
@@ -255,8 +269,39 @@ ${modelLines(provider)}
|
|
|
255
269
|
export default config;
|
|
256
270
|
`;
|
|
257
271
|
}
|
|
258
|
-
function
|
|
259
|
-
const
|
|
272
|
+
function printInitNextSteps(options) {
|
|
273
|
+
const cli = getCliName();
|
|
274
|
+
const sections = [
|
|
275
|
+
{
|
|
276
|
+
label: 'Try the demo on a sample app',
|
|
277
|
+
commands: [{ command: `${cli} explore https://todomvc.com/examples/react/dist/` }],
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
if (options.url) {
|
|
281
|
+
sections.push({
|
|
282
|
+
label: 'Explore your app',
|
|
283
|
+
commands: [
|
|
284
|
+
{ label: 'Whole site', command: `${cli} explore ${options.url}` },
|
|
285
|
+
{ label: 'One page', command: `${cli} explore /dashboard` },
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (options.siteCommands)
|
|
290
|
+
sections.push({ label: 'Explore any site', commands: options.siteCommands });
|
|
291
|
+
sections.push({
|
|
292
|
+
label: 'Run it from VS Code',
|
|
293
|
+
commands: [{ command: 'https://testomat.ai/explorbot' }],
|
|
294
|
+
}, {
|
|
295
|
+
label: 'Docs',
|
|
296
|
+
commands: [{ command: 'https://testomat.ai/docs/explorbot' }],
|
|
297
|
+
});
|
|
298
|
+
printNextSteps(sections);
|
|
299
|
+
log('');
|
|
300
|
+
log('Add knowledge (logins, credentials) — created on first use in ./knowledge:');
|
|
301
|
+
tag('substep').log(chalk.yellow(`${cli} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
302
|
+
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
303
|
+
}
|
|
304
|
+
function writeEnvKey(key, value, envPath = globalEnvPath()) {
|
|
260
305
|
let content = '# AI provider API keys';
|
|
261
306
|
if (existsSync(envPath))
|
|
262
307
|
content = readFileSync(envPath, 'utf8').trimEnd();
|
|
@@ -268,3 +313,9 @@ function writeEnvKey(key, value) {
|
|
|
268
313
|
lines[index] = `${key}=${value}`;
|
|
269
314
|
writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
|
|
270
315
|
}
|
|
316
|
+
function printApiKeySteps(provider, envPath) {
|
|
317
|
+
const { envKey, keyUrl } = PROVIDERS[provider];
|
|
318
|
+
tag('warning').log(`No API key yet — explorbot needs one to connect to ${provider}`);
|
|
319
|
+
tag('substep').log(`1. Create a key at ${keyUrl}`);
|
|
320
|
+
tag('substep').log(`2. Paste it into ${envPath}: ${envKey}=<your-key>`);
|
|
321
|
+
}
|
|
@@ -5,7 +5,11 @@ interface InitWizardProps {
|
|
|
5
5
|
onLocal: () => void;
|
|
6
6
|
onComplete: () => void;
|
|
7
7
|
onCancel: () => void;
|
|
8
|
-
|
|
8
|
+
onLocalSetup?: (setup: {
|
|
9
|
+
provider: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}) => void;
|
|
9
13
|
}
|
|
10
14
|
declare const InitWizard: React.FC<InitWizardProps>;
|
|
11
15
|
export default InitWizard;
|
|
@@ -7,16 +7,23 @@ import { ConfigParser, PROVIDERS, createModel } from '../config.js';
|
|
|
7
7
|
import { globalDir } from '../global-config.js';
|
|
8
8
|
import InputReadline from './InputReadline.js';
|
|
9
9
|
const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
|
10
|
-
const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel,
|
|
10
|
+
const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalSetup }) => {
|
|
11
11
|
const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
|
|
12
12
|
const [targetIndex, setTargetIndex] = useState(0);
|
|
13
13
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
14
14
|
const [apiKey, setApiKey] = useState('');
|
|
15
|
+
const [url, setUrl] = useState('');
|
|
16
|
+
const [urlError, setUrlError] = useState('');
|
|
15
17
|
const [status, setStatus] = useState('');
|
|
16
18
|
const [error, setError] = useState('');
|
|
17
19
|
const provider = PROVIDER_NAMES[providerIndex];
|
|
18
20
|
const envKey = PROVIDERS[provider].envKey;
|
|
19
21
|
const save = () => {
|
|
22
|
+
if (mode === 'local') {
|
|
23
|
+
setStatus('');
|
|
24
|
+
setStep('url');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
20
27
|
writeGlobalConfig(provider, apiKey.trim());
|
|
21
28
|
onComplete();
|
|
22
29
|
};
|
|
@@ -45,7 +52,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
|
|
|
45
52
|
onCancel();
|
|
46
53
|
return;
|
|
47
54
|
}
|
|
48
|
-
if (step === 'key')
|
|
55
|
+
if (step === 'key' || step === 'url')
|
|
49
56
|
return;
|
|
50
57
|
if (key.escape) {
|
|
51
58
|
onCancel();
|
|
@@ -67,12 +74,8 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
|
|
|
67
74
|
setProviderIndex((index) => Math.max(0, index - 1));
|
|
68
75
|
if (key.downArrow)
|
|
69
76
|
setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
|
|
70
|
-
if (key.return)
|
|
71
|
-
|
|
72
|
-
onLocalProvider?.(provider);
|
|
73
|
-
else
|
|
74
|
-
setStep('key');
|
|
75
|
-
}
|
|
77
|
+
if (key.return)
|
|
78
|
+
setStep('key');
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
81
|
if (status)
|
|
@@ -109,10 +112,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
|
|
|
109
112
|
React.createElement(Text, null,
|
|
110
113
|
"Enter the API key for ",
|
|
111
114
|
provider,
|
|
112
|
-
|
|
115
|
+
' ',
|
|
113
116
|
React.createElement(Text, { dimColor: true },
|
|
114
117
|
"(stored as ",
|
|
115
118
|
envKey,
|
|
119
|
+
", create one at ",
|
|
120
|
+
PROVIDERS[provider].keyUrl,
|
|
116
121
|
")")),
|
|
117
122
|
React.createElement(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1 },
|
|
118
123
|
React.createElement(InputReadline, { value: apiKey, onChange: setApiKey, onSubmit: () => setStep('validate'), placeholder: process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key', isActive: true, showPrompt: false, mask: true })))),
|
|
@@ -121,13 +126,26 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
|
|
|
121
126
|
status && React.createElement(Text, { color: "yellow" }, status),
|
|
122
127
|
error && (React.createElement(Box, { flexDirection: "column" },
|
|
123
128
|
React.createElement(Text, { color: "red" }, error),
|
|
124
|
-
React.createElement(Text, { dimColor: true }, "r: re-enter the key | n:
|
|
129
|
+
React.createElement(Text, { dimColor: true }, "r: re-enter the key | n: continue anyway"))))),
|
|
130
|
+
step === 'url' && (React.createElement(Box, { flexDirection: "column" },
|
|
131
|
+
React.createElement(Text, null,
|
|
132
|
+
"What is the URL of the web application to test? ",
|
|
133
|
+
React.createElement(Text, { dimColor: true }, "(written to web.url in the config)")),
|
|
134
|
+
React.createElement(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1 },
|
|
135
|
+
React.createElement(InputReadline, { value: url, onChange: setUrl, onSubmit: () => {
|
|
136
|
+
if (!URL.canParse(url.trim())) {
|
|
137
|
+
setUrlError('Enter a full URL, e.g. http://localhost:3000');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
onLocalSetup?.({ provider, apiKey: apiKey.trim(), url: url.trim() });
|
|
141
|
+
}, placeholder: "http://localhost:3000", isActive: true, showPrompt: false })),
|
|
142
|
+
urlError && React.createElement(Text, { color: "red" }, urlError))),
|
|
125
143
|
React.createElement(Box, { marginTop: 1 },
|
|
126
144
|
React.createElement(Text, { dimColor: true },
|
|
127
145
|
"Config goes to ",
|
|
128
146
|
mode === 'local' ? 'the current directory' : globalDir(),
|
|
129
147
|
" | ",
|
|
130
|
-
step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
|
|
148
|
+
step === 'key' || step === 'url' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
|
|
131
149
|
" | Ctrl+C: exit"))));
|
|
132
150
|
};
|
|
133
151
|
function defaultModelId(provider) {
|
package/dist/src/config.d.ts
CHANGED
|
@@ -141,6 +141,7 @@ interface AgentsConfig {
|
|
|
141
141
|
}
|
|
142
142
|
interface AIConfig {
|
|
143
143
|
model: any;
|
|
144
|
+
recommendedModels?: string;
|
|
144
145
|
apiKey?: string;
|
|
145
146
|
config?: Record<string, any>;
|
|
146
147
|
langfuse?: {
|
|
@@ -315,6 +316,7 @@ interface ConfiguredModel {
|
|
|
315
316
|
}
|
|
316
317
|
interface ProviderInfo {
|
|
317
318
|
envKey: string;
|
|
319
|
+
keyUrl: string;
|
|
318
320
|
load: () => Promise<(modelId: string) => any>;
|
|
319
321
|
}
|
|
320
322
|
interface EnvVar {
|
package/dist/src/config.js
CHANGED
|
@@ -18,13 +18,13 @@ import { getCliName } from './utils/cli-name.js';
|
|
|
18
18
|
import { deepMerge } from './utils/merge.js';
|
|
19
19
|
import { log, tag } from './utils/logger.js';
|
|
20
20
|
export const PROVIDERS = {
|
|
21
|
-
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
22
|
-
anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
23
|
-
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
24
|
-
groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
25
|
-
mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
26
|
-
openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
27
|
-
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
21
|
+
openai: { envKey: 'OPENAI_API_KEY', keyUrl: 'https://platform.openai.com/api-keys', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
22
|
+
anthropic: { envKey: 'ANTHROPIC_API_KEY', keyUrl: 'https://console.anthropic.com/settings/keys', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
23
|
+
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', keyUrl: 'https://aistudio.google.com/apikey', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
24
|
+
groq: { envKey: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
25
|
+
mistral: { envKey: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
26
|
+
openrouter: { envKey: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
27
|
+
sambanova: { envKey: 'SAMBANOVA_API_KEY', keyUrl: 'https://cloud.sambanova.ai/apis', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
28
28
|
};
|
|
29
29
|
export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
|
|
30
30
|
let cachedOutputRoot = null;
|
|
@@ -572,11 +572,33 @@ export async function resolveConfigModels(ai) {
|
|
|
572
572
|
if (typeof ai[role] === 'string')
|
|
573
573
|
ai[role] = await resolveModel(ai[role], role);
|
|
574
574
|
}
|
|
575
|
+
await fillRecommendedModels(ai);
|
|
575
576
|
for (const agent of Object.values(ai.agents || {})) {
|
|
576
577
|
if (typeof agent?.model === 'string')
|
|
577
578
|
agent.model = await resolveModel(agent.model);
|
|
578
579
|
}
|
|
579
580
|
}
|
|
581
|
+
async function fillRecommendedModels(ai) {
|
|
582
|
+
const provider = ai.recommendedModels;
|
|
583
|
+
if (!provider)
|
|
584
|
+
return;
|
|
585
|
+
const recommended = ConfigParser.recommendedModels()[provider];
|
|
586
|
+
if (!recommended) {
|
|
587
|
+
throw new Error(`No recommended models for "${provider}". Use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
|
|
588
|
+
}
|
|
589
|
+
for (const role of MODEL_ROLES) {
|
|
590
|
+
if (ai[role])
|
|
591
|
+
continue;
|
|
592
|
+
if (!recommended[role])
|
|
593
|
+
continue;
|
|
594
|
+
ai[role] = await resolveModel(provider, role);
|
|
595
|
+
}
|
|
596
|
+
if (!ai.decisionModel && recommended.decisionModel)
|
|
597
|
+
ai.decisionModel = { provider, model: recommended.decisionModel };
|
|
598
|
+
const missing = missingModelRoles(provider);
|
|
599
|
+
if (missing.length)
|
|
600
|
+
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set them explicitly as "provider/model-id"`);
|
|
601
|
+
}
|
|
580
602
|
export function resolveLangfuse(ai) {
|
|
581
603
|
if (!ai)
|
|
582
604
|
return;
|
|
@@ -15,21 +15,28 @@ You need Node.js 24+ (or Bun), an AI provider key, and a modern terminal — iTe
|
|
|
15
15
|
|
|
16
16
|
## 2. Configure
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Run the setup wizard:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
npx explorbot init
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
It asks for your AI provider, its API key, and your app's URL, then writes everything for you: `explorbot.config.js`, an `.env` file with the key stored, and an `output/` folder. No manual file editing.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The generated config keeps models simple — one line picks the recommended set for your provider:
|
|
27
27
|
|
|
28
|
-
```
|
|
29
|
-
|
|
28
|
+
```javascript
|
|
29
|
+
export default {
|
|
30
|
+
web: {
|
|
31
|
+
url: 'http://localhost:3000',
|
|
32
|
+
},
|
|
33
|
+
ai: {
|
|
34
|
+
recommendedModels: 'openrouter',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
30
37
|
```
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
`ai.recommendedModels` fills every model role from the provider's bundled recommendations. To pin a single role instead, write it as `'provider/model-id'` — it overrides the recommendation:
|
|
33
40
|
|
|
34
41
|
```javascript
|
|
35
42
|
export default {
|
|
@@ -37,10 +44,8 @@ export default {
|
|
|
37
44
|
url: 'http://localhost:3000',
|
|
38
45
|
},
|
|
39
46
|
ai: {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
agenticModel: 'openrouter/anthropic/claude-haiku-5.5',
|
|
43
|
-
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
|
|
47
|
+
recommendedModels: 'openrouter',
|
|
48
|
+
agenticModel: 'groq/openai/gpt-oss-120b',
|
|
44
49
|
},
|
|
45
50
|
};
|
|
46
51
|
```
|
|
@@ -84,6 +89,14 @@ Explorbot uses three models. Pick each one for speed and cost:
|
|
|
84
89
|
|
|
85
90
|
Captain and Pilot barely use tokens, so a smarter `agenticModel` improves results for almost no extra cost. OpenRouter is the simplest start — one key, many models. To use OpenAI, Anthropic, Groq, or others, see [Providers](./providers.md). For every config option, see [Configuration](../reference/configuration.md).
|
|
86
91
|
|
|
92
|
+
Want to see Explorbot work before pointing it at your app? Run it on the sample app:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx explorbot explore https://todomvc.com/examples/react/dist/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
You can also run it from the [VS Code extension](https://testomat.ai/explorbot) — pick a site, start a bot, and watch it test without leaving the editor. Full documentation lives at [testomat.ai/docs/explorbot](https://testomat.ai/docs/explorbot).
|
|
99
|
+
|
|
87
100
|
## 3. Tell Explorbot how to log in
|
|
88
101
|
|
|
89
102
|
Most apps need a login. Give Explorbot the credentials once, and it signs in on its own:
|
package/docs/basics/providers.md
CHANGED
|
@@ -4,6 +4,8 @@ Explorbot connects to AI providers through the [Vercel AI SDK](https://sdk.verce
|
|
|
4
4
|
|
|
5
5
|
Every provider below is set up the classical way: install its package, import it, build the client. Explorbot bundles some of these packages — for those you can skip the install and name the model as `'provider/model-id'` instead. See [Getting Started](./getting-started.md#2-configure) for that list and the two styles side by side.
|
|
6
6
|
|
|
7
|
+
A third, shortest style names just the provider: `ai: { recommendedModels: 'openrouter' }` fills every unset role from that provider's bundled recommendations. An explicit role set alongside it wins, so `recommendedModels` with a pinned `agenticModel` mixes both styles.
|
|
8
|
+
|
|
7
9
|
> The `export default` config block inside each `<!-- START/END provider -->` marker is generated from [`models.json`](../../models.json). After editing that file, run `bunosh docs:sync`. Everything else — including the import blocks — is hand-written.
|
|
8
10
|
|
|
9
11
|
## Requirements
|
|
@@ -920,7 +920,9 @@ npx explorbot init --config-path ./explorbot.config.js
|
|
|
920
920
|
npx explorbot init --force
|
|
921
921
|
```
|
|
922
922
|
|
|
923
|
-
|
|
923
|
+
The local wizard picks the provider, takes the API key (optionally checked with one test AI call), and asks for your app's URL — then writes everything: the key goes into `.env`, the URL into `web.url`, and the models come from `ai.recommendedModels: '<provider>'`. Nothing is left to edit by hand.
|
|
924
|
+
|
|
925
|
+
Passing `--config-path` or `--path` means local, and so does running outside a terminal (agents, CI): the chooser is skipped and the project config is written with placeholder values instead. Init then prints the exact paths of the files that still need attention, a demo command to try Explorbot on a sample app (`npx explorbot explore https://todomvc.com/examples/react/dist/`), and links to the [VS Code extension](https://testomat.ai/explorbot) and the [docs](https://testomat.ai/docs/explorbot).
|
|
924
926
|
|
|
925
927
|
`--global` runs the global wizard instead — pick a provider, paste the API key, optionally check it with one test AI call. The wizard writes `~/.explorbot/config.js` with the recommended model ids of this Explorbot version and stores the key in `~/.explorbot/.env`.
|
|
926
928
|
|
|
@@ -939,7 +941,8 @@ The global config holds models and keys, never a site: every command names the s
|
|
|
939
941
|
| `-p, --path <path>` | Working directory for initialization |
|
|
940
942
|
| `-g, --global` | Configure `~/.explorbot` to run from anywhere |
|
|
941
943
|
| `--provider <name>` | AI provider for the global config, skips the wizard |
|
|
942
|
-
| `--api-key <key>` | API key stored in `~/.explorbot/.env` |
|
|
944
|
+
| `--api-key <key>` | API key stored in `~/.explorbot/.env` (local init: written to the project `.env`) |
|
|
945
|
+
| `--url <url>` | Web application URL written to `web.url` in the local config |
|
|
943
946
|
|
|
944
947
|
See [Configuration](configuration.md#running-from-anywhere-the-global-installation) for the directory layout and how a site is resolved.
|
|
945
948
|
|
|
@@ -536,6 +536,7 @@ export default {
|
|
|
536
536
|
// AI provider settings
|
|
537
537
|
ai: {
|
|
538
538
|
model: groq('openai/gpt-oss-20b'), // Default model instance (required)
|
|
539
|
+
recommendedModels: 'openrouter', // Fills every unset role from this provider's bundled recommendations; explicit roles win
|
|
539
540
|
visionModel: groq('meta-llama/llama-4-scout-17b-16e-instruct'), // Model for screenshot analysis; setting it enables vision features
|
|
540
541
|
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-1.13' }, // Optional; see providers docs
|
|
541
542
|
config: {}, // Additional provider config
|
package/package.json
CHANGED
|
@@ -583,6 +583,14 @@ export class ExploreCommand extends BaseCommand {
|
|
|
583
583
|
});
|
|
584
584
|
}
|
|
585
585
|
|
|
586
|
+
sections.push({
|
|
587
|
+
label: 'Explore more',
|
|
588
|
+
commands: [
|
|
589
|
+
{ label: 'Whole site', command: `${cli} explore /` },
|
|
590
|
+
{ label: 'Docs', command: 'https://testomat.ai/docs/explorbot' },
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
|
|
586
594
|
printNextSteps(sections);
|
|
587
595
|
}
|
|
588
596
|
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
+
import figureSet from 'figures';
|
|
4
5
|
import { ConfigParser, type ModelRole, PROVIDERS, missingModelRoles } from '../config.ts';
|
|
5
6
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from '../global-config.ts';
|
|
6
7
|
import { getCliName } from '../utils/cli-name.ts';
|
|
7
8
|
import { log, tag } from '../utils/logger.js';
|
|
8
|
-
import { relativeToCwd } from '../utils/next-steps.ts';
|
|
9
|
+
import { type NextStepCommand, type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
|
|
9
10
|
|
|
10
|
-
function defaultConfigTemplate(provider: string): string {
|
|
11
|
+
function defaultConfigTemplate(provider: string, url?: string): string {
|
|
12
|
+
const appUrl = url || 'http://<your-app-host-here>';
|
|
11
13
|
return `// 'provider/model-id' uses a bundled provider.
|
|
12
14
|
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
13
|
-
// https://
|
|
15
|
+
// https://testomat.ai/docs/explorbot
|
|
14
16
|
|
|
15
17
|
const config = {
|
|
16
18
|
web: {
|
|
17
19
|
// use application host without path prefix (e.g., http://localhost:3000)
|
|
18
|
-
url: '
|
|
20
|
+
url: '${appUrl}',
|
|
19
21
|
},
|
|
20
22
|
|
|
21
23
|
ai: {
|
|
22
|
-
${
|
|
24
|
+
// every role (model, visionModel, agenticModel) takes the recommended ${provider} models bundled with this version
|
|
25
|
+
// override a single role as 'provider/model-id':
|
|
26
|
+
// model: '${provider}/<model-id>',
|
|
27
|
+
recommendedModels: '${provider}',
|
|
23
28
|
},
|
|
24
29
|
|
|
25
30
|
reporter: {
|
|
@@ -37,8 +42,9 @@ export default config;
|
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function envTemplate(provider: string): string {
|
|
45
|
+
const { keyUrl } = PROVIDERS[provider];
|
|
40
46
|
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
41
|
-
if (name === provider) return
|
|
47
|
+
if (name === provider) return `# Create a key at ${keyUrl} and paste it after '='\n${envKey}=`;
|
|
42
48
|
return `# ${envKey}=`;
|
|
43
49
|
});
|
|
44
50
|
|
|
@@ -69,10 +75,10 @@ export async function runInit(options: InitCommandOptions): Promise<void> {
|
|
|
69
75
|
const choice = await renderInitWizard('choose');
|
|
70
76
|
if (choice !== 'local') return;
|
|
71
77
|
|
|
72
|
-
const
|
|
73
|
-
if (!
|
|
78
|
+
const setup = await renderLocalSetupWizard();
|
|
79
|
+
if (!setup) return;
|
|
74
80
|
|
|
75
|
-
runInitCommand({ ...options,
|
|
81
|
+
runInitCommand({ ...options, ...setup });
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
@@ -82,24 +88,28 @@ export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
|
82
88
|
|
|
83
89
|
mkdirSync(globalDir(), { recursive: true });
|
|
84
90
|
writeFileSync(globalConfigPath(), globalConfigTemplate(provider), 'utf8');
|
|
85
|
-
log(
|
|
91
|
+
tag('success').log(`${figureSet.tick} Created global config: ${globalConfigPath()}`);
|
|
86
92
|
|
|
87
93
|
const envKey = PROVIDERS[provider].envKey;
|
|
88
94
|
writeEnvKey(envKey, apiKey || '');
|
|
89
|
-
log(
|
|
95
|
+
tag('success').log(`${figureSet.tick} Stored ${envKey} in ${globalEnvPath()}`);
|
|
90
96
|
|
|
91
97
|
const missing = missingModelRoles(provider);
|
|
92
98
|
if (missing.length) {
|
|
93
99
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
|
|
94
100
|
}
|
|
95
101
|
if (!apiKey && !process.env[envKey]) {
|
|
96
|
-
|
|
102
|
+
printApiKeySteps(provider, globalEnvPath());
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
log('');
|
|
100
|
-
log('
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
log(`${figureSet.star} What's next:`);
|
|
107
|
+
printInitNextSteps({
|
|
108
|
+
siteCommands: [
|
|
109
|
+
{ label: 'Any site', command: `${getCliName()} explore https://your-app.example.com` },
|
|
110
|
+
{ label: 'Saved sites', command: `${getCliName()} sites` },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
export function runInitCommand(options: InitCommandOptions): void {
|
|
@@ -141,38 +151,42 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
141
151
|
process.exit(1);
|
|
142
152
|
}
|
|
143
153
|
|
|
144
|
-
writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
|
|
145
|
-
log(
|
|
154
|
+
writeFileSync(outPath, defaultConfigTemplate(provider, options.url), 'utf8');
|
|
155
|
+
tag('success').log(`${figureSet.tick} Created config file: ${relativeToCwd(outPath)}`);
|
|
146
156
|
|
|
147
157
|
const envPath = resolve(process.cwd(), '.env');
|
|
148
158
|
if (!existsSync(envPath)) {
|
|
149
159
|
writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
|
|
150
|
-
log(
|
|
160
|
+
tag('success').log(`${figureSet.tick} Created env file: ${relativeToCwd(envPath)}`);
|
|
151
161
|
} else {
|
|
152
162
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
153
163
|
}
|
|
154
164
|
|
|
165
|
+
const envKey = PROVIDERS[provider].envKey;
|
|
166
|
+
if (options.apiKey) {
|
|
167
|
+
writeEnvKey(envKey, options.apiKey, envPath);
|
|
168
|
+
tag('success').log(`${figureSet.tick} Stored ${envKey} in ${relativeToCwd(envPath)}`);
|
|
169
|
+
}
|
|
170
|
+
if (!options.apiKey && !process.env[envKey]) {
|
|
171
|
+
printApiKeySteps(provider, relativeToCwd(envPath));
|
|
172
|
+
}
|
|
173
|
+
if (!options.url) {
|
|
174
|
+
tag('warning').log(`Set your application URL in web.url: ${relativeToCwd(outPath)}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
const missing = missingModelRoles(provider);
|
|
156
178
|
if (missing.length) {
|
|
157
179
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
158
180
|
}
|
|
159
181
|
|
|
160
|
-
log('');
|
|
161
|
-
log('Next steps:');
|
|
162
|
-
log('1. Configure AI provider in .env');
|
|
163
|
-
log('2. Set AI models config file');
|
|
164
|
-
log('3. Set web application URL in the config file');
|
|
165
|
-
log('4. Add initial knowledge (how to authorize to the application, etc.)');
|
|
166
|
-
tag('substep').log(chalk.yellow(`${getCliName()} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
167
|
-
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
168
|
-
|
|
169
|
-
log('5. Launch application on a relative URL');
|
|
170
|
-
tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
|
|
171
|
-
|
|
172
182
|
if (!existsSync('./output')) {
|
|
173
183
|
mkdirSync('./output', { recursive: true });
|
|
174
|
-
log(
|
|
184
|
+
tag('success').log(`${figureSet.tick} Created directory: output`);
|
|
175
185
|
}
|
|
186
|
+
|
|
187
|
+
log('');
|
|
188
|
+
log(`${figureSet.star} What's next:`);
|
|
189
|
+
printInitNextSteps({ url: options.url });
|
|
176
190
|
} catch (error) {
|
|
177
191
|
log('Failed to create config file:', error);
|
|
178
192
|
process.exit(1);
|
|
@@ -227,13 +241,13 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
|
|
|
227
241
|
});
|
|
228
242
|
}
|
|
229
243
|
|
|
230
|
-
async function
|
|
244
|
+
async function renderLocalSetupWizard(): Promise<LocalSetup | null> {
|
|
231
245
|
const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
|
|
232
246
|
|
|
233
247
|
return new Promise((resolve) => {
|
|
234
|
-
const finish = (
|
|
248
|
+
const finish = (setup: LocalSetup | null) => {
|
|
235
249
|
unmount();
|
|
236
|
-
resolve(
|
|
250
|
+
resolve(setup);
|
|
237
251
|
};
|
|
238
252
|
const { unmount } = render(
|
|
239
253
|
React.createElement(InitWizard, {
|
|
@@ -242,7 +256,7 @@ async function renderLocalProviderWizard(): Promise<string | null> {
|
|
|
242
256
|
onLocal: () => finish(null),
|
|
243
257
|
onComplete: () => finish(null),
|
|
244
258
|
onCancel: () => finish(null),
|
|
245
|
-
|
|
259
|
+
onLocalSetup: (setup) => finish(setup),
|
|
246
260
|
}),
|
|
247
261
|
{ exitOnCtrlC: false, patchConsole: false }
|
|
248
262
|
);
|
|
@@ -274,7 +288,7 @@ function globalConfigTemplate(provider: string): string {
|
|
|
274
288
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
275
289
|
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
276
290
|
// Model ids are snapshotted from the recommendations of this Explorbot version.
|
|
277
|
-
// https://
|
|
291
|
+
// https://testomat.ai/docs/explorbot
|
|
278
292
|
const config = {
|
|
279
293
|
ai: {
|
|
280
294
|
${modelLines(provider)}
|
|
@@ -292,8 +306,47 @@ export default config;
|
|
|
292
306
|
`;
|
|
293
307
|
}
|
|
294
308
|
|
|
295
|
-
function
|
|
296
|
-
const
|
|
309
|
+
function printInitNextSteps(options: { url?: string; siteCommands?: NextStepCommand[] }): void {
|
|
310
|
+
const cli = getCliName();
|
|
311
|
+
const sections: NextStepSection[] = [
|
|
312
|
+
{
|
|
313
|
+
label: 'Try the demo on a sample app',
|
|
314
|
+
commands: [{ command: `${cli} explore https://todomvc.com/examples/react/dist/` }],
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
if (options.url) {
|
|
319
|
+
sections.push({
|
|
320
|
+
label: 'Explore your app',
|
|
321
|
+
commands: [
|
|
322
|
+
{ label: 'Whole site', command: `${cli} explore ${options.url}` },
|
|
323
|
+
{ label: 'One page', command: `${cli} explore /dashboard` },
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (options.siteCommands) sections.push({ label: 'Explore any site', commands: options.siteCommands });
|
|
329
|
+
|
|
330
|
+
sections.push(
|
|
331
|
+
{
|
|
332
|
+
label: 'Run it from VS Code',
|
|
333
|
+
commands: [{ command: 'https://testomat.ai/explorbot' }],
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
label: 'Docs',
|
|
337
|
+
commands: [{ command: 'https://testomat.ai/docs/explorbot' }],
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
printNextSteps(sections);
|
|
342
|
+
|
|
343
|
+
log('');
|
|
344
|
+
log('Add knowledge (logins, credentials) — created on first use in ./knowledge:');
|
|
345
|
+
tag('substep').log(chalk.yellow(`${cli} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
346
|
+
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function writeEnvKey(key: string, value: string, envPath = globalEnvPath()): void {
|
|
297
350
|
let content = '# AI provider API keys';
|
|
298
351
|
if (existsSync(envPath)) content = readFileSync(envPath, 'utf8').trimEnd();
|
|
299
352
|
|
|
@@ -306,6 +359,19 @@ function writeEnvKey(key: string, value: string): void {
|
|
|
306
359
|
writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
|
|
307
360
|
}
|
|
308
361
|
|
|
362
|
+
function printApiKeySteps(provider: string, envPath: string): void {
|
|
363
|
+
const { envKey, keyUrl } = PROVIDERS[provider];
|
|
364
|
+
tag('warning').log(`No API key yet — explorbot needs one to connect to ${provider}`);
|
|
365
|
+
tag('substep').log(`1. Create a key at ${keyUrl}`);
|
|
366
|
+
tag('substep').log(`2. Paste it into ${envPath}: ${envKey}=<your-key>`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
type LocalSetup = {
|
|
370
|
+
provider: string;
|
|
371
|
+
apiKey: string;
|
|
372
|
+
url: string;
|
|
373
|
+
};
|
|
374
|
+
|
|
309
375
|
type InitCommandOptions = {
|
|
310
376
|
configPath?: string;
|
|
311
377
|
force?: boolean;
|
|
@@ -313,4 +379,5 @@ type InitCommandOptions = {
|
|
|
313
379
|
global?: boolean;
|
|
314
380
|
provider?: string;
|
|
315
381
|
apiKey?: string;
|
|
382
|
+
url?: string;
|
|
316
383
|
};
|
|
@@ -15,14 +15,16 @@ interface InitWizardProps {
|
|
|
15
15
|
onLocal: () => void;
|
|
16
16
|
onComplete: () => void;
|
|
17
17
|
onCancel: () => void;
|
|
18
|
-
|
|
18
|
+
onLocalSetup?: (setup: { provider: string; apiKey: string; url: string }) => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel,
|
|
22
|
-
const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate'>(mode === 'choose' ? 'target' : 'provider');
|
|
21
|
+
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalSetup }) => {
|
|
22
|
+
const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate' | 'url'>(mode === 'choose' ? 'target' : 'provider');
|
|
23
23
|
const [targetIndex, setTargetIndex] = useState(0);
|
|
24
24
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
25
25
|
const [apiKey, setApiKey] = useState('');
|
|
26
|
+
const [url, setUrl] = useState('');
|
|
27
|
+
const [urlError, setUrlError] = useState('');
|
|
26
28
|
const [status, setStatus] = useState('');
|
|
27
29
|
const [error, setError] = useState('');
|
|
28
30
|
|
|
@@ -30,6 +32,11 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
30
32
|
const envKey = PROVIDERS[provider].envKey;
|
|
31
33
|
|
|
32
34
|
const save = () => {
|
|
35
|
+
if (mode === 'local') {
|
|
36
|
+
setStatus('');
|
|
37
|
+
setStep('url');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
33
40
|
writeGlobalConfig(provider, apiKey.trim());
|
|
34
41
|
onComplete();
|
|
35
42
|
};
|
|
@@ -61,7 +68,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
61
68
|
return;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
if (step === 'key') return;
|
|
71
|
+
if (step === 'key' || step === 'url') return;
|
|
65
72
|
|
|
66
73
|
if (key.escape) {
|
|
67
74
|
onCancel();
|
|
@@ -79,10 +86,7 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
79
86
|
if (step === 'provider') {
|
|
80
87
|
if (key.upArrow) setProviderIndex((index) => Math.max(0, index - 1));
|
|
81
88
|
if (key.downArrow) setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
|
|
82
|
-
if (key.return)
|
|
83
|
-
if (mode === 'local') onLocalProvider?.(provider);
|
|
84
|
-
else setStep('key');
|
|
85
|
-
}
|
|
89
|
+
if (key.return) setStep('key');
|
|
86
90
|
return;
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -132,7 +136,10 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
132
136
|
{step === 'key' && (
|
|
133
137
|
<Box flexDirection="column">
|
|
134
138
|
<Text>
|
|
135
|
-
Enter the API key for {provider}
|
|
139
|
+
Enter the API key for {provider}{' '}
|
|
140
|
+
<Text dimColor>
|
|
141
|
+
(stored as {envKey}, create one at {PROVIDERS[provider].keyUrl})
|
|
142
|
+
</Text>
|
|
136
143
|
</Text>
|
|
137
144
|
<Box borderStyle="single" borderColor="blue" paddingX={1}>
|
|
138
145
|
<InputReadline value={apiKey} onChange={setApiKey} onSubmit={() => setStep('validate')} placeholder={process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key'} isActive showPrompt={false} mask />
|
|
@@ -147,15 +154,40 @@ const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLoc
|
|
|
147
154
|
{error && (
|
|
148
155
|
<Box flexDirection="column">
|
|
149
156
|
<Text color="red">{error}</Text>
|
|
150
|
-
<Text dimColor>r: re-enter the key | n:
|
|
157
|
+
<Text dimColor>r: re-enter the key | n: continue anyway</Text>
|
|
151
158
|
</Box>
|
|
152
159
|
)}
|
|
153
160
|
</Box>
|
|
154
161
|
)}
|
|
155
162
|
|
|
163
|
+
{step === 'url' && (
|
|
164
|
+
<Box flexDirection="column">
|
|
165
|
+
<Text>
|
|
166
|
+
What is the URL of the web application to test? <Text dimColor>(written to web.url in the config)</Text>
|
|
167
|
+
</Text>
|
|
168
|
+
<Box borderStyle="single" borderColor="blue" paddingX={1}>
|
|
169
|
+
<InputReadline
|
|
170
|
+
value={url}
|
|
171
|
+
onChange={setUrl}
|
|
172
|
+
onSubmit={() => {
|
|
173
|
+
if (!URL.canParse(url.trim())) {
|
|
174
|
+
setUrlError('Enter a full URL, e.g. http://localhost:3000');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
onLocalSetup?.({ provider, apiKey: apiKey.trim(), url: url.trim() });
|
|
178
|
+
}}
|
|
179
|
+
placeholder="http://localhost:3000"
|
|
180
|
+
isActive
|
|
181
|
+
showPrompt={false}
|
|
182
|
+
/>
|
|
183
|
+
</Box>
|
|
184
|
+
{urlError && <Text color="red">{urlError}</Text>}
|
|
185
|
+
</Box>
|
|
186
|
+
)}
|
|
187
|
+
|
|
156
188
|
<Box marginTop={1}>
|
|
157
189
|
<Text dimColor>
|
|
158
|
-
Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
190
|
+
Config goes to {mode === 'local' ? 'the current directory' : globalDir()} | {step === 'key' || step === 'url' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
159
191
|
</Text>
|
|
160
192
|
</Box>
|
|
161
193
|
</Box>
|
package/src/config.ts
CHANGED
|
@@ -11,13 +11,13 @@ import { deepMerge } from './utils/merge.js';
|
|
|
11
11
|
import { log, tag } from './utils/logger.js';
|
|
12
12
|
|
|
13
13
|
export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
14
|
-
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
15
|
-
anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
16
|
-
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
17
|
-
groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
18
|
-
mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
19
|
-
openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
20
|
-
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
14
|
+
openai: { envKey: 'OPENAI_API_KEY', keyUrl: 'https://platform.openai.com/api-keys', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
15
|
+
anthropic: { envKey: 'ANTHROPIC_API_KEY', keyUrl: 'https://console.anthropic.com/settings/keys', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
|
|
16
|
+
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', keyUrl: 'https://aistudio.google.com/apikey', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
|
|
17
|
+
groq: { envKey: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys', load: async () => (await import('@ai-sdk/groq')).createGroq() },
|
|
18
|
+
mistral: { envKey: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
|
|
19
|
+
openrouter: { envKey: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
20
|
+
sambanova: { envKey: 'SAMBANOVA_API_KEY', keyUrl: 'https://cloud.sambanova.ai/apis', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
@@ -169,6 +169,7 @@ interface AgentsConfig {
|
|
|
169
169
|
|
|
170
170
|
interface AIConfig {
|
|
171
171
|
model: any;
|
|
172
|
+
recommendedModels?: string;
|
|
172
173
|
apiKey?: string;
|
|
173
174
|
config?: Record<string, any>;
|
|
174
175
|
langfuse?: {
|
|
@@ -890,11 +891,34 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
|
890
891
|
if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
|
|
891
892
|
}
|
|
892
893
|
|
|
894
|
+
await fillRecommendedModels(ai);
|
|
895
|
+
|
|
893
896
|
for (const agent of Object.values(ai.agents || {})) {
|
|
894
897
|
if (typeof agent?.model === 'string') agent.model = await resolveModel(agent.model);
|
|
895
898
|
}
|
|
896
899
|
}
|
|
897
900
|
|
|
901
|
+
async function fillRecommendedModels(ai: AIConfig): Promise<void> {
|
|
902
|
+
const provider = ai.recommendedModels;
|
|
903
|
+
if (!provider) return;
|
|
904
|
+
|
|
905
|
+
const recommended = ConfigParser.recommendedModels()[provider];
|
|
906
|
+
if (!recommended) {
|
|
907
|
+
throw new Error(`No recommended models for "${provider}". Use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
for (const role of MODEL_ROLES) {
|
|
911
|
+
if (ai[role]) continue;
|
|
912
|
+
if (!recommended[role]) continue;
|
|
913
|
+
ai[role] = await resolveModel(provider, role);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (!ai.decisionModel && recommended.decisionModel) ai.decisionModel = { provider, model: recommended.decisionModel };
|
|
917
|
+
|
|
918
|
+
const missing = missingModelRoles(provider);
|
|
919
|
+
if (missing.length) tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set them explicitly as "provider/model-id"`);
|
|
920
|
+
}
|
|
921
|
+
|
|
898
922
|
export function resolveLangfuse(ai?: AIConfig): void {
|
|
899
923
|
if (!ai) return;
|
|
900
924
|
|
|
@@ -994,6 +1018,7 @@ interface ConfiguredModel {
|
|
|
994
1018
|
|
|
995
1019
|
interface ProviderInfo {
|
|
996
1020
|
envKey: string;
|
|
1021
|
+
keyUrl: string;
|
|
997
1022
|
load: () => Promise<(modelId: string) => any>;
|
|
998
1023
|
}
|
|
999
1024
|
|