explorbot 0.4.8 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/api-tester/src/cli.ts +1 -1
- package/boat/api-tester/src/config.ts +34 -26
- package/dist/boat/api-tester/src/cli.js +1 -1
- package/dist/boat/api-tester/src/config.js +32 -26
- package/dist/package.json +1 -1
- package/dist/src/ai/fisherman/tools.js +10 -4
- package/dist/src/ai/pilot.js +11 -6
- package/dist/src/ai/tools.js +8 -1
- package/dist/src/commands/config-command.d.ts +2 -0
- package/dist/src/commands/config-command.js +8 -2
- package/dist/src/commands/init-command.js +6 -26
- package/dist/src/commands/sites-command.js +6 -1
- package/dist/src/config.d.ts +5 -4
- package/dist/src/config.js +25 -23
- package/dist/src/global-config.d.ts +6 -0
- package/dist/src/global-config.js +82 -7
- package/dist/src/utils/html.js +6 -0
- package/dist/src/utils/merge.d.ts +1 -0
- package/dist/src/utils/merge.js +11 -0
- package/docs/reference/commands.md +10 -2
- package/docs/reference/configuration.md +37 -4
- package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
- package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
- package/package.json +1 -1
- package/src/ai/fisherman/tools.ts +10 -4
- package/src/ai/pilot.ts +11 -6
- package/src/ai/tools.ts +7 -1
- package/src/commands/config-command.ts +9 -2
- package/src/commands/init-command.ts +6 -27
- package/src/commands/sites-command.ts +6 -1
- package/src/config.ts +28 -25
- package/src/global-config.ts +81 -6
- package/src/utils/html.ts +6 -0
- package/src/utils/merge.ts +13 -0
|
@@ -40,7 +40,7 @@ export function createApiCommands(name = 'api'): Command {
|
|
|
40
40
|
if (runOptions.endpoint && URL.canParse(runOptions.endpoint)) runOptions.baseEndpoint ||= runOptions.endpoint;
|
|
41
41
|
try {
|
|
42
42
|
const config = await parser.loadConfig(runOptions);
|
|
43
|
-
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
43
|
+
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
44
44
|
} catch (error) {
|
|
45
45
|
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
46
46
|
process.exit(1);
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
resolveOutputRoot,
|
|
19
19
|
setOutputDir,
|
|
20
20
|
} from '../../../src/config.ts';
|
|
21
|
-
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
|
|
21
|
+
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
|
|
22
|
+
import { deepMerge } from '../../../src/utils/merge.ts';
|
|
22
23
|
|
|
23
24
|
export type { AIConfig };
|
|
24
25
|
|
|
@@ -58,6 +59,7 @@ export class ApibotConfigParser {
|
|
|
58
59
|
private config: ApibotConfig | null = null;
|
|
59
60
|
private configPath: string | null = null;
|
|
60
61
|
private site: SiteRecord | null = null;
|
|
62
|
+
private siteConfigPath: string | null = null;
|
|
61
63
|
|
|
62
64
|
private constructor() {}
|
|
63
65
|
|
|
@@ -111,20 +113,22 @@ export class ApibotConfigParser {
|
|
|
111
113
|
};
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
this.applyEnvSpec(
|
|
116
|
-
this.applyEnvHeaders(
|
|
117
|
-
if (options?.baseEndpoint)
|
|
118
|
-
await resolveConfigModels(
|
|
119
|
-
resolveLangfuse(
|
|
120
|
-
this.configPath = resolvedPath;
|
|
116
|
+
let config = this.mergeWithDefaults(loadedConfig);
|
|
117
|
+
this.applyEnvSpec(config.api);
|
|
118
|
+
this.applyEnvHeaders(config.api);
|
|
119
|
+
if (options?.baseEndpoint) config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
|
|
120
|
+
await resolveConfigModels(config.ai);
|
|
121
|
+
resolveLangfuse(config.ai);
|
|
121
122
|
this.site = null;
|
|
123
|
+
this.siteConfigPath = null;
|
|
122
124
|
|
|
123
125
|
if (isGlobalConfigPath(resolvedPath)) {
|
|
124
|
-
this.enterGlobalMode(
|
|
126
|
+
config = await this.enterGlobalMode(config, options?.endpoint);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
this.validateConfig(
|
|
129
|
+
this.validateConfig(config);
|
|
130
|
+
this.config = config;
|
|
131
|
+
this.configPath = resolvedPath;
|
|
128
132
|
setOutputDir(this.getOutputDir());
|
|
129
133
|
|
|
130
134
|
return this.config;
|
|
@@ -144,6 +148,10 @@ export class ApibotConfigParser {
|
|
|
144
148
|
return this.configPath;
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
getSiteConfigPath(): string | null {
|
|
152
|
+
return this.siteConfigPath;
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
getOutputDir(): string {
|
|
148
156
|
const config = this.getConfig();
|
|
149
157
|
return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
|
|
@@ -207,17 +215,29 @@ export class ApibotConfigParser {
|
|
|
207
215
|
api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
private enterGlobalMode(config: ApibotConfig, endpoint?: string):
|
|
218
|
+
private async enterGlobalMode(config: ApibotConfig, endpoint?: string): Promise<ApibotConfig> {
|
|
211
219
|
const site = resolveSiteTarget(endpoint);
|
|
212
220
|
this.site = registerSite(site.baseUrl);
|
|
213
221
|
|
|
222
|
+
const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
|
|
223
|
+
this.siteConfigPath = path;
|
|
224
|
+
|
|
225
|
+
const overrides: Partial<ApibotConfig> = {};
|
|
226
|
+
if (siteConfig.ai) overrides.ai = siteConfig.ai;
|
|
227
|
+
if (siteConfig.api) overrides.api = siteConfig.api;
|
|
228
|
+
|
|
229
|
+
const merged = deepMerge(config, overrides);
|
|
230
|
+
await resolveConfigModels(merged.ai);
|
|
231
|
+
resolveLangfuse(merged.ai);
|
|
232
|
+
|
|
214
233
|
let baseEndpoint = site.baseUrl;
|
|
215
234
|
const envUrl = process.env.EXPLORBOT_URL;
|
|
216
235
|
if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl) baseEndpoint = envUrl.replace(/\/$/, '');
|
|
217
236
|
|
|
218
|
-
|
|
219
|
-
|
|
237
|
+
merged.dirs = { output: 'output', knowledge: 'knowledge' };
|
|
238
|
+
merged.api = { ...merged.api, baseEndpoint };
|
|
220
239
|
materializeKnowledge(this.site.dir);
|
|
240
|
+
return merged;
|
|
221
241
|
}
|
|
222
242
|
|
|
223
243
|
private async loadEnvConfig(): Promise<ApibotConfig> {
|
|
@@ -310,19 +330,7 @@ export class ApibotConfigParser {
|
|
|
310
330
|
}
|
|
311
331
|
|
|
312
332
|
private mergeWithDefaults(config: Partial<ApibotConfig>): ApibotConfig {
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
private deepMerge(target: any, source: any): any {
|
|
317
|
-
const result = { ...target };
|
|
318
|
-
for (const key in source) {
|
|
319
|
-
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
320
|
-
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
321
|
-
} else {
|
|
322
|
-
result[key] = source[key];
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return result;
|
|
333
|
+
return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
|
|
326
334
|
}
|
|
327
335
|
}
|
|
328
336
|
|
|
@@ -38,7 +38,7 @@ export function createApiCommands(name = 'api') {
|
|
|
38
38
|
runOptions.baseEndpoint ||= runOptions.endpoint;
|
|
39
39
|
try {
|
|
40
40
|
const config = await parser.loadConfig(runOptions);
|
|
41
|
-
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
41
|
+
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
42
42
|
}
|
|
43
43
|
catch (error) {
|
|
44
44
|
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
@@ -11,7 +11,8 @@ import path, { resolve } from 'node:path';
|
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { parseEnv } from 'node:util';
|
|
13
13
|
import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
|
|
14
|
-
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
|
|
14
|
+
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
|
|
15
|
+
import { deepMerge } from "../../../src/utils/merge.js";
|
|
15
16
|
function isAbsoluteEndpoint(value) {
|
|
16
17
|
return !!value && (value.startsWith('http://') || value.startsWith('https://'));
|
|
17
18
|
}
|
|
@@ -30,6 +31,7 @@ export class ApibotConfigParser {
|
|
|
30
31
|
config = null;
|
|
31
32
|
configPath = null;
|
|
32
33
|
site = null;
|
|
34
|
+
siteConfigPath = null;
|
|
33
35
|
constructor() { }
|
|
34
36
|
static getInstance() {
|
|
35
37
|
if (!ApibotConfigParser.instance) {
|
|
@@ -76,19 +78,21 @@ export class ApibotConfigParser {
|
|
|
76
78
|
dirs: loadedConfig.dirs,
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
|
-
|
|
80
|
-
this.applyEnvSpec(
|
|
81
|
-
this.applyEnvHeaders(
|
|
81
|
+
let config = this.mergeWithDefaults(loadedConfig);
|
|
82
|
+
this.applyEnvSpec(config.api);
|
|
83
|
+
this.applyEnvHeaders(config.api);
|
|
82
84
|
if (options?.baseEndpoint)
|
|
83
|
-
|
|
84
|
-
await resolveConfigModels(
|
|
85
|
-
resolveLangfuse(
|
|
86
|
-
this.configPath = resolvedPath;
|
|
85
|
+
config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
|
|
86
|
+
await resolveConfigModels(config.ai);
|
|
87
|
+
resolveLangfuse(config.ai);
|
|
87
88
|
this.site = null;
|
|
89
|
+
this.siteConfigPath = null;
|
|
88
90
|
if (isGlobalConfigPath(resolvedPath)) {
|
|
89
|
-
this.enterGlobalMode(
|
|
91
|
+
config = await this.enterGlobalMode(config, options?.endpoint);
|
|
90
92
|
}
|
|
91
|
-
this.validateConfig(
|
|
93
|
+
this.validateConfig(config);
|
|
94
|
+
this.config = config;
|
|
95
|
+
this.configPath = resolvedPath;
|
|
92
96
|
setOutputDir(this.getOutputDir());
|
|
93
97
|
return this.config;
|
|
94
98
|
}
|
|
@@ -106,6 +110,9 @@ export class ApibotConfigParser {
|
|
|
106
110
|
getConfigPath() {
|
|
107
111
|
return this.configPath;
|
|
108
112
|
}
|
|
113
|
+
getSiteConfigPath() {
|
|
114
|
+
return this.siteConfigPath;
|
|
115
|
+
}
|
|
109
116
|
getOutputDir() {
|
|
110
117
|
const config = this.getConfig();
|
|
111
118
|
return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
|
|
@@ -168,16 +175,27 @@ export class ApibotConfigParser {
|
|
|
168
175
|
return;
|
|
169
176
|
api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
|
|
170
177
|
}
|
|
171
|
-
enterGlobalMode(config, endpoint) {
|
|
178
|
+
async enterGlobalMode(config, endpoint) {
|
|
172
179
|
const site = resolveSiteTarget(endpoint);
|
|
173
180
|
this.site = registerSite(site.baseUrl);
|
|
181
|
+
const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
|
|
182
|
+
this.siteConfigPath = path;
|
|
183
|
+
const overrides = {};
|
|
184
|
+
if (siteConfig.ai)
|
|
185
|
+
overrides.ai = siteConfig.ai;
|
|
186
|
+
if (siteConfig.api)
|
|
187
|
+
overrides.api = siteConfig.api;
|
|
188
|
+
const merged = deepMerge(config, overrides);
|
|
189
|
+
await resolveConfigModels(merged.ai);
|
|
190
|
+
resolveLangfuse(merged.ai);
|
|
174
191
|
let baseEndpoint = site.baseUrl;
|
|
175
192
|
const envUrl = process.env.EXPLORBOT_URL;
|
|
176
193
|
if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl)
|
|
177
194
|
baseEndpoint = envUrl.replace(/\/$/, '');
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
merged.dirs = { output: 'output', knowledge: 'knowledge' };
|
|
196
|
+
merged.api = { ...merged.api, baseEndpoint };
|
|
180
197
|
materializeKnowledge(this.site.dir);
|
|
198
|
+
return merged;
|
|
181
199
|
}
|
|
182
200
|
async loadEnvConfig() {
|
|
183
201
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
@@ -261,18 +279,6 @@ export class ApibotConfigParser {
|
|
|
261
279
|
}
|
|
262
280
|
}
|
|
263
281
|
mergeWithDefaults(config) {
|
|
264
|
-
return
|
|
265
|
-
}
|
|
266
|
-
deepMerge(target, source) {
|
|
267
|
-
const result = { ...target };
|
|
268
|
-
for (const key in source) {
|
|
269
|
-
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
270
|
-
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
result[key] = source[key];
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
return result;
|
|
282
|
+
return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
|
|
277
283
|
}
|
|
278
284
|
}
|
package/dist/package.json
CHANGED
|
@@ -196,13 +196,19 @@ export function createAskApiTool(fisherman, task) {
|
|
|
196
196
|
return {
|
|
197
197
|
askApi: tool({
|
|
198
198
|
description: dedent `
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
Read the app's data over the API, changing nothing.
|
|
200
|
+
Answers questions about records: which ones are there, what they are called, whether a particular one exists.
|
|
201
|
+
|
|
202
|
+
Use it to:
|
|
203
|
+
- check whether suitable data already exists, before precondition() creates any
|
|
204
|
+
- get the exact name or id of a record a step must act on
|
|
205
|
+
- find out whether an action was stored, when the app reported success but the page does not show the result
|
|
206
|
+
- find out whether data exists at all, when a list or dropdown is empty
|
|
207
|
+
|
|
202
208
|
It never creates, edits or deletes anything — precondition() does that.
|
|
203
209
|
`,
|
|
204
210
|
inputSchema: z.object({
|
|
205
|
-
question: z.string().describe('What to find out about data
|
|
211
|
+
question: z.string().describe('What to find out about the data'),
|
|
206
212
|
}),
|
|
207
213
|
execute: async ({ question }) => {
|
|
208
214
|
tag('info').log(`Ask API: ${question}`);
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -922,14 +922,14 @@ export class Pilot {
|
|
|
922
922
|
const lines = [];
|
|
923
923
|
for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
|
|
924
924
|
if (passed)
|
|
925
|
-
lines.push(`
|
|
925
|
+
lines.push(`verify: ${assertion}`);
|
|
926
926
|
}
|
|
927
927
|
for (const exec of testerConversation.getToolExecutions()) {
|
|
928
928
|
if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
|
|
929
929
|
continue;
|
|
930
930
|
const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
|
|
931
|
-
const
|
|
932
|
-
lines.push(
|
|
931
|
+
const analysis = exec.output?.analysis;
|
|
932
|
+
lines.push(`${exec.toolName}: ${description}${analysis ? ` -> ${analysis}` : ''}`);
|
|
933
933
|
}
|
|
934
934
|
return [...new Set(lines)].join('\n');
|
|
935
935
|
}
|
|
@@ -1053,9 +1053,14 @@ export class Pilot {
|
|
|
1053
1053
|
|
|
1054
1054
|
YOUR Pilot-only tools, both over the API:
|
|
1055
1055
|
|
|
1056
|
-
askApi(question) —
|
|
1057
|
-
|
|
1058
|
-
|
|
1056
|
+
askApi(question) — read the app's data over the API. It changes nothing. Use when:
|
|
1057
|
+
|
|
1058
|
+
- Before precondition() — check whether suitable data already exists.
|
|
1059
|
+
- A step needs the exact name or id of an existing record.
|
|
1060
|
+
- The app reported success but the page does not show the result — ask whether it was stored.
|
|
1061
|
+
- A list or dropdown is empty — ask whether the data exists at all.
|
|
1062
|
+
|
|
1063
|
+
The page is not the only witness. A record missing from the page may still exist.
|
|
1059
1064
|
|
|
1060
1065
|
precondition(description) — create FRESH disposable test data. Never request users. Use when:
|
|
1061
1066
|
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -1202,7 +1202,8 @@ async function extractWebElements(error) {
|
|
|
1202
1202
|
function formatElementList(matched) {
|
|
1203
1203
|
if (!matched)
|
|
1204
1204
|
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1205
|
-
|
|
1205
|
+
const keys = matched.map((el) => `${el.text}::${el.visible}::${el.html}`);
|
|
1206
|
+
const list = matched
|
|
1206
1207
|
.map((el, i) => {
|
|
1207
1208
|
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1208
1209
|
if (el.visible !== undefined)
|
|
@@ -1210,10 +1211,16 @@ function formatElementList(matched) {
|
|
|
1210
1211
|
const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
|
|
1211
1212
|
if (wrapped.length)
|
|
1212
1213
|
lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
|
|
1214
|
+
const same = keys.map((_, j) => j).filter((j) => j !== i && keys[j] === keys[i]);
|
|
1215
|
+
if (same.length)
|
|
1216
|
+
lines.push(`Identical to element ${same.map((j) => j + 1).join(', ')}`);
|
|
1213
1217
|
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1214
1218
|
return lines.join('\n');
|
|
1215
1219
|
})
|
|
1216
1220
|
.join('\n\n');
|
|
1221
|
+
if (new Set(keys).size === matched.length)
|
|
1222
|
+
return list;
|
|
1223
|
+
return `${list}\n\nIdentical matches are not told apart by their number — picking one is a guess. Click the one you mean by appearance with visualClick().`;
|
|
1217
1224
|
}
|
|
1218
1225
|
export async function formatMatchedElements(error) {
|
|
1219
1226
|
return formatElementList(await extractWebElements(error));
|
|
@@ -15,11 +15,13 @@ export declare class ConfigCommand extends BaseCommand {
|
|
|
15
15
|
}
|
|
16
16
|
interface ConfigSummaryOptions {
|
|
17
17
|
configPath?: string | null;
|
|
18
|
+
siteConfigPath?: string | null;
|
|
18
19
|
root?: string;
|
|
19
20
|
json?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export interface ConfigData {
|
|
22
23
|
config: string;
|
|
24
|
+
siteConfig: string;
|
|
23
25
|
url: string;
|
|
24
26
|
browser: string;
|
|
25
27
|
headless: boolean;
|
|
@@ -13,7 +13,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
13
13
|
description = 'Show models, config file and paths used by this run';
|
|
14
14
|
async execute() {
|
|
15
15
|
const parser = ConfigParser.getInstance();
|
|
16
|
-
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
|
|
16
|
+
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot() }));
|
|
17
17
|
}
|
|
18
18
|
static async summary(options = {}) {
|
|
19
19
|
const parser = ConfigParser.getInstance();
|
|
@@ -24,12 +24,15 @@ export class ConfigCommand extends BaseCommand {
|
|
|
24
24
|
throw error;
|
|
25
25
|
return load(site.url);
|
|
26
26
|
});
|
|
27
|
-
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
27
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
28
28
|
}
|
|
29
29
|
static data(config, options = {}) {
|
|
30
30
|
let configPath = '';
|
|
31
31
|
if (options.configPath && existsSync(options.configPath))
|
|
32
32
|
configPath = options.configPath;
|
|
33
|
+
let siteConfigPath = '';
|
|
34
|
+
if (options.siteConfigPath && existsSync(options.siteConfigPath))
|
|
35
|
+
siteConfigPath = options.siteConfigPath;
|
|
33
36
|
const dirs = {};
|
|
34
37
|
if (options.root) {
|
|
35
38
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
@@ -55,6 +58,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
55
58
|
}
|
|
56
59
|
return {
|
|
57
60
|
config: configPath,
|
|
61
|
+
siteConfig: siteConfigPath,
|
|
58
62
|
url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
|
|
59
63
|
browser: config.playwright?.browser || '',
|
|
60
64
|
headless: !config.playwright?.show,
|
|
@@ -75,6 +79,8 @@ export class ConfigCommand extends BaseCommand {
|
|
|
75
79
|
const lines = [];
|
|
76
80
|
const section = (title, entries) => lines.push(...renderSection(title, entries));
|
|
77
81
|
const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
82
|
+
if (data.siteConfig)
|
|
83
|
+
general.push(['site config', data.siteConfig]);
|
|
78
84
|
if (data.url)
|
|
79
85
|
general.push(['url', data.url]);
|
|
80
86
|
if (data.browser) {
|
|
@@ -6,10 +6,7 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
|
|
|
6
6
|
import { getCliName } from "../utils/cli-name.js";
|
|
7
7
|
import { log, tag } from '../utils/logger.js';
|
|
8
8
|
import { relativeToCwd } from "../utils/next-steps.js";
|
|
9
|
-
function defaultConfigTemplate(provider
|
|
10
|
-
let moduleExport = 'module.exports = config;';
|
|
11
|
-
if (esm)
|
|
12
|
-
moduleExport = 'export default config;';
|
|
9
|
+
function defaultConfigTemplate(provider) {
|
|
13
10
|
return `// 'provider/model-id' uses a bundled provider.
|
|
14
11
|
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
15
12
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
@@ -34,7 +31,7 @@ ${modelLines(provider)}
|
|
|
34
31
|
},
|
|
35
32
|
};
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
export default config;
|
|
38
35
|
`;
|
|
39
36
|
}
|
|
40
37
|
export function envTemplate(provider) {
|
|
@@ -128,8 +125,7 @@ export function runInitCommand(options) {
|
|
|
128
125
|
log('Use --force to overwrite existing file');
|
|
129
126
|
process.exit(1);
|
|
130
127
|
}
|
|
131
|
-
|
|
132
|
-
writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
|
|
128
|
+
writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
|
|
133
129
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
134
130
|
const envPath = resolve(process.cwd(), '.env');
|
|
135
131
|
if (!existsSync(envPath)) {
|
|
@@ -234,6 +230,8 @@ export function modelLines(provider, only) {
|
|
|
234
230
|
function globalConfigTemplate(provider) {
|
|
235
231
|
const { envKey } = PROVIDERS[provider];
|
|
236
232
|
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
233
|
+
// Settings shared by every site. Each site extends them in
|
|
234
|
+
// ~/.explorbot/sites/<host>/explorbot.config.js, written on its first run.
|
|
237
235
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
238
236
|
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
239
237
|
// Model ids are snapshotted from the recommendations of this Explorbot version.
|
|
@@ -251,27 +249,9 @@ ${modelLines(provider)}
|
|
|
251
249
|
},
|
|
252
250
|
};
|
|
253
251
|
|
|
254
|
-
|
|
252
|
+
export default config;
|
|
255
253
|
`;
|
|
256
254
|
}
|
|
257
|
-
function isModuleProject(configDir) {
|
|
258
|
-
let currentDir = resolve(configDir);
|
|
259
|
-
while (true) {
|
|
260
|
-
const packagePath = join(currentDir, 'package.json');
|
|
261
|
-
if (existsSync(packagePath)) {
|
|
262
|
-
try {
|
|
263
|
-
return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
const parentDir = dirname(currentDir);
|
|
270
|
-
if (parentDir === currentDir)
|
|
271
|
-
return false;
|
|
272
|
-
currentDir = parentDir;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
255
|
function writeEnvKey(key, value) {
|
|
276
256
|
const envPath = globalEnvPath();
|
|
277
257
|
let content = '# AI provider API keys';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listSites, sitesDir } from '../global-config.js';
|
|
1
|
+
import { findSiteConfig, listSites, sitesDir } from '../global-config.js';
|
|
2
2
|
import { getCliName } from '../utils/cli-name.js';
|
|
3
3
|
import { tag } from '../utils/logger.js';
|
|
4
4
|
import { BaseCommand } from './base-command.js';
|
|
@@ -16,6 +16,11 @@ export class SitesCommand extends BaseCommand {
|
|
|
16
16
|
tag('info').log(`Registered sites (${sites.length}):`);
|
|
17
17
|
for (const site of sites) {
|
|
18
18
|
tag('info').log(` ${site.folder.padEnd(width)} ${site.url} last run ${site.lastRunAt.slice(0, 16).replace('T', ' ')}`);
|
|
19
|
+
let config = 'inherits global config';
|
|
20
|
+
const sitePath = findSiteConfig(site.dir);
|
|
21
|
+
if (sitePath)
|
|
22
|
+
config = sitePath;
|
|
23
|
+
tag('info').log(` ${' '.repeat(width)} ${config}`);
|
|
19
24
|
}
|
|
20
25
|
tag('info').log('');
|
|
21
26
|
tag('info').log(`Stored in ${sitesDir()}`);
|
package/dist/src/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SiteRecord } from './global-config.js';
|
|
1
|
+
import { EXPLORBOT_CONFIG_PATHS, type SiteRecord } from './global-config.js';
|
|
2
2
|
export declare const PROVIDERS: Record<string, ProviderInfo>;
|
|
3
3
|
export declare const MODEL_ROLES: ModelRole[];
|
|
4
4
|
interface PlaywrightConfig {
|
|
@@ -224,7 +224,7 @@ interface ExplorbotConfig {
|
|
|
224
224
|
dynamicPageRegex?: string;
|
|
225
225
|
}
|
|
226
226
|
type RuleEntry = string | Record<string, string>;
|
|
227
|
-
export
|
|
227
|
+
export { EXPLORBOT_CONFIG_PATHS };
|
|
228
228
|
export declare const EXPLORBOT_ENV_VARS: EnvVar[];
|
|
229
229
|
export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, ScoutAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
|
|
230
230
|
export declare class ConfigParser {
|
|
@@ -235,6 +235,7 @@ export declare class ConfigParser {
|
|
|
235
235
|
runtimeTarget: string | null;
|
|
236
236
|
site: SiteRecord | null;
|
|
237
237
|
siteStartPath: string;
|
|
238
|
+
siteConfigPath: string | null;
|
|
238
239
|
constructor();
|
|
239
240
|
static loadEnv(filePath: string, keepExisting?: boolean): void;
|
|
240
241
|
static recommendedModels(): Record<string, Record<string, string>>;
|
|
@@ -252,6 +253,7 @@ export declare class ConfigParser {
|
|
|
252
253
|
resolveProjectDir(relativeDir: string): string;
|
|
253
254
|
isGlobalMode(): boolean;
|
|
254
255
|
getSite(): SiteRecord | null;
|
|
256
|
+
getSiteConfigPath(): string | null;
|
|
255
257
|
resolveTargetPath(target?: string): string;
|
|
256
258
|
getStatesDir(): string;
|
|
257
259
|
getPlansDir(): string;
|
|
@@ -260,7 +262,7 @@ export declare class ConfigParser {
|
|
|
260
262
|
static setupTestConfig(): void;
|
|
261
263
|
static getTestDirectories(): string[];
|
|
262
264
|
static cleanupAllTestDirectories(): void;
|
|
263
|
-
enterGlobalMode(config: ExplorbotConfig, target: string | null):
|
|
265
|
+
enterGlobalMode(config: ExplorbotConfig, target: string | null): Promise<ExplorbotConfig>;
|
|
264
266
|
applyEnvSpec(config: ExplorbotConfig): void;
|
|
265
267
|
buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
|
|
266
268
|
findConfigFile(): string | null;
|
|
@@ -271,7 +273,6 @@ export declare class ConfigParser {
|
|
|
271
273
|
validateConfig(config: ExplorbotConfig): void;
|
|
272
274
|
getNestedValue(obj: any, path: string): any;
|
|
273
275
|
mergeWithDefaults(config: Partial<ExplorbotConfig>): ExplorbotConfig;
|
|
274
|
-
deepMerge(target: any, source: any): any;
|
|
275
276
|
ensureDirectory(path: string): void;
|
|
276
277
|
}
|
|
277
278
|
export declare function setOutputDir(dir: string): void;
|
package/dist/src/config.js
CHANGED
|
@@ -13,8 +13,9 @@ import { pathToFileURL } from 'node:url';
|
|
|
13
13
|
import { parseEnv } from 'node:util';
|
|
14
14
|
import dedent from 'dedent';
|
|
15
15
|
import matter from 'gray-matter';
|
|
16
|
-
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from './global-config.js';
|
|
16
|
+
import { EXPLORBOT_CONFIG_PATHS, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from './global-config.js';
|
|
17
17
|
import { getCliName } from './utils/cli-name.js';
|
|
18
|
+
import { deepMerge } from './utils/merge.js';
|
|
18
19
|
import { log, tag } from './utils/logger.js';
|
|
19
20
|
export const PROVIDERS = {
|
|
20
21
|
openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
|
|
@@ -37,7 +38,7 @@ const config = {
|
|
|
37
38
|
model: null,
|
|
38
39
|
},
|
|
39
40
|
};
|
|
40
|
-
export
|
|
41
|
+
export { EXPLORBOT_CONFIG_PATHS };
|
|
41
42
|
export const EXPLORBOT_ENV_VARS = [
|
|
42
43
|
{ name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
|
|
43
44
|
{ name: 'EXPLORBOT_AI_MODEL', description: 'Pins the main model — a model id for the provider, or a standalone provider/model-id' },
|
|
@@ -62,6 +63,7 @@ export class ConfigParser {
|
|
|
62
63
|
runtimeTarget = null;
|
|
63
64
|
site = null;
|
|
64
65
|
siteStartPath = '/';
|
|
66
|
+
siteConfigPath = null;
|
|
65
67
|
constructor() { }
|
|
66
68
|
static loadEnv(filePath, keepExisting = false) {
|
|
67
69
|
const resolved = resolve(filePath);
|
|
@@ -125,14 +127,16 @@ export class ConfigParser {
|
|
|
125
127
|
sourcePath = join(outputRoot, 'explorbot.config.js');
|
|
126
128
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
127
129
|
}
|
|
128
|
-
|
|
129
|
-
await resolveConfigModels(
|
|
130
|
-
this.runtimeTarget = target;
|
|
131
|
-
this.configPath = sourcePath;
|
|
130
|
+
let config = this.resolveConfig(loadedConfig, options);
|
|
131
|
+
await resolveConfigModels(config.ai);
|
|
132
132
|
this.site = null;
|
|
133
|
+
this.siteConfigPath = null;
|
|
133
134
|
if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
|
|
134
|
-
this.enterGlobalMode(
|
|
135
|
+
config = await this.enterGlobalMode(config, target);
|
|
135
136
|
}
|
|
137
|
+
this.config = config;
|
|
138
|
+
this.runtimeTarget = target;
|
|
139
|
+
this.configPath = sourcePath;
|
|
136
140
|
this.applyEnvSpec(this.config);
|
|
137
141
|
// Restore original directory after successful config load
|
|
138
142
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
@@ -186,6 +190,9 @@ export class ConfigParser {
|
|
|
186
190
|
getSite() {
|
|
187
191
|
return this.site;
|
|
188
192
|
}
|
|
193
|
+
getSiteConfigPath() {
|
|
194
|
+
return this.siteConfigPath;
|
|
195
|
+
}
|
|
189
196
|
resolveTargetPath(target) {
|
|
190
197
|
if (!this.site) {
|
|
191
198
|
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
@@ -222,6 +229,7 @@ export class ConfigParser {
|
|
|
222
229
|
ConfigParser.instance.runtimeTarget = null;
|
|
223
230
|
ConfigParser.instance.site = null;
|
|
224
231
|
ConfigParser.instance.siteStartPath = '/';
|
|
232
|
+
ConfigParser.instance.siteConfigPath = null;
|
|
225
233
|
}
|
|
226
234
|
}
|
|
227
235
|
// For testing purposes only - sets up minimal default config
|
|
@@ -269,14 +277,20 @@ export class ConfigParser {
|
|
|
269
277
|
// Ignore cleanup errors
|
|
270
278
|
}
|
|
271
279
|
}
|
|
272
|
-
enterGlobalMode(config, target) {
|
|
280
|
+
async enterGlobalMode(config, target) {
|
|
273
281
|
const site = resolveSiteTarget(target || undefined, config.web?.url || config.playwright?.url);
|
|
274
282
|
this.site = registerSite(site.baseUrl);
|
|
275
283
|
this.siteStartPath = site.path;
|
|
276
|
-
|
|
277
|
-
|
|
284
|
+
const { path: sitePath, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
|
|
285
|
+
this.siteConfigPath = sitePath;
|
|
286
|
+
const merged = deepMerge(config, siteConfig);
|
|
287
|
+
await resolveConfigModels(merged.ai);
|
|
288
|
+
resolveLangfuse(merged.ai);
|
|
289
|
+
merged.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
290
|
+
merged.playwright = { ...merged.playwright, browser: merged.playwright?.browser || 'chromium', url: site.baseUrl };
|
|
278
291
|
materializeKnowledge(this.site.dir);
|
|
279
292
|
log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
|
|
293
|
+
return merged;
|
|
280
294
|
}
|
|
281
295
|
applyEnvSpec(config) {
|
|
282
296
|
const spec = process.env.EXPLORBOT_SPEC;
|
|
@@ -414,19 +428,7 @@ export class ConfigParser {
|
|
|
414
428
|
output: 'output',
|
|
415
429
|
},
|
|
416
430
|
};
|
|
417
|
-
return
|
|
418
|
-
}
|
|
419
|
-
deepMerge(target, source) {
|
|
420
|
-
const result = { ...target };
|
|
421
|
-
for (const key in source) {
|
|
422
|
-
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
|
|
423
|
-
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
|
424
|
-
}
|
|
425
|
-
else {
|
|
426
|
-
result[key] = source[key];
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return result;
|
|
431
|
+
return deepMerge(defaults, config);
|
|
430
432
|
}
|
|
431
433
|
ensureDirectory(path) {
|
|
432
434
|
if (!existsSync(path)) {
|