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
@@ -42,13 +42,35 @@ export function extractEndpointDefinition(schema, endpoint, baseEndpoint) {
42
42
  throw new Error('OpenAPI spec has no paths defined');
43
43
  }
44
44
  const basePath = toBasePath(baseEndpoint);
45
- const matched = collectMatchingPaths(schema, basePath, (normalized) => matchesEndpoint(normalized, endpoint));
45
+ const matched = collectEndpointPaths(schema, basePath, endpoint);
46
46
  if (!Object.keys(matched).length) {
47
47
  const available = listNormalizedPaths(schema, basePath);
48
48
  throw new Error(`Endpoint "${endpoint}" not found in spec. Available: ${available}`);
49
49
  }
50
50
  return safeStringify(matched);
51
51
  }
52
+ export function resolveEndpoints(schema, pattern, baseEndpoint) {
53
+ if (!schema?.paths) {
54
+ throw new Error('OpenAPI spec has no paths defined');
55
+ }
56
+ const basePath = toBasePath(baseEndpoint);
57
+ const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
58
+ const matched = normalized.filter((specPath) => matchesPattern(specPath, pattern));
59
+ if (!matched.length) {
60
+ throw new Error(`Endpoint "${pattern}" not found in spec. Available: ${listNormalizedPaths(schema, basePath)}`);
61
+ }
62
+ const roots = matched.map((specPath) => toCollection(specPath, normalized, pattern));
63
+ const resolved = [...new Set(roots.map((root) => fillParameters(root, pattern)))];
64
+ const endpoints = resolved.filter((specPath) => !specPath.includes('{'));
65
+ if (!endpoints.length) {
66
+ throw new Error(`Endpoint "${pattern}" leaves ${listParameters(resolved)} unresolved. Give the value in the endpoint or in the base endpoint.`);
67
+ }
68
+ const skipped = resolved.filter((specPath) => specPath.includes('{'));
69
+ if (skipped.length) {
70
+ tag('warning').log(`Skipped, no value for their parameters: ${skipped.join(', ')}`);
71
+ }
72
+ return endpoints;
73
+ }
52
74
  export function searchEndpoints(schema, query, baseEndpoint) {
53
75
  if (!schema?.paths)
54
76
  return 'No endpoints available';
@@ -136,6 +158,76 @@ function stripBasePath(specPath, basePath) {
136
158
  }
137
159
  return `/${specSegments.slice(i).join('/')}`;
138
160
  }
