@jackwener/opencli 1.7.21 → 1.7.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ OpenCLI gives you one surface for three different kinds of automation:
14
14
  - **Let AI Agents operate any website** — install the `opencli-adapter-author` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives.
15
15
  - **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`.
16
16
 
17
- It also works as a **CLI hub** for local tools such as `gh`, `docker`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT.
17
+ It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, and ChatGPT.
18
18
 
19
19
  ## Highlights
20
20
 
@@ -290,6 +290,7 @@ OpenCLI acts as a universal hub for your existing command-line tools — unified
290
290
  | **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
291
291
  | **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
292
292
  | **docker** | Docker | `opencli docker ps` |
293
+ | **longbridge** | Longbridge CLI — market data, account management, and trading via Longbridge OpenAPI | `opencli longbridge quote TSLA.US --format json` |
293
294
  | **ntn** | Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments | `opencli ntn pages list` |
294
295
  | **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
295
296
  | **dws** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dws msg send --to user "hello"` |
package/README.zh-CN.md CHANGED
@@ -14,7 +14,7 @@ OpenCLI 可以用同一套 CLI 做三类事情:
14
14
  - **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-adapter-author` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。
15
15
  - **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。
16
16
 
17
- 除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT 等 Electron 应用。
17
+ 除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT 等 Electron 应用。
18
18
 
19
19
  ## 亮点
20
20
 
@@ -332,6 +332,7 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
332
332
  | **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
333
333
  | **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
334
334
  | **docker** | Docker 命令行工具 | `opencli docker ps` |
335
+ | **longbridge** | Longbridge CLI — 通过 Longbridge OpenAPI 获取行情、账户和交易能力 | `opencli longbridge quote TSLA.US --format json` |
335
336
  | **ntn** | Notion CLI — 基于官方 Notion API 的页面、数据库、块、搜索、评论命令 | `opencli ntn pages list` |
336
337
  | **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
337
338
  | **dws** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dws msg send --to user "hello"` |
@@ -5,6 +5,7 @@ const BOSS_DOMAIN = 'www.zhipin.com';
5
5
  const CHAT_URL = `https://${BOSS_DOMAIN}/web/chat/index`;
6
6
  const COOKIE_EXPIRED_CODES = new Set([7, 37]);
7
7
  const COOKIE_EXPIRED_MSG = 'Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。';
8
+ const RECRUITER_ONLY_MSG = '该命令仅支持招聘端(BOSS 端)账号,请使用招聘者账号登录后重试。';
8
9
  const DEFAULT_TIMEOUT = 15_000;
9
10
  // ── Core helpers ────────────────────────────────────────────────────────────
10
11
  /**
@@ -55,9 +56,23 @@ export function checkAuth(data) {
55
56
  throw new AuthRequiredError(BOSS_DOMAIN, COOKIE_EXPIRED_MSG);
56
57
  }
57
58
  }
59
+ /**
60
+ * Map BOSS code=24 ("请切换身份后再试") to a typed AuthRequiredError.
61
+ * Recruiter-only commands (recommend, joblist, stats, resume, mark,
62
+ * exchange, invite, greet, batchgreet) have no geek-side equivalent;
63
+ * surfacing this as a generic COMMAND_EXEC hides what the user must do.
64
+ * chatlist / chatmsg avoid this path by using `allowNonZero: true` and
65
+ * branching to the geek-side fetch when they see code 24.
66
+ */
67
+ function checkRecruiterSide(data) {
68
+ if (data.code === IDENTITY_MISMATCH_CODE) {
69
+ throw new AuthRequiredError(BOSS_DOMAIN, RECRUITER_ONLY_MSG);
70
+ }
71
+ }
58
72
  /**
59
73
  * Throw if the API response is not code 0.
60
- * Checks for cookie expiry first, then throws with the provided message.
74
+ * Checks for cookie expiry first, then identity mismatch, then throws
75
+ * with the provided message.
61
76
  */
