explorbot 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +21 -5
  2. package/bin/explorbot-cli.ts +29 -7
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +5 -2
  5. package/boat/doc-collector/src/ai/tools.ts +12 -11
  6. package/boat/doc-collector/src/cli.ts +1 -6
  7. package/boat/doc-collector/src/docbot.ts +3 -3
  8. package/boat/doc-collector/src/screenshots.ts +2 -2
  9. package/dist/bin/explorbot-cli.js +26 -6
  10. package/dist/boat/api-tester/src/config.js +43 -4
  11. package/dist/boat/doc-collector/src/ai/documentarian.js +4 -2
  12. package/dist/boat/doc-collector/src/ai/tools.js +11 -11
  13. package/dist/boat/doc-collector/src/cli.js +1 -5
  14. package/dist/boat/doc-collector/src/docbot.js +3 -3
  15. package/dist/boat/doc-collector/src/screenshots.js +2 -2
  16. package/dist/models.json +30 -0
  17. package/dist/package.json +5 -2
  18. package/dist/src/action.d.ts +11 -3
  19. package/dist/src/action.js +22 -5
  20. package/dist/src/ai/agent.d.ts +17 -0
  21. package/dist/src/ai/captain/idle-mode.js +1 -1
  22. package/dist/src/ai/captain/test-mode.js +1 -1
  23. package/dist/src/ai/captain/web-mode.js +18 -22
  24. package/dist/src/ai/captain.js +13 -26
  25. package/dist/src/ai/driller.d.ts +2 -13
  26. package/dist/src/ai/driller.js +23 -38
  27. package/dist/src/ai/historian/codeceptjs.js +1 -1
  28. package/dist/src/ai/historian/playwright.d.ts +3 -2
  29. package/dist/src/ai/historian/playwright.js +1 -1
  30. package/dist/src/ai/historian/screencast.d.ts +2 -1
  31. package/dist/src/ai/historian/screencast.js +2 -2
  32. package/dist/src/ai/historian.d.ts +5 -4
  33. package/dist/src/ai/navigator.d.ts +6 -2
  34. package/dist/src/ai/navigator.js +26 -22
  35. package/dist/src/ai/pilot.d.ts +13 -6
  36. package/dist/src/ai/pilot.js +49 -44
  37. package/dist/src/ai/planner.d.ts +3 -2
  38. package/dist/src/ai/planner.js +19 -17
  39. package/dist/src/ai/provider.js +0 -1
  40. package/dist/src/ai/quartermaster.d.ts +3 -2
  41. package/dist/src/ai/quartermaster.js +4 -4
  42. package/dist/src/ai/rerunner.d.ts +2 -11
  43. package/dist/src/ai/rerunner.js +9 -22
  44. package/dist/src/ai/researcher/coordinates.js +4 -3
  45. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  46. package/dist/src/ai/researcher/deep-analysis.js +6 -6
  47. package/dist/src/ai/researcher/locators.d.ts +9 -2
  48. package/dist/src/ai/researcher/locators.js +65 -8
  49. package/dist/src/ai/researcher/sections.d.ts +2 -0
  50. package/dist/src/ai/researcher/sections.js +2 -2
  51. package/dist/src/ai/researcher.js +29 -54
  52. package/dist/src/ai/task-agent.d.ts +18 -5
  53. package/dist/src/ai/task-agent.js +35 -6
  54. package/dist/src/ai/tester.d.ts +6 -12
  55. package/dist/src/ai/tester.js +37 -51
  56. package/dist/src/ai/tools.d.ts +11 -10
  57. package/dist/src/ai/tools.js +24 -36
  58. package/dist/src/command-handler.js +1 -1
  59. package/dist/src/commands/context-aria-command.js +1 -1
  60. package/dist/src/commands/context-command.js +7 -5
  61. package/dist/src/commands/context-data-command.js +2 -2
  62. package/dist/src/commands/context-experience-command.js +2 -2
  63. package/dist/src/commands/context-html-command.js +2 -2
  64. package/dist/src/commands/context-knowledge-command.js +1 -1
  65. package/dist/src/commands/drill-command.js +1 -1
  66. package/dist/src/commands/explore-command.js +4 -3
  67. package/dist/src/commands/freesail-command.js +2 -2
  68. package/dist/src/commands/learn-command.js +4 -4
  69. package/dist/src/commands/path-command.js +1 -1
  70. package/dist/src/commands/research-command.js +1 -1
  71. package/dist/src/commands/test-command.js +1 -1
  72. package/dist/src/components/App.js +1 -1
  73. package/dist/src/config.d.ts +17 -0
  74. package/dist/src/config.js +144 -10
  75. package/dist/src/experience-tracker.js +1 -1
  76. package/dist/src/explorbot.d.ts +17 -8
  77. package/dist/src/explorbot.js +74 -36
  78. package/dist/src/explorer.d.ts +66 -91
  79. package/dist/src/explorer.js +326 -567
  80. package/dist/src/state-manager.d.ts +6 -1
  81. package/dist/src/state-manager.js +1 -0
  82. package/dist/src/utils/hooks-runner.js +2 -4
  83. package/dist/src/utils/test-files.js +1 -1
  84. package/dist/src/utils/web-annotate.d.ts +5 -0
  85. package/dist/src/utils/web-annotate.js +58 -0
  86. package/dist/src/utils/web-eidx.d.ts +2 -0
  87. package/dist/src/utils/web-eidx.js +20 -0
  88. package/dist/src/utils/web-element.d.ts +1 -0
  89. package/dist/src/utils/web-element.js +29 -0
  90. package/models.json +30 -0
  91. package/package.json +5 -2
  92. package/src/action.ts +25 -5
  93. package/src/ai/agent.ts +20 -0
  94. package/src/ai/captain/idle-mode.ts +1 -1
  95. package/src/ai/captain/test-mode.ts +1 -1
  96. package/src/ai/captain/web-mode.ts +18 -23
  97. package/src/ai/captain.ts +12 -25
  98. package/src/ai/driller.ts +50 -67
  99. package/src/ai/historian/codeceptjs.ts +1 -1
  100. package/src/ai/historian/playwright.ts +4 -3
  101. package/src/ai/historian/screencast.ts +4 -3
  102. package/src/ai/historian.ts +3 -2
  103. package/src/ai/navigator.ts +30 -25
  104. package/src/ai/pilot.ts +55 -47
  105. package/src/ai/planner.ts +22 -20
  106. package/src/ai/provider.ts +0 -1
  107. package/src/ai/quartermaster.ts +5 -4
  108. package/src/ai/rerunner.ts +10 -29
  109. package/src/ai/researcher/coordinates.ts +4 -3
  110. package/src/ai/researcher/deep-analysis.ts +8 -6
  111. package/src/ai/researcher/locators.ts +66 -9
  112. package/src/ai/researcher/sections.ts +4 -2
  113. package/src/ai/researcher.ts +32 -61
  114. package/src/ai/task-agent.ts +45 -11
  115. package/src/ai/tester.ts +40 -57
  116. package/src/ai/tools.ts +32 -53
  117. package/src/command-handler.ts +1 -1
  118. package/src/commands/context-aria-command.ts +1 -1
  119. package/src/commands/context-command.ts +7 -5
  120. package/src/commands/context-data-command.ts +2 -2
  121. package/src/commands/context-experience-command.ts +2 -2
  122. package/src/commands/context-html-command.ts +2 -2
  123. package/src/commands/context-knowledge-command.ts +1 -1
  124. package/src/commands/drill-command.ts +1 -1
  125. package/src/commands/explore-command.ts +4 -3
  126. package/src/commands/freesail-command.ts +2 -2
  127. package/src/commands/learn-command.ts +4 -4
  128. package/src/commands/path-command.ts +1 -1
  129. package/src/commands/research-command.ts +1 -1
  130. package/src/commands/test-command.ts +1 -1
  131. package/src/components/App.tsx +1 -1
  132. package/src/config.ts +173 -11
  133. package/src/experience-tracker.ts +1 -1
  134. package/src/explorbot.ts +78 -39
  135. package/src/explorer.ts +389 -628
  136. package/src/state-manager.ts +7 -1
  137. package/src/utils/hooks-runner.ts +2 -4
  138. package/src/utils/test-files.ts +1 -1
  139. package/src/utils/web-annotate.ts +64 -0
  140. package/src/utils/web-eidx.ts +21 -0
  141. package/src/utils/web-element.ts +30 -0
