@jackwener/opencli 1.5.3 → 1.5.5

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.
Files changed (46) hide show
  1. package/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
  2. package/README.md +213 -18
  3. package/dist/build-manifest.d.ts +2 -3
  4. package/dist/build-manifest.js +75 -170
  5. package/dist/build-manifest.test.js +113 -88
  6. package/dist/cli-manifest.json +1253 -1105
  7. package/dist/cli.js +14 -14
  8. package/dist/clis/antigravity/serve.js +2 -2
  9. package/dist/clis/sinafinance/rolling-news.d.ts +4 -0
  10. package/dist/clis/sinafinance/rolling-news.js +40 -0
  11. package/dist/clis/sinafinance/stock.d.ts +8 -0
  12. package/dist/clis/sinafinance/stock.js +117 -0
  13. package/dist/commanderAdapter.js +26 -3
  14. package/dist/daemon.js +19 -7
  15. package/dist/errors.d.ts +29 -1
  16. package/dist/errors.js +49 -11
  17. package/dist/external-clis.yaml +16 -0
  18. package/dist/external.js +3 -3
  19. package/dist/main.js +2 -1
  20. package/dist/serialization.js +6 -1
  21. package/dist/serialization.test.d.ts +1 -0
  22. package/dist/serialization.test.js +23 -0
  23. package/dist/tui.js +2 -1
  24. package/docs/adapters/browser/sinafinance.md +56 -6
  25. package/extension/dist/background.js +12 -6
  26. package/extension/manifest.json +2 -2
  27. package/extension/package-lock.json +2 -2
  28. package/extension/package.json +1 -1
  29. package/extension/src/background.ts +21 -6
  30. package/extension/src/protocol.ts +2 -1
  31. package/package.json +1 -1
  32. package/src/build-manifest.test.ts +117 -88
  33. package/src/build-manifest.ts +81 -180
  34. package/src/cli.ts +14 -14
  35. package/src/clis/antigravity/serve.ts +2 -2
  36. package/src/clis/sinafinance/rolling-news.ts +42 -0
  37. package/src/clis/sinafinance/stock.ts +127 -0
  38. package/src/commanderAdapter.ts +25 -2
  39. package/src/daemon.ts +21 -8
  40. package/src/errors.ts +71 -10
  41. package/src/external-clis.yaml +16 -0
  42. package/src/external.ts +3 -3
  43. package/src/main.ts +2 -1
  44. package/src/serialization.test.ts +26 -0
  45. package/src/serialization.ts +6 -1
  46. package/src/tui.ts +2 -1
@@ -44,6 +44,11 @@ export function formatArgSummary(args) {
44
44
  })
45
45
  .join(' ');
46
46
  }
47
+ function summarizeChoices(choices) {
48
+ if (choices.length <= 4)
49
+ return choices.join(', ');
50
+ return `${choices.slice(0, 4).join(', ')}, ... (+${choices.length - 4} more)`;
51
+ }
47
52
  /** Generate the --help appendix showing registry metadata not exposed by Commander. */
