explorbot 0.4.1 → 0.4.3

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 (141) hide show
  1. package/bin/explorbot-cli.ts +14 -4
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +14 -4
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
  35. package/dist/src/ai/fisherman/request-haul.js +31 -0
  36. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  37. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
  38. package/dist/src/ai/fisherman.d.ts +14 -4
  39. package/dist/src/ai/fisherman.js +96 -18
  40. package/dist/src/ai/pilot.d.ts +13 -1
  41. package/dist/src/ai/pilot.js +40 -22
  42. package/dist/src/ai/provider.js +8 -14
  43. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  44. package/dist/src/ai/researcher/coordinates.js +13 -1
  45. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  46. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  47. package/dist/src/ai/researcher.js +1 -1
  48. package/dist/src/ai/rules.js +10 -0
  49. package/dist/src/ai/tester.js +20 -20
  50. package/dist/src/ai/tools.d.ts +1 -1
  51. package/dist/src/ai/tools.js +18 -74
  52. package/dist/src/api/request-result.js +3 -1
  53. package/dist/src/api/request-store.d.ts +7 -6
  54. package/dist/src/api/request-store.js +65 -37
  55. package/dist/src/api/xhr-capture.d.ts +2 -0
  56. package/dist/src/api/xhr-capture.js +35 -10
  57. package/dist/src/commands/config-command.js +2 -9
  58. package/dist/src/commands/help-json-command.d.ts +31 -0
  59. package/dist/src/commands/help-json-command.js +58 -0
  60. package/dist/src/commands/index.js +2 -0
  61. package/dist/src/commands/init-command.js +3 -7
  62. package/dist/src/commands/plans-command.d.ts +4 -1
  63. package/dist/src/commands/plans-command.js +4 -4
  64. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  65. package/dist/src/commands/recommended-models-command.js +85 -0
  66. package/dist/src/config.d.ts +2 -0
  67. package/dist/src/config.js +7 -2
  68. package/dist/src/experience-tracker.js +1 -1
  69. package/dist/src/explorbot.js +3 -3
  70. package/dist/src/global-config.d.ts +2 -0
  71. package/dist/src/global-config.js +6 -0
  72. package/dist/src/state-manager.d.ts +3 -3
  73. package/dist/src/state-manager.js +8 -8
  74. package/dist/src/test-plan.d.ts +1 -0
  75. package/dist/src/test-plan.js +13 -9
  76. package/dist/src/utils/aria.d.ts +1 -1
  77. package/dist/src/utils/aria.js +23 -2
  78. package/dist/src/utils/cli-section.d.ts +1 -0
  79. package/dist/src/utils/cli-section.js +12 -0
  80. package/dist/src/utils/html-diff.d.ts +1 -0
  81. package/dist/src/utils/html-diff.js +5 -5
  82. package/dist/src/utils/html.d.ts +2 -0
  83. package/dist/src/utils/html.js +5 -4
  84. package/dist/src/utils/overlay.d.ts +19 -55
  85. package/dist/src/utils/overlay.js +94 -148
  86. package/dist/src/utils/region.d.ts +41 -0
  87. package/dist/src/utils/region.js +99 -0
  88. package/dist/src/utils/similarity.d.ts +1 -0
  89. package/dist/src/utils/similarity.js +11 -0
  90. package/dist/src/utils/url-matcher.d.ts +1 -1
  91. package/dist/src/utils/url-matcher.js +4 -4
  92. package/docs/doc-collection/basics.md +11 -5
  93. package/docs/doc-collection/crawling.md +21 -1
  94. package/docs/reference/commands.md +4 -0
  95. package/docs/reference/configuration.md +4 -0
  96. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  97. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  98. package/docs/workflow/agentic-usage.md +12 -0
  99. package/docs/workflow/application-spec.md +4 -0
  100. package/package.json +1 -1
  101. package/src/action-result.ts +15 -16
  102. package/src/action.ts +16 -15
  103. package/src/ai/fisherman/request-haul.ts +36 -0
  104. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
  105. package/src/ai/fisherman.ts +110 -20
  106. package/src/ai/pilot.ts +38 -20
  107. package/src/ai/provider.ts +8 -12
  108. package/src/ai/researcher/coordinates.ts +13 -1
  109. package/src/ai/researcher/deep-analysis.ts +9 -9
  110. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  111. package/src/ai/researcher.ts +1 -1
  112. package/src/ai/rules.ts +10 -0
  113. package/src/ai/tester.ts +18 -18
  114. package/src/ai/tools.ts +18 -86
  115. package/src/api/request-result.ts +2 -1
  116. package/src/api/request-store.ts +68 -42
  117. package/src/api/xhr-capture.ts +39 -11
  118. package/src/commands/config-command.ts +2 -7
  119. package/src/commands/help-json-command.ts +74 -0
  120. package/src/commands/index.ts +2 -0
  121. package/src/commands/init-command.ts +4 -11
  122. package/src/commands/plans-command.ts +5 -5
  123. package/src/commands/recommended-models-command.ts +92 -0
  124. package/src/config.ts +9 -2
  125. package/src/experience-tracker.ts +1 -1
  126. package/src/explorbot.ts +3 -3
  127. package/src/global-config.ts +8 -0
  128. package/src/state-manager.ts +10 -9
  129. package/src/test-plan.ts +12 -10
  130. package/src/utils/aria.ts +26 -3
  131. package/src/utils/cli-section.ts +12 -0
  132. package/src/utils/html-diff.ts +6 -6
  133. package/src/utils/html.ts +6 -4
  134. package/src/utils/overlay.ts +105 -192
  135. package/src/utils/region.ts +132 -0
  136. package/src/utils/similarity.ts +9 -0
  137. package/src/utils/url-matcher.ts +3 -3
  138. package/dist/src/ai/fisherman-tools.d.ts +0 -138
  139. package/dist/src/utils/request-map.d.ts +0 -7
  140. package/dist/src/utils/request-map.js +0 -16
  141. package/src/utils/request-map.ts +0 -19
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { isDynamicSegment } from "../utils/url-matcher.js";
3
+ import { generalizeUrl, isDynamicSegment } from "../utils/url-matcher.js";
4
4
  import { RequestResult } from "./request-result.js";
