explorbot 0.2.1 → 0.2.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.
- package/README.md +1 -1
- package/bin/explorbot-cli.ts +52 -37
- package/boat/api-tester/src/apibot.ts +4 -2
- package/boat/api-tester/src/cli.ts +2 -2
- package/boat/api-tester/src/config.ts +39 -8
- package/boat/doc-collector/src/ai/documentarian.ts +3 -0
- package/boat/doc-collector/src/ai/tools.ts +17 -5
- package/boat/doc-collector/src/cli.ts +2 -0
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +39 -11
- package/boat/doc-collector/src/docs-renderer.ts +18 -4
- package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
- package/boat/doc-collector/src/screenshots.ts +22 -14
- package/boat/doc-collector/src/state-diagram.ts +61 -14
- package/boat/prima/bin/prima-cli.ts +5 -0
- package/boat/prima/package.json +16 -0
- package/boat/prima/src/cli.ts +222 -0
- package/boat/prima/src/envelope.ts +141 -0
- package/boat/prima/src/prima.ts +705 -0
- package/boat/prima/src/pw-parser.ts +17 -0
- package/boat/prima/src/pw-registry.ts +75 -0
- package/dist/bin/explorbot-cli.js +44 -31
- package/dist/boat/api-tester/src/apibot.js +3 -2
- package/dist/boat/api-tester/src/cli.js +2 -2
- package/dist/boat/api-tester/src/config.js +36 -8
- package/dist/boat/doc-collector/src/ai/documentarian.js +3 -0
- package/dist/boat/doc-collector/src/ai/tools.js +10 -4
- package/dist/boat/doc-collector/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +36 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
- package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
- package/dist/boat/doc-collector/src/screenshots.js +23 -14
- package/dist/boat/doc-collector/src/state-diagram.js +57 -13
- package/dist/boat/prima/bin/prima-cli.js +4 -0
- package/dist/boat/prima/src/cli.js +200 -0
- package/dist/boat/prima/src/envelope.js +116 -0
- package/dist/boat/prima/src/prima.js +635 -0
- package/dist/boat/prima/src/pw-parser.js +18 -0
- package/dist/boat/prima/src/pw-registry.js +66 -0
- package/dist/models.json +3 -0
- package/dist/package.json +9 -2
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +5 -5
- package/dist/src/ai/captain/mixin.js +3 -4
- package/dist/src/ai/captain/web-mode.js +1 -1
- package/dist/src/ai/navigator.d.ts +4 -0
- package/dist/src/ai/navigator.js +11 -6
- package/dist/src/ai/planner.d.ts +1 -0
- package/dist/src/ai/planner.js +6 -0
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/task-agent.js +1 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +13 -0
- package/dist/src/application-spec-contract.d.ts +8 -0
- package/dist/src/application-spec-contract.js +8 -0
- package/dist/src/application-spec.d.ts +15 -0
- package/dist/src/application-spec.js +71 -0
- package/dist/src/browser-server.d.ts +12 -6
- package/dist/src/browser-server.js +74 -19
- package/dist/src/commands/clean-command.js +2 -7
- package/dist/src/commands/init-command.d.ts +5 -0
- package/dist/src/commands/init-command.js +119 -1
- package/dist/src/commands/navigate-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/sites-command.d.ts +6 -0
- package/dist/src/commands/sites-command.js +23 -0
- package/dist/src/components/InitWizard.d.ts +10 -0
- package/dist/src/components/InitWizard.js +133 -0
- package/dist/src/components/InputReadline.d.ts +1 -0
- package/dist/src/components/InputReadline.js +7 -4
- package/dist/src/config.d.ts +24 -5
- package/dist/src/config.js +146 -37
- package/dist/src/explorbot.d.ts +9 -0
- package/dist/src/explorbot.js +24 -5
- package/dist/src/explorer.d.ts +5 -1
- package/dist/src/explorer.js +54 -19
- package/dist/src/global-config.d.ts +22 -0
- package/dist/src/global-config.js +117 -0
- package/dist/src/knowledge-tracker.d.ts +5 -1
- package/dist/src/knowledge-tracker.js +14 -1
- package/dist/src/utils/cli-name.js +6 -2
- package/dist/src/utils/test-files.js +1 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +9 -0
- package/models.json +3 -0
- package/package.json +9 -2
- package/src/action.ts +9 -5
- package/src/ai/captain/mixin.ts +3 -3
- package/src/ai/captain/web-mode.ts +1 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner.ts +7 -0
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher.ts +1 -1
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +15 -0
- package/src/application-spec-contract.ts +10 -0
- package/src/application-spec.ts +87 -0
- package/src/browser-server.ts +74 -19
- package/src/commands/clean-command.ts +1 -6
- package/src/commands/init-command.ts +146 -1
- package/src/commands/navigate-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/sites-command.ts +27 -0
- package/src/components/InitWizard.tsx +166 -0
- package/src/components/InputReadline.tsx +8 -4
- package/src/config.ts +162 -39
- package/src/explorbot.ts +30 -5
- package/src/explorer.ts +59 -20
- package/src/global-config.ts +148 -0
- package/src/knowledge-tracker.ts +17 -1
- package/src/utils/cli-name.ts +5 -2
- package/src/utils/test-files.ts +1 -2
- package/src/utils/url-matcher.ts +10 -0
package/src/browser-server.ts
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { chromium, firefox, webkit } from 'playwright
|
|
3
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
4
4
|
import { ConfigParser } from './config.js';
|
|
5
5
|
import { getCliName } from './utils/cli-name.ts';
|
|
6
6
|
import { log } from './utils/logger.js';
|
|
7
7
|
import { type NextStepSection, printNextSteps } from './utils/next-steps.ts';
|
|
8
8
|
|
|
9
9
|
const ENDPOINT_FILENAME = '.browser-endpoint';
|
|
10
|
+
const INSTANCE_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
11
|
+
const KEEP_ALIVE_INTERVAL = 1 << 30;
|
|
10
12
|
|
|
11
|
-
function getEndpointFilePath(): string {
|
|
13
|
+
function getEndpointFilePath(instance = 'default'): string {
|
|
14
|
+
if (!INSTANCE_NAME_PATTERN.test(instance)) {
|
|
15
|
+
throw new Error(`Invalid browser instance name: "${instance}". Use lowercase letters, digits and dashes.`);
|
|
16
|
+
}
|
|
12
17
|
const configParser = ConfigParser.getInstance();
|
|
13
18
|
const outputDir = configParser.getOutputDir();
|
|
14
|
-
return path.join(outputDir, ENDPOINT_FILENAME);
|
|
19
|
+
if (instance === 'default') return path.join(outputDir, ENDPOINT_FILENAME);
|
|
20
|
+
return path.join(outputDir, `${ENDPOINT_FILENAME}-${instance}`);
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
function readEndpoint(): string | null {
|
|
18
|
-
const filePath = getEndpointFilePath();
|
|
23
|
+
function readEndpoint(instance = 'default'): string | null {
|
|
24
|
+
const filePath = getEndpointFilePath(instance);
|
|
19
25
|
if (!existsSync(filePath)) return null;
|
|
20
26
|
return readFileSync(filePath, 'utf8').trim();
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
function writeEndpoint(wsEndpoint: string): void {
|
|
24
|
-
const filePath = getEndpointFilePath();
|
|
29
|
+
function writeEndpoint(wsEndpoint: string, instance = 'default'): void {
|
|
30
|
+
const filePath = getEndpointFilePath(instance);
|
|
25
31
|
const dir = path.dirname(filePath);
|
|
26
32
|
if (!existsSync(dir)) {
|
|
27
33
|
mkdirSync(dir, { recursive: true });
|
|
@@ -29,11 +35,28 @@ function writeEndpoint(wsEndpoint: string): void {
|
|
|
29
35
|
writeFileSync(filePath, wsEndpoint, 'utf8');
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
function removeEndpointFile(): void {
|
|
33
|
-
const filePath = getEndpointFilePath();
|
|
38
|
+
function removeEndpointFile(instance = 'default'): void {
|
|
39
|
+
const filePath = getEndpointFilePath(instance);
|
|
34
40
|
if (existsSync(filePath)) unlinkSync(filePath);
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
function listInstances(): Array<{ name: string; endpoint: string }> {
|
|
44
|
+
const dir = path.dirname(getEndpointFilePath());
|
|
45
|
+
if (!existsSync(dir)) return [];
|
|
46
|
+
|
|
47
|
+
const instances: Array<{ name: string; endpoint: string }> = [];
|
|
48
|
+
for (const fileName of readdirSync(dir)) {
|
|
49
|
+
if (!fileName.startsWith(ENDPOINT_FILENAME)) continue;
|
|
50
|
+
const suffix = fileName.slice(ENDPOINT_FILENAME.length);
|
|
51
|
+
if (suffix && !suffix.startsWith('-')) continue;
|
|
52
|
+
if (suffix === '-') continue;
|
|
53
|
+
const name = suffix.slice(1) || 'default';
|
|
54
|
+
if (!INSTANCE_NAME_PATTERN.test(name)) continue;
|
|
55
|
+
instances.push({ name, endpoint: readFileSync(path.join(dir, fileName), 'utf8').trim() });
|
|
56
|
+
}
|
|
57
|
+
return instances;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
async function isServerRunning(wsEndpoint: string): Promise<boolean> {
|
|
38
61
|
try {
|
|
39
62
|
const browser = await chromium.connect(wsEndpoint, { timeout: 3000 });
|
|
@@ -46,7 +69,7 @@ async function isServerRunning(wsEndpoint: string): Promise<boolean> {
|
|
|
46
69
|
|
|
47
70
|
const BROWSER_LAUNCHERS = { chromium, firefox, webkit } as const;
|
|
48
71
|
|
|
49
|
-
async function launchServer(opts: { browser?: string; show?: boolean }): Promise<any> {
|
|
72
|
+
async function launchServer(opts: { browser?: string; show?: boolean }, instance = 'default'): Promise<any> {
|
|
50
73
|
const browserName = (opts.browser || 'chromium') as keyof typeof BROWSER_LAUNCHERS;
|
|
51
74
|
const launcher = BROWSER_LAUNCHERS[browserName];
|
|
52
75
|
if (!launcher) throw new Error(`Unsupported browser: ${browserName}`);
|
|
@@ -56,19 +79,21 @@ async function launchServer(opts: { browser?: string; show?: boolean }): Promise
|
|
|
56
79
|
});
|
|
57
80
|
|
|
58
81
|
const wsEndpoint = server.wsEndpoint();
|
|
59
|
-
writeEndpoint(wsEndpoint);
|
|
82
|
+
writeEndpoint(wsEndpoint, instance);
|
|
60
83
|
|
|
61
84
|
log(`Browser server started: ${browserName} (${opts.show ? 'headed' : 'headless'})`);
|
|
62
85
|
|
|
63
86
|
const cli = getCliName();
|
|
87
|
+
let instanceFlag = '';
|
|
88
|
+
if (instance !== 'default') instanceFlag = ` --instance ${instance}`;
|
|
64
89
|
const sections: NextStepSection[] = [
|
|
65
90
|
{
|
|
66
91
|
label: 'Browser server',
|
|
67
|
-
path: getEndpointFilePath(),
|
|
92
|
+
path: getEndpointFilePath(instance),
|
|
68
93
|
commands: [
|
|
69
94
|
{ label: 'Endpoint', command: wsEndpoint },
|
|
70
|
-
{ label: 'Status', command: `${cli} browser status` },
|
|
71
|
-
{ label: 'Stop', command: `${cli} browser stop` },
|
|
95
|
+
{ label: 'Status', command: `${cli} browser status${instanceFlag}` },
|
|
96
|
+
{ label: 'Stop', command: `${cli} browser stop${instanceFlag}` },
|
|
72
97
|
],
|
|
73
98
|
},
|
|
74
99
|
];
|
|
@@ -77,12 +102,42 @@ async function launchServer(opts: { browser?: string; show?: boolean }): Promise
|
|
|
77
102
|
return server;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
|
-
async function
|
|
81
|
-
const endpoint =
|
|
105
|
+
async function stopServer(instance = 'default'): Promise<boolean> {
|
|
106
|
+
const endpoint = await getAliveEndpoint(instance);
|
|
107
|
+
if (!endpoint) return false;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const browser = await chromium.connect(endpoint, { timeout: 3000 });
|
|
111
|
+
await browser.close();
|
|
112
|
+
} catch {}
|
|
113
|
+
|
|
114
|
+
removeEndpointFile(instance);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function keepServerRunning(stop: () => unknown): Promise<never> {
|
|
119
|
+
console.log('Browser server is running. Press Ctrl+C to stop.');
|
|
120
|
+
const heartbeat = setInterval(() => {}, KEEP_ALIVE_INTERVAL);
|
|
121
|
+
|
|
122
|
+
const cleanup = async () => {
|
|
123
|
+
console.log('\nStopping browser server...');
|
|
124
|
+
clearInterval(heartbeat);
|
|
125
|
+
await stop();
|
|
126
|
+
process.exit(0);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
process.on('SIGINT', cleanup);
|
|
130
|
+
process.on('SIGTERM', cleanup);
|
|
131
|
+
|
|
132
|
+
return new Promise<never>(() => {});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function getAliveEndpoint(instance = 'default'): Promise<string | null> {
|
|
136
|
+
const endpoint = readEndpoint(instance);
|
|
82
137
|
if (!endpoint) return null;
|
|
83
138
|
if (await isServerRunning(endpoint)) return endpoint;
|
|
84
|
-
removeEndpointFile();
|
|
139
|
+
removeEndpointFile(instance);
|
|
85
140
|
return null;
|
|
86
141
|
}
|
|
87
142
|
|
|
88
|
-
export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint };
|
|
143
|
+
export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };
|
|
@@ -15,12 +15,7 @@ export const CLEAN_TARGETS: Record<string, { description: string; getDir: () =>
|
|
|
15
15
|
|
|
16
16
|
function getExperienceDir(): string {
|
|
17
17
|
const configParser = ConfigParser.getInstance();
|
|
18
|
-
|
|
19
|
-
const configPath = configParser.getConfigPath();
|
|
20
|
-
if (configPath) {
|
|
21
|
-
return join(dirname(configPath), config.dirs?.experience || 'experience');
|
|
22
|
-
}
|
|
23
|
-
return config.dirs?.experience || 'experience';
|
|
18
|
+
return configParser.resolveProjectDir(configParser.getConfig().dirs?.experience || 'experience');
|
|
24
19
|
}
|
|
25
20
|
|
|
26
21
|
function cleanDirectoryContents(dirPath: string): number {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
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
4
|
import dedent from 'dedent';
|
|
5
|
+
import { ConfigParser, PROVIDERS } from '../config.ts';
|
|
6
|
+
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from '../global-config.ts';
|
|
5
7
|
import { getCliName } from '../utils/cli-name.ts';
|
|
6
8
|
import { log, tag } from '../utils/logger.js';
|
|
7
9
|
import { relativeToCwd } from '../utils/next-steps.ts';
|
|
@@ -61,6 +63,48 @@ LANGFUSE_BASE_URL=
|
|
|
61
63
|
TESTOMATIO=
|
|
62
64
|
`;
|
|
63
65
|
|
|
66
|
+
export async function runInit(options: InitCommandOptions): Promise<void> {
|
|
67
|
+
if (options.global || options.provider) {
|
|
68
|
+
await runGlobalInit(options);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (options.configPath || options.path || !process.stdin.isTTY) {
|
|
73
|
+
runInitCommand(options);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const choice = await renderInitWizard('choose');
|
|
78
|
+
if (choice === 'local') runInitCommand(options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
82
|
+
if (!PROVIDERS[provider]) {
|
|
83
|
+
throw new Error(`Unknown AI provider "${provider}". Supported providers: ${Object.keys(PROVIDERS).join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
mkdirSync(globalDir(), { recursive: true });
|
|
87
|
+
writeFileSync(globalConfigPath(), globalConfigTemplate(provider), 'utf8');
|
|
88
|
+
log(`Created global config: ${globalConfigPath()}`);
|
|
89
|
+
|
|
90
|
+
const envKey = PROVIDERS[provider].envKey;
|
|
91
|
+
writeEnvKey(envKey, apiKey || '');
|
|
92
|
+
log(`Stored ${envKey} in ${globalEnvPath()}`);
|
|
93
|
+
|
|
94
|
+
const missing = missingRoles(provider);
|
|
95
|
+
if (missing.length) {
|
|
96
|
+
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
|
|
97
|
+
}
|
|
98
|
+
if (!apiKey && !process.env[envKey]) {
|
|
99
|
+
tag('warning').log(`Add your API key to ${globalEnvPath()}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log('');
|
|
103
|
+
log('Explorbot now runs from any directory:');
|
|
104
|
+
tag('substep').log(chalk.yellow(`${getCliName()} explore https://your-app.example.com`));
|
|
105
|
+
tag('substep').log(chalk.yellow(`${getCliName()} sites`));
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
export function runInitCommand(options: InitCommandOptions): void {
|
|
65
109
|
const configPath = options.configPath ?? './explorbot.config.js';
|
|
66
110
|
const force = options.force ?? false;
|
|
@@ -134,8 +178,109 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
134
178
|
}
|
|
135
179
|
}
|
|
136
180
|
|
|
181
|
+
async function runGlobalInit(options: InitCommandOptions): Promise<void> {
|
|
182
|
+
const existing = findGlobalConfig();
|
|
183
|
+
if (existing && !options.force) {
|
|
184
|
+
log(`Global config already exists: ${existing}`);
|
|
185
|
+
log('Use --force to overwrite existing file');
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (options.provider) {
|
|
190
|
+
writeGlobalConfig(options.provider, options.apiKey);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!process.stdin.isTTY) {
|
|
195
|
+
log('Cannot run the setup wizard outside an interactive terminal');
|
|
196
|
+
log(`Pass a provider instead: ${getCliName()} init --global --provider ${Object.keys(PROVIDERS)[0]}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await renderInitWizard('global');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'global' | null> {
|
|
204
|
+
const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
|
|
205
|
+
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
const finish = (choice: 'local' | 'global' | null) => {
|
|
208
|
+
unmount();
|
|
209
|
+
resolve(choice);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const { unmount } = render(
|
|
213
|
+
React.createElement(InitWizard, {
|
|
214
|
+
mode,
|
|
215
|
+
globalConfigExists: !!findGlobalConfig(),
|
|
216
|
+
onLocal: () => finish('local'),
|
|
217
|
+
onComplete: () => finish('global'),
|
|
218
|
+
onCancel: () => finish(null),
|
|
219
|
+
}),
|
|
220
|
+
{ exitOnCtrlC: false, patchConsole: false }
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function globalConfigTemplate(provider: string): string {
|
|
226
|
+
const { envKey } = PROVIDERS[provider];
|
|
227
|
+
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
228
|
+
const roles: Array<[ModelRoleName, string]> = [
|
|
229
|
+
['model', 'fast model with tool calling capabilities'],
|
|
230
|
+
['visionModel', 'vision model for screenshot analysis'],
|
|
231
|
+
['agenticModel', 'agentic model for decision making'],
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const models = roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
235
|
+
|
|
236
|
+
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
237
|
+
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
238
|
+
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
239
|
+
// Model ids are snapshotted from the recommendations of this Explorbot version.
|
|
240
|
+
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
241
|
+
const config = {
|
|
242
|
+
ai: {
|
|
243
|
+
${models}
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
reporter: {
|
|
247
|
+
// Save a local HTML report after each run.
|
|
248
|
+
html: true,
|
|
249
|
+
// Save a local markdown report after each run.
|
|
250
|
+
markdown: true,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export default config;
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function missingRoles(provider: string): string[] {
|
|
259
|
+
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
260
|
+
return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function writeEnvKey(key: string, value: string): void {
|
|
264
|
+
const envPath = globalEnvPath();
|
|
265
|
+
let content = '# AI provider API keys';
|
|
266
|
+
if (existsSync(envPath)) content = readFileSync(envPath, 'utf8').trimEnd();
|
|
267
|
+
|
|
268
|
+
const lines = content.split('\n');
|
|
269
|
+
const index = lines.findIndex((line) => line.startsWith(`${key}=`));
|
|
270
|
+
|
|
271
|
+
if (index < 0) lines.push(`${key}=${value}`);
|
|
272
|
+
if (index >= 0 && value) lines[index] = `${key}=${value}`;
|
|
273
|
+
|
|
274
|
+
writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type ModelRoleName = 'model' | 'visionModel' | 'agenticModel';
|
|
278
|
+
|
|
137
279
|
type InitCommandOptions = {
|
|
138
280
|
configPath?: string;
|
|
139
281
|
force?: boolean;
|
|
140
282
|
path?: string;
|
|
283
|
+
global?: boolean;
|
|
284
|
+
provider?: string;
|
|
285
|
+
apiKey?: string;
|
|
141
286
|
};
|
|
@@ -15,7 +15,7 @@ export class NavigateCommand extends BaseCommand {
|
|
|
15
15
|
throw new Error('Navigate command requires a target URI or state');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
await this.explorBot.
|
|
18
|
+
await this.explorBot.visit(destination);
|
|
19
19
|
tag('success').log(`Navigation requested: ${destination}`);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -24,7 +24,7 @@ export class ResearchCommand extends BaseCommand {
|
|
|
24
24
|
const target = remaining.join(' ');
|
|
25
25
|
|
|
26
26
|
if (target) {
|
|
27
|
-
await this.explorBot.
|
|
27
|
+
await this.explorBot.visit(target);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const state = this.explorBot.stateManager().getCurrentState();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { listSites, sitesDir } from '../global-config.js';
|
|
2
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
3
|
+
import { tag } from '../utils/logger.js';
|
|
4
|
+
import { BaseCommand } from './base-command.js';
|
|
5
|
+
|
|
6
|
+
export class SitesCommand extends BaseCommand {
|
|
7
|
+
name = 'sites';
|
|
8
|
+
description = 'List sites registered in the global installation';
|
|
9
|
+
|
|
10
|
+
async execute(): Promise<void> {
|
|
11
|
+
const sites = listSites();
|
|
12
|
+
|
|
13
|
+
if (sites.length === 0) {
|
|
14
|
+
tag('info').log(`No sites registered in ${sitesDir()}`);
|
|
15
|
+
tag('info').log(`Explore one to register it: ${getCliName()} explore https://app.example.com`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const width = sites.reduce((max, site) => Math.max(max, site.folder.length), 0);
|
|
20
|
+
tag('info').log(`Registered sites (${sites.length}):`);
|
|
21
|
+
for (const site of sites) {
|
|
22
|
+
tag('info').log(` ${site.folder.padEnd(width)} ${site.url} last run ${site.lastRunAt.slice(0, 16).replace('T', ' ')}`);
|
|
23
|
+
}
|
|
24
|
+
tag('info').log('');
|
|
25
|
+
tag('info').log(`Stored in ${sitesDir()}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import figureSet from 'figures';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { AIProvider } from '../ai/provider.js';
|
|
5
|
+
import { writeGlobalConfig } from '../commands/init-command.js';
|
|
6
|
+
import { ConfigParser, PROVIDERS, createModel } from '../config.js';
|
|
7
|
+
import { globalDir } from '../global-config.js';
|
|
8
|
+
import InputReadline from './InputReadline.js';
|
|
9
|
+
|
|
10
|
+
const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
|
11
|
+
|
|
12
|
+
interface InitWizardProps {
|
|
13
|
+
mode: 'choose' | 'global';
|
|
14
|
+
globalConfigExists: boolean;
|
|
15
|
+
onLocal: () => void;
|
|
16
|
+
onComplete: () => void;
|
|
17
|
+
onCancel: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const InitWizard: React.FC<InitWizardProps> = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
|
|
21
|
+
const [step, setStep] = useState<'target' | 'provider' | 'key' | 'validate'>(mode === 'choose' ? 'target' : 'provider');
|
|
22
|
+
const [targetIndex, setTargetIndex] = useState(0);
|
|
23
|
+
const [providerIndex, setProviderIndex] = useState(0);
|
|
24
|
+
const [apiKey, setApiKey] = useState('');
|
|
25
|
+
const [status, setStatus] = useState('');
|
|
26
|
+
const [error, setError] = useState('');
|
|
27
|
+
|
|
28
|
+
const provider = PROVIDER_NAMES[providerIndex];
|
|
29
|
+
const envKey = PROVIDERS[provider].envKey;
|
|
30
|
+
|
|
31
|
+
const save = () => {
|
|
32
|
+
writeGlobalConfig(provider, apiKey.trim());
|
|
33
|
+
onComplete();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const validate = async () => {
|
|
37
|
+
const modelId = defaultModelId(provider);
|
|
38
|
+
if (!modelId) {
|
|
39
|
+
save();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setStatus(`Sending a test request to ${provider}...`);
|
|
44
|
+
setError('');
|
|
45
|
+
if (apiKey.trim()) process.env[envKey] = apiKey.trim();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const model = await createModel(provider, modelId);
|
|
49
|
+
await new AIProvider({ model }).validateConnection();
|
|
50
|
+
save();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setStatus('');
|
|
53
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
useInput((input, key) => {
|
|
58
|
+
if (key.ctrl && input === 'c') {
|
|
59
|
+
onCancel();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (step === 'key') return;
|
|
64
|
+
|
|
65
|
+
if (key.escape) {
|
|
66
|
+
onCancel();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (step === 'target') {
|
|
71
|
+
if (key.upArrow) setTargetIndex(0);
|
|
72
|
+
if (key.downArrow && !globalConfigExists) setTargetIndex(1);
|
|
73
|
+
if (key.return && targetIndex === 0) onLocal();
|
|
74
|
+
if (key.return && targetIndex === 1) setStep('provider');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (step === 'provider') {
|
|
79
|
+
if (key.upArrow) setProviderIndex((index) => Math.max(0, index - 1));
|
|
80
|
+
if (key.downArrow) setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
|
|
81
|
+
if (key.return) setStep('key');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (status) return;
|
|
86
|
+
|
|
87
|
+
if (input.toLowerCase() === 'y') void validate();
|
|
88
|
+
if (input.toLowerCase() === 'n') save();
|
|
89
|
+
if (input.toLowerCase() === 'r') {
|
|
90
|
+
setError('');
|
|
91
|
+
setStep('key');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Box flexDirection="column" padding={1}>
|
|
97
|
+
<Box marginBottom={1}>
|
|
98
|
+
<Text color="cyan" bold>
|
|
99
|
+
Explorbot setup
|
|
100
|
+
</Text>
|
|
101
|
+
</Box>
|
|
102
|
+
|
|
103
|
+
{step === 'target' && (
|
|
104
|
+
<Box flexDirection="column">
|
|
105
|
+
<Text>Where should explorbot be initialized?</Text>
|
|
106
|
+
<Text color={targetIndex === 0 ? 'blue' : undefined}>
|
|
107
|
+
{targetIndex === 0 ? `${figureSet.pointer} ` : ' '}Local <Text dimColor>— creates the config file in the current directory</Text>
|
|
108
|
+
</Text>
|
|
109
|
+
<Text color={targetIndex === 1 ? 'blue' : undefined} dimColor={globalConfigExists}>
|
|
110
|
+
{targetIndex === 1 ? `${figureSet.pointer} ` : ' '}Global <Text dimColor>— initializes explorbot to run from anywhere on this machine</Text>
|
|
111
|
+
{globalConfigExists && <Text dimColor> (already installed)</Text>}
|
|
112
|
+
</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{step === 'provider' && (
|
|
117
|
+
<Box flexDirection="column">
|
|
118
|
+
<Text>Pick an AI provider:</Text>
|
|
119
|
+
{PROVIDER_NAMES.map((name, index) => (
|
|
120
|
+
<Text key={name} color={index === providerIndex ? 'blue' : undefined}>
|
|
121
|
+
{index === providerIndex ? `${figureSet.pointer} ` : ' '}
|
|
122
|
+
{name}
|
|
123
|
+
</Text>
|
|
124
|
+
))}
|
|
125
|
+
</Box>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{step === 'key' && (
|
|
129
|
+
<Box flexDirection="column">
|
|
130
|
+
<Text>
|
|
131
|
+
Enter the API key for {provider} <Text dimColor>(stored as {envKey})</Text>
|
|
132
|
+
</Text>
|
|
133
|
+
<Box borderStyle="single" borderColor="blue" paddingX={1}>
|
|
134
|
+
<InputReadline value={apiKey} onChange={setApiKey} onSubmit={() => setStep('validate')} placeholder={process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key'} isActive showPrompt={false} mask />
|
|
135
|
+
</Box>
|
|
136
|
+
</Box>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{step === 'validate' && (
|
|
140
|
+
<Box flexDirection="column">
|
|
141
|
+
{!status && !error && <Text>Check the key with a test AI call? (y/n)</Text>}
|
|
142
|
+
{status && <Text color="yellow">{status}</Text>}
|
|
143
|
+
{error && (
|
|
144
|
+
<Box flexDirection="column">
|
|
145
|
+
<Text color="red">{error}</Text>
|
|
146
|
+
<Text dimColor>r: re-enter the key | n: save anyway</Text>
|
|
147
|
+
</Box>
|
|
148
|
+
)}
|
|
149
|
+
</Box>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<Box marginTop={1}>
|
|
153
|
+
<Text dimColor>
|
|
154
|
+
Config goes to {globalDir()} | {step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm'} | Ctrl+C: exit
|
|
155
|
+
</Text>
|
|
156
|
+
</Box>
|
|
157
|
+
</Box>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function defaultModelId(provider: string): string {
|
|
162
|
+
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
163
|
+
return recommended.model || recommended.agenticModel || recommended.visionModel || '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default InitWizard;
|
|
@@ -17,6 +17,7 @@ interface InputReadlineProps {
|
|
|
17
17
|
value?: string;
|
|
18
18
|
placeholder?: string;
|
|
19
19
|
showPrompt?: boolean;
|
|
20
|
+
mask?: boolean;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface InputState {
|
|
@@ -26,7 +27,7 @@ interface InputState {
|
|
|
26
27
|
selectedIndex: number;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler, exitOnEmptyInput = false, onSubmit, onChange, onCommandStart, onCommandComplete, isActive = true, visible = true, value: controlledValue, placeholder = '', showPrompt = true }) => {
|
|
30
|
+
const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler, exitOnEmptyInput = false, onSubmit, onChange, onCommandStart, onCommandComplete, isActive = true, visible = true, value: controlledValue, placeholder = '', showPrompt = true, mask = false }) => {
|
|
30
31
|
const isControlled = controlledValue !== undefined;
|
|
31
32
|
const [inputState, setInputState] = useState<InputState>({
|
|
32
33
|
value: controlledValue || '',
|
|
@@ -616,11 +617,14 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
|
|
|
616
617
|
}
|
|
617
618
|
}, [commandHandler, exitOnEmptyInput]);
|
|
618
619
|
|
|
619
|
-
|
|
620
|
-
|
|
620
|
+
let renderedValue = displayValue;
|
|
621
|
+
if (mask) renderedValue = '•'.repeat(displayValue.length);
|
|
622
|
+
|
|
623
|
+
const beforeCursor = renderedValue.slice(0, cursorPosition);
|
|
624
|
+
const afterCursor = renderedValue.slice(cursorPosition);
|
|
621
625
|
const beforeLines = beforeCursor.split('\n');
|
|
622
626
|
const afterLines = afterCursor.split('\n');
|
|
623
|
-
const lines =
|
|
627
|
+
const lines = renderedValue.split('\n');
|
|
624
628
|
const cursorLineIndex = beforeLines.length - 1;
|
|
625
629
|
const cursorLineBefore = beforeLines[cursorLineIndex] || '';
|
|
626
630
|
const cursorLineAfter = afterLines[0] || '';
|