explorbot 0.4.5 → 0.4.7

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 (85) hide show
  1. package/boat/api-tester/src/ai/curler.ts +70 -66
  2. package/boat/api-tester/src/apibot.ts +19 -2
  3. package/boat/api-tester/src/cli.ts +87 -274
  4. package/boat/api-tester/src/commands/api-command.ts +10 -0
  5. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  6. package/boat/api-tester/src/commands/init-command.ts +119 -0
  7. package/boat/api-tester/src/commands/know-command.ts +44 -0
  8. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  9. package/boat/api-tester/src/commands/test-command.ts +54 -0
  10. package/boat/api-tester/src/config.ts +18 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +55 -56
  12. package/dist/boat/api-tester/src/apibot.js +15 -1
  13. package/dist/boat/api-tester/src/cli.js +89 -243
  14. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  15. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  16. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  17. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  18. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  19. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  20. package/dist/boat/api-tester/src/config.js +3 -1
  21. package/dist/package.json +4 -4
  22. package/dist/rules/researcher/pagination.md +6 -0
  23. package/dist/src/action-result.d.ts +6 -0
  24. package/dist/src/action-result.js +12 -0
  25. package/dist/src/ai/planner.js +4 -0
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  28. package/dist/src/ai/researcher/locators.js +1 -1
  29. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  30. package/dist/src/ai/researcher/pagination.js +62 -0
  31. package/dist/src/ai/researcher/parser.d.ts +3 -0
  32. package/dist/src/ai/researcher/parser.js +22 -6
  33. package/dist/src/ai/researcher/sections.js +1 -1
  34. package/dist/src/ai/researcher.js +7 -2
  35. package/dist/src/ai/rules.js +16 -0
  36. package/dist/src/ai/scout.js +8 -2
  37. package/dist/src/ai/tools.d.ts +1 -1
  38. package/dist/src/ai/tools.js +25 -13
  39. package/dist/src/api/spec-reader.d.ts +1 -0
  40. package/dist/src/api/spec-reader.js +93 -1
  41. package/dist/src/commands/base-command.d.ts +3 -3
  42. package/dist/src/commands/init-command.d.ts +3 -0
  43. package/dist/src/commands/init-command.js +6 -3
  44. package/dist/src/commands/options/ws-option.d.ts +7 -0
  45. package/dist/src/commands/options/ws-option.js +14 -0
  46. package/dist/src/config.d.ts +1 -0
  47. package/dist/src/config.js +14 -11
  48. package/dist/src/explorer.d.ts +1 -1
  49. package/dist/src/explorer.js +1 -1
  50. package/dist/src/remote.d.ts +2 -0
  51. package/dist/src/remote.js +23 -16
  52. package/dist/src/utils/aria.d.ts +2 -0
  53. package/dist/src/utils/aria.js +6 -1
  54. package/dist/src/utils/html-diff.js +4 -1
  55. package/dist/src/utils/markdown-query.d.ts +2 -0
  56. package/dist/src/utils/markdown-query.js +39 -0
  57. package/dist/src/utils/pagination.d.ts +16 -0
  58. package/dist/src/utils/pagination.js +20 -0
  59. package/docs/api-testing/basics.md +26 -2
  60. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  61. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +345 -0
  62. package/package.json +4 -4
  63. package/rules/researcher/pagination.md +6 -0
  64. package/src/action-result.ts +16 -0
  65. package/src/ai/planner.ts +4 -0
  66. package/src/ai/researcher/deep-analysis.ts +13 -6
  67. package/src/ai/researcher/locators.ts +1 -1
  68. package/src/ai/researcher/pagination.ts +68 -0
  69. package/src/ai/researcher/parser.ts +23 -5
  70. package/src/ai/researcher/sections.ts +1 -1
  71. package/src/ai/researcher.ts +9 -3
  72. package/src/ai/rules.ts +16 -0
  73. package/src/ai/scout.ts +9 -2
  74. package/src/ai/tools.ts +22 -14
  75. package/src/api/spec-reader.ts +106 -1
  76. package/src/commands/base-command.ts +3 -3
  77. package/src/commands/init-command.ts +6 -3
  78. package/src/commands/options/ws-option.ts +14 -0
  79. package/src/config.ts +15 -11
  80. package/src/explorer.ts +1 -1
  81. package/src/remote.ts +22 -15
  82. package/src/utils/aria.ts +8 -1
  83. package/src/utils/html-diff.ts +3 -1
  84. package/src/utils/markdown-query.ts +39 -0
  85. package/src/utils/pagination.ts +36 -0