5
5
  const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
6
6
  export class RequestStore {
@@ -10,6 +10,7 @@ export class RequestStore {
10
10
  onFailedListeners = [];
11
11
  outputDir;
12
12
  sessionStartedAt = new Date();
13
+ readEndpointKeys = new Set();
13
14
  constructor(outputDir) {
14
15
  this.outputDir = outputDir;
15
16
  }
@@ -17,6 +18,14 @@ export class RequestStore {
17
18
  this.capturedRequests.push(result);
18
19
  result.save(this.outputDir);
19
20
  }
21
+ addReadRequest(result) {
22
+ const key = readEndpointKey(result);
23
+ if (this.readEndpointKeys.has(key))
24
+ return;
25
+ this.readEndpointKeys.add(key);
26
+ this.capturedRequests.push(result);
27
+ result.save(this.outputDir);
28
+ }
20
29
  addFailedRequest(result) {
21
30
  this.failedRequests.push(result);
22
31
  for (const cb of this.onFailedListeners) {
@@ -38,39 +47,23 @@ export class RequestStore {
38
47
  this.madeRequests.push(result);
39
48
  result.save(this.outputDir);
40
49
  }
41
- addRequest(result) {
42
- this.addMadeRequest(result);
43
- }
44
50
  getCapturedRequests() {
45
51
  return this.capturedRequests;
46
52
  }
47
53
  getMadeRequests() {
48
54
  return this.madeRequests;
49
55
  }
50
- getRequests() {
51
- return this.madeRequests;
52
- }
53
56
  getLastRequest() {
54
57
  return this.madeRequests[this.madeRequests.length - 1];
55
58
  }
56
- getRequestsByEndpoint(pathPrefix) {
57
- return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix));
58
- }
59
- getRequestsByMethod(method) {
60
- const upper = method.toUpperCase();
61
- return this.madeRequests.filter((r) => r.method === upper);
62
- }
63
- getRequestsByStatus(status) {
64
- return this.madeRequests.filter((r) => r.status === status);
65
- }
66
- toEndpointList(scopePath) {
67
- let requests = this.capturedRequests;
59
+ toEndpointList(scopePath, methods = 'write') {
60
+ let requests = this.capturedRequests.filter((r) => matchesFamily(r, methods));
68
61
  if (scopePath)
69
- requests = this.getWriteRequestsForScope(scopePath);
62
+ requests = this.getRequestsForScope(scopePath, methods);
70
63
  const seen = new Set();
71
64
  const lines = [];
72
65
  for (const req of requests) {
73
- const key = `${req.method} ${normalizePathPattern(req.path)}`;
66
+ const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}${queryParamHint(req)}`;
74
67
  if (seen.has(key))
75
68
  continue;
76
69
  seen.add(key);
@@ -92,13 +85,17 @@ export class RequestStore {
92
85
  }
93
86
  findCapturedRequest(method, searchPath) {
94
87
  const upper = method.toUpperCase();
95
- const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
88
+ const search = generalizeUrl(searchPath, () => '{id}')
89
+ .split('/')
90
+ .filter(Boolean);
96
91
  let best;
97
92
  let bestScore = -1;
98
93
  for (const req of this.capturedRequests) {
99
94
  if (req.method !== upper)
100
95
  continue;
101
- const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
96
+ const segments = generalizeUrl(req.path, () => '{id}')
97
+ .split('/')
98
+ .filter(Boolean);
102
99
  if (segments.length < search.length)
103
100
  continue;
104
101
  if (!search.every((segment, i) => segment === segments[i]))
@@ -131,6 +128,12 @@ export class RequestStore {
131
128
  const result = RequestResult.load(path.join(requestsDir, file));
132
129
  if (existingIds.has(result.id))
133
130
  continue;
131
+ if (!result.isWrite) {
132
+ const key = readEndpointKey(result);
133
+ if (this.readEndpointKeys.has(key))
134
+ continue;
135
+ this.readEndpointKeys.add(key);
136
+ }
134
137
  this.capturedRequests.push(result);
135
138
  }
136
139
  catch {
@@ -139,18 +142,29 @@ export class RequestStore {
139
142
  }
140
143
  }
141
144
  getWriteRequestsForScope(scopePath) {
142
- const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
143
- const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
145
+ return this.getRequestsForScope(scopePath, 'write');
146
+ }
147
+ getReadRequestsForScope(scopePath) {
148
+ return this.getRequestsForScope(scopePath, 'read');
149
+ }
150
+ clear() {
151
+ this.capturedRequests = [];
152
+ this.madeRequests = [];
153
+ this.failedRequests = [];
154
+ this.readEndpointKeys.clear();
155
+ }
156
+ getRequestsForScope(scopePath, methods) {
157
+ const candidates = this.capturedRequests.filter((r) => matchesFamily(r, methods));
144
158
  const scopeSegments = scopePath.split('/').filter(Boolean);
145
159
  if (scopeSegments.length === 0)
146
- return writes;
160
+ return candidates;
147
161
  let scoped = [];
148
162
  let fewest = Number.POSITIVE_INFINITY;
149
163
  let ambiguous = false;
150
164
  for (const segment of scopeSegments) {
151
165
  if (isDynamicSegment(segment))
152
166
  continue;
153
- const matches = writes.filter((r) => r.path.split('/').includes(segment));
167
+ const matches = candidates.filter((r) => r.path.split('/').includes(segment));
154
168
  if (matches.length === 0 || matches.length > fewest)
155
169
  continue;
156
170
  if (matches.length === fewest) {
@@ -166,15 +180,29 @@ export class RequestStore {
166
180
  return [];
167
181
  return scoped;
168
182
  }
169
- clear() {
170
- this.capturedRequests = [];
171
- this.madeRequests = [];
172
- this.failedRequests = [];
173
- }
174
183
  }
175
- function normalizePathPattern(urlPath) {
176
- return urlPath
177
- .split('/')
178
- .map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
179
- .join('/');
184
+ export function isFailedRequest(request) {
185
+ return request.status >= 400 || Boolean(request.error);
186
+ }
187
+ function readEndpointKey(result) {
188
+ return `${result.method} ${generalizeUrl(result.path, () => '{id}')}?${queryParamNames(result).join(',')}`;
189
+ }
190
+ function matchesFamily(result, methods) {
191
+ if (methods === 'write')
192
+ return result.isWrite;
193
+ return result.method === 'GET';
194
+ }
195
+ function queryParamHint(result) {
196
+ if (result.isWrite)
197
+ return '';
198
+ const names = queryParamNames(result);
199
+ if (names.length === 0)
200
+ return '';
201
+ return ` ?${names.join(',')}`;
202
+ }
203
+ function queryParamNames(result) {
204
+ const query = result.fullUrl.split('?')[1];
205
+ if (!query)
206
+ return [];
207
+ return [...new Set(new URLSearchParams(query).keys())].sort();
180
208
  }
@@ -7,4 +7,6 @@ export declare class XhrCapture {
7
7
  attach(page: any): void;
8
8
  detach(page: any): void;
9
9
  captureResponse(response: any): Promise<void>;
10
+ captureReadEndpoint(request: any, response: any): void;
11
+ toHeaderMap(headers: Record<string, unknown>): Record<string, string>;
10
12
  }
@@ -52,24 +52,24 @@ export class XhrCapture {
52
52
  });
53
53
  this.store.addFailedRequest(failure);
54
54
  }
55
- if (!WRITE_METHODS.has(method))
56
- return;
57
55
  const contentType = response.headers()['content-type'] || '';
58
56
  if (!JSON_CONTENT_TYPES.test(contentType))
59
57
  return;
58
+ if (method === 'GET') {
59
+ if (status !== 200)
60
+ return;
61
+ this.captureReadEndpoint(request, response);
62
+ return;
63
+ }
64
+ if (!WRITE_METHODS.has(method))
65
+ return;
60
66
  if (status === 304)
61
67
  return;
62
68
  const parsedUrl = new URL(url);
63
69
  const origin = parsedUrl.pathname + parsedUrl.search;
64
70
  const id = generateRequestId(method, parsedUrl.pathname, 'xhr_');
65
- const requestHeaders = {};
66
- for (const [k, v] of Object.entries(request.headers())) {
67
- requestHeaders[k] = String(v);
68
- }
69
- const responseHeaders = {};
70
- for (const [k, v] of Object.entries(response.headers())) {
71
- responseHeaders[k] = String(v);
72
- }
71
+ const requestHeaders = this.toHeaderMap(request.headers());
72
+ const responseHeaders = this.toHeaderMap(response.headers());
73
73
  let rawBody = '';
74
74
  try {
75
75
  rawBody = await response.text();
@@ -103,4 +103,29 @@ export class XhrCapture {
103
103
  result.rawResponseBodyValue = rawBody;
104
104
  this.store.addCapturedRequest(result);
105
105
  }
106
+ captureReadEndpoint(request, response) {
107
+ const parsedUrl = new URL(request.url());
108
+ const requestHeaders = this.toHeaderMap(request.headers());
109
+ const result = new RequestResult({
110
+ id: generateRequestId('GET', parsedUrl.pathname, 'xhr_'),
111
+ method: 'GET',
112
+ path: parsedUrl.pathname,
113
+ fullUrl: parsedUrl.pathname + parsedUrl.search,
114
+ requestHeaders,
115
+ status: response.status(),
116
+ statusText: response.statusText(),
117
+ responseHeaders: {},
118
+ timing: 0,
119
+ timestamp: new Date(),
120
+ });
121
+ result.rawResponseBodyValue = '';
122
+ this.store.addReadRequest(result);
123
+ }
124
+ toHeaderMap(headers) {
125
+ const map = {};
126
+ for (const [k, v] of Object.entries(headers)) {
127
+ map[k] = String(v);
128
+ }
129
+ return map;
130
+ }
106
131
  }
@@ -5,6 +5,7 @@ import { ConfigParser, EXPLORBOT_ENV_VARS, configuredModels } from '../config.js
5
5
  import { listSites } from '../global-config.js';
6
6
  import { Reporter } from '../reporter.js';
7
7
  import { getCliName } from '../utils/cli-name.js';
8
+ import { renderSection } from '../utils/cli-section.js';
8
9
  import { tag } from '../utils/logger.js';
9
10
  import { BaseCommand } from './base-command.js';
10
11
  export class ConfigCommand extends BaseCommand {
@@ -68,15 +69,7 @@ export class ConfigCommand extends BaseCommand {
68
69
  if (options.json)
69
70
  return JSON.stringify(data, null, 2);
70
71
  const lines = [];
71
- const section = (title, entries) => {
72
- if (!entries.length)
73
- return;
74
- const width = Math.max(...entries.map(([label]) => label.length));
75
- lines.push(chalk.bold(title));
76
- for (const [label, value] of entries)
77
- lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
78
- lines.push('');
79
- };
72
+ const section = (title, entries) => lines.push(...renderSection(title, entries));
80
73
  const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
81
74
  if (data.url)
82
75
  general.push(['url', data.url]);
@@ -0,0 +1,31 @@
1
+ import type { Command } from 'commander';
2
+ export declare class HelpJsonCommand {
3
+ static register(program: Command): void;
4
+ static data(cmd: Command, root?: boolean): CommandDefinition;
5
+ static find(cmd: Command, names: string[]): Command | undefined;
6
+ }
7
+ interface CommandDefinition {
8
+ name: string;
9
+ description: string;
10
+ aliases: string[];
11
+ version?: string;
12
+ arguments: {
13
+ name: string;
14
+ description: string;
15
+ required: boolean;
16
+ variadic: boolean;
17
+ }[];
18
+ options: {
19
+ flags: string;
20
+ description: string;
21
+ default?: unknown;
22
+ choices?: string[];
23
+ }[];
24
+ commands: CommandDefinition[];
25
+ env?: {
26
+ name: string;
27
+ description: string;
28
+ required: boolean;
29
+ }[];
30
+ }
31
+ export {};
@@ -0,0 +1,58 @@
1
+ import { EXPLORBOT_ENV_VARS } from '../config.js';
2
+ const DESCRIPTION = 'Print command definitions as JSON for agents and tools';
3
+ export class HelpJsonCommand {
4
+ static register(program) {
5
+ program
6
+ .command('help-json [command...]')
7
+ .description(DESCRIPTION)
8
+ .action(async (names) => {
9
+ const target = HelpJsonCommand.find(program, names);
10
+ if (!target) {
11
+ console.error(`Unknown command: ${names.join(' ')}`);
12
+ process.exit(1);
13
+ }
14
+ const json = JSON.stringify(HelpJsonCommand.data(target, target === program), null, 2);
15
+ await new Promise((resolve) => process.stdout.write(`${json}\n`, () => resolve()));
16
+ });
17
+ }
18
+ static data(cmd, root = false) {
19
+ const helper = cmd.createHelp();
20
+ const definition = {
21
+ name: cmd.name(),
22
+ description: cmd.description(),
23
+ aliases: cmd.aliases(),
24
+ arguments: cmd.registeredArguments.map((argument) => ({
25
+ name: argument.name(),
26
+ description: argument.description,
27
+ required: argument.required,
28
+ variadic: argument.variadic,
29
+ })),
30
+ options: helper.visibleOptions(cmd).map((option) => ({
31
+ flags: option.flags,
32
+ description: option.description,
33
+ default: option.defaultValue,
34
+ choices: option.argChoices,
35
+ })),
36
+ commands: helper.visibleCommands(cmd).map((sub) => HelpJsonCommand.data(sub)),
37
+ };
38
+ if (!root)
39
+ return definition;
40
+ definition.version = cmd.version();
41
+ definition.env = EXPLORBOT_ENV_VARS.map((variable) => ({
42
+ name: variable.name,
43
+ description: variable.description,
44
+ required: !!variable.required,
45
+ }));
46
+ return definition;
47
+ }
48
+ static find(cmd, names) {
49
+ let target = cmd;
50
+ for (const name of names) {
51
+ const sub = target.commands.find((candidate) => candidate.name() === name || candidate.aliases().includes(name));
52
+ if (!sub)
53
+ return undefined;
54
+ target = sub;
55
+ }
56
+ return target;
57
+ }
58
+ }
@@ -26,6 +26,7 @@ import { PlanLoadCommand } from './plan-load-command.js';
26
26
  import { PlanReloadCommand } from './plan-reload-command.js';
27
27
  import { PlanSaveCommand } from './plan-save-command.js';
28
28
  import { PlansCommand } from './plans-command.js';
29
+ import { RecommendedModelsCommand } from './recommended-models-command.js';
29
30
  import { RerunCommand } from './rerun-command.js';
30
31
  import { ResearchCommand } from './research-command.js';
31
32
  import { RunsCommand } from './runs-command.js';
@@ -66,6 +67,7 @@ const commandClasses = [
66
67
  RerunCommand,
67
68
  StatusCommand,
68
69
  ConfigCommand,
70
+ RecommendedModelsCommand,
69
71
  DebugCommand,
70
72
  ExitCommand,
71
73
  ];
@@ -1,7 +1,7 @@
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 { ConfigParser, PROVIDERS } from "../config.js";
4
+ import { ConfigParser, PROVIDERS, missingModelRoles } from "../config.js";
5
5
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
6
6
  import { getCliName } from "../utils/cli-name.js";
7
7
  import { log, tag } from '../utils/logger.js';
@@ -82,7 +82,7 @@ export function writeGlobalConfig(provider, apiKey) {
82
82
  const envKey = PROVIDERS[provider].envKey;
83
83
  writeEnvKey(envKey, apiKey || '');
84
84
  log(`Stored ${envKey} in ${globalEnvPath()}`);
85
- const missing = missingRoles(provider);
85
+ const missing = missingModelRoles(provider);
86
86
  if (missing.length) {
87
87
  tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
88
88
  }
@@ -139,7 +139,7 @@ export function runInitCommand(options) {
139
139
  else {
140
140
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
141
141
  }
142
- const missing = missingRoles(provider);
142
+ const missing = missingModelRoles(provider);
143
143
  if (missing.length) {
144
144
  tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
145
145
  }
@@ -269,10 +269,6 @@ function isModuleProject(configDir) {
269
269
  currentDir = parentDir;
270
270
  }
271
271
  }
272
- function missingRoles(provider) {
273
- const recommended = ConfigParser.recommendedModels()[provider] || {};
274
- return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
275
- }
276
272
  function writeEnvKey(key, value) {
277
273
  const envPath = globalEnvPath();
278
274
  let content = '# AI provider API keys';
@@ -11,7 +11,10 @@ export declare class PlansCommand extends BaseCommand {
11
11
  getPlanFiles(): PlanFile[];
12
12
  printPlans(files: PlanFile[]): void;
13
13
  printPlanDetails(plan: Plan, file: PlanFile): void;
14
- resolvePlanFile(target: string, files: PlanFile[]): PlanFile;
14
+ resolvePlanFile(target: string, files: PlanFile[]): {
15
+ plan: Plan;
16
+ file: PlanFile;
17
+ };
15
18
  }
16
19
  interface PlanFile {
17
20
  name: string;
@@ -17,8 +17,7 @@ export class PlansCommand extends BaseCommand {
17
17
  this.printPlans(files);
18
18
  return;
19
19
  }
20
- const file = this.resolvePlanFile(target, files);
21
- const plan = Plan.fromMarkdown(file.path);
20
+ const { plan, file } = this.resolvePlanFile(target, files);
22
21
  this.printPlanDetails(plan, file);
23
22
  }
24
23
  getPlanFiles() {
@@ -68,16 +67,17 @@ export class PlansCommand extends BaseCommand {
68
67
  const file = files[index - 1];
69
68
  if (!file)
70
69
  throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
71
- return file;
70
+ return { plan: Plan.fromMarkdown(file.path), file };
72
71
  }
73
72
  const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
74
73
  if (!plan?.filePath) {
75
74
  throw new Error(`Plan file not found: ${target}`);
76
75
  }
77
- return {
76
+ const file = {
78
77
  name: path.basename(plan.filePath),
79
78
  path: plan.filePath,
80
79
  modifiedAt: statSync(plan.filePath).mtimeMs,
81
80
  };
81
+ return { plan, file };
82
82
  }
83
83
  }
@@ -0,0 +1,9 @@
1
+ import type { Command } from 'commander';
2
+ import { BaseCommand } from './base-command.js';
3
+ export declare class RecommendedModelsCommand extends BaseCommand {
4
+ name: string;
5
+ description: string;
6
+ execute(): Promise<void>;
7
+ static register(program: Command): void;
8
+ static render(json?: boolean): string;
9
+ }
@@ -0,0 +1,85 @@
1
+ import chalk from 'chalk';
2
+ import { ConfigParser, EXPLORBOT_ENV_VARS, MODEL_ROLES, PROVIDERS } from '../config.js';
3
+ import { getCliName } from '../utils/cli-name.js';
4
+ import { renderSection } from '../utils/cli-section.js';
5
+ import { tag } from '../utils/logger.js';
6
+ import { BaseCommand } from './base-command.js';
7
+ const DESCRIPTION = 'Show the models recommended for every AI provider';
8
+ const ROLES = {
9
+ model: { hint: 'fast', env: 'EXPLORBOT_AI_MODEL' },
10
+ visionModel: { hint: 'screenshots', env: 'EXPLORBOT_VISION_MODEL' },
11
+ agenticModel: { hint: 'smart', env: 'EXPLORBOT_AGENTIC_MODEL' },
12
+ };
13
+ const MODEL_ENV_VARS = MODEL_ROLES.map((role) => ROLES[role].env);
14
+ export class RecommendedModelsCommand extends BaseCommand {
15
+ name = 'recommended-models';
16
+ description = DESCRIPTION;
17
+ async execute() {
18
+ tag('info').log(RecommendedModelsCommand.render());
19
+ }
20
+ static register(program) {
21
+ program
22
+ .command('recommended-models')
23
+ .description(DESCRIPTION)
24
+ .option('--json', 'Print the recommendations as JSON')
25
+ .action((options) => console.log(RecommendedModelsCommand.render(options.json)));
26
+ }
27
+ static render(json = false) {
28
+ const recommended = ConfigParser.recommendedModels();
29
+ if (json)
30
+ return JSON.stringify(recommended, null, 2);
31
+ const lines = [
32
+ chalk.bold('Export the provider key, then pick one of two ways to select models:'),
33
+ ` ${chalk.bold('EXPLORBOT_AI_PROVIDER')} — every role takes that provider's recommendation below`,
34
+ ` ${chalk.bold(MODEL_ENV_VARS.join(' + '))} — no provider variable, pin every role yourself as ${chalk.dim('provider/model-id')}`,
35
+ '',
36
+ ];
37
+ for (const [provider, roles] of Object.entries(recommended)) {
38
+ lines.push(chalk.bold.yellow(`${provider}:`));
39
+ const models = [];
40
+ for (const role of MODEL_ROLES) {
41
+ const label = `${role} (${ROLES[role].hint})`;
42
+ if (roles[role])
43
+ models.push([label, roles[role]]);
44
+ if (!roles[role])
45
+ models.push([label, chalk.dim('not served, pair with another provider')]);
46
+ }
47
+ lines.push(...renderSection('models', models, 2));
48
+ const envKey = PROVIDERS[provider]?.envKey;
49
+ if (!envKey) {
50
+ lines.push(` ${chalk.dim('not bundled — import the provider in your config')}`, '');
51
+ continue;
52
+ }
53
+ const env = [
54
+ ['key', envKey],
55
+ ['provider', `EXPLORBOT_AI_PROVIDER=${provider}`],
56
+ ];
57
+ let label = 'or models';
58
+ for (const role of MODEL_ROLES) {
59
+ if (!roles[role])
60
+ continue;
61
+ env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
62
+ label = '';
63
+ }
64
+ lines.push(...renderSection('env variables', env, 2));
65
+ }
66
+ const modelVars = ['EXPLORBOT_AI_PROVIDER', ...MODEL_ENV_VARS];
67
+ const current = [];
68
+ for (const variable of EXPLORBOT_ENV_VARS) {
69
+ if (!modelVars.includes(variable.name))
70
+ continue;
71
+ if (process.env[variable.name])
72
+ current.push([variable.name, process.env[variable.name]]);
73
+ }
74
+ for (const [provider, { envKey }] of Object.entries(PROVIDERS)) {
75
+ if (process.env[envKey])
76
+ current.push([envKey, chalk.dim(`set, ${provider} usable`)]);
77
+ }
78
+ if (!current.length)
79
+ lines.push(chalk.dim('Nothing set — export one of the variables above, or write models into your config'), '');
80
+ if (current.length)
81
+ lines.push(...renderSection('Currently set', current));
82
+ lines.push(chalk.bold('Example using OpenRouter:'), ` OPENROUTER_API_KEY=sk-... EXPLORBOT_AI_PROVIDER=openrouter ${getCliName()} <command>`);
83
+ return lines.join('\n');
84
+ }
85
+ }
@@ -1,5 +1,6 @@
1
1
  import { type SiteRecord } from './global-config.js';
2
2
  export declare const PROVIDERS: Record<string, ProviderInfo>;
3
+ export declare const MODEL_ROLES: ModelRole[];
3
4
  interface PlaywrightConfig {
4
5
  browser: 'chromium' | 'firefox' | 'webkit';
5
6
  url: string;
@@ -270,6 +271,7 @@ export declare class ConfigParser {
270
271
  }
271
272
  export declare function outputPath(...segments: string[]): string;
272
273
  export declare function resolveModel(spec: string, role?: ModelRole): Promise<any>;
274
+ export declare function missingModelRoles(provider: string): ModelRole[];
273
275
  export declare class ConfigMissingError extends Error {
274
276
  }
275
277
  export declare function envConfigRequested(): boolean;
@@ -25,6 +25,7 @@ export const PROVIDERS = {
25
25
  openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
26
26
  sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
27
27
  };
28
+ export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
28
29
  let cachedOutputRoot = null;
29
30
  const config = {
30
31
  playwright: {
@@ -458,6 +459,10 @@ export async function resolveModel(spec, role = 'model') {
458
459
  }
459
460
  return createModel(spec, modelId);
460
461
  }
462
+ export function missingModelRoles(provider) {
463
+ const recommended = ConfigParser.recommendedModels()[provider] || {};
464
+ return MODEL_ROLES.filter((role) => !recommended[role]);
465
+ }
461
466
  export class ConfigMissingError extends Error {
462
467
  }
463
468
  export function envConfigRequested() {
@@ -478,6 +483,7 @@ export function missingConfigMessage(configFile = 'explorbot.config.js') {
478
483
  EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
479
484
 
480
485
  Providers: ${Object.keys(PROVIDERS).join(', ')}
486
+ See the models each one recommends: ${cli} recommended-models
481
487
  `;
482
488
  }
483
489
  export function modelName(model) {
@@ -511,8 +517,7 @@ export function configuredModels(ai) {
511
517
  export async function resolveConfigModels(ai) {
512
518
  if (!ai)
513
519
  return;
514
- const roles = ['model', 'visionModel', 'agenticModel'];
515
- for (const role of roles) {
520
+ for (const role of MODEL_ROLES) {
516
521
  if (typeof ai[role] === 'string')
517
522
  ai[role] = await resolveModel(ai[role], role);
518
523
  }
@@ -105,7 +105,7 @@ export class ExperienceTracker {
105
105
  url: state.url ? extractStatePath(state.url) : '',
106
106
  title: state.title,
107
107
  };
108
- if (state.overlay.present && state.overlay.name) {
108
+ if (state.overlay.isOpen && state.overlay.name) {
109
109
  frontmatter.region = state.overlay.name;
110
110
  if (state.overlay.root)
111
111
  frontmatter.root = state.overlay.root;
@@ -399,10 +399,10 @@ export class ExplorBot {
399
399
  return plan;
400
400
  }
401
401
  loadPlans(filename) {
402
- const plan = Plan.loadFromFile(filename, this.getPlansDir());
403
- if (!plan?.filePath)
402
+ const filePath = Plan.resolveFile(filename, this.getPlansDir());
403
+ if (!filePath)
404
404
  throw new Error(`Plan file not found: ${filename}`);
405
- return parsePlansFromMarkdown(plan.filePath);
405
+ return parsePlansFromMarkdown(filePath);
406
406
  }
407
407
  setCurrentPlan(plan) {
408
408
  this.currentPlan = plan;
@@ -6,6 +6,8 @@ export declare function isGlobalConfigPath(configPath: string): boolean;
6
6
  export declare function sitesDir(): string;
7
7
  export declare function siteFolderName(url: string): string;
8
8
  export declare function listSites(): SiteRecord[];
9
+ export declare function findSiteWith(subpath: string): SiteRecord | undefined;
10
+ export declare function listSitePlanDirs(): string[];
9
11
  export declare function registerSite(baseUrl: string): SiteRecord;
10
12
  export declare function resolveSiteTarget(target?: string, defaultBaseUrl?: string): SiteTarget;
11
13
  interface SiteRecord {
@@ -38,6 +38,12 @@ export function listSites() {
38
38
  .filter((site) => !!site)
39
39
  .sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
40
40
  }
41
+ export function findSiteWith(subpath) {
42
+ return listSites().find((site) => existsSync(join(site.dir, subpath)));
43
+ }
44
+ export function listSitePlanDirs() {
45
+ return listSites().map((site) => join(site.dir, 'output', 'plans'));
46
+ }
41
47
  export function registerSite(baseUrl) {
42
48
  const folder = siteFolderName(baseUrl);
43
49
  const dir = join(sitesDir(), folder);