Compare commits

...

2 Commits

Author SHA1 Message Date
jackwener ddb022ca99 docs: add missing doubao-app desktop adapter page (fixes docs-build) 2026-03-24 00:23:02 +08:00
jackwener 5e73940a4b feat: refine error handling with semantic error types
- Add 5 new CliError subclasses: AuthRequiredError, TimeoutError,
  ArgumentError, EmptyResultError, SelectorError
- Centralize getErrorMessage() and ERROR_ICONS in errors.ts
- Data-driven error rendering in commanderAdapter.ts (replaces 5 if/else)
- withTimeoutMs accepts factory function for backward compatibility
- browser/errors.ts returns BrowserConnectError instead of bare Error
- Migrate 5 benchmark adapters to AuthRequiredError
- 318 unit tests passing, 0 regressions
2026-03-24 00:18:52 +08:00
15 changed files with 279 additions and 50 deletions
+41
View File
@@ -0,0 +1,41 @@
# Doubao App
Drive the **Doubao (豆包) AI desktop app** via Chrome DevTools Protocol. This adapter controls the Electron-based Doubao client directly.
## Prerequisites
1. Install the Doubao desktop app from [doubao.com](https://www.doubao.com/).
2. Launch with remote debugging enabled:
```bash
/Applications/Doubao.app/Contents/MacOS/Doubao \
--remote-debugging-port=9226
```
3. Set the CDP endpoint:
```bash
export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9226"
```
## Commands
| Command | Description |
|---------|-------------|
| `opencli doubao-app status` | Check if the Doubao app is running and reachable |
| `opencli doubao-app new` | Start a new conversation |
| `opencli doubao-app send "message"` | Send a message to the active chat |
| `opencli doubao-app read` | Read all messages in the current conversation |
| `opencli doubao-app ask "message"` | Send a prompt and wait for the reply |
| `opencli doubao-app screenshot` | Capture a screenshot of the app window |
| `opencli doubao-app dump` | Dump the current page DOM snapshot |
## How It Works
The adapter connects to Doubao's Electron renderer via CDP and uses `data-testid` selectors to interact with the chat UI. Text injection uses React's internal value setter for reliable textarea updates.
## Limitations
- macOS only (Electron app path)
- Requires Doubao to be launched with `--remote-debugging-port`
- `read` returns messages visible in the current conversation only
+17 -11
View File
@@ -5,31 +5,37 @@
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
*/
import { BrowserConnectError } from '../errors.js';
export type ConnectFailureKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): Error {
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
switch (kind) {
case 'daemon-not-running':
return new Error(
'Cannot connect to opencli daemon.\n\n' +
return new BrowserConnectError(
'Cannot connect to opencli daemon.' +
(detail ? `\n\n${detail}` : ''),
'The daemon should start automatically. If it doesn\'t, try:\n' +
' node dist/daemon.js\n' +
'Make sure port 19825 is available.' +
(detail ? `\n\n${detail}` : ''),
'Make sure port 19825 is available.',
);
case 'extension-not-connected':
return new Error(
'opencli Browser Bridge extension is not connected.\n\n' +
return new BrowserConnectError(
'opencli Browser Bridge extension is not connected.' +
(detail ? `\n\n${detail}` : ''),
'Please install the extension:\n' +
' 1. Download from GitHub Releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
' 3. Click "Load unpacked" → select the extension folder\n' +
' 4. Make sure Chrome is running' +
(detail ? `\n\n${detail}` : ''),
' 4. Make sure Chrome is running',
);
case 'command-failed':
return new Error(`Browser command failed: ${detail ?? 'unknown error'}`);
return new BrowserConnectError(
`Browser command failed: ${detail ?? 'unknown error'}`,
);
default:
return new Error(detail ?? 'Failed to connect to browser');
return new BrowserConnectError(
detail ?? 'Failed to connect to browser',
);
}
}
+2 -3
View File
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { getErrorMessage } from './errors.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLIS_DIR = path.resolve(__dirname, 'clis');
@@ -73,9 +74,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function extractBalancedBlock(
source: string,
+2 -1
View File
@@ -3,6 +3,7 @@
*/
import type { IPage } from '../../types.js';
import { AuthRequiredError } from '../../errors.js';
const MIXIN_KEY_ENC_TAB = [
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
@@ -98,7 +99,7 @@ export async function fetchJson(page: IPage, url: string): Promise<any> {
export async function getSelfUid(page: IPage): Promise<string> {
const nav = await getNavData(page);
const mid = nav?.data?.mid;
if (!mid) throw new Error('Not logged in to Bilibili');
if (!mid) throw new AuthRequiredError('bilibili.com');
return String(mid);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError } from '../../errors.js';
import {
getCourses, initSession, enterCourse, getTabIframeUrl,
parseAssignmentsFromDom, sleep,
@@ -33,7 +34,7 @@ cli({
// 2. Get courses
const courses = await getCourses(page);
if (!courses.length) throw new Error('未获取到课程列表,请确认已登录学习通');
if (!courses.length) throw new AuthRequiredError('mooc2-ans.chaoxing.com', '未获取到课程列表');
const filtered = courseFilter
? courses.filter(c => c.title.includes(courseFilter))
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError } from '../../errors.js';
// ── Twitter GraphQL constants ──────────────────────────────────────────
@@ -139,7 +140,7 @@ cli({
const ct0 = await page.evaluate(`() => {
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
}`);
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
// Build auth headers in TypeScript
const headers = JSON.stringify({
+2 -4
View File
@@ -7,6 +7,7 @@
*/
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError } from '../../errors.js';
cli({
site: 'xiaohongshu',
@@ -86,10 +87,7 @@ cli({
if (!payload || typeof payload !== 'object') return [];
if ((payload as any).loginWall) {
throw new Error(
'Xiaohongshu search results are blocked behind a login wall for the current browser session. ' +
'Open https://www.xiaohongshu.com/search_result in Chrome and sign in, then retry.'
);
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
}
const data: any[] = Array.isArray((payload as any).results) ? (payload as any).results : [];
+2 -1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError } from '../../errors.js';
cli({
site: 'zhihu',
@@ -31,7 +32,7 @@ cli({
}
`);
if (!result || result.error) throw new Error('Failed to fetch question. Are you logged in?');
if (!result || result.error) throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
const answers = (result.answers ?? []).slice(0, Number(limit)).map((a: any, i: number) => ({
rank: i + 1,
+4 -7
View File
@@ -16,11 +16,7 @@ import { type CliCommand, fullName, getRegistry } from './registry.js';
import { formatRegistryHelpText } from './serialization.js';
import { render as renderOutput } from './output.js';
import { executeCommand } from './execution.js';
import { CliError } from './errors.js';
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
import { CliError, ERROR_ICONS, getErrorMessage } from './errors.js';
/**
* Register a single CliCommand as a Commander subcommand.
@@ -90,8 +86,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
});
} catch (err) {
if (err instanceof CliError) {
console.error(chalk.red(`Error [${err.code}]: ${err.message}`));
if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`));
const icon = ERROR_ICONS[err.code] ?? '⚠️';
console.error(chalk.red(`${icon} ${err.message}`));
if (err.hint) console.error(chalk.yellow(`${err.hint}`));
} else if (optionsRecord.verbose === true && err instanceof Error && err.stack) {
console.error(chalk.red(err.stack));
} else {
+2 -3
View File
@@ -15,6 +15,7 @@ import { pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
import { log } from './logger.js';
import { getErrorMessage } from './errors.js';
import type { ManifestEntry } from './build-manifest.js';
/** Plugins directory: ~/.opencli/plugins/ */
@@ -45,9 +46,7 @@ interface YamlCliDefinition {
navigateBefore?: boolean | string;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy {
if (!rawStrategy) return fallback;
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import {
CliError,
BrowserConnectError,
AdapterLoadError,
CommandExecutionError,
ConfigError,
AuthRequiredError,
TimeoutError,
ArgumentError,
EmptyResultError,
SelectorError,
} from './errors.js';
describe('Error type hierarchy', () => {
it('all error types extend CliError', () => {
const errors = [
new BrowserConnectError('test'),
new AdapterLoadError('test'),
new CommandExecutionError('test'),
new ConfigError('test'),
new AuthRequiredError('example.com'),
new TimeoutError('test', 30),
new ArgumentError('test'),
new EmptyResultError('test/cmd'),
new SelectorError('.btn'),
];
for (const err of errors) {
expect(err).toBeInstanceOf(CliError);
expect(err).toBeInstanceOf(Error);
}
});
it('AuthRequiredError has correct code, domain, and auto-generated hint', () => {
const err = new AuthRequiredError('bilibili.com');
expect(err.code).toBe('AUTH_REQUIRED');
expect(err.domain).toBe('bilibili.com');
expect(err.message).toBe('Not logged in to bilibili.com');
expect(err.hint).toContain('https://bilibili.com');
});
it('AuthRequiredError accepts custom message', () => {
const err = new AuthRequiredError('x.com', 'No ct0 cookie found');
expect(err.message).toBe('No ct0 cookie found');
expect(err.hint).toContain('https://x.com');
});
it('TimeoutError has correct code and hint', () => {
const err = new TimeoutError('bilibili/hot', 60);
expect(err.code).toBe('TIMEOUT');
expect(err.message).toBe('bilibili/hot timed out after 60s');
expect(err.hint).toContain('timeout');
});
it('ArgumentError has correct code', () => {
const err = new ArgumentError('Argument "limit" must be a valid number');
expect(err.code).toBe('ARGUMENT');
});
it('EmptyResultError has default hint', () => {
const err = new EmptyResultError('hackernews/top');
expect(err.code).toBe('EMPTY_RESULT');
expect(err.message).toBe('hackernews/top returned no data');
expect(err.hint).toBeTruthy();
});
it('SelectorError has default hint about page changes', () => {
const err = new SelectorError('.submit-btn');
expect(err.code).toBe('SELECTOR');
expect(err.message).toContain('.submit-btn');
expect(err.hint).toContain('report');
});
it('BrowserConnectError has correct code', () => {
const err = new BrowserConnectError('Cannot connect');
expect(err.code).toBe('BROWSER_CONNECT');
});
});
+92 -2
View File
@@ -2,11 +2,12 @@
* Unified error types for opencli.
*
* All errors thrown by the framework should extend CliError so that
* the top-level handler in main.ts can render consistent, helpful output.
* the top-level handler in commanderAdapter.ts can render consistent,
* helpful output with emoji-coded severity and actionable hints.
*/
export class CliError extends Error {
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */
readonly code: string;
/** Human-readable hint on how to fix the problem */
readonly hint?: string;
@@ -19,6 +20,8 @@ export class CliError extends Error {
}
}
// ── Browser / Connection ────────────────────────────────────────────────────
export class BrowserConnectError extends CliError {
constructor(message: string, hint?: string) {
super('BROWSER_CONNECT', message, hint);
@@ -26,6 +29,8 @@ export class BrowserConnectError extends CliError {
}
}
// ── Adapter loading ─────────────────────────────────────────────────────────
export class AdapterLoadError extends CliError {
constructor(message: string, hint?: string) {
super('ADAPTER_LOAD', message, hint);
@@ -33,6 +38,8 @@ export class AdapterLoadError extends CliError {
}
}
// ── Command execution ───────────────────────────────────────────────────────
export class CommandExecutionError extends CliError {
constructor(message: string, hint?: string) {
super('COMMAND_EXEC', message, hint);
@@ -40,9 +47,92 @@ export class CommandExecutionError extends CliError {
}
}
// ── Configuration ───────────────────────────────────────────────────────────
export class ConfigError extends CliError {
constructor(message: string, hint?: string) {
super('CONFIG', message, hint);
this.name = 'ConfigError';
}
}
// ── Authentication / Login ──────────────────────────────────────────────────
export class AuthRequiredError extends CliError {
readonly domain: string;
constructor(domain: string, message?: string) {
super(
'AUTH_REQUIRED',
message ?? `Not logged in to ${domain}`,
`Please open Chrome and log in to https://${domain}`,
);
this.name = 'AuthRequiredError';
this.domain = domain;
}
}
// ── Timeout ─────────────────────────────────────────────────────────────────
export class TimeoutError extends CliError {
constructor(label: string, seconds: number) {
super(
'TIMEOUT',
`${label} timed out after ${seconds}s`,
'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
);
this.name = 'TimeoutError';
}
}
// ── Argument validation ─────────────────────────────────────────────────────
export class ArgumentError extends CliError {
constructor(message: string, hint?: string) {
super('ARGUMENT', message, hint);
this.name = 'ArgumentError';
}
}
// ── Empty result ────────────────────────────────────────────────────────────
export class EmptyResultError extends CliError {
constructor(command: string, hint?: string) {
super(
'EMPTY_RESULT',
`${command} returned no data`,
hint ?? 'The page structure may have changed, or you may need to log in',
);
this.name = 'EmptyResultError';
}
}
// ── Selector / DOM ──────────────────────────────────────────────────────────
export class SelectorError extends CliError {
constructor(selector: string, hint?: string) {
super(
'SELECTOR',
`Could not find element: ${selector}`,
hint ?? 'The page UI may have changed. Please report this issue.',
);
this.name = 'SelectorError';
}
}
// ── Utilities ───────────────────────────────────────────────────────────
/** Extract a human-readable message from an unknown caught value. */
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Error code → emoji mapping for CLI output rendering. */
export const ERROR_ICONS: Record<string, string> = {
AUTH_REQUIRED: '🔒',
BROWSER_CONNECT: '🔌',
TIMEOUT: '⏱ ',
ARGUMENT: '❌',
EMPTY_RESULT: '📭',
SELECTOR: '🔍',
};
+15 -10
View File
@@ -13,7 +13,7 @@ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegist
import type { IPage } from './types.js';
import { pathToFileURL } from 'node:url';
import { executePipeline } from './pipeline/index.js';
import { AdapterLoadError } from './errors.js';
import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
import { shouldUseBrowserSession } from './capabilityRouting.js';
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
@@ -21,9 +21,7 @@ import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMM
const _loadedModules = new Set<string>();
type CommandArgs = Record<string, unknown>;
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Validates and coerces arguments based on the command's Arg definitions.
@@ -36,7 +34,10 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
// 1. Check required
if (argDef.required && (val === undefined || val === null || val === '')) {
throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
throw new ArgumentError(
`Argument "${argDef.name}" is required.`,
argDef.help ?? `Provide a value for --${argDef.name}`,
);
}
if (val !== undefined && val !== null) {
@@ -44,7 +45,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
if (argDef.type === 'int' || argDef.type === 'number') {
const num = Number(val);
if (Number.isNaN(num)) {
throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
throw new ArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
}
result[argDef.name] = num;
} else if (argDef.type === 'boolean' || argDef.type === 'bool') {
@@ -52,7 +53,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
const lower = val.toLowerCase();
if (lower === 'true' || lower === '1') result[argDef.name] = true;
else if (lower === 'false' || lower === '0') result[argDef.name] = false;
else throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
else throw new ArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
} else {
result[argDef.name] = Boolean(val);
}
@@ -62,7 +63,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
const coercedVal = result[argDef.name];
if (argDef.choices && argDef.choices.length > 0) {
if (!argDef.choices.map(String).includes(String(coercedVal))) {
throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
throw new ArgumentError(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
}
}
} else if (argDef.default !== undefined) {
@@ -104,7 +105,10 @@ async function runCommand(
if (cmd.func) return cmd.func(page!, kwargs, debug);
if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
throw new Error(`Command ${fullName(cmd)} has no func or pipeline`);
throw new CommandExecutionError(
`Command ${fullName(cmd)} has no func or pipeline`,
'This is likely a bug in the adapter definition. Please report this issue.',
);
}
/**
@@ -140,7 +144,8 @@ export async function executeCommand(
try {
kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
} catch (err) {
throw new Error(`[Argument Validation Error]\n${getErrorMessage(err)}`);
if (err instanceof ArgumentError) throw err;
throw new ArgumentError(getErrorMessage(err));
}
if (shouldUseBrowserSession(cmd)) {
+15 -3
View File
@@ -1,5 +1,6 @@
import { BrowserBridge, CDPBridge } from './browser/index.js';
import type { IPage } from './types.js';
import { TimeoutError } from './errors.js';
/**
* Returns the appropriate browser factory based on environment config.
@@ -21,15 +22,26 @@ export async function runWithTimeout<T>(
promise: Promise<T>,
opts: { timeout: number; label?: string },
): Promise<T> {
return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
const label = opts.label ?? 'Operation';
return withTimeoutMs(promise, opts.timeout * 1000,
() => new TimeoutError(label, opts.timeout));
}
/**
* Timeout with milliseconds unit. Used for low-level internal timeouts.
* Accepts a factory function to create the rejection error, keeping this
* utility decoupled from specific error types.
*/
export function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
export function withTimeoutMs<T>(
promise: Promise<T>,
timeoutMs: number,
makeError: string | (() => Error) = 'Operation timed out',
): Promise<T> {
const reject_ = typeof makeError === 'string'
? () => new Error(makeError)
: makeError;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
const timer = setTimeout(() => reject(reject_()), timeoutMs);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
+2 -3
View File
@@ -2,6 +2,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import yaml from 'js-yaml';
import { getErrorMessage } from './errors.js';
/** All recognized pipeline step names */
const KNOWN_STEP_NAMES = new Set([
@@ -37,9 +38,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function validateClisWithTarget(dirs: string[], target?: string): ValidationReport {
const results: FileValidationResult[] = [];