explorbot 0.5.6 → 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 +17 -4
- package/boat/doc-collector/src/cli.ts +9 -3
- package/boat/doc-collector/src/docbot.ts +11 -2
- package/dist/bin/explorbot-cli.js +19 -5
- package/dist/boat/doc-collector/src/cli.js +9 -3
- package/dist/boat/doc-collector/src/docbot.js +13 -2
- package/dist/package.json +1 -1
- package/dist/src/ai/navigator.d.ts +2 -0
- package/dist/src/ai/navigator.js +10 -0
- package/dist/src/ai/pilot.js +9 -2
- package/dist/src/commands/explore-command.d.ts +5 -0
- package/dist/src/commands/explore-command.js +60 -23
- package/dist/src/commands/freesail-command.js +9 -1
- 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/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +5 -6
- package/dist/src/utils/sitemap.d.ts +11 -0
- package/dist/src/utils/sitemap.js +106 -0
- package/dist/src/utils/spec.d.ts +5 -0
- package/dist/src/utils/spec.js +16 -0
- package/dist/src/utils/url-filter.d.ts +10 -0
- package/dist/src/utils/url-filter.js +52 -0
- 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/ai/navigator.ts +13 -1
- package/src/ai/pilot.ts +9 -2
- package/src/commands/explore-command.ts +56 -23
- package/src/commands/freesail-command.ts +10 -1
- package/src/commands/init-command.ts +105 -38
- package/src/components/InitWizard.tsx +43 -11
- package/src/config.ts +32 -7
- package/src/test-plan.ts +6 -6
- package/src/utils/sitemap.ts +108 -0
- package/src/utils/spec.ts +21 -0
- package/src/utils/url-filter.ts +53 -0
|
@@ -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
|
|
package/src/test-plan.ts
CHANGED
|
@@ -331,10 +331,12 @@ export class Test extends Task {
|
|
|
331
331
|
});
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
hasAchieved(expectation: string): boolean {
|
|
335
|
+
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
336
|
+
}
|
|
337
|
+
|
|
334
338
|
hasAchievedAny(): boolean {
|
|
335
|
-
return this.expected.some((expectation) =>
|
|
336
|
-
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
337
|
-
});
|
|
339
|
+
return this.expected.some((expectation) => this.hasAchieved(expectation));
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
getRunResult(): 'success' | 'partial' | 'failed' {
|
|
@@ -344,9 +346,7 @@ export class Test extends Task {
|
|
|
344
346
|
}
|
|
345
347
|
|
|
346
348
|
hasAchievedAll(): boolean {
|
|
347
|
-
return this.expected.every((expectation) =>
|
|
348
|
-
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
349
|
-
});
|
|
349
|
+
return this.expected.every((expectation) => this.hasAchieved(expectation));
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
isComplete(): boolean {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { tag } from './logger.js';
|
|
3
|
+
import type { UrlFilter } from './url-filter.ts';
|
|
4
|
+
|
|
5
|
+
const ENTRY_PATTERN = /<(url|sitemap)\b[^>]*>(.*?)<\/\1>/gis;
|
|
6
|
+
const XML_ENTITIES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
7
|
+
const MAX_NESTED_SITEMAPS = 50;
|
|
8
|
+
const DEFAULT_PRIORITY = 0.5;
|
|
9
|
+
const STDIN_SOURCE = '-';
|
|
10
|
+
|
|
11
|
+
export async function readSitemap(source: string, filter?: UrlFilter): Promise<string[]> {
|
|
12
|
+
const entries: SitemapEntry[] = [];
|
|
13
|
+
const seen = new Set<string>();
|
|
14
|
+
const pending = [source];
|
|
15
|
+
let fetched = 0;
|
|
16
|
+
|
|
17
|
+
while (pending.length > 0) {
|
|
18
|
+
if (fetched >= MAX_NESTED_SITEMAPS) {
|
|
19
|
+
tag('warning').log(`Sitemap index lists more than ${MAX_NESTED_SITEMAPS} sitemaps, the rest are ignored`);
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
const next = pending.shift()!;
|
|
23
|
+
fetched++;
|
|
24
|
+
const content = await readSource(next);
|
|
25
|
+
const isIndex = content.includes('<sitemapindex');
|
|
26
|
+
for (const entry of extractEntries(content)) {
|
|
27
|
+
if (seen.has(entry.url)) continue;
|
|
28
|
+
seen.add(entry.url);
|
|
29
|
+
if (isIndex) {
|
|
30
|
+
pending.push(entry.url);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
entries.push(entry);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return applyFilter(entries, filter).map((entry) => entry.url);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function readCliSitemap(path: string | undefined, source: string | undefined, filter?: UrlFilter): Promise<string[] | undefined> {
|
|
41
|
+
const usage = 'Pass a page path, --sitemap <source>, or pipe a sitemap to stdin';
|
|
42
|
+
let resolved = source;
|
|
43
|
+
if (!resolved && !path && !process.stdin.isTTY) resolved = STDIN_SOURCE;
|
|
44
|
+
if (!resolved && !path) throw new Error(usage);
|
|
45
|
+
if (!resolved) return undefined;
|
|
46
|
+
const urls = await readSitemap(resolved, filter);
|
|
47
|
+
if (urls.length === 0 && !source) throw new Error(usage);
|
|
48
|
+
if (urls.length === 0) throw new Error(`No URLs left in sitemap ${resolved}`);
|
|
49
|
+
return urls;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function keepSiteUrls(urls: string[], siteUrl: string): string[] {
|
|
53
|
+
const siteOrigin = URL.parse(siteUrl)?.origin;
|
|
54
|
+
const kept = urls.filter((url) => url.startsWith('/') || URL.parse(url)?.origin === siteOrigin);
|
|
55
|
+
if (kept.length < urls.length) tag('warning').log(`Skipped ${urls.length - kept.length} sitemap URL(s) outside ${siteOrigin}`);
|
|
56
|
+
return kept;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function extractEntries(content: string): SitemapEntry[] {
|
|
60
|
+
if (!content.trimStart().startsWith('<')) {
|
|
61
|
+
return content
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((line) => line.trim())
|
|
64
|
+
.filter((line) => line.startsWith('http://') || line.startsWith('https://') || line.startsWith('/'))
|
|
65
|
+
.map((url) => ({ url, priority: DEFAULT_PRIORITY }));
|
|
66
|
+
}
|
|
67
|
+
const entries: SitemapEntry[] = [];
|
|
68
|
+
for (const match of content.matchAll(ENTRY_PATTERN)) {
|
|
69
|
+
const url = tagValue(match[2], 'loc');
|
|
70
|
+
if (!url) continue;
|
|
71
|
+
const entry: SitemapEntry = { url, priority: DEFAULT_PRIORITY };
|
|
72
|
+
const priority = Number.parseFloat(tagValue(match[2], 'priority') || '');
|
|
73
|
+
if (!Number.isNaN(priority)) entry.priority = priority;
|
|
74
|
+
const lastmod = Date.parse(tagValue(match[2], 'lastmod') || '');
|
|
75
|
+
if (!Number.isNaN(lastmod)) entry.lastmod = lastmod;
|
|
76
|
+
entries.push(entry);
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function applyFilter(entries: SitemapEntry[], filter: UrlFilter | undefined): SitemapEntry[] {
|
|
82
|
+
if (!filter) return entries;
|
|
83
|
+
let result = entries.filter((entry) => filter.allows(entry.url));
|
|
84
|
+
if (filter.sort === 'priority') result = result.toSorted((a, b) => b.priority - a.priority);
|
|
85
|
+
if (filter.sort === 'lastmod') result = result.toSorted((a, b) => (b.lastmod ?? 0) - (a.lastmod ?? 0));
|
|
86
|
+
return result.slice(0, filter.limit);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tagValue(block: string, name: string): string | undefined {
|
|
90
|
+
const match = block.match(new RegExp(`<${name}>\\s*(?:<!\\[CDATA\\[)?\\s*(.*?)\\s*(?:\\]\\]>)?\\s*</${name}>`, 'is'));
|
|
91
|
+
return match?.[1].replace(/&(amp|lt|gt|quot|apos);/g, (entity) => XML_ENTITIES[entity]) || undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readSource(source: string): Promise<string> {
|
|
95
|
+
if (source === STDIN_SOURCE) return readFileSync(0, 'utf8');
|
|
96
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
97
|
+
const response = await fetch(source);
|
|
98
|
+
if (!response.ok) throw new Error(`Sitemap ${source} returned HTTP ${response.status}`);
|
|
99
|
+
return await response.text();
|
|
100
|
+
}
|
|
101
|
+
return readFileSync(source, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SitemapEntry {
|
|
105
|
+
url: string;
|
|
106
|
+
priority: number;
|
|
107
|
+
lastmod?: number;
|
|
108
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { tag } from './logger.js';
|
|
2
|
+
|
|
3
|
+
export function parseSpecPairs(raw: string | undefined): SpecPair[] {
|
|
4
|
+
const pairs: SpecPair[] = [];
|
|
5
|
+
for (const pair of (raw || '').split(';')) {
|
|
6
|
+
const trimmed = pair.trim();
|
|
7
|
+
if (!trimmed) continue;
|
|
8
|
+
const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
|
|
9
|
+
if (!sepMatch) {
|
|
10
|
+
tag('warning').log(`Ignoring malformed spec pair: ${trimmed}`);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
pairs.push({ key: sepMatch[1].trim().toLowerCase(), value: sepMatch[2].trim() });
|
|
14
|
+
}
|
|
15
|
+
return pairs;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SpecPair {
|
|
19
|
+
key: string;
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { tag } from './logger.js';
|
|
2
|
+
import { parseSpecPairs } from './spec.ts';
|
|
3
|
+
import { matchesUrl } from './url-matcher.ts';
|
|
4
|
+
|
|
5
|
+
export class UrlFilter {
|
|
6
|
+
readonly include: string[] = [];
|
|
7
|
+
readonly exclude: string[] = [];
|
|
8
|
+
sort?: 'priority' | 'lastmod';
|
|
9
|
+
limit = Number.POSITIVE_INFINITY;
|
|
10
|
+
private admitted = new Set<string>();
|
|
11
|
+
|
|
12
|
+
constructor(spec?: string) {
|
|
13
|
+
for (const { key, value } of parseSpecPairs(spec)) {
|
|
14
|
+
if (key === 'filter') {
|
|
15
|
+
this.include.push(value);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (key === 'exclude') {
|
|
19
|
+
this.exclude.push(value);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (key === 'sort' && (value === 'priority' || value === 'lastmod')) {
|
|
23
|
+
this.sort = value;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (key === 'limit' && Number.parseInt(value, 10) > 0) {
|
|
27
|
+
this.limit = Number.parseInt(value, 10);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
tag('warning').log(`Ignoring URL filter pair ${key}:${value} (use filter|exclude:<url pattern>, sort:priority|lastmod, limit:<n>)`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
allows(url: string): boolean {
|
|
35
|
+
const path = urlPath(url);
|
|
36
|
+
if (this.include.length > 0 && !this.include.some((pattern) => matchesUrl(pattern, path))) return false;
|
|
37
|
+
return !this.exclude.some((pattern) => matchesUrl(pattern, path));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
admit(url: string): boolean {
|
|
41
|
+
if (!this.allows(url)) return false;
|
|
42
|
+
const path = urlPath(url);
|
|
43
|
+
if (this.admitted.has(path)) return true;
|
|
44
|
+
if (this.admitted.size >= this.limit) return false;
|
|
45
|
+
this.admitted.add(path);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function urlPath(url: string): string {
|
|
51
|
+
const parsed = new URL(url, 'http://localhost');
|
|
52
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
53
|
+
}
|