62
77
  export function assertOk(data, errorPrefix) {
63
78
  if (!data || typeof data !== 'object') {
@@ -66,6 +81,7 @@ export function assertOk(data, errorPrefix) {
66
81
  if (data.code === 0)
67
82
  return;
68
83
  checkAuth(data);
84
+ checkRecruiterSide(data);
69
85
  const prefix = errorPrefix ? `${errorPrefix}: ` : '';
70
86
  throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`);
71
87
  }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { assertOk } from './utils.js';
4
+
5
+ describe('assertOk', () => {
6
+ it('returns silently on code 0', () => {
7
+ expect(() => assertOk({ code: 0 })).not.toThrow();
8
+ });
9
+
10
+ it('maps expired cookie codes (7, 37) to AuthRequiredError', () => {
11
+ expect(() => assertOk({ code: 7, message: 'expired' })).toThrow(AuthRequiredError);
12
+ expect(() => assertOk({ code: 37, message: 'expired' })).toThrow(AuthRequiredError);
13
+ });
14
+
15
+ it('maps code 24 (identity mismatch) to AuthRequiredError with recruiter-only hint', () => {
16
+ try {
17
+ assertOk({ code: 24, message: '请切换身份后再试' });
18
+ throw new Error('assertOk should have thrown');
19
+ } catch (err) {
20
+ expect(err).toBeInstanceOf(AuthRequiredError);
21
+ expect(String(err.message)).toContain('招聘端');
22
+ }
23
+ });
24
+
25
+ it('falls through to CommandExecutionError for other non-zero codes', () => {
26
+ expect(() => assertOk({ code: 99, message: 'something else' }))
27
+ .toThrow(CommandExecutionError);
28
+ });
29
+
30
+ it('throws CommandExecutionError on malformed (non-object) response', () => {
31
+ expect(() => assertOk(null)).toThrow(CommandExecutionError);
32
+ expect(() => assertOk('not-an-object')).toThrow(CommandExecutionError);
33
+ });
34
+ });
@@ -2,6 +2,7 @@
2
2
  * Weibo comments — get comments on a post.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
5
6
  cli({
6
7
  site: 'weibo',
7
8
  name: 'comments',
@@ -19,7 +20,7 @@ cli({
19
20
  await page.goto('https://weibo.com');
20
21
  await page.wait(2);
21
22
  const id = String(kwargs.id);
22
- const data = await page.evaluate(`
23
+ const data = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
23
24
  (async () => {
24
25
  const id = ${JSON.stringify(id)};
25
26
  const count = ${count};
@@ -46,9 +47,7 @@ cli({
46
47
  return item;
47
48
  });
48
49
  })()
49
- `);
50
- if (!Array.isArray(data))
51
- return [];
50
+ `)), 'weibo comments');
52
51
  return data;
53
52
  },
54
53
  });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './comments.js';
