diff --git a/clis/barchart/quote.ts b/clis/barchart/quote.ts
index a553d7d14..279e941d0 100644
--- a/clis/barchart/quote.ts
+++ b/clis/barchart/quote.ts
@@ -3,6 +3,7 @@
* Auth: CSRF token from + session cookies.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
+import { CommandExecutionError } from '@jackwener/opencli/errors';
cli({
site: 'barchart',
@@ -111,7 +112,7 @@ cli({
})()
`);
- if (!data || data.error) return [];
+ if (!data || data.error) throw new CommandExecutionError(data?.error || `Failed to fetch quote for ${symbol}`);
const r = data.row || {};
// API returns formatted strings like "+1.41" and "+0.56%"; use raw if available
diff --git a/clis/xueqiu/earnings-date.ts b/clis/xueqiu/earnings-date.ts
index ddd9c154b..05aae6cb6 100644
--- a/clis/xueqiu/earnings-date.ts
+++ b/clis/xueqiu/earnings-date.ts
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
+import { EmptyResultError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';
cli({
@@ -23,8 +24,7 @@ cli({
const symbol = String(kwargs.symbol).toUpperCase();
const url = `https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=${encodeURIComponent(symbol)}&page=1&size=100`;
const d = await fetchXueqiuJson(page, url);
- if ('error' in d) return [d];
- if (!d.data?.items) return [{ error: '获取失败: ' + symbol, help: '请确认股票代码是否正确' }];
+ if (!d.data?.items) throw new EmptyResultError('xueqiu/earnings-date', '请确认股票代码是否正确: ' + symbol);
// subtype 2 = 预计财报发布
const now = Date.now();
diff --git a/clis/xueqiu/feed.ts b/clis/xueqiu/feed.ts
index 4c2794ba9..f2732c423 100644
--- a/clis/xueqiu/feed.ts
+++ b/clis/xueqiu/feed.ts
@@ -26,7 +26,7 @@ cli({
await page.goto('https://xueqiu.com');
const url = `https://xueqiu.com/v4/statuses/home_timeline.json?page=${kwargs.page}&count=${kwargs.limit}`;
const d = await fetchXueqiuJson(page, url);
- if ('error' in d) return [d];
+
return ((d.home_timeline || d.list || []) as any[]).slice(0, kwargs.limit as number).map((item: any) => {
const user = item.user || {};
return {
diff --git a/clis/xueqiu/groups.ts b/clis/xueqiu/groups.ts
index 8bc98e7be..c1946839e 100644
--- a/clis/xueqiu/groups.ts
+++ b/clis/xueqiu/groups.ts
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
+import { AuthRequiredError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';
cli({
@@ -11,8 +12,7 @@ cli({
func: async (page, _kwargs) => {
await page.goto('https://xueqiu.com');
const d = await fetchXueqiuJson(page, 'https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20');
- if ('error' in d) return [d];
- if (!d.data?.stocks) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
+ if (!d.data?.stocks) throw new AuthRequiredError('xueqiu.com');
return ((d.data.stocks || []) as any[]).map((g: any) => ({
pid: String(g.id),
name: g.name,
diff --git a/clis/xueqiu/hot-stock.ts b/clis/xueqiu/hot-stock.ts
index f097d402a..bf0bb1e84 100644
--- a/clis/xueqiu/hot-stock.ts
+++ b/clis/xueqiu/hot-stock.ts
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
+import { AuthRequiredError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';
cli({
@@ -16,8 +17,7 @@ cli({
await page.goto('https://xueqiu.com');
const url = `https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${kwargs.limit}&type=${kwargs.type}`;
const d = await fetchXueqiuJson(page, url);
- if ('error' in d) return [d];
- if (!d.data?.items) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
+ if (!d.data?.items) throw new AuthRequiredError('xueqiu.com');
return ((d.data.items || []) as any[]).map((s: any, i: number) => ({
rank: i + 1,
symbol: s.symbol,
diff --git a/clis/xueqiu/hot.ts b/clis/xueqiu/hot.ts
index 100eb590e..cf332a61c 100644
--- a/clis/xueqiu/hot.ts
+++ b/clis/xueqiu/hot.ts
@@ -24,7 +24,7 @@ cli({
func: async (page, kwargs) => {
await page.goto('https://xueqiu.com');
const d = await fetchXueqiuJson(page, 'https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1');
- if ('error' in d) return [d];
+
return ((d.list || []) as any[]).slice(0, kwargs.limit as number).map((item: any, i: number) => {
const user = item.user || {};
return {
diff --git a/clis/xueqiu/kline.ts b/clis/xueqiu/kline.ts
index 617c93e7c..904d1aecb 100644
--- a/clis/xueqiu/kline.ts
+++ b/clis/xueqiu/kline.ts
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
+import { EmptyResultError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';
cli({
@@ -24,8 +25,8 @@ cli({
const beginTs = Date.now();
const url = `https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=${encodeURIComponent(symbol)}&begin=${beginTs}&period=day&type=before&count=-${days}`;
const d = await fetchXueqiuJson(page, url);
- if ('error' in d) return [d];
- if (!d.data?.item?.length) return [];
+
+ if (!d.data?.item?.length) throw new EmptyResultError('xueqiu/kline', '请确认股票代码是否正确: ' + symbol);
const columns: string[] = d.data.column || [];
const colIdx: Record = {};
diff --git a/clis/xueqiu/search.ts b/clis/xueqiu/search.ts
index 200272738..6239a6475 100644
--- a/clis/xueqiu/search.ts
+++ b/clis/xueqiu/search.ts
@@ -16,7 +16,7 @@ cli({
await page.goto('https://xueqiu.com');
const url = `https://xueqiu.com/stock/search.json?code=${encodeURIComponent(String(kwargs.query))}&size=${kwargs.limit}`;
const d = await fetchXueqiuJson(page, url);
- if ('error' in d) return [d];
+
return ((d.stocks || []) as any[]).slice(0, kwargs.limit as number).map((s: any) => {
let symbol = '';
if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {
diff --git a/clis/xueqiu/stock.ts b/clis/xueqiu/stock.ts
index 7d5fc3078..149411db6 100644
--- a/clis/xueqiu/stock.ts
+++ b/clis/xueqiu/stock.ts
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
+import { EmptyResultError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';
function fmtAmount(v: number | null | undefined): string | null {
@@ -29,8 +30,7 @@ cli({
const symbol = String(kwargs.symbol).toUpperCase();
const url = `https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`;
const d = await fetchXueqiuJson(page, url);
- if ('error' in d) return [d];
- if (!d.data?.items?.length) return [{ error: '未找到股票: ' + symbol, help: '请确认股票代码是否正确,如 SH600519、AAPL' }];
+ if (!d.data?.items?.length) throw new EmptyResultError('xueqiu/stock', '请确认股票代码是否正确: ' + symbol);
const item = d.data.items[0];
const q = item.quote || {};
const m = item.market || {};
diff --git a/clis/xueqiu/utils.ts b/clis/xueqiu/utils.ts
index fb40c2a0e..0e34f9020 100644
--- a/clis/xueqiu/utils.ts
+++ b/clis/xueqiu/utils.ts
@@ -1,13 +1,12 @@
import type { IPage } from '@jackwener/opencli/types';
-
-export interface XueqiuError { error: string; help: string; }
+import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
/**
* Fetch a xueqiu JSON API from inside the browser context (credentials included).
* Page must already be navigated to xueqiu.com before calling this function.
- * Returns { error, help } on HTTP errors; otherwise returns the parsed JSON.
+ * Throws CliError on HTTP errors; otherwise returns the parsed JSON.
*/
-export async function fetchXueqiuJson(page: IPage, url: string): Promise {
+export async function fetchXueqiuJson(page: IPage, url: string): Promise {
const result = await page.evaluate(`(async () => {
const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
if (!res.ok) return { __xqErr: res.status };
@@ -22,12 +21,12 @@ export async function fetchXueqiuJson(page: IPage, url: string): Promise ({
symbol: s.symbol,
name: s.name,
diff --git a/clis/yahoo-finance/quote.ts b/clis/yahoo-finance/quote.ts
index 47733435e..4acd1874e 100644
--- a/clis/yahoo-finance/quote.ts
+++ b/clis/yahoo-finance/quote.ts
@@ -2,6 +2,7 @@
* Yahoo Finance stock quote — multi-strategy API fallback.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
+import { CommandExecutionError } from '@jackwener/opencli/errors';
cli({
site: 'yahoo-finance',
@@ -66,7 +67,7 @@ cli({
return {error: 'Could not fetch quote for ' + sym};
})()
`);
- if (!data || data.error) return [];
+ if (!data || data.error) throw new CommandExecutionError(data?.error || `Failed to fetch quote for ${symbol}`);
return [data];
},
});
diff --git a/skills/opencli-explorer/SKILL.md b/skills/opencli-explorer/SKILL.md
index d2584b403..c46a71e08 100644
--- a/skills/opencli-explorer/SKILL.md
+++ b/skills/opencli-explorer/SKILL.md
@@ -167,7 +167,7 @@ cat clis//feed.ts # 读最相似的那个
所有适配器统一使用 TypeScript `cli()` API,放入 `clis//.ts` 即自动注册。
-完整模板(Tier 1~4)、分页模式、错误处理规范(`{ error, help }` 格式)→ **[adapter-templates.md](references/adapter-templates.md)**
+完整模板(Tier 1~4)、分页模式、错误处理规范(`throw CliError` + YAML envelope)→ **[adapter-templates.md](references/adapter-templates.md)**
**最简结构**(Tier 2 Cookie):
@@ -241,7 +241,7 @@ git add clis/mysite/ && git commit -m "feat(mysite): add mycommand" && git push
|------|------|---------|
| 缺少 `navigate` | `Target page context` 错误 | 在 evaluate 前加 `page.goto()` |
| 缺少 `strategy: public` | 公开 API 也启动浏览器 | 加 `strategy: Strategy.PUBLIC` + `browser: false` |
-| **风控被拦截(伪 200)** | JSON 里核心数据是空串 | 必须断言!返回 `{ error, help }` 提示重新登录 |
+| **风控被拦截(伪 200)** | JSON 里核心数据是空串 | 必须断言!`throw new AuthRequiredError(domain)` 提示重新登录 |
| **SPA 返回 HTML** | `fetch('/api/xxx')` 返回 `` | 页面 host 是 `app.xxx.com`,真实 API 在 `api.xxx.com`;搜 JS bundle 找 baseURL |
| **400 缺少上下文 Header** | 带了 Bearer 仍然 400,报 `Missing X-Server-Id` | 先调 `/servers` 拿业务上下文 ID,加进 headers |
| **文件写错目录** | `opencli list` 找不到命令 | Repo 贡献放 `clis//` + build;私人 adapter 放 `~/.opencli/clis//` |
diff --git a/skills/opencli-explorer/references/adapter-templates.md b/skills/opencli-explorer/references/adapter-templates.md
index b5b30d9da..db594c520 100644
--- a/skills/opencli-explorer/references/adapter-templates.md
+++ b/skills/opencli-explorer/references/adapter-templates.md
@@ -102,6 +102,7 @@ cli({
```typescript
// clis/slock/channels.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
+import { AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'slock',
@@ -119,7 +120,7 @@ cli({
await page.goto('https://app.slock.ai');
const data = await page.evaluate(`(async () => {
const token = localStorage.getItem('slock_access_token');
- if (!token) return { error: 'Not logged in', help: 'Open https://app.slock.ai and log in, then retry' };
+ if (!token) return { error: 'Not logged in' };
// 多租户 SaaS:先拿工作空间列表
const slug = ${JSON.stringify(kwargs.server || null)} || localStorage.getItem('slock_last_server_slug');
@@ -134,7 +135,7 @@ cli({
});
return res.json();
})()`);
- if ((data as any).error) return [data as any];
+ if ((data as any).error) throw new AuthRequiredError('app.slock.ai');
return (data as any[]).slice(0, kwargs.limit).map((ch: any, i: number) => ({
rank: i + 1,
name: ch.name || '',
@@ -156,6 +157,7 @@ cli({
```typescript
// clis/twitter/lists.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
+import { AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'twitter',
@@ -172,7 +174,7 @@ cli({
await page.goto('https://x.com');
const data = await page.evaluate(`(async () => {
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
- if (!ct0) return { error: 'Not logged in', help: 'Open https://x.com and log in, then retry' };
+ if (!ct0) return { error: 'Not logged in' };
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const res = await fetch('/i/api/graphql/QUERY_ID/ListsManagePinTimeline', {
headers: {
@@ -188,7 +190,7 @@ cli({
?.filter(e => e.content?.itemContent?.list)
?.map(e => e.content.itemContent.list) || [];
})()`);
- if ((data as any).error) return [data as any];
+ if ((data as any).error) throw new AuthRequiredError('x.com');
return (data as any[]).slice(0, kwargs.limit).map((l: any, i: number) => ({
rank: i + 1,
name: l.name || '',
@@ -349,7 +351,7 @@ const server = servers.find(s => s.slug === slug) || servers[0];
// clis/mysite/utils.ts
export async function getServerContext(slug: string | null): Promise<{ token: string; server: any }> {
const token = localStorage.getItem('mysite_access_token');
- if (!token) throw { error: 'Not logged in', help: 'Open https://app.mysite.com and log in, then retry' };
+ if (!token) return { error: 'Not logged in' };
const servers = await fetch('https://api.mysite.com/api/servers', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json());
@@ -365,9 +367,12 @@ import { getServerContext } from './utils.js';
func: async (page, kwargs) => {
await page.goto('https://app.mysite.com');
const data = await page.evaluate(`(async () => {
- const { token, server } = await (${getServerContext.toString()})(${JSON.stringify(kwargs.server || null)});
+ const ctx = await (${getServerContext.toString()})(${JSON.stringify(kwargs.server || null)});
+ if (ctx.error) return ctx; // bubble error sentinel to func() body
+ const { token, server } = ctx;
// ...
})()`);
+ if (data?.error) throw new AuthRequiredError('app.mysite.com', data.error);
}
```
@@ -380,42 +385,51 @@ func: async (page, kwargs) => {
## 错误处理规范
-### 返回 `{ error, help }` 而非 throw
+### 使用 `throw CliError` 子类
-```typescript
-// ❌ 不推荐:throw 导致 CLI 打印 stack trace,用户不知道怎么修复
-if (!token) throw new Error('Not logged in');
-
-// ✅ 推荐:返回结构化错误,help 告诉 AI Agent 或用户下一步怎么做
-if (!token) return [{ error: 'Not logged in', help: 'Open https://site.com and log in, then retry' }];
-```
-
-**字段约定**:
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| `error` | `string` | 问题描述,事实性,不带感叹号 |
-| `help` | `string` | 具体的修复动作,可直接执行 |
-
-**常用 help 模板**:
+所有错误都通过 `throw` 类型化的 `CliError` 子类来表达。框架层统一捕获并输出 YAML Error Envelope 到 stderr + 非零 exit code。**不要用 `return [{error, help}]`**——stdout 是纯数据通道。
```typescript
-// 未登录
-{ error: 'Not logged in', help: 'Open https://site.com in the browser and log in, then retry' }
+import { AuthRequiredError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
-// 找不到资源
-{ error: `Channel not found: ${kwargs.channel}`, help: 'Run `opencli site channels` to see available channels' }
+// ❌ 不推荐:错误伪装成数据混入 stdout
+if (!token) return [{ error: 'Not logged in', help: '...' }];
-// 权限不足
-{ error: 'Forbidden (403)', help: 'Check that your account has access to this resource' }
+// ✅ 推荐:throw 类型化错误,框架自动输出 YAML envelope 到 stderr
+if (!token) throw new AuthRequiredError('site.com');
+```
-// API 结构变更
-{ error: 'Unexpected response structure', help: 'Run `opencli browser network --detail N` to inspect the current API response' }
+**注意 `page.evaluate()` 内部**:browser 环境没有 CliError,在 evaluate 内返回 `{ error, help }` 后,在 `func()` 体内检查并 throw:
-// 风控降级(伪 200)
-{ error: 'Core data is empty — possible risk-control block', help: 'Re-login to the site in the browser, then retry' }
+```typescript
+const data = await page.evaluate(`(async () => {
+ const token = localStorage.getItem('token');
+ if (!token) return { error: 'Not logged in' };
+ // ...
+})()`);
+if ((data as any).error) throw new AuthRequiredError('site.com', (data as any).error);
```
-**何时 throw vs 返回 error 对象**:
-- 程序错误(参数类型错、配置缺失)→ `throw`,这是 bug
-- 运行时用户可修复的情况(未登录、找不到资源、API 变更)→ 返回 `{ error, help }`
+**可用的 CliError 子类**:
+
+| 子类 | code | 场景 | exit code |
+|------|------|------|-----------|
+| `AuthRequiredError` | AUTH_REQUIRED | 未登录、Cookie 过期 | 77 |
+| `EmptyResultError` | EMPTY_RESULT | API 返回空数据 | 66 |
+| `CommandExecutionError` | COMMAND_EXEC | 通用执行失败 | 1 |
+| `TimeoutError` | TIMEOUT | 超时 | 75 |
+| `ArgumentError` | ARGUMENT | 参数错误 | 2 |
+| `SelectorError` | SELECTOR | DOM 元素找不到 | 1 |
+| `BrowserConnectError` | BROWSER_CONNECT | 浏览器连接失败 | 69 |
+| `ConfigError` | CONFIG | 配置缺失 | 78 |
+
+**错误输出示例**(YAML envelope → stderr):
+
+```yaml
+ok: false
+error:
+ code: AUTH_REQUIRED
+ message: Not logged in to site.com
+ help: Please open Chrome or Chromium and log in to https://site.com
+ exitCode: 77
+```
diff --git a/skills/opencli-explorer/references/advanced-patterns.md b/skills/opencli-explorer/references/advanced-patterns.md
index e09d6ed62..0c60d7669 100644
--- a/skills/opencli-explorer/references/advanced-patterns.md
+++ b/skills/opencli-explorer/references/advanced-patterns.md
@@ -10,6 +10,7 @@
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
+import { AuthRequiredError } from '@jackwener/opencli/errors';
import type { IPage } from '@jackwener/opencli/types';
import { apiGet } from './utils.js'; // 复用平台 SDK
@@ -40,7 +41,7 @@ cli({
// Step 4: 断言风控降级(空值断言)
const subtitles = payload.data?.subtitle?.subtitles || [];
const url = subtitles[0]?.subtitle_url;
- if (!url) return [{ error: 'subtitle_url is empty — possible risk-control block', help: 'Re-login to Bilibili, then retry' }];
+ if (!url) throw new AuthRequiredError('bilibili.com', 'subtitle_url is empty — possible risk-control block');
// Step 5: 拉取最终数据(CDN JSON)
const items = await page.evaluate(`(async () => {
diff --git a/skills/opencli-oneshot/SKILL.md b/skills/opencli-oneshot/SKILL.md
index cce497f05..37bba25a6 100644
--- a/skills/opencli-oneshot/SKILL.md
+++ b/skills/opencli-oneshot/SKILL.md
@@ -246,6 +246,7 @@ cli({
```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
+import { AuthRequiredError } from '@jackwener/opencli/errors';
cli({
site: 'twitter',
@@ -274,6 +275,7 @@ cli({
});
return res.json();
})()`);
+ if ((data as any).error) throw new AuthRequiredError('x.com');
// 解析 data...
return [];
},
diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts
index c7873dcba..4db75dbcb 100644
--- a/src/commanderAdapter.test.ts
+++ b/src/commanderAdapter.test.ts
@@ -253,7 +253,7 @@ describe('commanderAdapter default formats', () => {
});
});
-describe('commanderAdapter empty result hints', () => {
+describe('commanderAdapter error envelope output', () => {
const cmd: CliCommand = {
site: 'xiaohongshu',
name: 'note',
@@ -272,12 +272,12 @@ describe('commanderAdapter empty result hints', () => {
process.exitCode = undefined;
});
- it('prints the adapter hint instead of the generic outdated-adapter message', async () => {
+ it('outputs YAML error envelope with adapter hint to stderr', async () => {
const program = new Command();
const siteCmd = program.command('xiaohongshu');
registerCommandToProgram(siteCmd, cmd);
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockExecuteCommand.mockRejectedValueOnce(
new EmptyResultError(
'xiaohongshu/note',
@@ -287,29 +287,31 @@ describe('commanderAdapter empty result hints', () => {
await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
- const output = errorSpy.mock.calls.flat().join('\n');
+ const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
+ expect(output).toContain('ok: false');
+ expect(output).toContain('code: EMPTY_RESULT');
expect(output).toContain('xsec_token');
- expect(output).not.toContain('this adapter may be outdated');
- errorSpy.mockRestore();
+ stderrSpy.mockRestore();
});
- it('prints selector-specific hints too', async () => {
+ it('outputs YAML error envelope for selector errors', async () => {
const program = new Command();
const siteCmd = program.command('xiaohongshu');
registerCommandToProgram(siteCmd, cmd);
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockExecuteCommand.mockRejectedValueOnce(
new SelectorError('.note-title', 'The note title selector no longer matches the current page.'),
);
await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
- const output = errorSpy.mock.calls.flat().join('\n');
+ const output = stderrSpy.mock.calls.map(c => String(c[0])).join('');
+ expect(output).toContain('ok: false');
+ expect(output).toContain('code: SELECTOR');
expect(output).toContain('selector no longer matches');
- expect(output).not.toContain('this adapter may be outdated');
- errorSpy.mockRestore();
+ stderrSpy.mockRestore();
});
});
diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts
index 81cc7bdce..8e4d8cddf 100644
--- a/src/commanderAdapter.ts
+++ b/src/commanderAdapter.ts
@@ -12,6 +12,7 @@
import { Command } from 'commander';
import chalk from 'chalk';
+import yaml from 'js-yaml';
import { type CliCommand, fullName, getRegistry } from './registry.js';
import { formatRegistryHelpText } from './serialization.js';
import { render as renderOutput } from './output.js';
@@ -19,18 +20,9 @@ import { executeCommand } from './execution.js';
import {
CliError,
EXIT_CODES,
- ERROR_ICONS,
- getErrorMessage,
- BrowserConnectError,
- AuthRequiredError,
- TimeoutError,
- SelectorError,
- EmptyResultError,
ArgumentError,
- AdapterLoadError,
- CommandExecutionError,
+ toEnvelope,
} from './errors.js';
-import { getDaemonHealth } from './browser/daemon-client.js';
import { isDiagnosticEnabled } from './diagnostic.js';
export function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown {
@@ -130,174 +122,44 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
footerExtra: resolved.footerExtra?.(kwargs),
});
} catch (err) {
- await renderError(err, fullName(cmd), optionsRecord.verbose === true);
+ renderError(err, fullName(cmd), optionsRecord.verbose === true);
process.exitCode = resolveExitCode(err);
}
});
}
-// ── Error classification ─────────────────────────────────────────────────────
-
-const ISSUES_URL = 'https://github.com/jackwener/opencli/issues';
-
-export type GenericErrorKind = 'auth' | 'http' | 'not-found' | 'other';
-
-interface ClassifiedError {
- kind: GenericErrorKind;
- icon: string;
- exitCode: number;
- hint: string;
-}
-
-const GENERIC_ERROR_MAP: Record> = {
- auth: { icon: '🔒', exitCode: EXIT_CODES.NOPERM, hint: 'Open Chrome or Chromium, log in to the target site, then retry.' },
- http: { icon: '🌐', exitCode: EXIT_CODES.GENERIC_ERROR, hint: 'Check your login status, or the site may be temporarily unavailable.' },
- 'not-found': { icon: '📭', exitCode: EXIT_CODES.EMPTY_RESULT, hint: 'The resource was not found. The adapter or page structure may have changed.' },
- other: { icon: '💥', exitCode: EXIT_CODES.GENERIC_ERROR, hint: '' },
-};
-
-/** Pattern-based classifier for untyped errors thrown by adapters. */
-function classifyGenericError(msg: string): ClassifiedError {
- const m = msg.toLowerCase();
- let kind: GenericErrorKind = 'other';
- if (/not logged in|login required|please log in|未登录|请先登录|authentication required|cookie expired/.test(m)) kind = 'auth';
- else if (/\b(status[: ]+)?[45]\d{2}\b|http[/ ][45]\d{2}/.test(m)) kind = 'http';
- else if (/not found|未找到|could not find|no .+ found/.test(m)) kind = 'not-found';
- return { kind, ...GENERIC_ERROR_MAP[kind] };
-}
-
// ── Exit code resolution ─────────────────────────────────────────────────────
function resolveExitCode(err: unknown): number {
if (err instanceof CliError) return err.exitCode;
- return classifyGenericError(getErrorMessage(err)).exitCode;
+ return EXIT_CODES.GENERIC_ERROR;
}
-/** Render a status line for BrowserConnectError based on real-time or kind-derived state. */
-function renderBridgeStatus(running: boolean, extensionConnected: boolean): void {
- const ok = chalk.green('✓');
- const fail = chalk.red('✗');
- console.error(` Daemon ${running ? ok : fail} ${running ? 'running' : 'not running'}`);
- console.error(` Extension ${extensionConnected ? ok : fail} ${extensionConnected ? 'connected' : 'not connected'}`);
- console.error();
- if (!running) {
- console.error(chalk.yellow(' Run the command again — daemon should auto-start.'));
- console.error(chalk.dim(' Still failing? Run: opencli doctor'));
- } else if (!extensionConnected) {
- console.error(chalk.yellow(' Install the Browser Bridge extension to continue:'));
- console.error(chalk.dim(' 1. Download from github.com/jackwener/opencli/releases'));
- console.error(chalk.dim(' 2. chrome://extensions → Enable Developer Mode → Load unpacked'));
- } else {
- console.error(chalk.yellow(' Connection failed despite extension being active.'));
- console.error(chalk.dim(' Try reloading the extension, or run: opencli doctor'));
- }
-}
+// ── Error rendering ─────────────────────────────────────────────────────────
/** Emit AutoFix hint for repairable adapter errors (skipped if already in diagnostic mode). */
-function emitAutoFixHint(cmdName: string): void {
- if (isDiagnosticEnabled()) return; // Already collecting diagnostics, don't repeat
- console.error();
- console.error(chalk.cyan('💡 AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 for repair context.'));
- console.error(chalk.dim(` OPENCLI_DIAGNOSTIC=1 ${cmdName}`));
+function emitAutoFixHint(envelope: string, cmdName: string): string {
+ if (isDiagnosticEnabled()) return envelope;
+ return envelope + `# AutoFix: re-run with OPENCLI_DIAGNOSTIC=1 for repair context\n# OPENCLI_DIAGNOSTIC=1 ${cmdName}\n`;
}
-async function renderError(err: unknown, cmdName: string, verbose: boolean): Promise {
- // ── BrowserConnectError: real-time diagnosis, kind as fallback ────────
- if (err instanceof BrowserConnectError) {
- console.error(chalk.red(`🔌 ${err.message}`));
- if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
- console.error();
- try {
- const health = await getDaemonHealth({ timeout: 300 });
- renderBridgeStatus(health.state !== 'stopped', health.state === 'ready');
- } catch (_statusErr) {
- // getDaemonHealth itself failed — derive best-guess state from kind.
- const running = err.kind !== 'daemon-not-running';
- const extensionConnected = err.kind === 'command-failed';
- renderBridgeStatus(running, extensionConnected);
- }
- return;
- }
-
- // ── AuthRequiredError ─────────────────────────────────────────────────
- if (err instanceof AuthRequiredError) {
- console.error(chalk.red(`🔒 Not logged in to ${err.domain}`));
- // Respect custom hints set by the adapter; fall back to generic guidance.
- console.error(chalk.yellow(`→ ${err.hint ?? `Open Chrome or Chromium and log in to https://${err.domain}, then retry.`}`));
- return;
- }
-
- // ── TimeoutError ──────────────────────────────────────────────────────
- if (err instanceof TimeoutError) {
- console.error(chalk.red(`⏱ ${err.message}`));
- console.error(chalk.yellow('→ Try again, or raise the limit:'));
- console.error(chalk.dim(` OPENCLI_BROWSER_COMMAND_TIMEOUT=60 ${cmdName}`));
- return;
- }
-
- // ── SelectorError / EmptyResultError: likely outdated adapter ─────────
- if (err instanceof SelectorError || err instanceof EmptyResultError) {
- const icon = ERROR_ICONS[err.code] ?? '⚠️';
- console.error(chalk.red(`${icon} ${err.message}`));
- console.error(chalk.yellow(`→ ${err.hint ?? 'The page structure may have changed — this adapter may be outdated.'}`));
- console.error(chalk.dim(` Debug: ${cmdName} --verbose`));
- console.error(chalk.dim(` Report: ${ISSUES_URL}`));
- emitAutoFixHint(cmdName);
- return;
- }
-
- // ── ArgumentError ─────────────────────────────────────────────────────
- if (err instanceof ArgumentError) {
- console.error(chalk.red(`❌ ${err.message}`));
- if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
- return;
- }
-
- // ── AdapterLoadError ──────────────────────────────────────────────────
- if (err instanceof AdapterLoadError) {
- console.error(chalk.red(`📦 ${err.message}`));
- if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
- return;
- }
+function renderError(err: unknown, cmdName: string, verbose: boolean): void {
+ const envelope = toEnvelope(err);
- // ── CommandExecutionError ─────────────────────────────────────────────
- if (err instanceof CommandExecutionError) {
- console.error(chalk.red(`💥 ${err.message}`));
- if (err.hint) {
- console.error(chalk.yellow(`→ ${err.hint}`));
- } else {
- console.error(chalk.dim(` Add --verbose for details, or report: ${ISSUES_URL}`));
- }
- return;
- }
-
- // ── Other typed CliError (fallback for future codes) ──────────────────
- if (err instanceof CliError) {
- const icon = ERROR_ICONS[err.code] ?? '⚠️';
- console.error(chalk.red(`${icon} ${err.message}`));
- if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
- return;
+ // In verbose mode, include stack trace for debugging
+ if (verbose && err instanceof Error && err.stack) {
+ envelope.error.stack = err.stack;
}
- // ── Generic Error from adapters: classify by message pattern ──────────
- const msg = getErrorMessage(err);
- const classified = classifyGenericError(msg);
+ let output = yaml.dump(envelope, { sortKeys: false, lineWidth: 120, noRefs: true });
- if (classified.kind !== 'other') {
- console.error(chalk.red(`${classified.icon} ${msg}`));
- console.error(chalk.yellow(`→ ${classified.hint}`));
- if (classified.kind === 'not-found') console.error(chalk.dim(` Report: ${ISSUES_URL}`));
- if (classified.kind === 'not-found') emitAutoFixHint(cmdName);
- return;
+ // Append AutoFix hint for repairable errors
+ const code = envelope.error.code;
+ if (code === 'SELECTOR' || code === 'EMPTY_RESULT' || code === 'ADAPTER_LOAD' || code === 'UNKNOWN') {
+ output = emitAutoFixHint(output, cmdName);
}
- // ── Unknown error: show stack in verbose mode ─────────────────────────
- if (verbose && err instanceof Error && err.stack) {
- console.error(chalk.red(err.stack));
- } else {
- console.error(chalk.red(`💥 Unexpected error: ${msg}`));
- console.error(chalk.dim(` Run with --verbose for details, or report: ${ISSUES_URL}`));
- }
+ process.stderr.write(output);
}
/**
diff --git a/src/errors.test.ts b/src/errors.test.ts
index f10f1bbc0..be7693fb6 100644
--- a/src/errors.test.ts
+++ b/src/errors.test.ts
@@ -10,6 +10,7 @@ import {
ArgumentError,
EmptyResultError,
SelectorError,
+ toEnvelope,
} from './errors.js';
describe('Error type hierarchy', () => {
@@ -77,3 +78,44 @@ describe('Error type hierarchy', () => {
expect(err.code).toBe('BROWSER_CONNECT');
});
});
+
+describe('toEnvelope', () => {
+ it('converts CliError to structured envelope', () => {
+ const err = new AuthRequiredError('bilibili.com');
+ const envelope = toEnvelope(err);
+ expect(envelope).toEqual({
+ ok: false,
+ error: {
+ code: 'AUTH_REQUIRED',
+ message: 'Not logged in to bilibili.com',
+ help: expect.stringContaining('https://bilibili.com'),
+ exitCode: 77,
+ },
+ });
+ });
+
+ it('converts CliError without hint (omits help field)', () => {
+ const err = new CommandExecutionError('Something broke');
+ const envelope = toEnvelope(err);
+ expect(envelope.error.code).toBe('COMMAND_EXEC');
+ expect(envelope.error).not.toHaveProperty('help');
+ });
+
+ it('converts unknown Error to UNKNOWN envelope', () => {
+ const envelope = toEnvelope(new Error('random failure'));
+ expect(envelope).toEqual({
+ ok: false,
+ error: {
+ code: 'UNKNOWN',
+ message: 'random failure',
+ exitCode: 1,
+ },
+ });
+ });
+
+ it('converts non-Error values to UNKNOWN envelope', () => {
+ const envelope = toEnvelope('string error');
+ expect(envelope.error.code).toBe('UNKNOWN');
+ expect(envelope.error.message).toBe('string error');
+ });
+});
diff --git a/src/errors.ts b/src/errors.ts
index 9ea9adece..ca17a4df2 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -137,6 +137,20 @@ export class SelectorError extends CliError {
}
}
+// ── Error Envelope ──────────────────────────────────────────────────────────
+
+/** Structured error output — unified contract for all consumers (AI agents, scripts, humans). */
+export interface ErrorEnvelope {
+ ok: false;
+ error: {
+ code: string;
+ message: string;
+ help?: string;
+ exitCode: number;
+ stack?: string;
+ };
+}
+
// ── Utilities ───────────────────────────────────────────────────────────────
/** Extract a human-readable message from an unknown caught value. */
@@ -144,19 +158,26 @@ 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 = {
- AUTH_REQUIRED: '🔒',
- BROWSER_CONNECT: '🔌',
- TIMEOUT: '⏱ ',
- ARGUMENT: '❌',
- EMPTY_RESULT: '📭',
- SELECTOR: '🔍',
- COMMAND_EXEC: '💥',
- ADAPTER_LOAD: '📦',
- NETWORK: '🌐',
- API_ERROR: '🚫',
- RATE_LIMITED: '⏳',
- PAGE_CHANGED: '🔄',
- CONFIG: '⚙️ ',
-};
+/** Build an ErrorEnvelope from any caught value. */
+export function toEnvelope(err: unknown): ErrorEnvelope {
+ if (err instanceof CliError) {
+ return {
+ ok: false,
+ error: {
+ code: err.code,
+ message: err.message,
+ ...(err.hint ? { help: err.hint } : {}),
+ exitCode: err.exitCode,
+ },
+ };
+ }
+ const msg = getErrorMessage(err);
+ return {
+ ok: false,
+ error: {
+ code: 'UNKNOWN',
+ message: msg,
+ exitCode: EXIT_CODES.GENERIC_ERROR,
+ },
+ };
+}
diff --git a/src/output.ts b/src/output.ts
index bcbc8de7f..ec8880ee7 100644
--- a/src/output.ts
+++ b/src/output.ts
@@ -24,11 +24,6 @@ function normalizeRows(data: unknown): Record[] {
}
function resolveColumns(rows: Record[], opts: RenderOptions): string[] {
- // When a command returns an error row ({ error, help }), override the declared
- // columns so the error is visible in table/csv/markdown output.
- if (opts.columns && rows.length > 0 && 'error' in rows[0] && !opts.columns.includes('error')) {
- return Object.keys(rows[0]);
- }
return opts.columns ?? Object.keys(rows[0] ?? {});
}