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