package/README.md CHANGED
@@ -14,6 +14,8 @@ npx explorbot start https://your-app.com
14
14
 
15
15
  It runs with no babysitting and reports back what it finds. This is vibe-testing.
16
16
 
17
+ Explorbot works with any AI provider through the [Vercel AI SDK](https://sdk.vercel.ai/providers). See [`models.json`](models.json) for the current recommended provider and model setup, and [Providers](docs/basics/providers.md) for how to configure each one.
18
+
17
19
  New here? Read the [Getting Started guide](docs/basics/getting-started.md).
18
20
 
19
21
  ## Use Cases
@@ -92,7 +94,7 @@ Explorbot won't replace your regression tests — it covers what they can't. You
92
94
  ## Requirements
93
95
 
94
96
  - Node.js 24+ or **Bun**
95
- - An **AI provider key** — OpenRouter recommended; Groq, Cerebras, OpenAI, Anthropic, and others via the [Vercel AI SDK](https://sdk.vercel.ai/providers)
97
+ - An **AI provider key** — OpenRouter recommended; Groq, Cerebras, [OpenAI](docs/basics/providers.md#openai), Anthropic, and others via the [Vercel AI SDK](https://sdk.vercel.ai/providers)
96
98
  - A **modern terminal** — iTerm2, WARP, Kitty, Ghostty, or Windows Terminal with WSL
97
99
  - A **compatible web app** — CRUD-heavy apps fit best. See [Prerequisites](docs/basics/prerequisites.md)
98
100
 
@@ -125,6 +127,19 @@ Type `/explore`, and Explorbot runs its loop on its own — research, plan, test
125
127
 
126
128
  That's the gist. The [**Getting Started guide**](docs/basics/getting-started.md) walks through the full setup — choosing models, teaching Explorbot to log in, and picking the right feature to start on.
127
129
 
130
+ ### Or skip the config file
131
+
132
+ For a CI job, a demo, or a coding agent, pass everything as environment variables. Name a provider and Explorbot picks its recommended models:
133
+
134
+ ```bash
135
+ EXPLORBOT_URL=https://app.example.com \
136
+ EXPLORBOT_AI_PROVIDER=openrouter \
137
+ EXPLORBOT_KNOWLEDGE="Log in as admin@example.com / secret123" \
138
+ npx explorbot explore /admin/users --max-tests 3
139
+ ```
140
+
141
+ Output lands in a temp directory and nothing is written to your project. See [Agentic Usage](docs/workflow/agentic-usage.md).
142
+
128
143
  ## Teaching Explorbot
129
144
 
130
145
  Explorbot gets better when you tell it about your app:
@@ -146,12 +161,13 @@ When you're ready to go deeper, the [full documentation](docs/) covers everythin
146
161
  ## FAQ
147
162
 
148
163
  **Can I run it in Cursor or Claude Code?**
149
- No, Explorbot is a separate application designed for constant testing. Cursor, Codex, and Claude Code are coding agents not relevant here.
150
-
151
- > However, Explorbot can be used as a subagent or terminal command controlled by a coding agent.
164
+ Not as a replacement — Explorbot is a separate application designed for constant testing, while Cursor, Codex, and Claude Code are coding agents. But a coding agent can drive Explorbot as a terminal command or subagent: it writes the test plan, Explorbot executes it against the real app. See [Agentic Usage](docs/workflow/agentic-usage.md).
152
165
 
153
166
  **Can I bring a Cursor or OpenAI subscription?**
154
- No. Their models are too slow for the way Explorbot works. Use pay-per-token providers like Groq and OpenRouter.
167
+ No. Explorbot needs an API key, not a chat subscription. Use pay-per-token access Groq, OpenRouter, or OpenAI's own API.
168
+
169
+ **Can I use OpenAI directly?**
170
+ Yes. Add your `OPENAI_API_KEY` and point the models at OpenAI — a nano-class model for `model` and `visionModel`, a stronger one for `agenticModel`. Expect it to run a bit slower than hosted OSS models on Groq or Cerebras. See [Providers](docs/basics/providers.md#openai) for the config.
155
171
 
156
172
  **I want to use Opus!!!**
157
173
  Opus is great for coding. Testing needs a simpler model that can safely consume lots of HTML tokens, fast. Save the expensive models for sophisticated decision-making.
@@ -9,7 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { ConfigParser } from '../src/config.js';
12
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
13
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
14
14
  import { Stats } from '../src/stats.js';
15
15
  import { Plan } from '../src/test-plan.js';
@@ -43,8 +43,6 @@ interface CLIOptions {
43
43
  }
44
44
 
45
45
  function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
46
- const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;
47
-
48
46
  return {
49
47
  from,
50
48
  verbose: options.verbose || options.debug,
@@ -53,7 +51,7 @@ function buildExplorBotOptions(from: string | undefined, options: CLIOptions): E
53
51
  show: options.show,
54
52
  headless: options.headless,
55
53
  incognito: options.incognito,
56
- session: sessionFile,
54
+ session: options.session,
57
55
  } as ExplorBotOptions;
58
56
  }
59
57
 
@@ -690,7 +688,7 @@ program
690
688
  path: options.path,
691
689
  config: options.config,
692
690
  headless: true,
693
- session: options.session === true ? 'output/session.json' : options.session,
691
+ session: options.session,
694
692
  };
695
693
 
696
694
  const explorBot = new ExplorBot(mainOptions);
@@ -720,11 +718,11 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
720
718
  await explorBot.start();
721
719
  await explorBot.agentNavigator().visit(url);
722
720
 
723
- const action = explorBot.getExplorer().createAction();
721
+ const action = explorBot.getExplorer().action();
724
722
  await action.execute(command);
725
723
 
726
724
  log('Command executed successfully');
727
- const state = explorBot.getExplorer().getStateManager().getCurrentState();
725
+ const state = explorBot.stateManager().getCurrentState();
728
726
  if (state) log(`URL: ${state.url}`);
729
727
 
730
728
  await explorBot.stop();
@@ -876,4 +874,28 @@ import { createDocsCommands } from '../boat/doc-collector/src/cli.ts';
876
874
  program.addCommand(createApiCommands('api'));
877
875
  program.addCommand(createDocsCommands('docs'));
878
876
 
877
+ const envHelp = () => {
878
+ const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
879
+ const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
880
+
881
+ return `
882
+ Environment variables (config-free one-liner mode):
883
+ Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.
884
+
885
+ ${rows}
886
+
887
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
888
+ Example:
889
+ EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
890
+ ${cli} explore /login --max-tests 3
891
+ `;
892
+ };
893
+
894
+ const addEnvHelp = (cmd: Command) => {
895
+ cmd.addHelpText('after', envHelp);
896
+ for (const sub of cmd.commands) addEnvHelp(sub);
897
+ };
898
+
899
+ addEnvHelp(program);
900
+
879
901
  program.parse();
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path, { resolve } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
- import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS } from '../../../src/config.ts';
4
+ import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
5
5
 
6
6
  export type { AIConfig };
7
7
 
@@ -54,8 +54,11 @@ export class ApibotConfigParser {
54
54
 
55
55
  const resolvedPath = options?.config || this.findConfigFile();
56
56
  if (!resolvedPath) {
57
- if (options?.path) process.chdir(originalCwd);
58
- throw new Error('No configuration file found. Create apibot.config.js or apibot.config.ts');
57
+ try {
58
+ return await this.loadEnvConfig();
59
+ } finally {
60
+ if (options?.path && originalCwd !== process.cwd()) process.chdir(originalCwd);
61
+ }
59
62
  }
60
63
 
61
64
  try {
@@ -123,6 +126,45 @@ export class ApibotConfigParser {
123
126
  }
124
127
  }
125
128
 
129
+ private async loadEnvConfig(): Promise<ApibotConfig> {
130
+ const provider = process.env.EXPLORBOT_AI_PROVIDER;
131
+ const modelSpec = process.env.EXPLORBOT_AI_MODEL;
132
+ if (!provider && !modelSpec) {
133
+ throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
134
+ }
135
+ if (modelSpec && !provider && !modelSpec.includes('/')) {
136
+ throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
137
+ }
138
+
139
+ const baseEndpoint = process.env.EXPLORBOT_URL;
140
+ if (!baseEndpoint) {
141
+ throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
142
+ }
143
+
144
+ const outputRoot = resolveOutputRoot();
145
+ materializeKnowledge(outputRoot);
146
+
147
+ const api: ApiConfig = { baseEndpoint };
148
+ if (process.env.EXPLORBOT_API_SPEC) {
149
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
150
+ }
151
+
152
+ let model: any;
153
+ if (provider && modelSpec) model = await createModel(provider, modelSpec);
154
+ if (provider && !modelSpec) model = await resolveModel(provider, 'model');
155
+ if (!provider) model = await resolveModel(modelSpec!, 'model');
156
+
157
+ this.config = {
158
+ ai: { model },
159
+ api,
160
+ dirs: { output: '.', knowledge: 'knowledge' },
161
+ };
162
+ this.configPath = path.join(outputRoot, 'apibot.config.js');
163
+ this.validateConfig(this.config);
164
+
165
+ return this.config;
166
+ }
167
+
126
168
  private findConfigFile(): string | null {
127
169
  const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
128
170
  for (const p of apibotPaths) {
@@ -2,6 +2,7 @@ import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
4
  import type Explorer from '../../../../src/explorer.ts';
5
+ import type { StateManager } from '../../../../src/state-manager.ts';
5
6
  import type { WebPageState } from '../../../../src/state-manager.ts';
6
7
  import { tag } from '../../../../src/utils/logger.ts';
7
8
  import type { DocbotConfig } from '../config.ts';
@@ -11,11 +12,13 @@ class Documentarian {
11
12
  private provider: AIProvider;
12
13
  private config: DocbotConfig;
13
14
  private explorer?: Explorer;
15
+ private stateManager?: StateManager;
14
16
 
15
- constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer) {
17
+ constructor(provider: AIProvider, config: DocbotConfig = {}, explorer?: Explorer, stateManager?: StateManager) {
16
18
  this.provider = provider;
17
19
  this.config = config;
18
20
  this.explorer = explorer;
21
+ this.stateManager = stateManager;
19
22
  }
20
23
 
21
24
  async document(state: WebPageState, research: string, captureState?: CaptureInteractionState): Promise<PageDocumentation> {
@@ -46,7 +49,7 @@ class Documentarian {
46
49
  try {
47
50
  tag('info').log('Starting interactive exploration...');
48
51
 
49
- const deterministicInteractions = await collectDocInteractions(this.explorer!, state, research, this.config, captureState);
52
+ const deterministicInteractions = await collectDocInteractions(this.explorer!, this.stateManager!, state, research, this.config, captureState);
50
53
  meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
51
54
  if (meaningfulInteractions.length > 0) {
52
55
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
@@ -1,6 +1,6 @@
1
1
  import { type ResearchElement, parseResearchSections } from '../../../../src/ai/researcher/parser.ts';
2
2
  import type Explorer from '../../../../src/explorer.ts';
3
- import type { WebPageState } from '../../../../src/state-manager.ts';
3
+ import type { StateManager, WebPageState } from '../../../../src/state-manager.ts';
4
4
  import { detectFocusArea } from '../../../../src/utils/aria.ts';
5
5
  import type { DocbotConfig } from '../config.ts';
6
6
 
@@ -58,14 +58,14 @@ const DEFAULT_WAIT_MS = 700;
58
58
  const TAB_WAIT_MS = 500;
59
59
  const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
60
60
 
61
- export async function collectDocInteractions(explorer: Explorer, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
61
+ export async function collectDocInteractions(explorer: Explorer, stateManager: StateManager, state: WebPageState, research: string, config: DocbotConfig = {}, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
62
62
  const sections = parseResearchSections(research);
63
63
  const transitions: DocStateTransition[] = [];
64
64
  const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
65
65
  const tabGroup = findTabGroup(sections);
66
66
 
67
67
  if (tabGroup) {
68
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
68
+ transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
69
69
  }
70
70
 
71
71
  for (const candidate of findActionCandidates(sections, config)) {
@@ -73,7 +73,7 @@ export async function collectDocInteractions(explorer: Explorer, state: WebPageS
73
73
  break;
74
74
  }
75
75
 
76
- const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
76
+ const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
77
77
  if (!transition) {
78
78
  continue;
79
79
  }
@@ -92,7 +92,7 @@ export function pickDocActionCandidates(research: string, config: DocbotConfig =
92
92
  }));
93
93
  }
94
94
 
95
- async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
95
+ async function exploreTabGroup(explorer: Explorer, stateManager: StateManager, tabGroup: { elements: ResearchElement[]; container?: string; sectionName: string }, restoreUrl: string, maxInteractions: number, captureState?: CaptureInteractionState): Promise<DocStateTransition[]> {
96
96
  const transitions: DocStateTransition[] = [];
97
97
 
98
98
  for (const element of tabGroup.elements) {
@@ -102,6 +102,7 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc
102
102
 
103
103
  const transition = await executeInteraction(
104
104
  explorer,
105
+ stateManager,
105
106
  {
106
107
  element,
107
108
  container: tabGroup.container,
@@ -123,8 +124,8 @@ async function exploreTabGroup(explorer: Explorer, tabGroup: { elements: Researc
123
124
  return transitions;
124
125
  }
125
126
 
126
- async function executeInteraction(explorer: Explorer, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
127
- const beforeState = explorer.getStateManager().getCurrentState();
127
+ async function executeInteraction(explorer: Explorer, stateManager: StateManager, candidate: InteractionCandidate, restoreUrl: string, waitMs: number, captureState?: CaptureInteractionState): Promise<DocStateTransition | null> {
128
+ const beforeState = stateManager.getCurrentState();
128
129
  if (!beforeState) {
129
130
  return null;
130
131
  }
@@ -136,7 +137,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
136
137
 
137
138
  await wait(waitMs);
138
139
 
139
- const afterState = explorer.getStateManager().getCurrentState();
140
+ const afterState = stateManager.getCurrentState();
140
141
  if (!afterState) {
141
142
  return null;
142
143
  }
@@ -164,7 +165,7 @@ async function executeInteraction(explorer: Explorer, candidate: InteractionCand
164
165
  }
165
166
 
166
167
  async function attemptInteraction(explorer: Explorer, candidate: InteractionCandidate): Promise<boolean> {
167
- const action = explorer.createAction();
168
+ const action = explorer.action();
168
169
 
169
170
  for (const command of buildClickCommands(candidate.element, candidate.container)) {
170
171
  const success = await action.attempt(command, buildPurpose(candidate));
@@ -178,7 +179,7 @@ async function attemptInteraction(explorer: Explorer, candidate: InteractionCand
178
179
 
179
180
  async function restoreInteractionState(explorer: Explorer, restoreUrl: string, primaryCommand?: string | null): Promise<void> {
180
181
  if (primaryCommand) {
181
- const action = explorer.createAction();
182
+ const action = explorer.action();
182
183
  const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
183
184
  if (restored) {
184
185
  await wait(TAB_WAIT_MS);
@@ -186,7 +187,7 @@ async function restoreInteractionState(explorer: Explorer, restoreUrl: string, p
186
187
  }
187
188
  }
188
189
 
189
- const action = explorer.createAction();
190
+ const action = explorer.action();
190
191
  await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
191
192
  }
192
193
 
@@ -5,11 +5,6 @@ import { setPreserveConsoleLogs } from '../../../src/utils/logger.ts';
5
5
  import { DocBot, type DocbotOptions } from './docbot.ts';
6
6
 
7
7
  function buildOptions(options: any): DocbotOptions {
8
- let session = options.session;
9
- if (options.session === true) {
10
- session = 'output/session.json';
11
- }
12
-
13
8
  return {
14
9
  verbose: options.verbose || options.debug,
15
10
  config: options.config,
@@ -17,7 +12,7 @@ function buildOptions(options: any): DocbotOptions {
17
12
  show: options.show,
18
13
  headless: options.headless,
19
14
  incognito: options.incognito,
20
- session,
15
+ session: options.session,
21
16
  docsConfig: options.docsConfig,
22
17
  };
23
18
  }
@@ -8,10 +8,10 @@ import { sanitizeFilename } from '../../../src/utils/strings.ts';
8
8
  import { Documentarian, type PageDocumentation } from './ai/documentarian.ts';
9
9
  import { type DocbotConfig, DocbotConfigParser } from './config.ts';
10
10
  import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
11
- import { renderMermaidBody } from './state-diagram.ts';
12
11
  import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
13
12
  import { extractResearchNavigationTargets } from './research-navigation.ts';
14
13
  import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
14
+ import { renderMermaidBody } from './state-diagram.ts';
15
15
 
16
16
  class DocBot {
17
17
  private explorBot: ExplorBot;
@@ -43,7 +43,7 @@ class DocBot {
43
43
  config: this.options.docsConfig,
44
44
  path: this.options.path,
45
45
  });
46
- this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
46
+ this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
47
47
  this.ensureDirectory(this.configParser.getOutputDir());
48
48
  this.ensureDirectory(this.getPagesDir());
49
49
  }
@@ -76,7 +76,7 @@ class DocBot {
76
76
  continue;
77
77
  }
78
78
 
79
- const stateManager = this.explorBot.getExplorer().getStateManager();
79
+ const stateManager = this.explorBot.stateManager();
80
80
  if (stateManager.hasVisitedState(target)) {
81
81
  continue;
82
82
  }
@@ -11,7 +11,7 @@ import type { DocbotConfig } from './config.ts';
11
11
  const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
12
12
 
13
13
  export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
14
- const page = explorer.playwrightHelper?.page;
14
+ const page = explorer.page;
15
15
  if (!page) {
16
16
  return [];
17
17
  }
@@ -62,7 +62,7 @@ export function getScreenshotSections(research: string): ScreenshotSection[] {
62
62
  }
63
63
 
64
64
  export async function captureInteractionScreenshot(explorer: Explorer, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
65
- const page = explorer.playwrightHelper?.page;
65
+ const page = explorer.page;
66
66
  if (!page) {
67
67
  return null;
68
68
  }
@@ -9,7 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { ConfigParser } from '../src/config.js';
12
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
13
  import { ExplorBot } from '../src/explorbot.js';
14
14
  import { Stats } from '../src/stats.js';
15
15
  import { Plan } from '../src/test-plan.js';
@@ -27,7 +27,6 @@ if (!process.env.EXPLORBOT_NO_BANNER) {
27
27
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
28
28
  }
29
29
  function buildExplorBotOptions(from, options) {
30
- const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;
31
30
  return {
32
31
  from,
33
32
  verbose: options.verbose || options.debug,
@@ -36,7 +35,7 @@ function buildExplorBotOptions(from, options) {
36
35
  show: options.show,
37
36
  headless: options.headless,
38
37
  incognito: options.incognito,
39
- session: sessionFile,
38
+ session: options.session,
40
39
  };
41
40
  }
42
41
  function addCommonOptions(cmd) {
@@ -623,7 +622,7 @@ program
623
622
  path: options.path,
624
623
  config: options.config,
625
624
  headless: true,
626
- session: options.session === true ? 'output/session.json' : options.session,
625
+ session: options.session,
627
626
  };
628
627
  const explorBot = new ExplorBot(mainOptions);
629
628
  await explorBot.start();
@@ -652,10 +651,10 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
652
651
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
653
652
  await explorBot.start();
654
653
  await explorBot.agentNavigator().visit(url);
655
- const action = explorBot.getExplorer().createAction();
654
+ const action = explorBot.getExplorer().action();
656
655
  await action.execute(command);
657
656
  log('Command executed successfully');
658
- const state = explorBot.getExplorer().getStateManager().getCurrentState();
657
+ const state = explorBot.stateManager().getCurrentState();
659
658
  if (state)
660
659
  log(`URL: ${state.url}`);
661
660
  await explorBot.stop();
@@ -793,4 +792,25 @@ import { createApiCommands } from "../boat/api-tester/src/cli.js";
793
792
  import { createDocsCommands } from "../boat/doc-collector/src/cli.js";
794
793
  program.addCommand(createApiCommands('api'));
795
794
  program.addCommand(createDocsCommands('docs'));
795
+ const envHelp = () => {
796
+ const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
797
+ const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
798
+ return `
799
+ Environment variables (config-free one-liner mode):
800
+ Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.
801
+
802
+ ${rows}
803
+
804
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
805
+ Example:
806
+ EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
807
+ ${cli} explore /login --max-tests 3
808
+ `;
809
+ };
810
+ const addEnvHelp = (cmd) => {
811
+ cmd.addHelpText('after', envHelp);
812
+ for (const sub of cmd.commands)
813
+ addEnvHelp(sub);
814
+ };
815
+ addEnvHelp(program);
796
816
  program.parse();
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { parseEnv } from 'node:util';
12
- import { EXPLORBOT_CONFIG_PATHS } from "../../../src/config.js";
12
+ import { EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from "../../../src/config.js";
13
13
  export class ApibotConfigParser {
14
14
  static instance;
15
15
  config = null;
@@ -37,9 +37,13 @@ export class ApibotConfigParser {
37
37
  ApibotConfigParser.loadEnv('.env');
38
38
  const resolvedPath = options?.config || this.findConfigFile();
39
39
  if (!resolvedPath) {
40
- if (options?.path)
41
- process.chdir(originalCwd);
42
- throw new Error('No configuration file found. Create apibot.config.js or apibot.config.ts');
40
+ try {
41
+ return await this.loadEnvConfig();
42
+ }
43
+ finally {
44
+ if (options?.path && originalCwd !== process.cwd())
45
+ process.chdir(originalCwd);
46
+ }
43
47
  }
44
48
  try {
45
49
  const configModule = await this.loadConfigModule(resolvedPath);
@@ -98,6 +102,41 @@ export class ApibotConfigParser {
98
102
  mkdirSync(dirPath, { recursive: true });
99
103
  }
100
104
  }
105
+ async loadEnvConfig() {
106
+ const provider = process.env.EXPLORBOT_AI_PROVIDER;
107
+ const modelSpec = process.env.EXPLORBOT_AI_MODEL;
108
+ if (!provider && !modelSpec) {
109
+ throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
110
+ }
111
+ if (modelSpec && !provider && !modelSpec.includes('/')) {
112
+ throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
113
+ }
114
+ const baseEndpoint = process.env.EXPLORBOT_URL;
115
+ if (!baseEndpoint) {
116
+ throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
117
+ }
118
+ const outputRoot = resolveOutputRoot();
119
+ materializeKnowledge(outputRoot);
120
+ const api = { baseEndpoint };
121
+ if (process.env.EXPLORBOT_API_SPEC) {
122
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
123
+ }
124
+ let model;
125
+ if (provider && modelSpec)
126
+ model = await createModel(provider, modelSpec);
127
+ if (provider && !modelSpec)
128
+ model = await resolveModel(provider, 'model');
129
+ if (!provider)
130
+ model = await resolveModel(modelSpec, 'model');
131
+ this.config = {
132
+ ai: { model },
133
+ api,
134
+ dirs: { output: '.', knowledge: 'knowledge' },
135
+ };
136
+ this.configPath = path.join(outputRoot, 'apibot.config.js');
137
+ this.validateConfig(this.config);
138
+ return this.config;
139
+ }
101
140
  findConfigFile() {
102
141
  const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
103
142
  for (const p of apibotPaths) {
@@ -6,10 +6,12 @@ class Documentarian {
6
6
  provider;
7
7
  config;
8
8
  explorer;
9
- constructor(provider, config = {}, explorer) {
9
+ stateManager;
10
+ constructor(provider, config = {}, explorer, stateManager) {
10
11
  this.provider = provider;
11
12
  this.config = config;
12
13
  this.explorer = explorer;
14
+ this.stateManager = stateManager;
13
15
  }
14
16
  async document(state, research, captureState) {
15
17
  const interactiveEnabled = this.config.docs?.interactive === true && this.explorer;
@@ -35,7 +37,7 @@ class Documentarian {
35
37
  let meaningfulInteractions = [];
36
38
  try {
37
39
  tag('info').log('Starting interactive exploration...');
38
- const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config, captureState);
40
+ const deterministicInteractions = await collectDocInteractions(this.explorer, this.stateManager, state, research, this.config, captureState);
39
41
  meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
40
42
  if (meaningfulInteractions.length > 0) {
41
43
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
@@ -6,19 +6,19 @@ const MAX_LINKS = 15;
6
6
  const DEFAULT_WAIT_MS = 700;
7
7
  const TAB_WAIT_MS = 500;
8
8
  const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
9
- export async function collectDocInteractions(explorer, state, research, config = {}, captureState) {
9
+ export async function collectDocInteractions(explorer, stateManager, state, research, config = {}, captureState) {
10
10
  const sections = parseResearchSections(research);
11
11
  const transitions = [];
12
12
  const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
13
13
  const tabGroup = findTabGroup(sections);
14
14
  if (tabGroup) {
15
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
15
+ transitions.push(...(await exploreTabGroup(explorer, stateManager, tabGroup, state.url, maxInteractions, captureState)));
16
16
  }
17
17
  for (const candidate of findActionCandidates(sections, config)) {
18
18
  if (transitions.length >= maxInteractions) {
19
19
  break;
20
20
  }
21
- const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
21
+ const transition = await executeInteraction(explorer, stateManager, candidate, state.url, DEFAULT_WAIT_MS, captureState);
22
22
  if (!transition) {
23
23
  continue;
24
24
  }
@@ -33,13 +33,13 @@ export function pickDocActionCandidates(research, config = {}) {
33
33
  section: candidate.sectionName,
34
34
  }));
35
35
  }
36
- async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions, captureState) {
36
+ async function exploreTabGroup(explorer, stateManager, tabGroup, restoreUrl, maxInteractions, captureState) {
37
37
  const transitions = [];
38
38
  for (const element of tabGroup.elements) {
39
39
  if (transitions.length >= maxInteractions) {
40
40
  break;
41
41
  }
42
- const transition = await executeInteraction(explorer, {
42
+ const transition = await executeInteraction(explorer, stateManager, {
43
43
  element,
44
44
  container: tabGroup.container,
45
45
  role: 'tab',
@@ -53,8 +53,8 @@ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions,
53
53
  await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container));
54
54
  return transitions;
55
55
  }
56
- async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captureState) {
57
- const beforeState = explorer.getStateManager().getCurrentState();
56
+ async function executeInteraction(explorer, stateManager, candidate, restoreUrl, waitMs, captureState) {
57
+ const beforeState = stateManager.getCurrentState();
58
58
  if (!beforeState) {
59
59
  return null;
60
60
  }
@@ -63,7 +63,7 @@ async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captu
63
63
  return null;
64
64
  }
65
65
  await wait(waitMs);
66
- const afterState = explorer.getStateManager().getCurrentState();
66
+ const afterState = stateManager.getCurrentState();
67
67
  if (!afterState) {
68
68
  return null;
69
69
  }
@@ -86,7 +86,7 @@ async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captu
86
86
  return transition;
87
87
  }
88
88
  async function attemptInteraction(explorer, candidate) {
89
- const action = explorer.createAction();
89
+ const action = explorer.action();
90
90
  for (const command of buildClickCommands(candidate.element, candidate.container)) {
91
91
  const success = await action.attempt(command, buildPurpose(candidate));
92
92
  if (success) {
@@ -97,14 +97,14 @@ async function attemptInteraction(explorer, candidate) {
97
97
  }
98
98
  async function restoreInteractionState(explorer, restoreUrl, primaryCommand) {
99
99
  if (primaryCommand) {
100
- const action = explorer.createAction();
100
+ const action = explorer.action();
101
101
  const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
102
102
  if (restored) {
103
103
  await wait(TAB_WAIT_MS);
104
104
  return;
105
105
  }
106
106
  }
107
- const action = explorer.createAction();
107
+ const action = explorer.action();
108
108
  await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
109
109
  }
110
110
  function buildTransition(candidate, beforeState, afterState, changes) {