48
53
  export function formatRegistryHelpText(cmd) {
49
54
  const lines = [];
@@ -51,7 +56,7 @@ export function formatRegistryHelpText(cmd) {
51
56
  for (const a of choicesArgs) {
52
57
  const prefix = a.positional ? `<${a.name}>` : `--${a.name}`;
53
58
  const def = a.default != null ? ` (default: ${a.default})` : '';
54
- lines.push(` ${prefix}: ${a.choices.join(', ')}${def}`);
59
+ lines.push(` ${prefix}: ${summarizeChoices(a.choices)}${def}`);
55
60
  }
56
61
  const meta = [];
57
62
  meta.push(`Strategy: ${strategyLabel(cmd)}`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Strategy } from './registry.js';
3
+ import { formatRegistryHelpText } from './serialization.js';
4
+ describe('formatRegistryHelpText', () => {
5
+ it('summarizes long choices lists so help text stays readable', () => {
6
+ const cmd = {
7
+ site: 'demo',
8
+ name: 'dynamic',
9
+ description: 'Demo command',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [
13
+ {
14
+ name: 'field',
15
+ help: 'Field to use',
16
+ choices: ['all-fields', 'topic', 'title', 'author', 'publication-titles', 'year-published', 'doi'],
17
+ },
18
+ ],
19
+ columns: ['field'],
20
+ };
21
+ expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
22
+ });
23
+ });
package/dist/tui.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Uses raw stdin mode + ANSI escape codes for interactive prompts.
5
5
  */
6
6
  import chalk from 'chalk';
7
+ import { EXIT_CODES } from './errors.js';
7
8
  /**
8
9
  * Interactive multi-select checkbox prompt.
9
10
  *
@@ -130,7 +131,7 @@ export async function checkboxPrompt(items, opts = {}) {
130
131
  // Ctrl+C — exit process
131
132
  if (key === '\x03') {
132
133
  cleanup();
133
- process.exit(130);
134
+ process.exit(EXIT_CODES.INTERRUPTED);
134
135
  }
135
136
  }
136
137
  stdin.on('data', onData);
@@ -1,15 +1,19 @@
1
1
  # 新浪财经 (Sina Finance)
2
2
 
3
- **Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn`
3
+ **Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn`
4
4
 
5
5
  ## Commands
6
6
 
7
- | Command | Description |
8
- |---------|-------------|
9
- | `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 |
7
+ | Command | Description | Mode |
8
+ |---------|-------------|------|
9
+ | `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public |
10
+ | `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser |
11
+ | `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🌐 Public |
10
12
 
11
13
  ## Usage Examples
12
14
 
15
+ ### news - 7×24 实时快讯
16
+
13
17
  ```bash
14
18
  # Latest financial news
15
19
  opencli sinafinance news --limit 20
@@ -23,13 +27,59 @@ opencli sinafinance news --type 6 # 国际
23
27
  opencli sinafinance news -f json
24
28
  ```
25
29
 
26
- ### Options
30
+ ### rolling-news - 滚动新闻
31
+
32
+ ```bash
33
+ # Rolling news feed
34
+ opencli sinafinance rolling-news
35
+
36
+ # JSON output
37
+ opencli sinafinance rolling-news -f json
38
+ ```
39
+
40
+ ### stock - 股票行情
41
+
42
+ ```bash
43
+ # Search and view A-share stock
44
+ opencli sinafinance stock 贵州茅台 --market cn
45
+
46
+ # Search and view HK stock
47
+ opencli sinafinance stock 腾讯控股 --market hk
48
+
49
+ # Search and view US stock
50
+ opencli sinafinance stock aapl --market us
51
+
52
+ # Auto-detect market (searches cn, hk, us in order)
53
+ opencli sinafinance stock 招商证券
54
+
55
+ # JSON output
56
+ opencli sinafinance stock 贵州茅台 -f json
57
+ ```
58
+
59
+ ## Options
60
+
61
+ ### news
27
62
 
28
63
  | Option | Description |
29
64
  |--------|-------------|
30
65
  | `--limit` | Max results, up to 50 (default: 20) |
31
66
  | `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 |
32
67
 
68
+ ### stock
69
+
70
+ | Option | Description |
71
+ |--------|-------------|
72
+ | `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order |
73
+
33
74
  ## Prerequisites
34
75
 
35
- - No browser required — uses public API
76
+ - `news` & `stock`: No browser required — uses public API
77
+ - `rolling-news`: Chrome running and **logged into** `finance.sina.com.cn`
78
+ - For `rolling-news`: [Browser Bridge extension](/guide/browser-bridge) installed
79
+
80
+ ## Notes
81
+
82
+ - `news` and `stock` use public APIs — no browser or login needed
83
+ - `stock` supports Chinese names, Chinese codes, and ticker symbols; auto-detects market
84
+ - Market priority for auto-detection: cn (A股) → hk (港股) → us (美股)
85
+ - US stock `High`/`Low` columns show 52-week range; A股/港股 show today's range
@@ -1,6 +1,7 @@
1
1
  const DAEMON_PORT = 19825;
2
2
  const DAEMON_HOST = "localhost";
3
3
  const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
4
+ const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
4
5
  const WS_RECONNECT_BASE_DELAY = 2e3;
5
6
  const WS_RECONNECT_MAX_DELAY = 6e4;
6
7
 
@@ -149,8 +150,14 @@ console.error = (...args) => {
149
150
  _origError(...args);
150
151
  forwardLog("error", args);
151
152
  };
152
- function connect() {
153
+ async function connect() {
153
154
  if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
155
+ try {
156
+ const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
157
+ if (!res.ok) return;
158
+ } catch {
159
+ return;
160
+ }
154
161
  try {
155
162
  ws = new WebSocket(DAEMON_WS_URL);
156
163
  } catch {
@@ -192,7 +199,7 @@ function scheduleReconnect() {
192
199
  const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
193
200
  reconnectTimer = setTimeout(() => {
194
201
  reconnectTimer = null;
195
- connect();
202
+ void connect();
196
203
  }, delay);
197
204
  }
198
205
  const automationSessions = /* @__PURE__ */ new Map();
@@ -231,8 +238,7 @@ async function getAutomationWindow(workspace) {
231
238
  focused: false,
232
239
  width: 1280,
233
240
  height: 900,
234
- type: "normal",
235
- state: "normal"
241
+ type: "normal"
236
242
  });
237
243
  const session = {
238
244
  windowId: win.id,
@@ -260,7 +266,7 @@ function initialize() {
260
266
  initialized = true;
261
267
  chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
262
268
  registerListeners();
263
- connect();
269
+ void connect();
264
270
  console.log("[opencli] OpenCLI extension initialized");
265
271
  }
266
272
  chrome.runtime.onInstalled.addListener(() => {
@@ -270,7 +276,7 @@ chrome.runtime.onStartup.addListener(() => {
270
276
  initialize();
271
277
  });
272
278
  chrome.alarms.onAlarm.addListener((alarm) => {
273
- if (alarm.name === "keepalive") connect();
279
+ if (alarm.name === "keepalive") void connect();
274
280
  });
275
281
  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
276
282
  if (msg?.type === "getStatus") {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCLI",
4
- "version": "1.5.3",
4
+ "version": "1.5.5",
5
5
  "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -35,4 +35,4 @@
35
35
  "extension_pages": "script-src 'self'; object-src 'self'"
36
36
  },
37
37
  "homepage_url": "https://github.com/jackwener/opencli"
38
- }
38
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opencli-extension",
9
- "version": "1.5.3",
9
+ "version": "1.5.4",
10
10
  "devDependencies": {
11
11
  "@types/chrome": "^0.0.287",
12
12
  "typescript": "^5.7.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Command, Result } from './protocol';
9
- import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
9
+ import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
10
10
  import * as executor from './cdp';
11
11
 
12
12
  let ws: WebSocket | null = null;
@@ -34,9 +34,23 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
34
34
 
35
35
  // ─── WebSocket connection ────────────────────────────────────────────
36
36
 
37
- function connect(): void {
37
+ /**
38
+ * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
39
+ * connection. fetch() failures are silently catchable; new WebSocket() is not
40
+ * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
41
+ * JS handler can intercept it. By keeping the probe inside connect() every
42
+ * call site remains unchanged and the guard can never be accidentally skipped.
43
+ */
44
+ async function connect(): Promise<void> {
38
45
  if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
39
46
 
47
+ try {
48
+ const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
49
+ if (!res.ok) return; // unexpected response — not our daemon
50
+ } catch {
51
+ return; // daemon not running — skip WebSocket to avoid console noise
52
+ }
53
+
40
54
  try {
41
55
  ws = new WebSocket(DAEMON_WS_URL);
42
56
  } catch {
@@ -90,7 +104,7 @@ function scheduleReconnect(): void {
90
104
  const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
91
105
  reconnectTimer = setTimeout(() => {
92
106
  reconnectTimer = null;
93
- connect();
107
+ void connect();
94
108
  }, delay);
95
109
  }
96
110
 
@@ -146,13 +160,14 @@ async function getAutomationWindow(workspace: string): Promise<number> {
146
160
 
147
161
  // Create a new window with a data: URI that New Tab Override extensions cannot intercept.
148
162
  // Using about:blank would be hijacked by extensions like "New Tab Override".
163
+ // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
164
+ // state value for windows.create(). The window defaults to 'normal' state anyway.
149
165
  const win = await chrome.windows.create({
150
166
  url: BLANK_PAGE,
151
167
  focused: false,
152
168
  width: 1280,
153
169
  height: 900,
154
170
  type: 'normal',
155
- state: 'normal',
156
171
  });
157
172
  const session: AutomationSession = {
158
173
  windowId: win.id!,
@@ -187,7 +202,7 @@ function initialize(): void {
187
202
  initialized = true;
188
203
  chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
189
204
  executor.registerListeners();
190
- connect();
205
+ void connect();
191
206
  console.log('[opencli] OpenCLI extension initialized');
192
207
  }
193
208
 
@@ -200,7 +215,7 @@ chrome.runtime.onStartup.addListener(() => {
200
215
  });
201
216
 
202
217
  chrome.alarms.onAlarm.addListener((alarm) => {
203
- if (alarm.name === 'keepalive') connect();
218
+ if (alarm.name === 'keepalive') void connect();
204
219
  });
205
220
 
206
221
  // ─── Popup status API ───────────────────────────────────────────────
@@ -49,7 +49,8 @@ export interface Result {
49
49
  export const DAEMON_PORT = 19825;
50
50
  export const DAEMON_HOST = 'localhost';
51
51
  export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
52
- export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
52
+ /** Lightweight health-check endpoint — probed before each WebSocket attempt. */
53
+ export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
53
54
 
54
55
  /** Base reconnect delay for extension WebSocket (ms) */
55
56
  export const WS_RECONNECT_BASE_DELAY = 2000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -2,69 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';
6
-
7
- describe('parseTsArgsBlock', () => {
8
- it('keeps args with nested choices arrays', () => {
9
- const args = parseTsArgsBlock(`
10
- {
11
- name: 'period',
12
- type: 'string',
13
- default: 'seven',
14
- help: 'Stats period: seven or thirty',
15
- choices: ['seven', 'thirty'],
16
- },
17
- `);
18
-
19
- expect(args).toEqual([
20
- {
21
- name: 'period',
22
- type: 'string',
23
- default: 'seven',
24
- required: false,
25
- positional: undefined,
26
- help: 'Stats period: seven or thirty',
27
- choices: ['seven', 'thirty'],
28
- },
29
- ]);
30
- });
31
-
32
- it('keeps hyphenated arg names from TS adapters', () => {
33
- const args = parseTsArgsBlock(`
34
- {
35
- name: 'tweet-url',
36
- help: 'Single tweet URL to download',
37
- },
38
- {
39
- name: 'download-images',
40
- type: 'boolean',
41
- default: false,
42
- help: 'Download images locally',
43
- },
44
- `);
45
-
46
- expect(args).toEqual([
47
- {
48
- name: 'tweet-url',
49
- type: 'str',
50
- default: undefined,
51
- required: false,
52
- positional: undefined,
53
- help: 'Single tweet URL to download',
54
- choices: undefined,
55
- },
56
- {
57
- name: 'download-images',
58
- type: 'boolean',
59
- default: false,
60
- required: false,
61
- positional: undefined,
62
- help: 'Download images locally',
63
- choices: undefined,
64
- },
65
- ]);
66
- });
67
- });
5
+ import { cli, getRegistry, Strategy } from './registry.js';
6
+ import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js';
68
7
 
69
8
  describe('manifest helper rules', () => {
70
9
  const tempDirs: string[] = [];
@@ -127,43 +66,133 @@ describe('manifest helper rules', () => {
127
66
  const file = path.join(dir, 'utils.ts');
128
67
  fs.writeFileSync(file, `export function helper() { return 'noop'; }`);
129
68
 
130
- expect(scanTs(file, 'demo')).toBeNull();
69
+ return expect(loadTsManifestEntries(file, 'demo', async () => ({}))).resolves.toEqual([]);
131
70
  });
132
71
 
133
- it('keeps literal domain and navigateBefore for TS adapters', () => {
134
- const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
135
- const entry = scanTs(file, 'xueqiu');
136
-
137
- expect(entry).toMatchObject({
138
- site: 'xueqiu',
139
- name: 'fund-holdings',
140
- domain: 'danjuanfunds.com',
141
- navigateBefore: 'https://danjuanfunds.com/my-money',
142
- type: 'ts',
143
- modulePath: 'xueqiu/fund-holdings.js',
144
- });
72
+ it('builds TS manifest entries from exported runtime commands', async () => {
73
+ const site = `manifest-hydrate-${Date.now()}`;
74
+ const key = `${site}/dynamic`;
75
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
76
+ tempDirs.push(dir);
77
+ const file = path.join(dir, `${site}.ts`);
78
+ fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'dynamic' });`);
79
+
80
+ const entries = await loadTsManifestEntries(file, site, async () => ({
81
+ command: cli({
82
+ site,
83
+ name: 'dynamic',
84
+ description: 'dynamic command',
85
+ strategy: Strategy.PUBLIC,
86
+ browser: false,
87
+ args: [
88
+ {
89
+ name: 'model',
90
+ required: true,
91
+ positional: true,
92
+ help: 'Choose a model',
93
+ choices: ['auto', 'thinking'],
94
+ default: '30',
95
+ },
96
+ ],
97
+ domain: 'localhost',
98
+ navigateBefore: 'https://example.com/session',
99
+ deprecated: 'legacy command',
100
+ replacedBy: 'opencli demo new',
101
+ }),
102
+ }));
103
+
104
+ expect(entries).toEqual([
105
+ {
106
+ site,
107
+ name: 'dynamic',
108
+ description: 'dynamic command',
109
+ domain: 'localhost',
110
+ strategy: 'public',
111
+ browser: false,
112
+ args: [
113
+ {
114
+ name: 'model',
115
+ type: 'str',
116
+ required: true,
117
+ positional: true,
118
+ help: 'Choose a model',
119
+ choices: ['auto', 'thinking'],
120
+ default: '30',
121
+ },
122
+ ],
123
+ type: 'ts',
124
+ modulePath: `${site}/${site}.js`,
125
+ navigateBefore: 'https://example.com/session',
126
+ deprecated: 'legacy command',
127
+ replacedBy: 'opencli demo new',
128
+ },
129
+ ]);
130
+
131
+ getRegistry().delete(key);
145
132
  });
146
133
 
147
- it('captures deprecated metadata for TS adapters', () => {
134
+ it('falls back to registry delta for side-effect-only cli modules', async () => {
135
+ const site = `manifest-side-effect-${Date.now()}`;
136
+ const key = `${site}/legacy`;
148
137
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
149
138
  tempDirs.push(dir);
150
- const file = path.join(dir, 'legacy.ts');
151
- fs.writeFileSync(file, `
152
- import { cli } from '../../registry.js';
139
+ const file = path.join(dir, `${site}.ts`);
140
+ fs.writeFileSync(file, `cli({ site: '${site}', name: 'legacy' });`);
141
+
142
+ const entries = await loadTsManifestEntries(file, site, async () => {
153
143
  cli({
154
- site: 'demo',
144
+ site,
155
145
  name: 'legacy',
156
146
  description: 'legacy command',
157
147
  deprecated: 'legacy is deprecated',
158
148
  replacedBy: 'opencli demo new',
159
149
  });
160
- `);
161
-
162
- expect(scanTs(file, 'demo')).toMatchObject({
163
- site: 'demo',
164
- name: 'legacy',
165
- deprecated: 'legacy is deprecated',
166
- replacedBy: 'opencli demo new',
150
+ return {};
167
151
  });
152
+
153
+ expect(entries).toEqual([
154
+ {
155
+ site,
156
+ name: 'legacy',
157
+ description: 'legacy command',
158
+ strategy: 'cookie',
159
+ browser: true,
160
+ args: [],
161
+ type: 'ts',
162
+ modulePath: `${site}/${site}.js`,
163
+ deprecated: 'legacy is deprecated',
164
+ replacedBy: 'opencli demo new',
165
+ },
166
+ ]);
167
+
168
+ getRegistry().delete(key);
169
+ });
170
+
171
+ it('keeps every command a module exports instead of guessing by site', async () => {
172
+ const site = `manifest-multi-${Date.now()}`;
173
+ const screenKey = `${site}/screen`;
174
+ const statusKey = `${site}/status`;
175
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
176
+ tempDirs.push(dir);
177
+ const file = path.join(dir, `${site}.ts`);
178
+ fs.writeFileSync(file, `export const screen = cli({ site: '${site}', name: 'screen' });`);
179
+
180
+ const entries = await loadTsManifestEntries(file, site, async () => ({
181
+ screen: cli({
182
+ site,
183
+ name: 'screen',
184
+ description: 'capture screen',
185
+ }),
186
+ status: cli({
187
+ site,
188
+ name: 'status',
189
+ description: 'show status',
190
+ }),
191
+ }));
192
+
193
+ expect(entries.map(entry => entry.name)).toEqual(['screen', 'status']);
194
+
195
+ getRegistry().delete(screenKey);
196
+ getRegistry().delete(statusKey);
168
197
  });
169
198
  });