Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: Install dependencies
run: bun install

- name: Verify generated docs are up to date
run: bunx bunosh docs:sync --check

- name: Run unit tests
run: bun test tests/unit/

Expand Down
72 changes: 71 additions & 1 deletion Bunoshfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import { startFixture } from './tests/regression/fixture/server.ts';
import { parsePlansFromMarkdown } from './src/utils/test-plan-markdown.ts';
import { htmlCombinedSnapshot, htmlTextSnapshot, minifyHtml } from './src/utils/html.js';
import { analyzeDemoCandidates, createDemoVideo } from './.claude/skills/demo-video/demo-video.ts';
import { EXPLORBOT_ENV_VARS } from './src/config.ts';

const { exec, shell, writeToFile, task, ai } = global.bunosh;
const { exec, shell, writeToFile, task, stopOnFail, ai } = global.bunosh;

const CLI = resolve('bin/explorbot-cli.ts');
const REG_ROOT = resolve('tests/regression');
Expand Down Expand Up @@ -44,6 +45,75 @@ export async function worktreeCreate(name = '') {
say(`Created worktree for feature ${worktreeName} in ${newDir}`);
}

const ENV_DOCS = [
{ path: 'docs/workflow/agentic-usage.md', withRequired: true },
{ path: 'docs/reference/commands.md', withRequired: false },
];

/**
* Regenerate generated docs: provider blocks from models.json, EXPLORBOT_* tables from src/config.ts
* @param {object} options
* @param {boolean} [options.check=false] - Fail if the docs are stale instead of rewriting them (for CI/release)
*/
export async function docsSync(options = { check: false }) {
stopOnFail();

await task('docs/basics/providers.md', () => syncProviderDocs(options.check));

for (const doc of ENV_DOCS) {
await task(doc.path, () => syncEnvDoc(doc, options.check));
}
}

function syncProviderDocs(check) {
const path = 'docs/basics/providers.md';
const original = readFileSync(resolve(path), 'utf8');
let updated = original;

const models = JSON.parse(readFileSync(resolve('models.json'), 'utf8'));
for (const [provider, roles] of Object.entries(models)) {
const config = Object.entries(roles)
.map(([role, id]) => ` ${role}: ${provider}('${id}'),`)
.join('\n');
let block = `\`\`\`javascript\nexport default {\n ai: {\n${config}\n },\n};\n\`\`\``;

const missing = ['model', 'visionModel', 'agenticModel'].filter((role) => !roles[role]);
if (missing.length) {
const list = missing.map((role) => `\`${role}\``).join(' and ');
block += `\n\n> [!NOTE]\n> This provider currently doesn't serve ${list}, which is required for Explorbot to run at optimal cost and speed.\n> It is recommended to pair it with another AI provider.`;
}

updated = updated.replace(new RegExp(`(<!-- START provider:${provider} -->)[\\s\\S]*?(<!-- END provider:${provider} -->)`), (_m, start, end) => `${start}\n${block}\n${end}`);
}

return applyGenerated(path, original, updated, check);
}

function syncEnvDoc({ path, withRequired }, check) {
const original = readFileSync(resolve(path), 'utf8');

let header = '| Variable | Meaning |\n|---|---|';
if (withRequired) header = '| Variable | Required | Meaning |\n|---|---|---|';

const rows = EXPLORBOT_ENV_VARS.map((v) => {
if (!withRequired) return `| \`${v.name}\` | ${v.description} |`;
if (v.required) return `| \`${v.name}\` | yes | ${v.description} |`;
return `| \`${v.name}\` | no | ${v.description} |`;
}).join('\n');

const updated = original.replace(/(<!-- START env -->)[\s\S]*?(<!-- END env -->)/, (_m, start, end) => `${start}\n${header}\n${rows}\n${end}`);

return applyGenerated(path, original, updated, check);
}

function applyGenerated(path, original, updated, check) {
if (updated === original) return 'already current';
if (check) throw new Error('stale — run `bunosh docs:sync` and commit');

writeFileSync(resolve(path), updated);
return 'regenerated';
}