@@ -1,86 +1,31 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import { Command } from 'commander';
2
+ import { flushTelemetry } from "../../../src/ai/provider.js";
4
3
  import { ConfigCommand } from "../../../src/commands/config-command.js";
5
4
  import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
6
5
  import { listSites } from "../../../src/global-config.js";
7
6
  import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
8
- import { getStyles } from "./ai/chief/styles.js";
9
7
  import { ApiBot } from "./apibot.js";
8
+ import { ExploreCommand } from "./commands/explore-command.js";
9
+ import { runInit } from "./commands/init-command.js";
10
+ import { KnowCommand } from "./commands/know-command.js";
11
+ import { PlanCommand } from "./commands/plan-command.js";
12
+ import { TestCommand } from "./commands/test-command.js";
10
13
  import { ApibotConfigParser } from "./config.js";
11
- function buildOptions(options) {
12
- return {
13
- verbose: options.verbose || options.debug,
14
- config: options.config,
15
- path: options.path,
16
- baseEndpoint: options.endpoint,
17
- spec: options.spec,
18
- header: options.header,
19
- };
20
- }
21
- function addCommonOptions(cmd) {
22
- return cmd
23
- .option('-v, --verbose', 'Enable verbose logging')
24
- .option('--debug', 'Enable debug logging')
25
- .option('-c, --config <path>', 'Path to configuration file')
26
- .option('-p, --path <path>', 'Working directory path')
27
- .option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
28
- .option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
29
- .option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value, previous = []) => [...previous, value]);
30
- }
31
- function selectTests(tests, index) {
32
- if (!index || index === '*' || index === 'all') {
33
- return tests.filter((t) => t.status === 'pending');
34
- }
35
- const rangeMatch = index.match(/^(\d+)-(\d+)$/);
36
- if (rangeMatch) {
37
- const start = Number.parseInt(rangeMatch[1]) - 1;
38
- const end = Number.parseInt(rangeMatch[2]);
39
- return tests.slice(start, end);
40
- }
41
- if (index.includes(',')) {
42
- const indices = index.split(',').map((i) => Number.parseInt(i.trim()) - 1);
43
- return indices.map((i) => tests[i]).filter(Boolean);
44
- }
45
- const num = Number.parseInt(index);
46
- if (!Number.isNaN(num) && tests[num - 1]) {
47
- return [tests[num - 1]];
48
- }
49
- return tests.filter((t) => t.status === 'pending');
50
- }
51
14
  export function createApiCommands(name = 'api') {
52
15
  const cmd = new Command(name);
53
16
  cmd.description('AI-powered API testing tool');
54
- addCommonOptions(cmd.command('plan <endpoint>').description('Generate test plan for an API endpoint').option('--style <style>', 'Planning style: basename of a file in rules/chief/styles/').option('--fresh', 'Start planning from scratch')).action(async (endpoint, options) => {
55
- setPreserveConsoleLogs(true);
56
- try {
57
- const bot = new ApiBot({ ...buildOptions(options), endpoint });
58
- await bot.start();
59
- await bot.plan(endpoint, { style: options.style, fresh: options.fresh });
60
- const plan = bot.getCurrentPlan();
61
- if (!plan?.tests.length) {
62
- console.error('No test scenarios generated.');
63
- process.exit(1);
64
- }
65
- console.log(`\nPlan: ${plan.title} (${plan.tests.length} tests)\n`);
66
- plan.tests.forEach((test, i) => {
67
- console.log(` ${i + 1}. [${test.priority}] ${test.scenario}`);
68
- });
69
- const savedPath = bot.savePlan();
70
- if (savedPath) {
71
- console.log(`\nSaved to: ${savedPath}`);
72
- console.log('\nRun tests:');
73
- console.log(` ${name} test ${savedPath} 1 # run first test`);
74
- console.log(` ${name} test ${savedPath} 1-3 # run tests 1 to 3`);
75
- console.log(` ${name} test ${savedPath} * # run all tests`);
76
- }
77
- await bot.stop();
78
- process.exit(0);
79
- }
80
- catch (error) {
81
- console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
82
- process.exit(1);
83
- }
17
+ addCommonOptions(cmd.command('plan <endpoint>').description('Generate test plan for an API endpoint'))
18
+ .option('--style <style>', 'Planning style: basename of a file in rules/chief/styles/')
19
+ .option('--fresh', 'Start planning from scratch')
20
+ .action(async (endpoint, options) => {
21
+ await run(name, options, endpoint, async (bot) => {
22
+ const command = new PlanCommand(bot);
23
+ command.prefix = name;
24
+ command.style = options.style;
25
+ command.fresh = !!options.fresh;
26
+ await command.execute(endpoint);
27
+ return 0;
28
+ });
84
29
  });
85
30
  addCommonOptions(cmd.command('config [endpoint]').description('Show models, config file and paths used by this run'))
86
31
  .option('--json', 'Print the resolved config as JSON')
@@ -102,155 +47,34 @@ export function createApiCommands(name = 'api') {
102
47
  });
103
48
  RecommendedModelsCommand.register(cmd);
104
49
  addCommonOptions(cmd.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1-3, *')).action(async (planfile, index, options) => {
105
- setPreserveConsoleLogs(true);
106
- try {
107
- const bot = new ApiBot(buildOptions(options));
108
- await bot.start();
109
- const plan = bot.loadPlan(planfile);
110
- console.log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests)`);
111
- const tests = selectTests(plan.tests, index);
112
- console.log(`Running ${tests.length} test(s)\n`);
113
- let passed = 0;
114
- let failed = 0;
115
- for (const test of tests) {
116
- const specDefinition = bot.tryGetEndpointDefinition(test.startUrl);
117
- const result = await bot.agentCurler().test(test, {
118
- specDefinition,
119
- baseEndpoint: bot.getConfig().api.baseEndpoint,
120
- searchSpec: (query) => bot.searchSpec(query),
121
- });
122
- if (result.success)
123
- passed++;
124
- else
125
- failed++;
126
- }
127
- bot.savePlan();
128
- console.log(`\nResults: ${passed} passed, ${failed} failed out of ${tests.length}`);
129
- await bot.stop();
130
- process.exit(failed > 0 ? 1 : 0);
131
- }
132
- catch (error) {
133
- console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
134
- process.exit(1);
135
- }
50
+ await run(name, options, undefined, async (bot) => {
51
+ const command = new TestCommand(bot);
52
+ command.index = index;
53
+ await command.execute(planfile);
54
+ if (command.failed)
55
+ return 1;
56
+ return 0;
57
+ });
136
58
  });
137
- addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan. The endpoint may be the base endpoint itself')).action(async (endpoint, options) => {
138
- setPreserveConsoleLogs(true);
139
- try {
140
- if (URL.canParse(endpoint))
141
- options.endpoint ||= endpoint;
142
- const bot = new ApiBot({ ...buildOptions(options), endpoint });
143
- await bot.start();
144
- const styles = Object.keys(getStyles());
145
- let totalPassed = 0;
146
- let totalFailed = 0;
147
- let totalTests = 0;
148
- for (const style of styles) {
149
- console.log(`\n=== Style: ${style} ===\n`);
150
- const plan = await bot.plan(endpoint, { style, fresh: true });
151
- if (!plan?.tests.length) {
152
- console.log(`No tests generated for style: ${style}`);
153
- continue;
154
- }
155
- const pending = plan.getPendingTests();
156
- for (const test of pending) {
157
- const specDefinition = bot.tryGetEndpointDefinition(test.startUrl);
158
- const result = await bot.agentCurler().test(test, {
159
- specDefinition,
160
- baseEndpoint: bot.getConfig().api.baseEndpoint,
161
- searchSpec: (query) => bot.searchSpec(query),
162
- });
163
- totalTests++;
164
- if (result.success)
165
- totalPassed++;
166
- else
167
- totalFailed++;
168
- }
169
- bot.savePlan(style);
170
- }
171
- console.log('\n=== Final Results ===');
172
- console.log(`Total: ${totalTests} tests, ${totalPassed} passed, ${totalFailed} failed`);
173
- await bot.stop();
174
- process.exit(totalFailed > 0 ? 1 : 0);
175
- }
176
- catch (error) {
177
- console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
178
- process.exit(1);
179
- }
59
+ addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan, execute tests, re-plan. Use * to cover many endpoints, or the base endpoint for all of them')).action(async (endpoint, options) => {
60
+ await run(name, options, endpoint, async (bot) => {
61
+ const command = new ExploreCommand(bot);
62
+ await command.execute(endpoint);
63
+ if (command.result.failed)
64
+ return 1;
65
+ return 0;
66
+ });
180
67
  });
181
68
  cmd
182
69
  .command('init')
183
70
  .description('Initialize a new apibot project with configuration')
184
71
  .option('-f, --force', 'Overwrite existing config file')
185
72
  .option('-p, --path <path>', 'Working directory for initialization')
73
+ .option('--provider <name>', 'AI provider written into the config')
74
+ .option('--endpoint <url>', 'Base API endpoint, with --spec skips the questions')
75
+ .option('--spec <path>', 'OpenAPI spec file or URL')
186
76
  .action(async (options) => {
187
- const originalCwd = process.cwd();
188
- if (options.path) {
189
- const resolvedPath = path.resolve(options.path);
190
- fs.mkdirSync(resolvedPath, { recursive: true });
191
- process.chdir(resolvedPath);
192
- console.log(`Working in: ${resolvedPath}`);
193
- }
194
- const configPath = path.resolve('apibot.config.ts');
195
- if (fs.existsSync(configPath) && !options.force) {
196
- console.log(`Config file already exists: ${configPath}`);
197
- console.log('Use --force to overwrite.');
198
- process.exit(1);
199
- }
200
- const rl = await import('node:readline');
201
- const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
202
- const ask = (q, fallback = '') => new Promise((resolve) => iface.question(q, (a) => resolve(a.trim() || fallback)));
203
- console.log('Apibot — API Testing Tool Setup\n');
204
- const baseEndpoint = await ask('Base API endpoint (e.g., https://api.example.com/v1): ');
205
- if (!baseEndpoint) {
206
- console.error('Base endpoint is required.');
207
- iface.close();
208
- process.exit(1);
209
- }
210
- const spec = await ask('OpenAPI spec file or URL (e.g., openapi.yaml or https://.../ — or press Enter to skip): ');
211
- const knowledge = await ask('Describe your API (auth method, data formats, special rules — or press Enter to skip): ');
212
- iface.close();
213
- const specLine = spec ? `\n spec: ['${spec}'],` : '';
214
- const configContent = `import { openai } from '@ai-sdk/openai';
215
-
216
- export default {
217
- ai: {
218
- model: openai('gpt-4o'),
219
- },
220
- api: {
221
- baseEndpoint: '${baseEndpoint}',${specLine}
222
- headers: {
223
- // 'Authorization': 'Bearer <token>',
224
- },
225
- // bootstrap: async ({ headers, baseEndpoint }) => {
226
- // // Run before tests — e.g. obtain auth token
227
- // // Return headers to merge: { Authorization: 'Bearer ...' }
228
- // },
229
- // teardown: async ({ headers, baseEndpoint }) => {
230
- // // Run after tests — e.g. cleanup test data
231
- // },
232
- },
233
- dirs: {
234
- output: 'output',
235
- knowledge: 'knowledge',
236
- },
237
- };
238
- `;
239
- fs.writeFileSync(configPath, configContent, 'utf8');
240
- console.log(`\nCreated: ${configPath}`);
241
- fs.mkdirSync('output', { recursive: true });
242
- fs.mkdirSync('knowledge', { recursive: true });
243
- if (knowledge) {
244
- const knowledgePath = path.resolve('knowledge', 'general.md');
245
- fs.writeFileSync(knowledgePath, `---\nendpoint: "*"\n---\n${knowledge}\n`, 'utf8');
246
- console.log(`Created: ${knowledgePath}`);
247
- }
248
- console.log('\nNext steps:');
249
- console.log('1. Edit apibot.config.ts — set your AI provider and API headers');
250
- console.log(`2. Add API knowledge: ${name} know /users "CRUD endpoint for user management"`);
251
- console.log(`3. Plan tests: ${name} plan /users`);
252
- if (process.cwd() !== originalCwd)
253
- process.chdir(originalCwd);
77
+ await runInit({ ...options, baseEndpoint: options.endpoint, prefix: name });
254
78
  });
255
79
  cmd
256
80
  .command('know <endpoint> [description]')
@@ -259,38 +83,60 @@ export default {
259
83
  .option('-c, --config <path>', 'Path to configuration file')
260
84
  .option('-p, --path <path>', 'Working directory path')
261
85
  .action(async (endpoint, description, options) => {
262
- if (!description) {
263
- const rl = await import('node:readline');
264
- const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
265
- description = await new Promise((resolve) => iface.question(`Describe ${endpoint}: `, (a) => resolve(a.trim())));
266
- iface.close();
267
- }
268
- if (!description) {
269
- console.error('Description is required.');
270
- process.exit(1);
271
- }
272
- let knowledgeDir = 'knowledge';
86
+ const command = new KnowCommand(new ApiBot(buildOptions(options)));
87
+ command.prefix = name;
88
+ command.knowledge = description || (await askDescription(endpoint));
273
89
  try {
274
- const { ApibotConfigParser } = await import("./config.js");
275
- await ApibotConfigParser.getInstance().loadConfig({ config: options.config, path: options.path });
276
- knowledgeDir = ApibotConfigParser.getInstance().getKnowledgeDir();
90
+ await command.execute(endpoint);
277
91
  }
278
- catch {
279
- if (options.path)
280
- knowledgeDir = path.join(path.resolve(options.path), 'knowledge');
281
- }
282
- fs.mkdirSync(knowledgeDir, { recursive: true });
283
- const filename = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'general';
284
- const filePath = path.join(knowledgeDir, `${filename}.md`);
285
- const content = `---\nendpoint: "${endpoint}"\n---\n${description}\n`;
286
- if (fs.existsSync(filePath)) {
287
- fs.appendFileSync(filePath, `\n---\n${description}\n`, 'utf8');
288
- console.log(`Updated: ${filePath}`);
289
- }
290
- else {
291
- fs.writeFileSync(filePath, content, 'utf8');
292
- console.log(`Created: ${filePath}`);
92
+ catch (error) {
93
+ console.error(error instanceof Error ? error.message : 'Unknown error');
94
+ process.exit(1);
293
95
  }
294
96
  });
295
97
  return cmd;
296
98
  }
99
+ async function run(name, options, endpoint, body) {
100
+ setPreserveConsoleLogs(true);
101
+ try {
102
+ if (endpoint && URL.canParse(endpoint))
103
+ options.endpoint ||= endpoint;
104
+ const bot = new ApiBot({ ...buildOptions(options), endpoint });
105
+ await bot.start();
106
+ const code = await body(bot);
107
+ await bot.stop();
108
+ process.exit(code);
109
+ }
110
+ catch (error) {
111
+ console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
112
+ await flushTelemetry();
113
+ process.exit(1);
114
+ }
115
+ }
116
+ async function askDescription(endpoint) {
117
+ const rl = await import('node:readline');
118
+ const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
119
+ const answer = await new Promise((resolve) => iface.question(`Describe ${endpoint}: `, (text) => resolve(text.trim())));
120
+ iface.close();
121
+ return answer;
122
+ }
123
+ function buildOptions(options) {
124
+ return {
125
+ verbose: options.verbose || options.debug,
126
+ config: options.config,
127
+ path: options.path,
128
+ baseEndpoint: options.endpoint,
129
+ spec: options.spec,
130
+ header: options.header,
131
+ };
132
+ }
133
+ function addCommonOptions(cmd) {
134
+ return cmd
135
+ .option('-v, --verbose', 'Enable verbose logging')
136
+ .option('--debug', 'Enable debug logging')
137
+ .option('-c, --config <path>', 'Path to configuration file')
138
+ .option('-p, --path <path>', 'Working directory path')
139
+ .option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
140
+ .option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
141
+ .option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value, previous = []) => [...previous, value]);
142
+ }
@@ -0,0 +1,7 @@
1
+ import { BaseCommand } from "../../../../src/commands/base-command.js";
2
+ export class ApiCommand extends BaseCommand {
3
+ prefix = 'apibot';
4
+ get bot() {
5
+ return this.explorBot;
6
+ }
7
+ }
@@ -0,0 +1,41 @@
1
+ import figureSet from 'figures';
2
+ import { tag } from "../../../../src/utils/logger.js";
3
+ import { getStyles } from "../ai/chief/styles.js";
4
+ import { ApiCommand } from "./api-command.js";
5
+ export class ExploreCommand extends ApiCommand {
6
+ name = 'explore';
7
+ description = 'Full cycle: plan, execute tests, re-plan. Use * to cover many endpoints, or the base endpoint for all of them';
8
+ result = { tests: 0, passed: 0, failed: 0 };
9
+ async execute(endpoint) {
10
+ const styles = Object.keys(getStyles());
11
+ const endpoints = this.bot.expandEndpoints(endpoint);
12
+ for (const [index, target] of endpoints.entries()) {
13
+ let runStyles = [styles[index % styles.length]];
14
+ if (endpoints.length === 1)
15
+ runStyles = styles;
16
+ if (endpoints.length > 1)
17
+ tag('info').log(`Endpoint ${index + 1}/${endpoints.length}: ${target}`);
18
+ for (const style of runStyles) {
19
+ await this.runStyle(target, style);
20
+ }
21
+ }
22
+ tag('info').log(`${figureSet.tick} ${this.result.tests} tests completed: ${this.result.passed} passed, ${this.result.failed} failed`);
23
+ }
24
+ async runStyle(endpoint, style) {
25
+ tag('info').log(`Planning style: ${style}`);
26
+ const plan = await this.bot.plan(endpoint, { style, fresh: true });
27
+ if (!plan?.tests.length) {
28
+ tag('warning').log(`No tests generated for style: ${style}`);
29
+ return;
30
+ }
31
+ for (const test of plan.getPendingTests()) {
32
+ const outcome = await this.bot.runTest(test);
33
+ this.result.tests++;
34
+ if (outcome.success)
35
+ this.result.passed++;
36
+ else
37
+ this.result.failed++;
38
+ }
39
+ this.bot.savePlan(style);
40
+ }
41
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { envTemplate, modelLines } from "../../../../src/commands/init-command.js";
5
+ import { missingModelRoles } from "../../../../src/config.js";
6
+ import { log, tag } from "../../../../src/utils/logger.js";
7
+ export async function runInit(options) {
8
+ const provider = options.provider || 'openrouter';
9
+ const originalCwd = process.cwd();
10
+ if (options.path) {
11
+ const dir = path.resolve(options.path);
12
+ mkdirSync(dir, { recursive: true });
13
+ process.chdir(dir);
14
+ log(`Working in directory: ${dir}`);
15
+ }
16
+ const configPath = path.resolve('apibot.config.js');
17
+ if (existsSync(configPath) && !options.force) {
18
+ log(`Config file already exists: ${configPath}`);
19
+ log('Use --force to overwrite existing file');
20
+ process.exit(1);
21
+ }
22
+ const answers = await ask(options);
23
+ if (!answers.baseEndpoint) {
24
+ tag('error').log('Base endpoint is required.');
25
+ process.exit(1);
26
+ }
27
+ if (!answers.spec) {
28
+ tag('error').log('OpenAPI spec is required. Chief plans from it and Curler looks up schemas in it.');
29
+ process.exit(1);
30
+ }
31
+ writeFileSync(configPath, configTemplate(provider, answers.baseEndpoint, answers.spec), 'utf8');
32
+ log(`Created config file: ${configPath}`);
33
+ const envPath = path.resolve('.env');
34
+ if (!existsSync(envPath)) {
35
+ writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
36
+ log(`Created env file: ${envPath}`);
37
+ }
38
+ mkdirSync('output', { recursive: true });
39
+ mkdirSync('knowledge', { recursive: true });
40
+ if (answers.knowledge) {
41
+ const knowledgePath = path.resolve('knowledge', 'general.md');
42
+ writeFileSync(knowledgePath, `---\nendpoint: "*"\n---\n${answers.knowledge}\n`, 'utf8');
43
+ log(`Created knowledge file: ${knowledgePath}`);
44
+ }
45
+ const missing = missingModelRoles(provider);
46
+ if (missing.length) {
47
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${configPath}`);
48
+ }
49
+ log('');
50
+ log('Next steps:');
51
+ log('1. Add your provider API key to .env');
52
+ log('2. Describe the API so the plans match it');
53
+ tag('substep').log(chalk.yellow(`${options.prefix} know /users "CRUD endpoint for user management"`));
54
+ log('3. Plan and run tests for one endpoint');
55
+ tag('substep').log(chalk.yellow(`${options.prefix} explore /users`));
56
+ if (process.cwd() !== originalCwd)
57
+ process.chdir(originalCwd);
58
+ }
59
+ function configTemplate(provider, baseEndpoint, spec) {
60
+ return `// Models are written as 'provider/model-id' so they resolve without a local node_modules.
61
+ // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
62
+
63
+ export default {
64
+ ai: {
65
+ ${modelLines(provider, ['model', 'agenticModel'])}
66
+ },
67
+
68
+ api: {
69
+ baseEndpoint: '${baseEndpoint}',
70
+ spec: ['${spec}'],
71
+ },
72
+ };
73
+ `;
74
+ }
75
+ async function ask(options) {
76
+ if (options.baseEndpoint) {
77
+ return { baseEndpoint: options.baseEndpoint, spec: options.spec || '', knowledge: '' };
78
+ }
79
+ const rl = await import('node:readline');
80
+ const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
81
+ const question = (text) => new Promise((resolve) => iface.question(text, (answer) => resolve(answer.trim())));
82
+ log('Apibot — API Testing Tool Setup\n');
83
+ const baseEndpoint = await question('Base API endpoint (e.g. https://api.example.com/v1): ');
84
+ const spec = await question('OpenAPI spec file or URL: ');
85
+ const knowledge = await question('Describe your API, its auth and its rules (Enter to skip): ');
86
+ iface.close();
87
+ return { baseEndpoint, spec, knowledge };
88
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { tag } from "../../../../src/utils/logger.js";
4
+ import { ApiCommand } from "./api-command.js";
5
+ export class KnowCommand extends ApiCommand {
6
+ name = 'know';
7
+ aliases = ['add-knowledge'];
8
+ description = 'Add API knowledge for an endpoint';
9
+ knowledge = '';
10
+ async execute(endpoint) {
11
+ if (!this.knowledge) {
12
+ throw new Error('Description is required.');
13
+ }
14
+ const knowledgeDir = await this.resolveKnowledgeDir();
15
+ fs.mkdirSync(knowledgeDir, { recursive: true });
16
+ const filename = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'general';
17
+ const filePath = path.join(knowledgeDir, `${filename}.md`);
18
+ if (fs.existsSync(filePath)) {
19
+ fs.appendFileSync(filePath, `\n---\n${this.knowledge}\n`, 'utf8');
20
+ tag('success').log(`Updated: ${filePath}`);
21
+ return;
22
+ }
23
+ fs.writeFileSync(filePath, `---\nendpoint: "${endpoint}"\n---\n${this.knowledge}\n`, 'utf8');
24
+ tag('success').log(`Created: ${filePath}`);
25
+ }
26
+ async resolveKnowledgeDir() {
27
+ const parser = this.bot.getConfigParser();
28
+ const options = this.bot.getOptions();
29
+ try {
30
+ await parser.loadConfig(options);
31
+ return parser.getKnowledgeDir();
32
+ }
33
+ catch {
34
+ if (options.path)
35
+ return path.join(path.resolve(options.path), 'knowledge');
36
+ return 'knowledge';
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,37 @@
1
+ import { tag } from "../../../../src/utils/logger.js";
2
+ import { printNextSteps, relativeToCwd } from "../../../../src/utils/next-steps.js";
3
+ import { ApiCommand } from "./api-command.js";
4
+ export class PlanCommand extends ApiCommand {
5
+ name = 'plan';
6
+ description = 'Generate a test plan for an API endpoint';
7
+ style;
8
+ fresh = false;
9
+ async execute(endpoint) {
10
+ await this.bot.plan(endpoint, { style: this.style, fresh: this.fresh });
11
+ const plan = this.bot.getCurrentPlan();
12
+ if (!plan?.tests.length) {
13
+ throw new Error('No test scenarios generated.');
14
+ }
15
+ const lines = [`Plan: ${plan.title} (${plan.tests.length} tests)`];
16
+ for (const [i, test] of plan.tests.entries()) {
17
+ lines.push(` ${String(i + 1).padStart(2)}. [${test.priority}] ${test.scenario}`);
18
+ }
19
+ tag('multiline').log(lines.join('\n'), { maxLines: 24 });
20
+ const savedPath = this.bot.savePlan();
21
+ if (!savedPath)
22
+ return;
23
+ const relative = relativeToCwd(savedPath);
24
+ const sections = [
25
+ {
26
+ label: 'Plan',
27
+ path: savedPath,
28
+ commands: [
29
+ { label: 'Run first', command: `${this.prefix} test ${relative} 1` },
30
+ { label: 'Run all', command: `${this.prefix} test ${relative} *` },
31
+ { label: 'Run range', command: `${this.prefix} test ${relative} 1-3` },
32
+ ],
33
+ },
34
+ ];
35
+ printNextSteps(sections);
36
+ }
37
+ }
@@ -0,0 +1,45 @@
1
+ import figureSet from 'figures';
2
+ import { tag } from "../../../../src/utils/logger.js";
3
+ import { ApiCommand } from "./api-command.js";
4
+ export class TestCommand extends ApiCommand {
5
+ name = 'test';
6
+ description = 'Execute tests from a plan file. Index: 1, 1-3, *';
7
+ index;
8
+ failed = 0;
9
+ async execute(planfile) {
10
+ const plan = this.bot.loadPlan(planfile);
11
+ tag('info').log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests)`);
12
+ const tests = selectTests(plan.tests, this.index);
13
+ tag('info').log(`Running ${tests.length} test(s)`);
14
+ let passed = 0;
15
+ for (const test of tests) {
16
+ const result = await this.bot.runTest(test);
17
+ if (result.success)
18
+ passed++;
19
+ else
20
+ this.failed++;
21
+ }
22
+ this.bot.savePlan();
23
+ tag('info').log(`${figureSet.tick} ${tests.length} tests completed: ${passed} passed, ${this.failed} failed`);
24
+ }
25
+ }
26
+ export function selectTests(tests, index) {
27
+ if (!index || index === '*' || index === 'all') {
28
+ return tests.filter((t) => t.status === 'pending');
29
+ }
30
+ const rangeMatch = index.match(/^(\d+)-(\d+)$/);
31
+ if (rangeMatch) {
32
+ const start = Number.parseInt(rangeMatch[1]) - 1;
33
+ const end = Number.parseInt(rangeMatch[2]);
34
+ return tests.slice(start, end);
35
+ }
36
+ if (index.includes(',')) {
37
+ const indices = index.split(',').map((i) => Number.parseInt(i.trim()) - 1);
38
+ return indices.map((i) => tests[i]).filter(Boolean);
39
+ }
40
+ const num = Number.parseInt(index);
41
+ if (!Number.isNaN(num) && tests[num - 1]) {
42
+ return [tests[num - 1]];
43
+ }
44
+ return tests.filter((t) => t.status === 'pending');
45
+ }
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { parseEnv } from 'node:util';
13
- import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from "../../../src/config.js";
13
+ import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
14
14
  import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
15
15
  function isAbsoluteEndpoint(value) {
16
16
  return !!value && (value.startsWith('http://') || value.startsWith('https://'));
@@ -82,6 +82,7 @@ export class ApibotConfigParser {
82
82
  if (options?.baseEndpoint)
83
83
  this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
84
84
  await resolveConfigModels(this.config.ai);
85
+ resolveLangfuse(this.config.ai);
85
86
  this.configPath = resolvedPath;
86
87
  this.site = null;
87
88
  if (isGlobalConfigPath(resolvedPath)) {
@@ -208,6 +209,7 @@ export class ApibotConfigParser {
208
209
  api,
209
210
  dirs: { output: '.', knowledge: 'knowledge' },
210
211
  };
212
+ resolveLangfuse(this.config.ai);
211
213
  this.configPath = path.join(outputRoot, 'apibot.config.js');
212
214
  this.validateConfig(this.config);
213
215
  setOutputDir(this.getOutputDir());