5
+ import './favorites.js';
6
+ import './feed.js';
7
+ import './hot.js';
8
+ import './me.js';
9
+ import './post.js';
10
+ import './search.js';
11
+ import './user.js';
12
+
13
+ function envelope(data) {
14
+ return { session: 'site:weibo:test', data };
15
+ }
16
+
17
+ function makePage(evaluateResults = []) {
18
+ const queue = [...evaluateResults];
19
+ return {
20
+ goto: vi.fn().mockResolvedValue(undefined),
21
+ wait: vi.fn().mockResolvedValue(undefined),
22
+ evaluate: vi.fn(async (script) => {
23
+ if (String(script).includes('window.scrollBy')) return undefined;
24
+ return queue.length ? queue.shift() : undefined;
25
+ }),
26
+ };
27
+ }
28
+
29
+ describe('weibo read adapters Browser Bridge envelopes', () => {
30
+ it('unwraps comments, feed, hot, search, and favorites array payloads', async () => {
31
+ await expect(getRegistry().get('weibo/comments').func(
32
+ makePage([envelope([{ rank: 1, author: 'a', text: 't', likes: 0, replies: 0, time: '' }])]),
33
+ { id: '123', limit: 1 },
34
+ )).resolves.toHaveLength(1);
35
+
36
+ await expect(getRegistry().get('weibo/feed').func(
37
+ makePage([envelope('123456'), envelope([{ id: 'm1', author: 'a', text: 't', reposts: 0, comments: 0, likes: 0, time: '', url: 'https://weibo.com/1/m1' }])]),
38
+ { type: 'for-you', limit: 1 },
39
+ )).resolves.toHaveLength(1);
40
+
41
+ await expect(getRegistry().get('weibo/hot').func(
42
+ makePage([envelope([{ rank: 1, word: 'opencli', hot_value: 1, category: '', label: '', url: 'https://s.weibo.com/weibo?q=opencli' }])]),
43
+ { limit: 1 },
44
+ )).resolves.toHaveLength(1);
45
+
46
+ await expect(getRegistry().get('weibo/search').func(
47
+ makePage([envelope([{ id: 'm1', title: 'OpenCLI', author: 'a', time: '', url: 'https://weibo.com/1/m1' }])]),
48
+ { keyword: 'opencli', limit: 1 },
49
+ )).resolves.toEqual([{ rank: 1, id: 'm1', title: 'OpenCLI', author: 'a', time: '', url: 'https://weibo.com/1/m1' }]);
50
+
51
+ await expect(getRegistry().get('weibo/favorites').func(
52
+ makePage([envelope('123456'), envelope([{ text: '作者A\n这是一条收藏微博', url: 'https://weibo.com/123/AbCd1' }])]),
53
+ { limit: 1 },
54
+ )).resolves.toHaveLength(1);
55
+ });
56
+
57
+ it('unwraps me, post, and user object payloads', async () => {
58
+ await expect(getRegistry().get('weibo/me').func(
59
+ makePage([envelope('123456'), envelope({ screen_name: 'me', uid: '123456' })]),
60
+ {},
61
+ )).resolves.toMatchObject({ screen_name: 'me', uid: '123456' });
62
+
63
+ await expect(getRegistry().get('weibo/post').func(
64
+ makePage([envelope({ id: '1', text: 'post' })]),
65
+ { id: '1' },
66
+ )).resolves.toContainEqual({ field: 'text', value: 'post' });
67
+
68
+ await expect(getRegistry().get('weibo/user').func(
69
+ makePage([envelope({ screen_name: 'alice', uid: '42' })]),
70
+ { id: '42' },
71
+ )).resolves.toMatchObject({ screen_name: 'alice', uid: '42' });
72
+ });
73
+
74
+ it('fails typed instead of returning empty rows for malformed post-unwrap payloads', async () => {
75
+ await expect(getRegistry().get('weibo/hot').func(
76
+ makePage([envelope({ error: 'API error' })]),
77
+ { limit: 1 },
78
+ )).rejects.toBeInstanceOf(CommandExecutionError);
79
+
80
+ await expect(getRegistry().get('weibo/user').func(
81
+ makePage([envelope([{ screen_name: 'wrong shape' }])]),
82
+ { id: '42' },
83
+ )).rejects.toBeInstanceOf(CommandExecutionError);
84
+ });
85
+ });
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
- import { getSelfUid } from './utils.js';
3
+ import { getSelfUid, requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
4
4
 
5
5
  const DEFAULT_LIMIT = 20;
6
6
  const MAX_LIMIT = 50;
@@ -123,7 +123,7 @@ cli({
123
123
  await page.wait(1);
124
124
  }
125
125
 
126
- const rawData = await page.evaluate(`
126
+ const rawData = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
127
127
  (() => {
128
128
  const scrollers = document.querySelectorAll('.wbpro-scroller-item, .vue-recycle-scroller__item-view');
129
129
  const out = [];
@@ -145,9 +145,9 @@ cli({
145
145
  }
146
146
  return out;
147
147
  })()
148
- `);
148
+ `)), 'weibo favorites');
149
149
 
150
- if (!Array.isArray(rawData) || rawData.length === 0) {
150
+ if (rawData.length === 0) {
151
151
  throw new EmptyResultError('weibo favorites', 'No favorites were visible on the favorites page');
152
152
  }
153
153
 
@@ -2,7 +2,7 @@
2
2
  * Weibo feed — for-you or following timeline.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
- import { getSelfUid } from './utils.js';
5
+ import { getSelfUid, requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
6
6
  const TIMELINE_ENDPOINTS = {
7
7
  'for-you': 'unreadfriendstimeline',
8
8
  following: 'friendstimeline',
@@ -31,7 +31,7 @@ cli({
31
31
  await page.goto('https://weibo.com');
32
32
  await page.wait(2);
33
33
  const uid = await getSelfUid(page);
34
- const data = await page.evaluate(`
34
+ const data = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
35
35
  (async () => {
36
36
  const uid = ${JSON.stringify(uid)};
37
37
  const count = ${count};
@@ -63,9 +63,7 @@ cli({
63
63
  return item;
64
64
  });
65
65
  })()
66
- `);
67
- if (!Array.isArray(data))
68
- return [];
66
+ `)), 'weibo feed');
69
67
  return data;
