@jackwener/opencli 1.7.5 → 1.7.7

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 (121) hide show
  1. package/README.md +22 -10
  2. package/README.zh-CN.md +18 -9
  3. package/cli-manifest.json +401 -11
  4. package/clis/51job/company.js +125 -0
  5. package/clis/51job/detail.js +108 -0
  6. package/clis/51job/hot.js +55 -0
  7. package/clis/51job/search.js +79 -0
  8. package/clis/51job/utils.js +302 -0
  9. package/clis/51job/utils.test.js +69 -0
  10. package/clis/bilibili/video.js +68 -0
  11. package/clis/bilibili/video.test.js +132 -0
  12. package/clis/chatgpt/image.js +1 -1
  13. package/clis/deepseek/ask.js +37 -11
  14. package/clis/deepseek/ask.test.js +165 -0
  15. package/clis/deepseek/utils.js +192 -24
  16. package/clis/deepseek/utils.test.js +145 -0
  17. package/clis/gemini/image.js +1 -1
  18. package/clis/instagram/download.js +1 -1
  19. package/clis/jianyu/search.js +139 -3
  20. package/clis/jianyu/search.test.js +25 -0
  21. package/clis/jianyu/shared/procurement-detail.js +15 -0
  22. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  23. package/clis/twitter/likes.js +3 -2
  24. package/clis/twitter/search.js +4 -2
  25. package/clis/twitter/search.test.js +4 -0
  26. package/clis/twitter/shared.js +35 -2
  27. package/clis/twitter/shared.test.js +96 -0
  28. package/clis/twitter/thread.js +3 -1
  29. package/clis/twitter/timeline.js +3 -2
  30. package/clis/twitter/tweets.js +219 -0
  31. package/clis/twitter/tweets.test.js +125 -0
  32. package/clis/web/read.js +25 -5
  33. package/clis/web/read.test.js +76 -0
  34. package/clis/weread/ai-outline.js +170 -0
  35. package/clis/weread/ai-outline.test.js +83 -0
  36. package/clis/weread/book.js +57 -44
  37. package/clis/weread/commands.test.js +24 -0
  38. package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
  39. package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
  40. package/clis/youtube/channel.js +35 -0
  41. package/dist/src/browser/analyze.d.ts +103 -0
  42. package/dist/src/browser/analyze.js +230 -0
  43. package/dist/src/browser/analyze.test.d.ts +1 -0
  44. package/dist/src/browser/analyze.test.js +164 -0
  45. package/dist/src/browser/article-extract.d.ts +57 -0
  46. package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
  47. package/dist/src/browser/article-extract.e2e.test.js +105 -0
  48. package/dist/src/browser/article-extract.js +169 -0
  49. package/dist/src/browser/article-extract.test.d.ts +1 -0
  50. package/dist/src/browser/article-extract.test.js +94 -0
  51. package/dist/src/browser/base-page.d.ts +13 -3
  52. package/dist/src/browser/base-page.js +35 -25
  53. package/dist/src/browser/cdp.d.ts +1 -0
  54. package/dist/src/browser/cdp.js +23 -5
  55. package/dist/src/browser/compound.d.ts +59 -0
  56. package/dist/src/browser/compound.js +112 -0
  57. package/dist/src/browser/compound.test.d.ts +1 -0
  58. package/dist/src/browser/compound.test.js +175 -0
  59. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  60. package/dist/src/browser/dom-snapshot.js +76 -3
  61. package/dist/src/browser/dom-snapshot.test.js +65 -0
  62. package/dist/src/browser/extract.d.ts +69 -0
  63. package/dist/src/browser/extract.js +132 -0
  64. package/dist/src/browser/extract.test.d.ts +1 -0
  65. package/dist/src/browser/extract.test.js +129 -0
  66. package/dist/src/browser/find.d.ts +76 -0
  67. package/dist/src/browser/find.js +179 -0
  68. package/dist/src/browser/find.test.d.ts +1 -0
  69. package/dist/src/browser/find.test.js +120 -0
  70. package/dist/src/browser/html-tree.d.ts +75 -0
  71. package/dist/src/browser/html-tree.js +112 -0
  72. package/dist/src/browser/html-tree.test.d.ts +1 -0
  73. package/dist/src/browser/html-tree.test.js +181 -0
  74. package/dist/src/browser/network-cache.d.ts +48 -0
  75. package/dist/src/browser/network-cache.js +66 -0
  76. package/dist/src/browser/network-cache.test.d.ts +1 -0
  77. package/dist/src/browser/network-cache.test.js +58 -0
  78. package/dist/src/browser/network-key.d.ts +22 -0
  79. package/dist/src/browser/network-key.js +66 -0
  80. package/dist/src/browser/network-key.test.d.ts +1 -0
  81. package/dist/src/browser/network-key.test.js +49 -0
  82. package/dist/src/browser/shape-filter.d.ts +52 -0
  83. package/dist/src/browser/shape-filter.js +101 -0
  84. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  85. package/dist/src/browser/shape-filter.test.js +101 -0
  86. package/dist/src/browser/shape.d.ts +23 -0
  87. package/dist/src/browser/shape.js +95 -0
  88. package/dist/src/browser/shape.test.d.ts +1 -0
  89. package/dist/src/browser/shape.test.js +82 -0
  90. package/dist/src/browser/target-errors.d.ts +14 -1
  91. package/dist/src/browser/target-errors.js +13 -0
  92. package/dist/src/browser/target-errors.test.js +39 -6
  93. package/dist/src/browser/target-resolver.d.ts +57 -10
  94. package/dist/src/browser/target-resolver.js +195 -75
  95. package/dist/src/browser/target-resolver.test.js +80 -5
  96. package/dist/src/browser/verify-fixture.d.ts +59 -0
  97. package/dist/src/browser/verify-fixture.js +213 -0
  98. package/dist/src/browser/verify-fixture.test.d.ts +1 -0
  99. package/dist/src/browser/verify-fixture.test.js +161 -0
  100. package/dist/src/cli.d.ts +32 -0
  101. package/dist/src/cli.js +936 -141
  102. package/dist/src/cli.test.js +1051 -1
  103. package/dist/src/daemon.d.ts +3 -2
  104. package/dist/src/daemon.js +16 -4
  105. package/dist/src/daemon.test.d.ts +1 -0
  106. package/dist/src/daemon.test.js +19 -0
  107. package/dist/src/download/article-download.d.ts +12 -0
  108. package/dist/src/download/article-download.js +141 -17
  109. package/dist/src/download/article-download.test.js +196 -0
  110. package/dist/src/download/index.js +73 -86
  111. package/dist/src/errors.js +4 -2
  112. package/dist/src/errors.test.js +13 -0
  113. package/dist/src/execution.js +7 -2
  114. package/dist/src/execution.test.js +54 -0
  115. package/dist/src/launcher.d.ts +1 -1
  116. package/dist/src/launcher.js +3 -3
  117. package/dist/src/main.js +16 -0
  118. package/dist/src/output.js +1 -1
  119. package/dist/src/output.test.js +6 -0
  120. package/dist/src/types.d.ts +18 -3
  121. package/package.json +5 -1
