@jackwener/opencli 1.7.19 → 1.7.20

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.
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './bookmarks.js';
3
+
4
+ const { parseBookmarks, extractBookmarkTweet } = __test__;
5
+
6
+ describe('twitter bookmarks parser', () => {
7
+ it('extracts a baseline tweet with no media (has_media false, media_urls empty)', () => {
8
+ const tweet = extractBookmarkTweet({
9
+ rest_id: '1',
10
+ legacy: {
11
+ full_text: 'plain bookmark',
12
+ favorite_count: 5,
13
+ retweet_count: 1,
14
+ bookmark_count: 2,
15
+ created_at: 'Wed Apr 16 10:00:00 +0000 2026',
16
+ },
17
+ core: { user_results: { result: { legacy: { screen_name: 'alice', name: 'Alice' } } } },
18
+ }, new Set());
19
+ expect(tweet).toEqual({
20
+ id: '1',
21
+ author: 'alice',
22
+ name: 'Alice',
23
+ text: 'plain bookmark',
24
+ likes: 5,
25
+ retweets: 1,
26
+ bookmarks: 2,
27
+ created_at: 'Wed Apr 16 10:00:00 +0000 2026',
28
+ url: 'https://x.com/alice/status/1',
29
+ has_media: false,
30
+ media_urls: [],
31
+ });
32
+ });
33
+
34
+ it('includes photo media URLs from extended_entities', () => {
35
+ const tweet = extractBookmarkTweet({
36
+ rest_id: '101',
37
+ legacy: {
38
+ full_text: 'pic bookmark',
39
+ extended_entities: {
40
+ media: [
41
+ { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/abc.jpg' },
42
+ { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/def.jpg' },
43
+ ],
44
+ },
45
+ },
46
+ core: { user_results: { result: { legacy: { screen_name: 'bob' } } } },
47
+ }, new Set());
48
+ expect(tweet?.has_media).toBe(true);
49
+ expect(tweet?.media_urls).toEqual([
50
+ 'https://pbs.twimg.com/media/abc.jpg',
51
+ 'https://pbs.twimg.com/media/def.jpg',
52
+ ]);
53
+ });
54
+
55
+ it('extracts mp4 variant URL for video media', () => {
56
+ const tweet = extractBookmarkTweet({
57
+ rest_id: '102',
58
+ legacy: {
59
+ full_text: 'video bookmark',
60
+ extended_entities: {
61
+ media: [{
62
+ type: 'video',
63
+ media_url_https: 'https://pbs.twimg.com/amplify_video_thumb/thumb.jpg',
64
+ video_info: {
65
+ variants: [
66
+ { content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/playlist.m3u8' },
67
+ { content_type: 'video/mp4', bitrate: 832000, url: 'https://video.twimg.com/low.mp4' },
68
+ { content_type: 'video/mp4', bitrate: 2176000, url: 'https://video.twimg.com/high.mp4' },
69
+ ],
70
+ },
71
+ }],
72
+ },
73
+ },
74
+ core: { user_results: { result: { legacy: { screen_name: 'carol' } } } },
75
+ }, new Set());
76
+ expect(tweet?.has_media).toBe(true);
77
+ expect(tweet?.media_urls?.[0]).toMatch(/\.mp4$/);
78
+ });
79
+
80
+ it('falls back to entities.media when extended_entities is absent', () => {
81
+ const tweet = extractBookmarkTweet({
82
+ rest_id: '103',
83
+ legacy: {
84
+ full_text: 'entities-only media',
85
+ entities: {
86
+ media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/legacy.jpg' }],
87
+ },
88
+ },
89
+ core: { user_results: { result: { legacy: { screen_name: 'dave' } } } },
90
+ }, new Set());
91
+ expect(tweet?.has_media).toBe(true);
92
+ expect(tweet?.media_urls).toEqual(['https://pbs.twimg.com/media/legacy.jpg']);
93
+ });
94
+
95
+ it('prefers note_tweet text over truncated full_text', () => {
96
+ const tweet = extractBookmarkTweet({
97
+ rest_id: '2',
98
+ legacy: { full_text: 'short text…', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
99
+ note_tweet: { note_tweet_results: { result: { text: 'full long-form text body' } } },
100
+ core: { user_results: { result: { core: { screen_name: 'erin' } } } },
101
+ }, new Set());
102
+ expect(tweet?.text).toBe('full long-form text body');
103
+ });
104
+
105
+ it('deduplicates tweets across the seen Set', () => {
106
+ const data = {
107
+ data: {
108
+ bookmark_timeline_v2: {
109
+ timeline: {
110
+ instructions: [{
111
+ entries: [
112
+ {
113
+ entryId: 'tweet-3',
114
+ content: {
115
+ itemContent: {
116
+ tweet_results: {
117
+ result: {
118
+ rest_id: '3',
119
+ legacy: { full_text: 'first', favorite_count: 0, retweet_count: 0, bookmark_count: 0 },
120
+ core: { user_results: { result: { legacy: { screen_name: 'frank' } } } },
121
+ },
122
+ },
123
+ },
124
+ },
125
+ },
126
+ {
127
+ entryId: 'tweet-3-dup',
128
+ content: {
129
+ itemContent: {
130
+ tweet_results: {
131
+ result: {
132
+ rest_id: '3',
133
+ legacy: { full_text: 'duplicate' },
134
+ core: { user_results: { result: { legacy: { screen_name: 'frank' } } } },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ ],
141
+ }],
142
+ },
143
+ },
144
+ },
145
+ };
146
+ const seen = new Set();
147
+ const { tweets } = parseBookmarks(data, seen);
148
+ expect(tweets).toHaveLength(1);
149
+ expect(tweets[0].text).toBe('first');
150
+ });
151
+
152
+ it('extracts cursor + tweets from the bookmark_timeline_v2 envelope', () => {
153
+ const data = {
154
+ data: {
155
+ bookmark_timeline_v2: {
156
+ timeline: {
157
+ instructions: [
158
+ {
159
+ type: 'TimelineAddEntries',
160
+ entries: [
161
+ {
162
+ entryId: 'tweet-4',
163
+ content: {
164
+ itemContent: {
165
+ tweet_results: {
166
+ result: {
167
+ rest_id: '4',
168
+ legacy: {
169
+ full_text: 'envelope tweet',
170
+ favorite_count: 1,
171
+ retweet_count: 0,
172
+ bookmark_count: 0,
173
+ extended_entities: {
174
+ media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/x.jpg' }],
175
+ },
176
+ },
177
+ core: { user_results: { result: { legacy: { screen_name: 'gina' } } } },
178
+ },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ {
184
+ entryId: 'cursor-bottom-Y',
185
+ content: { __typename: 'TimelineTimelineCursor', cursorType: 'Bottom', value: 'NEXT' },
186
+ },
187
+ ],
188
+ },
189
+ ],
190
+ },
191
+ },
192
+ },
193
+ };
194
+ const { tweets, nextCursor } = parseBookmarks(data, new Set());
195
+ expect(tweets).toHaveLength(1);
196
+ expect(tweets[0].id).toBe('4');
197
+ expect(tweets[0].has_media).toBe(true);
198
+ expect(tweets[0].media_urls).toEqual(['https://pbs.twimg.com/media/x.jpg']);
199
+ expect(nextCursor).toBe('NEXT');
200
+ });
201
+
202
+ it('returns empty tweets + null cursor for unknown envelope', () => {
203
+ expect(parseBookmarks({}, new Set())).toEqual({ tweets: [], nextCursor: null });
204
+ });
205
+ });
@@ -73,6 +73,7 @@ export interface DaemonStatus {
73
73
  profileDisconnected?: boolean;
74
74
  profiles?: BrowserProfileStatus[];
75
75
  pending: number;
76
+ commandResultUnknown?: number;
76
77
  memoryMB: number;
77
78
  port: number;
78
79
  }
@@ -105,6 +105,9 @@ async function sendCommandRaw(action, params) {
105
105
  });
106
106
  const result = (await res.json());
107
107
  if (!result.ok) {
108
+ if (result.errorCode === 'command_result_unknown') {
109
+ throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
110
+ }
108
111
  const isDuplicateCommandId = res.status === 409
109
112
  || (result.error ?? '').includes('Duplicate command id');
110
113
  if (isDuplicateCommandId && attempt < maxRetries) {
@@ -176,4 +176,24 @@ describe('daemon-client', () => {
176
176
  });
177
177
  expect(ids[0]).not.toBe(ids[1]);
178
178
  });
179
+ it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
180
+ const fetchMock = vi.mocked(fetch);
181
+ fetchMock.mockResolvedValue({
182
+ ok: false,
183
+ status: 503,
184
+ json: () => Promise.resolve({
185
+ id: 'server',
186
+ ok: false,
187
+ errorCode: 'command_result_unknown',
188
+ error: 'Extension disconnected after command timeout',
189
+ errorHint: 'Inspect state before retrying.',
190
+ }),
191
+ });
192
+ await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({
193
+ name: 'BrowserCommandError',
194
+ code: 'command_result_unknown',
195
+ hint: 'Inspect state before retrying.',
196
+ });
197
+ expect(fetchMock).toHaveBeenCalledTimes(1);
198
+ });
179
199
  });
package/dist/src/cli.js CHANGED
@@ -15,7 +15,7 @@ import { serializeCommand, formatArgSummary } from './serialization.js';
15
15
  import { render as renderOutput } from './output.js';
16
16
  import { PKG_VERSION } from './version.js';
17
17
  import { printCompletionScript } from './completion.js';
18
- import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
18
+ import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
19
19
  import { registerAllCommands } from './commanderAdapter.js';
20
20
  import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
21
21
  import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
@@ -3005,6 +3005,7 @@ cli({
3005
3005
  .action((opts) => {
3006
3006
  const rows = loadExternalClis().map((ext) => ({
3007
3007
  name: ext.name,
3008
+ package: ext.package ?? '',
3008
3009
  binary: ext.binary,
3009
3010
  installed: isBinaryInstalled(ext.binary),
3010
3011
  description: ext.description ?? '',
@@ -3013,7 +3014,7 @@ cli({
3013
3014
  }));
3014
3015
  renderOutput(rows, {
3015
3016
  fmt: opts.format,
3016
- columns: ['name', 'binary', 'installed', 'description', 'homepage', 'tags'],
3017
+ columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'],
3017
3018
  title: 'opencli/external/list',
3018
3019
  source: 'opencli external list',
3019
3020
  });
@@ -3067,6 +3068,10 @@ cli({
3067
3068
  // Classification derives from each adapter's `domain` field — see classifyAdapter.
3068
3069
  // External CLIs are taken from the externalClis registry (passthrough binaries).
3069
3070
  const externalNames = externalClis.map(ext => ext.name);
3071
+ const externalHelpEntries = externalClis.map(ext => ({
3072
+ name: ext.name,
3073
+ label: formatExternalCliLabel(ext),
3074
+ }));
3070
3075
  const siteDomains = new Map();
3071
3076
  for (const [, cmd] of getRegistry()) {
3072
3077
  if (!siteDomains.has(cmd.site))
@@ -3080,7 +3085,7 @@ cli({
3080
3085
  else
3081
3086
  sites.push(site);
3082
3087
  }
3083
- const adapterGroups = { external: externalNames, apps, sites };
3088
+ const adapterGroups = { external: externalHelpEntries, apps, sites };
3084
3089
  const adapterNameSet = new Set([...externalNames, ...siteNames]);
3085
3090
  installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
3086
3091
  installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
@@ -168,6 +168,7 @@ describe('createProgram root help descriptions', () => {
168
168
  expect(data.site_adapters.sites).toEqual(['bilibili']);
169
169
  expect(data.external_clis.count).toBeGreaterThanOrEqual(0);
170
170
  expect(Array.isArray(data.external_clis.clis)).toBe(true);
171
+ expect(Array.isArray(data.external_clis.display)).toBe(true);
171
172
  // Adapters must NOT leak into the core commands list
172
173
  const commandNames = data.commands.map((cmd) => cmd.name);
173
174
  expect(commandNames).not.toContain('bilibili');
@@ -0,0 +1,18 @@
1
+ export declare const COMMAND_RESULT_UNKNOWN_CODE = "command_result_unknown";
2
+ export declare const COMMAND_RESULT_UNKNOWN_HINT = "Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.";
3
+ export declare const PROFILE_DISCONNECTED_HINT = "Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.";
4
+ export type DaemonFailureContract = {
5
+ message: string;
6
+ errorCode: string;
7
+ errorHint: string;
8
+ status: number;
9
+ countAsCommandResultUnknown: boolean;
10
+ };
11
+ export declare function commandResultUnknownMessage(action: string): string;
12
+ export declare function buildExtensionDisconnectFailure(input: {
13
+ contextId: string;
14
+ action: string;
15
+ dispatched: boolean;
16
+ }): DaemonFailureContract;
17
+ export declare function buildCommandDispatchFailure(contextId: string): DaemonFailureContract;
18
+ export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
@@ -0,0 +1,37 @@
1
+ export const COMMAND_RESULT_UNKNOWN_CODE = 'command_result_unknown';
2
+ export const COMMAND_RESULT_UNKNOWN_HINT = 'Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.';
3
+ export const PROFILE_DISCONNECTED_HINT = 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.';
4
+ export function commandResultUnknownMessage(action) {
5
+ return `Browser connection dropped after the ${action} command was dispatched; it may have completed.`;
6
+ }
7
+ export function buildExtensionDisconnectFailure(input) {
8
+ if (input.dispatched) {
9
+ return {
10
+ message: commandResultUnknownMessage(input.action),
11
+ errorCode: COMMAND_RESULT_UNKNOWN_CODE,
12
+ errorHint: COMMAND_RESULT_UNKNOWN_HINT,
13
+ status: 503,
14
+ countAsCommandResultUnknown: true,
15
+ };
16
+ }
17
+ return buildCommandDispatchFailure(input.contextId);
18
+ }
19
+ export function buildCommandDispatchFailure(contextId) {
20
+ return {
21
+ message: `Browser profile "${contextId}" disconnected before command dispatch`,
22
+ errorCode: 'profile_disconnected',
23
+ errorHint: PROFILE_DISCONNECTED_HINT,
24
+ status: 503,
25
+ countAsCommandResultUnknown: false,
26
+ };
27
+ }
28
+ export function getResponseCorsHeaders(pathname, origin) {
29
+ if (pathname !== '/ping')
30
+ return undefined;
31
+ if (!origin || !origin.startsWith('chrome-extension://'))
32
+ return undefined;
33
+ return {
34
+ 'Access-Control-Allow-Origin': origin,
35
+ Vary: 'Origin',
36
+ };
37
+ }
@@ -19,4 +19,4 @@
19
19
  * - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall
20
20
  * - Listens on localhost:19825
21
21
  */
22
- export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
22
+ export {};
@@ -27,9 +27,11 @@ import { log } from './logger.js';
27
27
  import { PKG_VERSION } from './version.js';
28
28
  import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
29
29
  import { recordExtensionVersion } from './update-check.js';
30
+ import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
30
31
  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
31
32
  const extensionProfiles = new Map();
32
33
  const pending = new Map();
34
+ let commandResultUnknownCount = 0;
33
35
  const LOG_BUFFER_SIZE = 200;
34
36
  const logBuffer = [];
35
37
  class DaemonCommandFailure extends Error {
@@ -110,7 +112,16 @@ function unregisterExtensionConnection(ws) {
110
112
  if (p.contextId !== contextId)
111
113
  continue;
112
114
  clearTimeout(p.timer);
113
- p.reject(new DaemonCommandFailure(`Browser profile "${contextId}" disconnected`, 'profile_disconnected', 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 503));
115
+ const failure = buildExtensionDisconnectFailure({
116
+ contextId,
117
+ action: p.action,
118
+ dispatched: p.dispatched,
119
+ });
120
+ if (failure.countAsCommandResultUnknown) {
121
+ commandResultUnknownCount++;
122
+ log.warn(`[daemon] Command result unknown after extension disconnect (id=${id}, action=${p.action}, context=${contextId})`);
123
+ }
124
+ p.reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
114
125
  pending.delete(id);
115
126
  }
116
127
  }
@@ -142,16 +153,6 @@ function jsonResponse(res, status, data, extraHeaders) {
142
153
  res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
143
154
  res.end(JSON.stringify(data));
144
155
  }
145
- export function getResponseCorsHeaders(pathname, origin) {
146
- if (pathname !== '/ping')
147
- return undefined;
148
- if (!origin || !origin.startsWith('chrome-extension://'))
149
- return undefined;
150
- return {
151
- 'Access-Control-Allow-Origin': origin,
152
- Vary: 'Origin',
153
- };
154
- }
155
156
  async function handleRequest(req, res) {
156
157
  // ─── Security: Origin & custom-header check ──────────────────────
157
158
  // Block browser-based CSRF: browsers always send an Origin header on
@@ -219,6 +220,7 @@ async function handleRequest(req, res) {
219
220
  profileDisconnected: route.errorCode === 'profile_disconnected',
220
221
  profiles,
221
222
  pending: pending.size,
223
+ commandResultUnknown: commandResultUnknownCount,
222
224
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
223
225
  port: PORT,
224
226
  });
@@ -277,8 +279,37 @@ async function handleRequest(req, res) {
277
279
  pending.delete(body.id);
278
280
  reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
279
281
  }, timeoutMs);
280
- pending.set(body.id, { contextId: route.connection.contextId, resolve, reject, timer });
281
- route.connection.ws.send(JSON.stringify(body));
282
+ const entry = {
283
+ contextId: route.connection.contextId,
284
+ action: typeof body.action === 'string' ? body.action : 'unknown',
285
+ dispatched: false,
286
+ resolve,
287
+ reject,
288
+ timer,
289
+ };
290
+ pending.set(body.id, entry);
291
+ const failBeforeDispatch = (err) => {
292
+ if (pending.get(body.id) !== entry)
293
+ return;
294
+ const failure = buildCommandDispatchFailure(entry.contextId);
295
+ clearTimeout(timer);
296
+ pending.delete(body.id);
297
+ reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
298
+ log.warn(`[daemon] Failed to dispatch command ${body.id}: ${err instanceof Error ? err.message : String(err)}`);
299
+ };
300
+ try {
301
+ route.connection.ws.send(JSON.stringify(body), (err) => {
302
+ if (err && !entry.dispatched)
303
+ failBeforeDispatch(err);
304
+ });
305
+ // Once ws accepts the frame, the command may execute even if the
306
+ // result is later lost; do not downgrade later disconnects to a
307
+ // pre-dispatch failure just because no result/ack has arrived yet.
308
+ entry.dispatched = true;
309
+ }
310
+ catch (err) {
311
+ failBeforeDispatch(err);
312
+ }
282
313
  });
283
314
  jsonResponse(res, 200, result);
284
315
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { getResponseCorsHeaders } from './daemon.js';
2
+ import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT, buildCommandDispatchFailure, buildExtensionDisconnectFailure, commandResultUnknownMessage, getResponseCorsHeaders, } from './daemon-utils.js';
3
3
  describe('getResponseCorsHeaders', () => {
4
4
  it('allows the Browser Bridge extension origin to read /ping', () => {
5
5
  expect(getResponseCorsHeaders('/ping', 'chrome-extension://abc123')).toEqual({
@@ -17,3 +17,44 @@ describe('getResponseCorsHeaders', () => {
17
17
  expect(getResponseCorsHeaders('/command', 'chrome-extension://abc123')).toBeUndefined();
18
18
  });
19
19
  });
20
+ describe('daemon command dispatch', () => {
21
+ it('uses a distinct command_result_unknown contract for ambiguous dispatched commands', () => {
22
+ expect(COMMAND_RESULT_UNKNOWN_CODE).toBe('command_result_unknown');
23
+ expect(commandResultUnknownMessage('navigate')).toContain('navigate command was dispatched');
24
+ expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Inspect the browser/session state');
25
+ expect(COMMAND_RESULT_UNKNOWN_HINT).toContain('Do not blindly retry write commands');
26
+ });
27
+ it('classifies dispatched extension disconnects as command_result_unknown', () => {
28
+ expect(buildExtensionDisconnectFailure({
29
+ contextId: 'work',
30
+ action: 'navigate',
31
+ dispatched: true,
32
+ })).toEqual({
33
+ message: 'Browser connection dropped after the navigate command was dispatched; it may have completed.',
34
+ errorCode: 'command_result_unknown',
35
+ errorHint: COMMAND_RESULT_UNKNOWN_HINT,
36
+ status: 503,
37
+ countAsCommandResultUnknown: true,
38
+ });
39
+ });
40
+ it('classifies pre-dispatch extension disconnects as profile_disconnected', () => {
41
+ expect(buildExtensionDisconnectFailure({
42
+ contextId: 'work',
43
+ action: 'navigate',
44
+ dispatched: false,
45
+ })).toMatchObject({
46
+ message: 'Browser profile "work" disconnected before command dispatch',
47
+ errorCode: 'profile_disconnected',
48
+ status: 503,
49
+ countAsCommandResultUnknown: false,
50
+ });
51
+ });
52
+ it('classifies ws.send dispatch failures as profile_disconnected', () => {
53
+ expect(buildCommandDispatchFailure('work')).toMatchObject({
54
+ message: 'Browser profile "work" disconnected before command dispatch',
55
+ errorCode: 'profile_disconnected',
56
+ status: 503,
57
+ countAsCommandResultUnknown: false,
58
+ });
59
+ });
60
+ });
@@ -12,7 +12,6 @@ export const builtinApps = {
12
12
  cursor: { port: 9226, processName: 'Cursor', bundleId: 'com.todesktop.runtime.Cursor', displayName: 'Cursor' },
13
13
  codex: { port: 9222, processName: 'Codex', bundleId: 'com.openai.codex', displayName: 'Codex' },
14
14
  chatwise: { port: 9228, processName: 'ChatWise', bundleId: 'com.chatwise.app', displayName: 'ChatWise' },
15
- notion: { port: 9230, processName: 'Notion', bundleId: 'notion.id', displayName: 'Notion' },
16
15
  'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
17
16
  'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
18
17
  antigravity: {
@@ -23,6 +23,7 @@ describe('electron-apps registry', () => {
23
23
  });
24
24
  it('isElectronApp returns false for non-Electron sites', () => {
25
25
  expect(isElectronApp('bilibili')).toBe(false);
26
+ expect(isElectronApp('notion')).toBe(false);
26
27
  expect(isElectronApp('unknown-app')).toBe(false);
27
28
  });
28
29
  it('loadApps merges user config additively', () => {
@@ -14,6 +14,12 @@
14
14
  install:
15
15
  mac: "brew install --cask obsidian"
16
16
 
17
+ - name: ntn
18
+ binary: ntn
19
+ description: "Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments"
20
+ homepage: "https://ntn.dev"
21
+ tags: [notion, notes, knowledge, productivity]
22
+
17
23
  - name: docker
18
24
  binary: docker
19
25
  description: "Docker command-line interface"
@@ -55,24 +61,27 @@
55
61
  install:
56
62
  default: "npm install -g vercel"
57
63
 
58
- - name: tg-cli
64
+ - name: tg
59
65
  binary: tg
66
+ package: tg-cli
60
67
  description: "Telegram CLI — local-first sync, search, export via MTProto for AI agents"
61
68
  homepage: "https://github.com/jackwener/tg-cli"
62
69
  tags: [telegram, messaging, search, export, ai-agent]
63
70
  install:
64
71
  default: "uv tool install kabi-tg-cli"
65
72
 
66
- - name: discord-cli
73
+ - name: discord
67
74
  binary: discord
75
+ package: discord-cli
68
76
  description: "Discord CLI — local-first sync, search, export via SQLite for AI agents"
69
77
  homepage: "https://github.com/jackwener/discord-cli"
70
78
  tags: [discord, messaging, search, export, ai-agent]
71
79
  install:
72
80
  default: "uv tool install kabi-discord-cli"
73
81
 
74
- - name: wx-cli
82
+ - name: wx
75
83
  binary: wx
84
+ package: wx-cli
76
85
  description: "WeChat local data CLI — sessions, messages, search, contacts, export for AI agents"
77
86
  homepage: "https://github.com/jackwener/wx-cli"
78
87
  tags: [wechat, messaging, search, export, ai-agent]
@@ -5,8 +5,11 @@ export interface ExternalCliInstall {
5
5
  default?: string;
6
6
  }
7
7
  export interface ExternalCliConfig {
8
+ /** User-facing OpenCLI subcommand and, by default, the executable name. */
8
9
  name: string;
9
10
  binary: string;
11
+ /** Distribution/project name when it differs from the executable name. */
12
+ package?: string;
10
13
  description?: string;
11
14
  homepage?: string;
12
15
  tags?: string[];
@@ -15,6 +18,7 @@ export interface ExternalCliConfig {
15
18
  export declare function loadExternalClis(): ExternalCliConfig[];
16
19
  export declare function isBinaryInstalled(binary: string): boolean;
17
20
  export declare function getInstallCmd(installConfig?: ExternalCliInstall): string | null;
21
+ export declare function formatExternalCliLabel(cli: ExternalCliConfig): string;
18
22
  /**
19
23
  * Safely parses a command string into a binary and argument list.
20
24
  * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
@@ -70,6 +70,9 @@ export function getInstallCmd(installConfig) {
70
70
  return installConfig.default;
71
71
  return null;
72
72
  }
73
+ export function formatExternalCliLabel(cli) {
74
+ return cli.package && cli.package !== cli.name ? `${cli.name}(${cli.package})` : cli.name;
75
+ }
73
76
  /**
74
77
  * Safely parses a command string into a binary and argument list.
75
78
  * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
@@ -1,4 +1,8 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import yaml from 'js-yaml';
2
6
  const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
3
7
  mockExecFileSync: vi.fn(),
4
8
  mockPlatform: vi.fn(() => 'darwin'),
@@ -14,7 +18,8 @@ vi.mock('node:os', async () => {
14
18
  platform: mockPlatform,
15
19
  };
16
20
  });
17
- import { installExternalCli, parseCommand } from './external.js';
21
+ import { formatExternalCliLabel, installExternalCli, parseCommand } from './external.js';
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
23
  describe('parseCommand', () => {
19
24
  it('splits binaries and quoted arguments without invoking a shell', () => {
20
25
  expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
@@ -29,6 +34,24 @@ describe('parseCommand', () => {
29
34
  expect(() => parseCommand('brew install $(whoami)')).toThrow('Install command contains unsafe shell operators');
30
35
  expect(() => parseCommand('brew install gh\nrm -rf /')).toThrow('Install command contains unsafe shell operators');
31
36
  });
37
+ it('keeps built-in install commands compatible with the shell-free parser', () => {
38
+ const raw = fs.readFileSync(path.join(__dirname, 'external-clis.yaml'), 'utf8');
39
+ const entries = (yaml.load(raw) || []);
40
+ for (const entry of entries) {
41
+ for (const command of Object.values(entry.install ?? {})) {
42
+ if (command)
43
+ expect(() => parseCommand(command)).not.toThrow();
44
+ }
45
+ }
46
+ });
47
+ });
48
+ describe('formatExternalCliLabel', () => {
49
+ it('shows the package name when the executable name differs', () => {
50
+ expect(formatExternalCliLabel({ name: 'wx', binary: 'wx', package: 'wx-cli' })).toBe('wx(wx-cli)');
51
+ });
52
+ it('keeps the label compact when package and name match', () => {
53
+ expect(formatExternalCliLabel({ name: 'docker', binary: 'docker', package: 'docker' })).toBe('docker');
54
+ });
32
55
  });
33
56
  describe('installExternalCli', () => {
34
57
  const cli = {