70
68
  },
71
69
  });
package/clis/weibo/hot.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Weibo hot search — browser cookie API.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
5
6
  cli({
6
7
  site: 'weibo',
7
8
  name: 'hot',
@@ -16,7 +17,7 @@ cli({
16
17
  func: async (page, kwargs) => {
17
18
  const count = Math.min(kwargs.limit || 30, 50);
18
19
  await page.goto('https://weibo.com');
19
- const data = await page.evaluate(`
20
+ const data = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
20
21
  (async () => {
21
22
  const resp = await fetch('/ajax/statuses/hot_band', {credentials: 'include'});
22
23
  if (!resp.ok) return {error: 'HTTP ' + resp.status};
@@ -32,9 +33,7 @@ cli({
32
33
  url: 'https://s.weibo.com/weibo?q=' + encodeURIComponent('#' + item.word + '#')
33
34
  }));
34
35
  })()
35
- `);
36
- if (!Array.isArray(data))
37
- return [];
36
+ `)), 'weibo hot');
38
37
  return data.slice(0, count);
39
38
  },
40
39
  });
package/clis/weibo/me.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { CommandExecutionError } from '@jackwener/opencli/errors';
6
- import { getSelfUid } from './utils.js';
6
+ import { getSelfUid, requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
7
7
  cli({
8
8
  site: 'weibo',
9
9
  name: 'me',
@@ -17,7 +17,7 @@ cli({
17
17
  await page.goto('https://weibo.com');
18
18
  await page.wait(2);
19
19
  const uid = await getSelfUid(page);
20
- const data = await page.evaluate(`
20
+ const data = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
21
21
  (async () => {
22
22
  const uid = ${JSON.stringify(uid)};
23
23
 
@@ -67,9 +67,7 @@ cli({
67
67
  profile_url: 'https://weibo.com' + (p.profile_url || '/u/' + p.id),
68
68
  };
69
69
  })()
70
- `);
71
- if (!data || typeof data !== 'object')
72
- throw new CommandExecutionError('Failed to fetch profile');
70
+ `)), 'weibo me');
73
71
  if (data.error)
74
72
  throw new CommandExecutionError(String(data.error));
75
73
  return data;
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { CommandExecutionError } from '@jackwener/opencli/errors';
6
+ import { requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
6
7
  cli({
7
8
  site: 'weibo',
8
9
  name: 'post',
@@ -18,7 +19,7 @@ cli({
18
19
  await page.goto('https://weibo.com');
19
20
  await page.wait(2);
20
21
  const id = String(kwargs.id);
21
- const data = await page.evaluate(`
22
+ const data = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
22
23
  (async () => {
23
24
  const id = ${JSON.stringify(id)};
24
25
  const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
@@ -63,9 +64,7 @@ cli({
63
64
 
64
65
  return result;
65
66
  })()
66
- `);
67
- if (!data || typeof data !== 'object')
68
- throw new CommandExecutionError('Failed to fetch post');
67
+ `)), 'weibo post');
69
68
  if (data.error)
70
69
  throw new CommandExecutionError(String(data.error));
71
70
  return Object.entries(data).map(([field, value]) => ({
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { CliError } from '@jackwener/opencli/errors';
6
+ import { requireArrayEvaluateResult, unwrapEvaluateResult } from './utils.js';
6
7
  cli({
7
8
  site: 'weibo',
8
9
  name: 'search',
@@ -21,7 +22,7 @@ cli({
21
22
  const keyword = encodeURIComponent(String(kwargs.keyword ?? '').trim());
22
23
  await page.goto(`https://s.weibo.com/weibo?q=${keyword}`);
23
24
  await page.wait(2);
24
- const data = await page.evaluate(`
25
+ const data = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
25
26
  (() => {
26
27
  const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
27
28
  const absoluteUrl = (href) => {
@@ -67,8 +68,8 @@ cli({
67
68
 
68
69
  return rows;
69
70
  })()
70
- `);
71
- if (!Array.isArray(data) || data.length === 0) {
71
+ `)), 'weibo search');
72
+ if (data.length === 0) {
72
73
  throw new CliError('NOT_FOUND', 'No Weibo search results found', 'Try a different keyword or ensure you are logged into weibo.com');
73
74
  }
74
75
  return data.slice(0, limit).map((item, index) => ({
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { CommandExecutionError } from '@jackwener/opencli/errors';
6
+ import { requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
6
7
  cli({
7
8
  site: 'weibo',
8
9
  name: 'user',
@@ -18,7 +19,7 @@ cli({
18
19
  await page.goto('https://weibo.com');
19
20
  await page.wait(2);
20
21
  const id = String(kwargs.id);
21
- const data = await page.evaluate(`
22
+ const data = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
22
23
  (async () => {
23
24
  const id = ${JSON.stringify(id)};
24
25
  const isUid = /^\\d+$/.test(id);
@@ -54,9 +55,7 @@ cli({
54
55
  ip_location: d.ip_location || '',
55
56
  };
56
57
  })()
57
- `);
58
- if (!data || typeof data !== 'object')
59
- throw new CommandExecutionError('Failed to fetch user profile');
58
+ `)), 'weibo user');
60
59
  if (data.error)
61
60
  throw new CommandExecutionError(String(data.error));
62
61
  return data;
@@ -1,10 +1,39 @@
1
1
  /**
2
2
  * Shared Weibo utilities — uid extraction.
3
3
  */
4
- import { AuthRequiredError } from '@jackwener/opencli/errors';
4
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
5
+ /**
6
+ * `page.evaluate` may return either the raw IIFE value or a
7
+ * `{ session, data }` envelope depending on the browser-bridge version.
8
+ * Adapter code that inspected the payload directly (e.g. `Array.isArray`,
9
+ * truthiness checks on uid strings) silently received the envelope wrapper
10
+ * instead of the inner value. This helper normalizes both shapes so callers
11
+ * can keep their existing checks unchanged.
12
+ */
13
+ export function unwrapEvaluateResult(payload) {
14
+ if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
15
+ return payload.data;
16
+ }
17
+ return payload;
18
+ }
19
+ export function requireArrayEvaluateResult(payload, label) {
20
+ if (!Array.isArray(payload)) {
21
+ if (payload && typeof payload === 'object' && 'error' in payload) {
22
+ throw new CommandExecutionError(`${label}: ${String(payload.error)}`);
23
+ }
24
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
25
+ }
26
+ return payload;
27
+ }
28
+ export function requireObjectEvaluateResult(payload, label) {
29
+ if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
30
+ throw new CommandExecutionError(`${label} returned malformed extraction payload`);
31
+ }
32
+ return payload;
33
+ }
5
34
  /** Get the currently logged-in user's uid from Vue store or config API. */
6
35
  export async function getSelfUid(page) {
7
- const uid = await page.evaluate(`
36
+ const uid = unwrapEvaluateResult(await page.evaluate(`
8
37
  (() => {
9
38
  const app = document.querySelector('#app')?.__vue_app__;
10
39
  const store = app?.config?.globalProperties?.$store;
@@ -12,18 +41,18 @@ export async function getSelfUid(page) {
12
41
  if (uid) return String(uid);
13
42
  return null;
14
43
  })()
15
- `);
44
+ `));
16
45
  if (uid)
17
46
  return uid;
18
47
  // Fallback: config API
19
- const config = await page.evaluate(`
48
+ const config = unwrapEvaluateResult(await page.evaluate(`
20
49
  (async () => {
21
50
  const resp = await fetch('/ajax/config/get_config', {credentials: 'include'});
22
51
  if (!resp.ok) return null;
23
52
  const data = await resp.json();
24
53
  return data.ok && data.data?.uid ? String(data.data.uid) : null;
25
54
  })()
26
- `);
55
+ `));
27
56
  if (config)
28
57
  return config;
29
58
  throw new AuthRequiredError('weibo.com');
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { requireArrayEvaluateResult, requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
4
+
5
+ describe('unwrapEvaluateResult (browser-bridge envelope normalization)', () => {
6
+ it('returns the raw array unchanged when payload is already an array', () => {
7
+ const arr = [{ id: '1' }, { id: '2' }];
8
+ expect(unwrapEvaluateResult(arr)).toBe(arr);
9
+ });
10
+ it('unwraps { session, data: [...] } envelope to the inner array', () => {
11
+ const arr = [{ id: '1' }];
12
+ const env = { session: 'site:weibo:abc', data: arr };
13
+ expect(unwrapEvaluateResult(env)).toBe(arr);
14
+ });
15
+ it('unwraps primitive data (e.g. uid string) from Browser Bridge envelopes', () => {
16
+ expect(unwrapEvaluateResult({ session: 'site:weibo:abc', data: '1234567890' })).toBe('1234567890');
17
+ });
18
+ it('unwraps null payload data so getSelfUid fallback can trigger', () => {
19
+ expect(unwrapEvaluateResult({ session: 'site:weibo:abc', data: null })).toBe(null);
20
+ });
21
+ it('passes non-envelope objects through unchanged (e.g. profile result)', () => {
22
+ const obj = { screen_name: 'alice', uid: '42' };
23
+ expect(unwrapEvaluateResult(obj)).toBe(obj);
24
+ });
25
+ it('handles null and undefined safely', () => {
26
+ expect(unwrapEvaluateResult(null)).toBe(null);
27
+ expect(unwrapEvaluateResult(undefined)).toBe(undefined);
28
+ });
29
+ it('keeps malformed array/object payloads as typed command failures after unwrap', () => {
30
+ expect(requireArrayEvaluateResult([{ id: '1' }], 'weibo feed')).toEqual([{ id: '1' }]);
31
+ expect(() => requireArrayEvaluateResult({ error: 'API error' }, 'weibo feed')).toThrow(CommandExecutionError);
32
+ expect(() => requireArrayEvaluateResult({ error: 'API error' }, 'weibo feed')).toThrow('weibo feed: API error');
33
+ expect(requireObjectEvaluateResult({ uid: '42' }, 'weibo me')).toEqual({ uid: '42' });
34
+ expect(() => requireObjectEvaluateResult([{ uid: '42' }], 'weibo me')).toThrow(CommandExecutionError);
35
+ });
36
+ });
package/dist/src/cli.js CHANGED
@@ -545,7 +545,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
545
545
  for (const ext of externalClis) {
546
546
  const isInstalled = isBinaryInstalled(ext.binary);
547
547
  const tag = isInstalled ? '[installed]' : '[auto-install]';
548
- console.log(` ${ext.name} ${tag}${ext.description ? ` — ${ext.description}` : ''}`);
548
+ console.log(` ${formatExternalCliLabel(ext)} ${tag}${ext.description ? ` — ${ext.description}` : ''}`);
549
549
  }
550
550
  console.log();
551
551
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  - name: ntn
18
18
  binary: ntn
19
+ package: notion
19
20
  description: "Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments"
20
21
  homepage: "https://ntn.dev"
21
22
  tags: [notion, notes, knowledge, productivity]
@@ -36,8 +37,18 @@
36
37
  install:
37
38
  default: "npm install -g @larksuite/cli"
38
39
 
40
+ - name: longbridge
41
+ binary: longbridge
42
+ description: "Longbridge CLI — AI-native market data, account management and trading commands for Longbridge OpenAPI"
43
+ homepage: "https://open.longbridge.com/zh-CN/docs/cli/"
44
+ tags: [longbridge, finance, trading, market-data, openapi, ai-agent]
45
+ install:
46
+ mac: "brew install --cask longbridge/tap/longbridge-terminal"
47
+ windows: "scoop install https://open.longbridge.com/longbridge/longbridge-terminal/longbridge.json"
48
+
39
49
  - name: dws
40
50
  binary: dws
51
+ package: DingTalk Workspace
41
52
  description: "DingTalk Workspace CLI — messages, docs, calendar, contacts and more for humans and AI agents"
42
53
  homepage: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
43
54
  tags: [dingtalk, collaboration, productivity, ai-agent]
@@ -47,6 +58,7 @@
47
58
 
48
59
  - name: wecom-cli
49
60
  binary: wecom-cli
61
+ package: 企业微信
50
62
  description: "WeCom/企业微信 CLI — contacts, todos, meetings, messages, calendar, docs and smart sheets for AI agents"
51
63
  homepage: "https://github.com/WecomTeam/wecom-cli"
52
64
  tags: [wecom, wechat-work, collaboration, productivity, ai-agent]
@@ -8,7 +8,12 @@ export interface ExternalCliConfig {
8
8
  /** User-facing OpenCLI subcommand and, by default, the executable name. */
9
9
  name: string;
10
10
  binary: string;
11
- /** Distribution/project name when it differs from the executable name. */
11
+ /**
12
+ * Display alias rendered alongside `name` in help/listing as `name(package)`.
13
+ * Use either the upstream distribution/project name (e.g. `tg-cli`, `discord-cli`)
14
+ * or a human-readable brand label (e.g. `notion`, `企业微信`) when the bare
15
+ * executable name is ambiguous.
16
+ */
12
17
  package?: string;
13
18
  description?: string;
14
19
  homepage?: string;
@@ -44,6 +44,21 @@ describe('parseCommand', () => {
44
44
  }
45
45
  }
46
46
  });
47
+ it('registers Longbridge with safe package-manager installers only', () => {
48
+ const raw = fs.readFileSync(path.join(__dirname, 'external-clis.yaml'), 'utf8');
49
+ const entries = (yaml.load(raw) || []);
50
+ const longbridge = entries.find((entry) => entry.name === 'longbridge');
51
+ expect(longbridge).toMatchObject({
52
+ binary: 'longbridge',
53
+ homepage: 'https://open.longbridge.com/zh-CN/docs/cli/',
54
+ install: {
55
+ mac: 'brew install --cask longbridge/tap/longbridge-terminal',
56
+ windows: 'scoop install https://open.longbridge.com/longbridge/longbridge-terminal/longbridge.json',
57
+ },
58
+ });
59
+ expect(longbridge?.install?.linux).toBeUndefined();
60
+ expect(longbridge?.install?.default).toBeUndefined();
61
+ });
47
62
  });
48
63
  describe('formatExternalCliLabel', () => {
49
64
  it('shows the package name when the executable name differs', () => {
@@ -52,6 +67,10 @@ describe('formatExternalCliLabel', () => {
52
67
  it('keeps the label compact when package and name match', () => {
53
68
  expect(formatExternalCliLabel({ name: 'docker', binary: 'docker', package: 'docker' })).toBe('docker');
54
69
  });
70
+ it('renders a human-readable brand alias for ambiguous executable names', () => {
71
+ expect(formatExternalCliLabel({ name: 'ntn', binary: 'ntn', package: 'notion' })).toBe('ntn(notion)');
72
+ expect(formatExternalCliLabel({ name: 'wecom-cli', binary: 'wecom-cli', package: '企业微信' })).toBe('wecom-cli(企业微信)');
73
+ });
55
74
  });
56
75
  describe('installExternalCli', () => {
57
76
  const cli = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.7.21",
3
+ "version": "1.7.22",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },