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.
Files changed (43) hide show
  1. package/bin/explorbot-cli.ts +17 -4
  2. package/boat/doc-collector/src/cli.ts +9 -3
  3. package/boat/doc-collector/src/docbot.ts +11 -2
  4. package/dist/bin/explorbot-cli.js +19 -5
  5. package/dist/boat/doc-collector/src/cli.js +9 -3
  6. package/dist/boat/doc-collector/src/docbot.js +13 -2
  7. package/dist/package.json +1 -1
  8. package/dist/src/ai/navigator.d.ts +2 -0
  9. package/dist/src/ai/navigator.js +10 -0
  10. package/dist/src/ai/pilot.js +9 -2
  11. package/dist/src/commands/explore-command.d.ts +5 -0
  12. package/dist/src/commands/explore-command.js +60 -23
  13. package/dist/src/commands/freesail-command.js +9 -1
  14. package/dist/src/commands/init-command.d.ts +1 -0
  15. package/dist/src/commands/init-command.js +87 -36
  16. package/dist/src/components/InitWizard.d.ts +5 -1
  17. package/dist/src/components/InitWizard.js +29 -11
  18. package/dist/src/config.d.ts +2 -0
  19. package/dist/src/config.js +29 -7
  20. package/dist/src/test-plan.d.ts +1 -0
  21. package/dist/src/test-plan.js +5 -6
  22. package/dist/src/utils/sitemap.d.ts +11 -0
  23. package/dist/src/utils/sitemap.js +106 -0
  24. package/dist/src/utils/spec.d.ts +5 -0
  25. package/dist/src/utils/spec.js +16 -0
  26. package/dist/src/utils/url-filter.d.ts +10 -0
  27. package/dist/src/utils/url-filter.js +52 -0
  28. package/docs/basics/getting-started.md +23 -10
  29. package/docs/basics/providers.md +2 -0
  30. package/docs/reference/commands.md +5 -2
  31. package/docs/reference/configuration.md +1 -0
  32. package/package.json +1 -1
  33. package/src/ai/navigator.ts +13 -1
  34. package/src/ai/pilot.ts +9 -2
  35. package/src/commands/explore-command.ts +56 -23
  36. package/src/commands/freesail-command.ts +10 -1
  37. package/src/commands/init-command.ts +105 -38
  38. package/src/components/InitWizard.tsx +43 -11
  39. package/src/config.ts +32 -7
  40. package/src/test-plan.ts +6 -6
  41. package/src/utils/sitemap.ts +108 -0
  42. package/src/utils/spec.ts +21 -0
  43. 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://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
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: 'http://<your-app-host-here>',
20
+ url: '${appUrl}',
19
21
  },
20
22
 
21
23
  ai: {
22
- ${modelLines(provider)}
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 `${envKey}=`;
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 provider = await renderLocalProviderWizard();
73
- if (!provider) return;
78
+ const setup = await renderLocalSetupWizard();
79
+ if (!setup) return;
74
80
 
75
- runInitCommand({ ...options, provider });
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(`Created global config: ${globalConfigPath()}`);
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(`Stored ${envKey} in ${globalEnvPath()}`);
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
- tag('warning').log(`Add your API key to ${globalEnvPath()}`);
102
+ printApiKeySteps(provider, globalEnvPath());
97
103
  }
98
104
 
99
105
  log('');
100
- log('Explorbot now runs from any directory:');
101
- tag('substep').log(chalk.yellow(`${getCliName()} explore https://your-app.example.com`));
102
- tag('substep').log(chalk.yellow(`${getCliName()} sites`));
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(`Created config file: ${relativeToCwd(outPath)}`);
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(`Created env file: ${relativeToCwd(envPath)}`);
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('Created directory: output');
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 renderLocalProviderWizard(): Promise<string | null> {
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 = (provider: string | null) => {
248
+ const finish = (setup: LocalSetup | null) => {
235
249
  unmount();
236
- resolve(provider);
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
- onLocalProvider: (provider: string) => finish(provider),
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://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
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 writeEnvKey(key: string, value: string): void {
296
- const envPath = globalEnvPath();
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
- onLocalProvider?: (provider: string) => void;
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, onLocalProvider }) => {
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} <Text dimColor>(stored as {envKey})</Text>
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: save anyway</Text>
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> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
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
+ }