@@ -60,94 +60,79 @@ export function requiresYtdlp(url) {
60
60
  */
61
61
  export async function httpDownload(url, destPath, options = {}, redirectCount = 0) {
62
62
  const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
63
- return new Promise((resolve) => {
64
- const requestHeaders = {
65
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
66
- ...headers,
67
- };
68
- if (cookies) {
69
- requestHeaders['Cookie'] = cookies;
63
+ const requestHeaders = {
64
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
65
+ ...headers,
66
+ };
67
+ if (cookies) {
68
+ requestHeaders['Cookie'] = cookies;
69
+ }
70
+ const tempPath = `${destPath}.tmp`;
71
+ const cleanupTempFile = async () => {
72
+ try {
73
+ await fs.promises.rm(tempPath, { force: true });
70
74
  }
71
- const tempPath = `${destPath}.tmp`;
72
- let settled = false;
73
- const finish = (result) => {
74
- if (settled)
75
- return;
76
- settled = true;
77
- resolve(result);
78
- };
79
- const cleanupTempFile = async () => {
80
- try {
81
- await fs.promises.rm(tempPath, { force: true });
82
- }
83
- catch {
84
- // Ignore cleanup errors so the original failure is preserved.
85
- }
86
- };
87
- void (async () => {
88
- const controller = new AbortController();
89
- const timer = setTimeout(() => controller.abort(), timeout);
90
- try {
91
- const response = await fetchWithNodeNetwork(url, {
92
- headers: requestHeaders,
93
- signal: controller.signal,
94
- redirect: 'manual',
95
- });
96
- clearTimeout(timer);
97
- // Handle redirects before creating any file handles.
98
- if (response.status >= 300 && response.status < 400) {
99
- const location = response.headers.get('location');
100
- if (location) {
101
- if (redirectCount >= maxRedirects) {
102
- finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
103
- return;
104
- }
105
- const redirectUrl = resolveRedirectUrl(url, location);
106
- const originalHost = new URL(url).hostname;
107
- const redirectHost = new URL(redirectUrl).hostname;
108
- const redirectOptions = originalHost === redirectHost
109
- ? options
110
- : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
111
- finish(await httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1));
112
- return;
113
- }
114
- }
115
- if (response.status !== 200) {
116
- finish({ success: false, size: 0, error: `HTTP ${response.status}` });
117
- return;
118
- }
119
- if (!response.body) {
120
- finish({ success: false, size: 0, error: 'Empty response body' });
121
- return;
122
- }
123
- const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
124
- let received = 0;
125
- const progressStream = new Transform({
126
- transform(chunk, _encoding, callback) {
127
- received += chunk.length;
128
- if (onProgress)
129
- onProgress(received, totalSize);
130
- callback(null, chunk);
131
- },
132
- });
133
- try {
134
- await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
135
- await pipeline(Readable.fromWeb(response.body), progressStream, fs.createWriteStream(tempPath));
136
- await fs.promises.rename(tempPath, destPath);
137
- finish({ success: true, size: received });
138
- }
139
- catch (err) {
140
- await cleanupTempFile();
141
- finish({ success: false, size: 0, error: getErrorMessage(err) });
75
+ catch {
76
+ // Ignore cleanup errors so the original failure is preserved.
77
+ }
78
+ };
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(), timeout);
81
+ try {
82
+ const response = await fetchWithNodeNetwork(url, {
83
+ headers: requestHeaders,
84
+ signal: controller.signal,
85
+ redirect: 'manual',
86
+ });
87
+ clearTimeout(timer);
88
+ // Handle redirects before creating any file handles.
89
+ if (response.status >= 300 && response.status < 400) {
90
+ const location = response.headers.get('location');
91
+ if (location) {
92
+ if (redirectCount >= maxRedirects) {
93
+ return { success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` };
142
94
  }
95
+ const redirectUrl = resolveRedirectUrl(url, location);
96
+ const originalHost = new URL(url).hostname;
97
+ const redirectHost = new URL(redirectUrl).hostname;
98
+ const redirectOptions = originalHost === redirectHost
99
+ ? options
100
+ : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
101
+ return httpDownload(redirectUrl, destPath, redirectOptions, redirectCount + 1);
143
102
  }
144
- catch (err) {
145
- clearTimeout(timer);
146
- await cleanupTempFile();
147
- finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
148
- }
149
- })();
150
- });
103
+ }
104
+ if (response.status !== 200) {
105
+ return { success: false, size: 0, error: `HTTP ${response.status}` };
106
+ }
107
+ if (!response.body) {
108
+ return { success: false, size: 0, error: 'Empty response body' };
109
+ }
110
+ const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
111
+ let received = 0;
112
+ const progressStream = new Transform({
113
+ transform(chunk, _encoding, callback) {
114
+ received += chunk.length;
115
+ if (onProgress)
116
+ onProgress(received, totalSize);
117
+ callback(null, chunk);
118
+ },
119
+ });
120
+ try {
121
+ await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
122
+ await pipeline(Readable.fromWeb(response.body), progressStream, fs.createWriteStream(tempPath));
123
+ await fs.promises.rename(tempPath, destPath);
124
+ return { success: true, size: received };
125
+ }
126
+ catch (err) {
127
+ await cleanupTempFile();
128
+ return { success: false, size: 0, error: getErrorMessage(err) };
129
+ }
130
+ }
131
+ catch (err) {
132
+ clearTimeout(timer);
133
+ await cleanupTempFile();
134
+ return { success: false, size: 0, error: err instanceof Error ? err.message : String(err) };
135
+ }
151
136
  }
152
137
  export function resolveRedirectUrl(currentUrl, location) {
153
138
  return new URL(location, currentUrl).toString();
@@ -172,7 +157,9 @@ export function exportCookiesToNetscape(cookies, filePath) {
172
157
  const includeSubdomains = 'TRUE';
173
158
  const cookiePath = cookie.path || '/';
174
159
  const secure = cookie.secure ? 'TRUE' : 'FALSE';
175
- const expiry = Math.floor(Date.now() / 1000) + 86400 * 365; // 1 year from now
160
+ const expiry = typeof cookie.expirationDate === 'number' && cookie.expirationDate > 0
161
+ ? Math.floor(cookie.expirationDate)
162
+ : Math.floor(Date.now() / 1000) + 86400 * 365; // fallback: 1 year from now
176
163
  const safeName = cookie.name.replace(/[\t\n\r]/g, '');
177
164
  const safeValue = cookie.value.replace(/[\t\n\r]/g, '');
178
165
  lines.push(`${domain}\t${includeSubdomains}\t${cookiePath}\t${secure}\t${expiry}\t${safeName}\t${safeValue}`);
@@ -107,11 +107,13 @@ export function getErrorMessage(error) {
107
107
  return error instanceof Error ? error.message : String(error);
108
108
  }
109
109
  /** Serialize an error cause chain into a readable string. */
110
- function serializeCause(cause) {
110
+ function serializeCause(cause, depth = 0) {
111
+ if (depth > 10)
112
+ return '(cause chain truncated)';
111
113
  if (cause instanceof Error) {
112
114
  const parts = [cause.message];
113
115
  if (cause.cause)
114
- parts.push(` caused by: ${serializeCause(cause.cause)}`);
116
+ parts.push(` caused by: ${serializeCause(cause.cause, depth + 1)}`);
115
117
  return parts.join('\n');
116
118
  }
117
119
  return String(cause);
@@ -93,4 +93,17 @@ describe('toEnvelope', () => {
93
93
  expect(envelope.error.code).toBe('UNKNOWN');
94
94
  expect(envelope.error.message).toBe('string error');
95
95
  });
96
+ it('serializes deep cause chains without stack overflow', () => {
97
+ // Build a 20-level deep cause chain — should truncate at depth 10
98
+ let deepErr = new Error('root');
99
+ for (let i = 0; i < 20; i++) {
100
+ deepErr = new Error(`level-${i}`, { cause: deepErr });
101
+ }
102
+ const topErr = new CommandExecutionError('top');
103
+ topErr.cause = deepErr;
104
+ const envelope = toEnvelope(topErr);
105
+ const causeStr = envelope.error.cause ?? '';
106
+ expect(causeStr).toContain('(cause chain truncated)');
107
+ expect(causeStr).not.toContain('root'); // root is beyond depth 10
108
+ });
96
109
  });
@@ -185,6 +185,9 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
185
185
  throw new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
186
186
  }
187
187
  }
188
+ // --live / OPENCLI_LIVE=1 keeps the automation window open after the
189
+ // command finishes, so agents (or humans) can inspect the page state.
190
+ const keepOpen = process.env.OPENCLI_LIVE === '1' || process.env.OPENCLI_LIVE === 'true';
188
191
  try {
189
192
  const result = await runWithTimeout(runCommand(cmd, page, kwargs, debug), {
190
193
  timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT,
@@ -192,7 +195,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
192
195
  });
193
196
  // Adapter commands are one-shot — close the automation window immediately
194
197
  // instead of waiting for the 30s idle timeout.
195
- await page.closeWindow?.().catch(() => { });
198
+ if (!keepOpen)
199
+ await page.closeWindow?.().catch(() => { });
196
200
  return result;
197
201
  }
198
202
  catch (err) {
@@ -206,7 +210,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
206
210
  // Close the automation window on failure too — without this, the window
207
211
  // lingers until the extension's idle timer fires (unreliable on Windows
208
212
  // where MV3 service workers may be suspended before setTimeout triggers).
209
- await page.closeWindow?.().catch(() => { });
213
+ if (!keepOpen)
214
+ await page.closeWindow?.().catch(() => { });
210
215
  throw err;
211
216
  }
212
217
  }, { workspace: `site:${cmd.site}`, cdpEndpoint });
@@ -60,6 +60,60 @@ describe('executeCommand — non-browser timeout', () => {
60
60
  expect(closeWindow).toHaveBeenCalledTimes(1);
61
61
  vi.restoreAllMocks();
62
62
  });
63
+ it('skips closeWindow when OPENCLI_LIVE=1 (success path)', async () => {
64
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
65
+ const mockPage = { closeWindow };
66
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
67
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
68
+ const prev = process.env.OPENCLI_LIVE;
69
+ process.env.OPENCLI_LIVE = '1';
70
+ try {
71
+ const cmd = cli({
72
+ site: 'test-execution',
73
+ name: 'browser-live-success',
74
+ description: 'test closeWindow skipped with --live on success',
75
+ browser: true,
76
+ strategy: Strategy.PUBLIC,
77
+ func: async () => [{ ok: true }],
78
+ });
79
+ await executeCommand(cmd, {});
80
+ expect(closeWindow).not.toHaveBeenCalled();
81
+ }
82
+ finally {
83
+ if (prev === undefined)
84
+ delete process.env.OPENCLI_LIVE;
85
+ else
86
+ process.env.OPENCLI_LIVE = prev;
87
+ vi.restoreAllMocks();
88
+ }
89
+ });
90
+ it('skips closeWindow when OPENCLI_LIVE=1 (failure path)', async () => {
91
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
92
+ const mockPage = { closeWindow };
93
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
94
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
95
+ const prev = process.env.OPENCLI_LIVE;
96
+ process.env.OPENCLI_LIVE = '1';
97
+ try {
98
+ const cmd = cli({
99
+ site: 'test-execution',
100
+ name: 'browser-live-failure',
101
+ description: 'test closeWindow skipped with --live on failure',
102
+ browser: true,
103
+ strategy: Strategy.PUBLIC,
104
+ func: async () => { throw new Error('adapter failure'); },
105
+ });
106
+ await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
107
+ expect(closeWindow).not.toHaveBeenCalled();
108
+ }
109
+ finally {
110
+ if (prev === undefined)
111
+ delete process.env.OPENCLI_LIVE;
112
+ else
113
+ process.env.OPENCLI_LIVE = prev;
114
+ vi.restoreAllMocks();
115
+ }
116
+ });
63
117
  it('does not re-run custom validation when args are already prepared', async () => {
64
118
  const validateArgs = vi.fn();
65
119
  const cmd = {
@@ -22,7 +22,7 @@ export declare function detectProcess(processName: string): boolean;
22
22
  /**
23
23
  * Kill a process by name. Sends SIGTERM first, then SIGKILL after grace period.
24
24
  */
25
- export declare function killProcess(processName: string): void;
25
+ export declare function killProcess(processName: string): Promise<void>;
26
26
  /**
27
27
  * Discover the app installation path on macOS.
28
28
  * Uses osascript to resolve the app name to a POSIX path.
@@ -52,7 +52,7 @@ export function detectProcess(processName) {
52
52
  /**
53
53
  * Kill a process by name. Sends SIGTERM first, then SIGKILL after grace period.
54
54
  */
55
- export function killProcess(processName) {
55
+ export async function killProcess(processName) {
56
56
  if (process.platform === 'win32')
57
57
  return; // pkill not available on Windows
58
58
  try {
@@ -65,7 +65,7 @@ export function killProcess(processName) {
65
65
  while (Date.now() < deadline) {
66
66
  if (!detectProcess(processName))
67
67
  return;
68
- execFileSync('sleep', ['0.2'], { stdio: 'pipe' });
68
+ await new Promise((r) => setTimeout(r, 200));
69
69
  }
70
70
  try {
71
71
  execFileSync('pkill', ['-9', '-x', processName], { stdio: 'pipe' });
@@ -190,7 +190,7 @@ export async function resolveElectronEndpoint(site) {
190
190
  throw new CommandExecutionError(`${label} needs to be restarted with CDP enabled.`, `Manually restart: kill the app and relaunch with --remote-debugging-port=${port}`);
191
191
  }
192
192
  process.stderr.write(` Restarting ${label}...\n`);
193
- killProcess(processName);
193
+ await killProcess(processName);
194
194
  }
195
195
  // Step 3: Discover path
196
196
  const appPath = discoverAppPath(label);
package/dist/src/main.js CHANGED
@@ -26,6 +26,22 @@ const __dirname = path.dirname(__filename);
26
26
  // Use findPackageRoot so the path works both in dev (src/main.ts) and prod (dist/src/main.js).
27
27
  const BUILTIN_CLIS = path.join(findPackageRoot(__filename), 'clis');
28
28
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
29
+ // ── Session lifecycle flags ──────────────────────────────────────────────
30
+ // `--live` / `--focus` are top-level-ish toggles that tweak the automation
31
+ // window's lifecycle. We strip them from argv before Commander runs so they
32
+ // can be placed anywhere and work on any subcommand (adapter or browser).
33
+ {
34
+ const liveIdx = process.argv.indexOf('--live');
35
+ if (liveIdx !== -1) {
36
+ process.env.OPENCLI_LIVE = '1';
37
+ process.argv.splice(liveIdx, 1);
38
+ }
39
+ const focusIdx = process.argv.indexOf('--focus');
40
+ if (focusIdx !== -1) {
41
+ process.env.OPENCLI_WINDOW_FOCUSED = '1';
42
+ process.argv.splice(focusIdx, 1);
43
+ }
44
+ }
29
45
  // ── Ultra-fast path: lightweight commands bypass full discovery ──────────
30
46
  // These are high-frequency or trivial paths that must not pay the startup tax.
31
47
  const argv = process.argv.slice(2);
@@ -74,7 +74,7 @@ function renderTable(data, opts) {
74
74
  console.log(table.toString());
75
75
  const footer = [];
76
76
  footer.push(`${rows.length} items`);
77
- if (opts.elapsed)
77
+ if (opts.elapsed !== undefined)
78
78
  footer.push(`${opts.elapsed.toFixed(1)}s`);
79
79
  if (opts.source)
80
80
  footer.push(opts.source);
@@ -30,6 +30,12 @@ describe('output TTY detection', () => {
30
30
  const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
31
31
  expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
32
32
  });
33
+ it('shows elapsed time when elapsed is 0', () => {
34
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
35
+ render([{ name: 'alice' }], { fmt: 'table', columns: ['name'], elapsed: 0 });
36
+ const out = logSpy.mock.calls.map((c) => c[0]).join('\n');
37
+ expect(out).toContain('0.0s');
38
+ });
33
39
  it('explicit -f table overrides non-TTY auto-downgrade', () => {
34
40
  Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
35
41
  render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
@@ -51,10 +51,25 @@ export interface IPage {
51
51
  url?: string;
52
52
  }): Promise<BrowserCookie[]>;
53
53
  snapshot(opts?: SnapshotOptions): Promise<any>;
54
- click(ref: string): Promise<void>;
55
- typeText(ref: string, text: string): Promise<void>;
54
+ click(ref: string, opts?: {
55
+ nth?: number;
56
+ firstOnMulti?: boolean;
57
+ }): Promise<{
58
+ matches_n: number;
59
+ match_level: 'exact' | 'stable' | 'reidentified';
60
+ }>;
61
+ typeText(ref: string, text: string, opts?: {
62
+ nth?: number;
63
+ firstOnMulti?: boolean;
64
+ }): Promise<{
65
+ matches_n: number;
66
+ match_level: 'exact' | 'stable' | 'reidentified';
67
+ }>;
56
68
  pressKey(key: string): Promise<void>;
57
- scrollTo(ref: string): Promise<any>;
69
+ scrollTo(ref: string, opts?: {
70
+ nth?: number;
71
+ firstOnMulti?: boolean;
72
+ }): Promise<any>;
58
73
  getFormState(): Promise<any>;
59
74
  wait(options: number | WaitOptions): Promise<void>;
60
75
  tabs(): Promise<any>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -74,18 +74,22 @@
74
74
  "url": "git+https://github.com/jackwener/opencli.git"
75
75
  },
76
76
  "dependencies": {
77
+ "@mozilla/readability": "^0.6.0",
77
78
  "cli-table3": "^0.6.5",
78
79
  "commander": "^14.0.3",
79
80
  "js-yaml": "^4.1.0",
80
81
  "turndown": "^7.2.2",
82
+ "turndown-plugin-gfm": "^1.0.2",
81
83
  "undici": "^8.0.2",
82
84
  "ws": "^8.18.0"
83
85
  },
84
86
  "devDependencies": {
87
+ "@types/jsdom": "^27.0.0",
85
88
  "@types/js-yaml": "^4.0.9",
86
89
  "@types/node": "^25.5.2",
87
90
  "@types/turndown": "^5.0.6",
88
91
  "@types/ws": "^8.5.13",
92
+ "jsdom": "^29.0.2",
89
93
  "tsx": "^4.19.3",
90
94
  "typescript": "^6.0.2",
91
95
  "vitepress": "^1.6.4",