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] ?? {}); }