161
+ function collectEndpointPaths(schema, basePath, endpoint) {
162
+ const normalized = Object.keys(schema.paths).map((specPath) => stripBasePath(specPath, basePath));
163
+ const roots = resolveEndpoint(normalized, endpoint);
164
+ if (!roots.length)
165
+ return collectMatchingPaths(schema, basePath, (path) => matchesEndpoint(path, endpoint));
166
+ return collectMatchingPaths(schema, basePath, (path) => roots.some((root) => path === root || path.startsWith(`${root}/`)));
167
+ }
168
+ function resolveEndpoint(specPaths, endpoint) {
169
+ const wanted = toSegments(endpoint);
170
+ if (!wanted.length)
171
+ return [];
172
+ const matched = specPaths.filter((specPath) => {
173
+ const segments = toSegments(specPath);
174
+ if (segments.length !== wanted.length)
175
+ return false;
176
+ return segmentsMatch(segments, wanted);
177
+ });
178
+ const literals = matched.map((specPath) => toSegments(specPath).filter((segment, i) => segment === wanted[i]).length);
179
+ const best = Math.max(0, ...literals);
180
+ return matched.filter((_, i) => literals[i] === best);
181
+ }
182
+ function matchesPattern(specPath, pattern) {
183
+ const wanted = toSegments(pattern);
184
+ const segments = toSegments(specPath);
185
+ if (segments.length < wanted.length)
186
+ return false;
187
+ return segmentsMatch(segments, wanted);
188
+ }
189
+ function segmentsMatch(segments, wanted) {
190
+ return wanted.every((want, i) => want === '*' || segments[i] === want || segments[i].startsWith('{'));
191
+ }
192
+ function toCollection(specPath, specPaths, pattern) {
193
+ const segments = toSegments(specPath);
194
+ const filled = toSegments(fillParameters(specPath, pattern));
195
+ let deepest = segments.length;
196
+ const unfilled = filled.findIndex((segment) => segment.startsWith('{'));
197
+ if (unfilled >= 0)
198
+ deepest = unfilled;
199
+ for (let i = Math.max(1, Math.min(toSegments(pattern).length, deepest)); i <= deepest; i++) {
200
+ const prefix = `/${segments.slice(0, i).join('/')}`;
201
+ if (specPaths.includes(prefix))
202
+ return prefix;
203
+ }
204
+ return specPath;
205
+ }
206
+ function fillParameters(specPath, pattern) {
207
+ const wanted = toSegments(pattern);
208
+ const segments = toSegments(specPath);
209
+ for (let i = 0; i < wanted.length && i < segments.length; i++) {
210
+ if (wanted[i] === '*')
211
+ continue;
212
+ if (!segments[i].startsWith('{'))
213
+ continue;
214
+ segments[i] = wanted[i];
215
+ }
216
+ return `/${segments.join('/')}`;
217
+ }
218
+ function listParameters(specPaths) {
219
+ const found = new Set();
220
+ for (const specPath of specPaths) {
221
+ for (const segment of toSegments(specPath)) {
222
+ if (segment.startsWith('{'))
223
+ found.add(segment);
224
+ }
225
+ }
226
+ return [...found].join(', ');
227
+ }
228
+ function toSegments(path) {
229
+ return path.split('/').filter(Boolean);
230
+ }
139
231
  function matchesEndpoint(specPath, endpoint) {
140
232
  if (specPath === endpoint)
141
233
  return true;
@@ -7,15 +7,15 @@ export interface Suggestion {
7
7
  command?: string;
8
8
  hint: string;
9
9
  }
10
- export declare abstract class BaseCommand {
10
+ export declare abstract class BaseCommand<T = ExplorBot> {
11
11
  abstract name: string;
12
12
  abstract description: string;
13
13
  aliases: string[];
14
14
  options: CommandOption[];
15
15
  tuiEnabled: boolean;
16
16
  suggestions: Suggestion[];
17
- explorBot: ExplorBot;
18
- constructor(explorBot: ExplorBot);
17
+ explorBot: T;
18
+ constructor(explorBot: T);
19
19
  abstract execute(args: string): Promise<void>;
20
20
  matches(commandName: string): boolean;
21
21
  printSuggestions(): void;
@@ -1,6 +1,9 @@
1
+ import { type ModelRole } from '../config.js';
2
+ export declare function envTemplate(provider: string): string;
1
3
  export declare function runInit(options: InitCommandOptions): Promise<void>;
2
4
  export declare function writeGlobalConfig(provider: string, apiKey?: string): void;
3
5
  export declare function runInitCommand(options: InitCommandOptions): void;
6
+ export declare function modelLines(provider: string, only?: ModelRole[]): string;
4
7
  type InitCommandOptions = {
5
8
  configPath?: string;
6
9
  force?: boolean;
@@ -37,7 +37,7 @@ ${modelLines(provider)}
37
37
  ${moduleExport}
38
38
  `;
39
39
  }
40
- function envTemplate(provider) {
40
+ export function envTemplate(provider) {
41
41
  const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
42
42
  if (name === provider)
43
43
  return `${envKey}=`;
@@ -219,14 +219,17 @@ async function renderLocalProviderWizard() {
219
219
  }), { exitOnCtrlC: false, patchConsole: false });
220
220
  });
221
221
  }
222
- function modelLines(provider) {
222
+ export function modelLines(provider, only) {
223
223
  const recommended = ConfigParser.recommendedModels()[provider] || {};
224
224
  const roles = [
225
225
  ['model', 'fast model with tool calling capabilities'],
226
226
  ['visionModel', 'vision model for screenshot analysis'],
227
227
  ['agenticModel', 'agentic model for decision making'],
228
228
  ];
229
- return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
229
+ let selected = roles;
230
+ if (only)
231
+ selected = roles.filter(([role]) => only.includes(role));
232
+ return selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
230
233
  }
231
234
  function globalConfigTemplate(provider) {
232
235
  const { envKey } = PROVIDERS[provider];
@@ -3,5 +3,12 @@ import { BaseOption } from './base-option.js';
3
3
  export declare class WsOption extends BaseOption {
4
4
  flags: string;
5
5
  description: string;
6
+ /**
7
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
8
+ * either runtime — so a command that ends by returning rather than through
9
+ * `showStatsAndExit` would never exit once it is attached. The option that
10
+ * opened the connection is what closes it.
11
+ */
12
+ register(command: Command): void;
6
13
  apply(options: Record<string, any>, command: Command): void;
7
14
  }
@@ -3,6 +3,20 @@ import { BaseOption } from './base-option.js';
3
3
  export class WsOption extends BaseOption {
4
4
  flags = '--ws <url>';
5
5
  description = 'Stream this run to a remote UI over WebSocket';
6
+ /**
7
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
8
+ * either runtime — so a command that ends by returning rather than through
9
+ * `showStatsAndExit` would never exit once it is attached. The option that
10
+ * opened the connection is what closes it.
11
+ */
12
+ register(command) {
13
+ super.register(command);
14
+ command.hook('postAction', async () => {
15
+ if (!remote.isAttached())
16
+ return;
17
+ await remote.close(0);
18
+ });
19
+ }
6
20
  apply(options, command) {
7
21
  const url = options.ws || process.env.EXPLORBOT_WS_URL;
8
22
  if (!url)
@@ -287,6 +287,7 @@ export declare function modelName(model: unknown): string;
287
287
  export declare function modelProvider(model: unknown): string;
288
288
  export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
289
289
  export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
290
+ export declare function resolveLangfuse(ai?: AIConfig): void;
290
291
  export declare function resolveOutputRoot(baseUrl?: string): string;
291
292
  export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
292
293
  export declare function materializeKnowledge(outputRoot: string): void;
@@ -377,17 +377,7 @@ export class ConfigParser {
377
377
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
378
378
  config.playwright.url = options.baseUrl;
379
379
  }
380
- if (config.ai) {
381
- const langfuse = config.ai.langfuse;
382
- const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
383
- const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
384
- config.ai.langfuse = {
385
- enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
386
- publicKey,
387
- secretKey,
388
- baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
389
- };
390
- }
380
+ resolveLangfuse(config.ai);
391
381
  return config;
392
382
  }
393
383
  validateConfig(config) {
@@ -540,6 +530,19 @@ export async function resolveConfigModels(ai) {
540
530
  agent.model = await resolveModel(agent.model);
541
531
  }
542
532
  }
533
+ export function resolveLangfuse(ai) {
534
+ if (!ai)
535
+ return;
536
+ const langfuse = ai.langfuse;
537
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
538
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
539
+ ai.langfuse = {
540
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
541
+ publicKey,
542
+ secretKey,
543
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
544
+ };
545
+ }
543
546
  export function resolveOutputRoot(baseUrl) {
544
547
  if (cachedOutputRoot)
545
548
  return cachedOutputRoot;
@@ -39,7 +39,7 @@ declare class Explorer {
39
39
  testPageErrorHandler: ((error: Error) => void) | null;
40
40
  testConsoleHandler: ((message: any) => void) | null;
41
41
  testDialogHandler: ((dialog: any) => void) | null;
42
- eventDispatcher: any;
42
+ eventDispatcher: NodeJS.EventEmitter<[never]>;
43
43
  constructor(config: ExplorbotConfig, options: ExplorerOptions | undefined, deps: ExplorerDeps);
44
44
  get actor(): CodeceptJS.I;
45
45
  get page(): Page | null;
@@ -208,7 +208,7 @@ class Explorer {
208
208
  codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
209
209
  }
210
210
  convertToCodeceptConfig(config) {
211
- const playwrightConfig = { ...config.playwright };
211
+ const playwrightConfig = { visibleLocator: true, ...config.playwright };
212
212
  if (this.options?.show !== undefined) {
213
213
  playwrightConfig.show = this.options.show;
214
214
  }
@@ -18,6 +18,7 @@ export declare class Remote implements LogDestination {
18
18
  asks: Map<string, (value: string | null) => void>;
19
19
  askCounter: number;
20
20
  lastActivity: string | null;
21
+ closing: Promise<void> | null;
21
22
  attach(url: string, command: string): void;
22
23
  isAttached(): boolean;
23
24
  send(type: string, data?: Record<string, unknown>): void;
@@ -30,6 +31,7 @@ export declare class Remote implements LogDestination {
30
31
  * back out of flattened text.
31
32
  */
32
33
  write(entry: TaggedLogEntry): void;
34
+ shutdown(exitCode: number): Promise<void>;
33
35
  connect(): void;
34
36
  reconnect(): void;
35
37
  flush(): Promise<void>;
@@ -26,6 +26,7 @@ export class Remote {
26
26
  asks = new Map();
27
27
  askCounter = 0;
28
28
  lastActivity = null;
29
+ closing = null;
29
30
  attach(url, command) {
30
31
  if (this.url)
31
32
  return;
@@ -68,23 +69,12 @@ export class Remote {
68
69
  });
69
70
  });
70
71
  }
71
- async close(exitCode) {
72
+ close(exitCode) {
72
73
  if (!this.url)
73
- return;
74
- this.send('result', { ok: exitCode === 0, exitCode });
75
- await this.flush();
76
- this.url = null;
77
- if (this.reconnectTimer)
78
- clearTimeout(this.reconnectTimer);
79
- // Whoever asks next has nobody to ask — leaving the callback installed would
80
- // route them into a closed socket and park them until the ask times out.
81
- executionController.clearInputCallback();
82
- for (const resolve of this.asks.values())
83
- resolve(null);
84
- this.asks.clear();
85
- this.queue = [];
86
- this.socket?.close();
87
- this.socket = null;
74
+ return Promise.resolve();
75
+ if (!this.closing)
76
+ this.closing = this.shutdown(exitCode);
77
+ return this.closing;
88
78
  }
89
79
  isEnabled() {
90
80
  return this.isAttached();
@@ -112,6 +102,23 @@ export class Remote {
112
102
  error: this.errorOf(entry.originalArgs),
113
103
  });
114
104
  }
105
+ async shutdown(exitCode) {
106
+ this.send('result', { ok: exitCode === 0, exitCode });
107
+ await this.flush();
108
+ this.url = null;
109
+ if (this.reconnectTimer)
110
+ clearTimeout(this.reconnectTimer);
111
+ // Whoever asks next has nobody to ask — leaving the callback installed would
112
+ // route them into a closed socket and park them until the ask times out.
113
+ executionController.clearInputCallback();
114
+ for (const resolve of this.asks.values())
115
+ resolve(null);
116
+ this.asks.clear();
117
+ this.queue = [];
118
+ this.socket?.close();
119
+ this.socket = null;
120
+ this.closing = null;
121
+ }
115
122
  connect() {
116
123
  if (!this.url)
117
124
  return;
@@ -16,4 +16,6 @@ export declare const LARGE_ARIA_CHANGE_THRESHOLD = 50;
16
16
  export interface AriaDiff {
17
17
  text: string | null;
18
18
  count: number;
19
+ added: number;
20
+ removed: number;
19
21
  }
@@ -511,7 +511,12 @@ export const diffAriaSnapshots = (previous, current) => {
511
511
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
512
512
  const added = [...byCount.added, ...renames.added];
513
513
  const removed = [...byCount.removed, ...renames.removed];
514
- return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
514
+ return {
515
+ text: formatDiff(added, removed, toggled, typed),
516
+ count: added.length + removed.length + toggled.length + typed.length,
517
+ added: added.length,
518
+ removed: removed.length,
519
+ };
515
520
  };
516
521
  export const detectFocusArea = (snapshot) => {
517
522
  let tree = parseSnapshot(snapshot);
@@ -4,7 +4,7 @@ import { isDynamicId, isGenericClass } from "./xpath.js";
4
4
  const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
5
5
  const SHELL_RATIO = 0.8;
6
6
  const ROOT_CONTENT_RATIO = 0.8;
7
- const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
7
+ const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log', 'tooltip']);
8
8
  const TEXT_LINE_PREFIX = 'TEXT:';
9
9
  const MESSAGE_MAX_LENGTH = 200;
10
10
  const MESSAGE_LIMIT = 8;
@@ -447,6 +447,9 @@ function semanticSelectorFor(element, allElements) {
447
447
  const selector = buildContainerSelector(current, allElements);
448
448
  if (selector)
449
449
  return selector;
450
+ const classAttr = (current.attrs ?? []).find((a) => a.name === 'class')?.value;
451
+ if (classAttr && filterContainerClasses(classAttr.split(/\s+/).filter(Boolean)).length > 0)
452
+ return undefined;
450
453
  current = dominantChild(current);
451
454
  }
452
455
  return undefined;
@@ -29,6 +29,8 @@ export declare class MarkdownQuery {
29
29
  text(): string;
30
30
  get(): string;
31
31
  toJson(): Record<string, string>[];
32
+ keyValue(): Record<string, string>;
33
+ setKeyValue(key: string, value: string | null): string;
32
34
  replace(content: string): string;
33
35
  replaceEach(replacer: (match: MarkdownQuery, index: number) => string): string;
34
36
  count(): number;
@@ -129,6 +129,12 @@ function matchText(text, matcher) {
129
129
  }
130
130
  return matcher.negated ? !result : result;
131
131
  }
132
+ function entryKey(line) {
133
+ const separator = line.indexOf(':');
134
+ if (separator < 1)
135
+ return null;
136
+ return line.slice(0, separator).trim().toLowerCase();
137
+ }
132
138
  function getTokenText(token) {
133
139
  const t = token;
134
140
  switch (token.type) {
@@ -340,6 +346,39 @@ export class MarkdownQuery {
340
346
  }
341
347
  return results;
342
348
  }
349
+ keyValue() {
350
+ const entries = {};
351
+ for (const range of this.matches) {
352
+ for (const line of getTokenText(range.token).split('\n')) {
353
+ const key = entryKey(line);
354
+ if (!key)
355
+ continue;
356
+ const value = line.slice(line.indexOf(':') + 1).trim();
357
+ if (value)
358
+ entries[key] = value;
359
+ }
360
+ }
361
+ return entries;
362
+ }
363
+ setKeyValue(key, value) {
364
+ return this.replaceEach((match) => {
365
+ const token = match.matches[0].token;
366
+ const lines = getTokenText(token)
367
+ .split('\n')
368
+ .map((line) => line.trim())
369
+ .filter(Boolean);
370
+ const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
371
+ if (index < 0 && value)
372
+ lines.push(`${key}: ${value}`);
373
+ if (index >= 0 && value)
374
+ lines[index] = `${key}: ${value}`;
375
+ if (index >= 0 && !value)
376
+ lines.splice(index, 1);
377
+ if (token.type !== 'blockquote')
378
+ return lines.join('\n');
379
+ return lines.map((line) => `> ${line}`).join('\n');
380
+ });
381
+ }
343
382
  replace(content) {
344
383
  return this.replaceEach(() => content);
345
384
  }
@@ -0,0 +1,16 @@
1
+ export declare function inspectList(css: string): ListMeasure | null;
2
+ export declare function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void;
3
+ export type PaginationStrategy = 'controls' | 'infinite';
4
+ export interface ListMeasure {
5
+ hasPagingControls: boolean;
6
+ isFeed: boolean;
7
+ scrolls: boolean;
8
+ items: number;
9
+ scrollTop: number;
10
+ pageScrollY: number;
11
+ }
12
+ export interface ScrollPosition {
13
+ css: string;
14
+ scrollTop: number;
15
+ pageScrollY: number;
16
+ }
@@ -0,0 +1,20 @@
1
+ export function inspectList(css) {
2
+ const element = document.querySelector(css);
3
+ if (!element)
4
+ return null;
5
+ const rect = element.getBoundingClientRect();
6
+ return {
7
+ hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
8
+ isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
9
+ scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
10
+ items: element.querySelectorAll(':scope > *').length,
11
+ scrollTop: element.scrollTop,
12
+ pageScrollY: window.scrollY,
13
+ };
14
+ }
15
+ export function restoreScroll({ css, scrollTop, pageScrollY }) {
16
+ const element = document.querySelector(css);
17
+ if (element)
18
+ element.scrollTop = scrollTop;
19
+ window.scrollTo(0, pageScrollY);
20
+ }
@@ -66,7 +66,7 @@ npx explorbot api explore https://api.example.com/v1 \
66
66
  -H "Authorization: Bearer $TOKEN"
67
67
  ```
68
68
 
69
- `api explore` takes the base endpoint as its argument, so one line covers the whole run: it plans in every style, executes each plan, and reports the totals. The other commands take a path within the API and read the base from `--endpoint`:
69
+ `api explore` takes an endpoint or a pattern as its argument, so one line covers the whole run: it plans, executes each plan, and reports the totals. Passing the base endpoint covers every collection the spec describes. The other commands take a path within the API and read the base from `--endpoint`:
70
70
 
71
71
  ```bash
72
72
  npx explorbot api plan /users \
@@ -81,7 +81,7 @@ The base endpoint keeps its path prefix: given `https://api.example.com/v1`, ste
81
81
 
82
82
  ### A dedicated API project
83
83
 
84
- If you don't have a web `explorbot.config.js`, run `npx explorbot api init`. It asks for your base endpoint, spec, and a one-line description of the API, then writes a standalone `apibot.config.ts` (with an `ai` and `api` section) plus `output/` and `knowledge/` directories. When both files exist, `apibot.config.*` takes precedence over `explorbot.config.*`.
84
+ If you don't have a web `explorbot.config.js`, run `npx explorbot api init`. It asks for your base endpoint, spec, and a one-line description of the API, then writes a standalone `apibot.config.js` (with an `ai` and `api` section) plus `output/` and `knowledge/` directories. When both files exist, `apibot.config.*` takes precedence over `explorbot.config.*`.
85
85
 
86
86
  ## Your first run
87
87
 
@@ -99,6 +99,30 @@ npx explorbot api test output/plans/users.md
99
99
 
100
100
  Curler runs the scenarios and prints how many passed and failed.
101
101
 
102
+ ### Covering many endpoints
103
+
104
+ `api explore` runs the whole loop for you: plan, test, re-plan. Given one endpoint it plans in every style.
105
+
106
+ ```bash
107
+ npx explorbot api explore /users
108
+ ```
109
+
110
+ The endpoint may be a pattern. `*` stands for one path segment, and a pattern also covers the paths below it, so `/users` and `/users/*` both cover `/users/{id}`. Quote it, or your shell will try to expand it first.
111
+
112
+ ```bash
113
+ npx explorbot api explore '/projects/acme/*'
114
+ ```
115
+
116
+ Explorbot explores collections, not raw paths: `/users/{id}` and `/users/{id}/posts` fold into `/users`, whose spec lookup brings them along anyway. When a pattern matches several collections the planning styles spread across them, one style per collection, so covering a whole API stays one plan per collection rather than one per style.
117
+
118
+ Pass `/` to take every collection in the spec. The path parameters have to come from somewhere, so put them in the base endpoint.
119
+
120
+ ```bash
121
+ npx explorbot api explore / --endpoint https://api.example.com/v2/acme
122
+ ```
123
+
124
+ If a parameter is left with no value the run stops and names it, rather than sending requests to a literal `{project_id}`. Collections whose own parameters no pattern can fill, like `/analytics/stats/{kind}`, are listed and skipped.
125
+
102
126
  ## Output files
103
127
 
104
128
  | Output | Location | What it is |