/**
* List demo-worthy segments from an Explorbot session log
* @param {string} log - Path to explorbot.log
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## 2026-07-19

### Environment Variables

Explorbot can now run without an `explorbot.config.js`. Set `EXPLORBOT_AI_PROVIDER` and the configuration is built from the environment instead — for CI one-liners, demos, and coding agents driving Explorbot as a terminal command. A config file always wins when one is present, so nothing changes for existing projects.

```bash
EXPLORBOT_URL=https://app.example.com \
EXPLORBOT_AI_PROVIDER=openrouter \
EXPLORBOT_KNOWLEDGE="Log in as admin@example.com / secret123" \
npx explorbot explore /login --max-tests 3
```

- **`EXPLORBOT_AI_PROVIDER`** — A provider name that fills every model role from that provider's recommended models, so there are no model IDs to look up and the run follows the recommendations as they change. Setting this turns on config-free mode. Providers: `openai`, `anthropic`, `google`, `groq`, `mistral`, `openrouter`, `sambanova`, each using its conventional API-key variable.
- **`EXPLORBOT_AI_MODEL`** — Pins the main `model`. With `EXPLORBOT_AI_PROVIDER` set it is the model id for that provider, used verbatim; on its own it must be `provider/model-id`, split on the first slash so `openrouter/openai/gpt-oss-120b:nitro` selects OpenRouter with model `openai/gpt-oss-120b:nitro`.
- **`EXPLORBOT_URL`** — Base URL to test. The API boat reads it as the base endpoint. Optional when the command already carries an absolute URL, as `docs collect https://…` does.
- **`EXPLORBOT_VISION_MODEL`** — Model for screenshot analysis, overriding the provider recommendation. Takes a provider name or `provider/model-id`.
- **`EXPLORBOT_AGENTIC_MODEL`** — Model for Captain and Pilot decisions, overriding the provider recommendation. Takes a provider name or `provider/model-id`. Combine providers by role, for example `EXPLORBOT_AI_PROVIDER=groq` with `EXPLORBOT_AGENTIC_MODEL=anthropic`.
- **`EXPLORBOT_OUTPUT`** — Output root for states, plans, research, and reports. Defaults to a fresh temp directory, so nothing is written to your project.
- **`EXPLORBOT_KNOWLEDGE`** — Inline knowledge text applied to every page, the quickest way to hand over credentials.
- **`EXPLORBOT_KNOWLEDGE_FILE`** — Path to a knowledge markdown file; its frontmatter is preserved, so it can target specific URLs.
- **`EXPLORBOT_API_SPEC`** — OpenAPI spec path for the API boat.

Config-free runs do not write experience and run with the Historian off, so no generated test files appear and a run leaves no state behind. Point `EXPLORBOT_OUTPUT` at a stable directory, or use a config file, when you want learning to accumulate across runs.

### Configuration
- **`experience.disabled`** — Stop writing experience files while still reading any that exist. Default: `false`.

### Changes
- Added `docs/workflow/agentic-usage.md` — driving Explorbot from a coding agent: the config-free one-liner API, and handing Explorbot a test plan the agent wrote itself (`npx explorbot test plan.md '*'`). Plan files are input only and are never rewritten, so they live in version control next to the code they cover.
- `npx explorbot --help` now lists the `EXPLORBOT_*` variables with an example, so an agent can discover the config-free mode without reading the docs.
- The recommended model IDs in `models.json` are now read at runtime, not just when regenerating the provider docs — they are what a bare provider name resolves to. `models.json` ships with the npm package.
- Documented every AI provider in `docs/basics/providers.md`, with the recommended-model block for each generated from `models.json` by `bunosh docs:sync` and verified in CI on release. Added Mistral as a supported provider — `mistral-small-latest` for the `model` and `visionModel` roles (it reads screenshots), `mistral-large-latest` for `agenticModel`.
- CodeceptJS screenshots now follow `dirs.output` instead of always landing in `output/states`.
- Bare `--session` writes to `$EXPLORBOT_OUTPUT/session.json` when that variable is set. In a temp-directory run the path is not known when the flag is parsed, so pass an explicit `--session <file>` there.

## 2026-07-17

### Changes
Expand Down
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ npx explorbot start https://your-app.com

It runs with no babysitting and reports back what it finds. This is vibe-testing.

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.

New here? Read the [Getting Started guide](docs/basics/getting-started.md).

## Use Cases
Expand Down Expand Up @@ -92,7 +94,7 @@ Explorbot won't replace your regression tests — it covers what they can't. You
## Requirements

- Node.js 24+ or **Bun**
- An **AI provider key** — OpenRouter recommended; Groq, Cerebras, OpenAI, Anthropic, and others via the [Vercel AI SDK](https://sdk.vercel.ai/providers)
- 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)
- A **modern terminal** — iTerm2, WARP, Kitty, Ghostty, or Windows Terminal with WSL
- A **compatible web app** — CRUD-heavy apps fit best. See [Prerequisites](docs/basics/prerequisites.md)

Expand Down Expand Up @@ -125,6 +127,19 @@ Type `/explore`, and Explorbot runs its loop on its own — research, plan, test

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.

### Or skip the config file

For a CI job, a demo, or a coding agent, pass everything as environment variables. Name a provider and Explorbot picks its recommended models:

```bash
EXPLORBOT_URL=https://app.example.com \
EXPLORBOT_AI_PROVIDER=openrouter \
EXPLORBOT_KNOWLEDGE="Log in as admin@example.com / secret123" \
npx explorbot explore /admin/users --max-tests 3
```

Output lands in a temp directory and nothing is written to your project. See [Agentic Usage](docs/workflow/agentic-usage.md).

## Teaching Explorbot

Explorbot gets better when you tell it about your app:
Expand All @@ -146,12 +161,13 @@ When you're ready to go deeper, the [full documentation](docs/) covers everythin
## FAQ

**Can I run it in Cursor or Claude Code?**
No, Explorbot is a separate application designed for constant testing. Cursor, Codex, and Claude Code are coding agents — not relevant here.

> However, Explorbot can be used as a subagent or terminal command controlled by a coding agent.
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).

**Can I bring a Cursor or OpenAI subscription?**
No. Their models are too slow for the way Explorbot works. Use pay-per-token providers like Groq and OpenRouter.
No. Explorbot needs an API key, not a chat subscription. Use pay-per-token access — Groq, OpenRouter, or OpenAI's own API.

**Can I use OpenAI directly?**
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.

**I want to use Opus!!!**
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.
Expand Down
32 changes: 27 additions & 5 deletions bin/explorbot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { render } from 'ink';
import React from 'react';
import { App } from '../src/components/App.js';
import { StatusPane } from '../src/components/StatusPane.js';
import { ConfigParser } from '../src/config.js';
import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
import { Stats } from '../src/stats.js';
import { Plan } from '../src/test-plan.js';
Expand Down Expand Up @@ -43,8 +43,6 @@ interface CLIOptions {
}

function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;

return {
from,
verbose: options.verbose || options.debug,
Expand All @@ -53,7 +51,7 @@ function buildExplorBotOptions(from: string | undefined, options: CLIOptions): E
show: options.show,
headless: options.headless,
incognito: options.incognito,
session: sessionFile,
session: options.session,
} as ExplorBotOptions;
}

Expand Down Expand Up @@ -690,7 +688,7 @@ program
path: options.path,
config: options.config,
headless: true,
session: options.session === true ? 'output/session.json' : options.session,
session: options.session,
};

const explorBot = new ExplorBot(mainOptions);
Expand Down Expand Up @@ -876,4 +874,28 @@ import { createDocsCommands } from '../boat/doc-collector/src/cli.ts';
program.addCommand(createApiCommands('api'));
program.addCommand(createDocsCommands('docs'));

const envHelp = () => {
const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');

return `
Environment variables (config-free one-liner mode):
Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.

${rows}

Providers: ${Object.keys(PROVIDERS).join(', ')}
Example:
EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
${cli} explore /login --max-tests 3
`;
};

const addEnvHelp = (cmd: Command) => {
cmd.addHelpText('after', envHelp);
for (const sub of cmd.commands) addEnvHelp(sub);
};

addEnvHelp(program);

program.parse();
48 changes: 45 additions & 3 deletions boat/api-tester/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path, { resolve } from 'node:path';
import { parseEnv } from 'node:util';
import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS } from '../../../src/config.ts';
import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from '../../../src/config.ts';

export type { AIConfig };

Expand Down Expand Up @@ -54,8 +54,11 @@ export class ApibotConfigParser {

const resolvedPath = options?.config || this.findConfigFile();
if (!resolvedPath) {
if (options?.path) process.chdir(originalCwd);
throw new Error('No configuration file found. Create apibot.config.js or apibot.config.ts');
try {
return await this.loadEnvConfig();
} finally {
if (options?.path && originalCwd !== process.cwd()) process.chdir(originalCwd);
}
}

try {
Expand Down Expand Up @@ -123,6 +126,45 @@ export class ApibotConfigParser {
}
}

private async loadEnvConfig(): Promise<ApibotConfig> {
const provider = process.env.EXPLORBOT_AI_PROVIDER;
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
if (!provider && !modelSpec) {
throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
}
if (modelSpec && !provider && !modelSpec.includes('/')) {
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
}

const baseEndpoint = process.env.EXPLORBOT_URL;
if (!baseEndpoint) {
throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
}

const outputRoot = resolveOutputRoot();
materializeKnowledge(outputRoot);

const api: ApiConfig = { baseEndpoint };
if (process.env.EXPLORBOT_API_SPEC) {
api.spec = [process.env.EXPLORBOT_API_SPEC];
}

let model: any;
if (provider && modelSpec) model = await createModel(provider, modelSpec);
if (provider && !modelSpec) model = await resolveModel(provider, 'model');
if (!provider) model = await resolveModel(modelSpec!, 'model');

this.config = {
ai: { model },
api,
dirs: { output: '.', knowledge: 'knowledge' },
};
this.configPath = path.join(outputRoot, 'apibot.config.js');
this.validateConfig(this.config);

return this.config;
}

private findConfigFile(): string | null {
const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
for (const p of apibotPaths) {
Expand Down
7 changes: 1 addition & 6 deletions boat/doc-collector/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,14 @@ import { setPreserveConsoleLogs } from '../../../src/utils/logger.ts';
import { DocBot, type DocbotOptions } from './docbot.ts';

function buildOptions(options: any): DocbotOptions {
let session = options.session;
if (options.session === true) {
session = 'output/session.json';
}

return {
verbose: options.verbose || options.debug,
config: options.config,
path: options.path,
show: options.show,
headless: options.headless,
incognito: options.incognito,
session,
session: options.session,
docsConfig: options.docsConfig,
};
}
Expand Down
Loading
Loading