@jackwener/opencli 1.5.2 → 1.5.4

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 (135) hide show
  1. package/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
  2. package/.github/workflows/ci.yml +6 -7
  3. package/README.md +89 -235
  4. package/dist/browser/cdp.js +20 -1
  5. package/dist/browser/daemon-client.js +3 -2
  6. package/dist/browser/dom-helpers.d.ts +11 -0
  7. package/dist/browser/dom-helpers.js +42 -0
  8. package/dist/browser/dom-helpers.test.d.ts +1 -0
  9. package/dist/browser/dom-helpers.test.js +92 -0
  10. package/dist/browser/index.d.ts +0 -12
  11. package/dist/browser/index.js +0 -13
  12. package/dist/browser/mcp.js +4 -3
  13. package/dist/browser/page.d.ts +1 -0
  14. package/dist/browser/page.js +14 -1
  15. package/dist/browser.test.js +15 -11
  16. package/dist/build-manifest.d.ts +2 -3
  17. package/dist/build-manifest.js +75 -170
  18. package/dist/build-manifest.test.js +113 -88
  19. package/dist/cli-manifest.json +1199 -1106
  20. package/dist/clis/36kr/hot.js +1 -1
  21. package/dist/clis/36kr/search.js +1 -1
  22. package/dist/clis/_shared/common.d.ts +8 -0
  23. package/dist/clis/_shared/common.js +10 -0
  24. package/dist/clis/bloomberg/news.js +1 -1
  25. package/dist/clis/douban/utils.js +3 -6
  26. package/dist/clis/medium/utils.js +1 -1
  27. package/dist/clis/producthunt/browse.js +1 -1
  28. package/dist/clis/producthunt/hot.js +1 -1
  29. package/dist/clis/sinablog/utils.js +6 -7
  30. package/dist/clis/substack/utils.js +2 -2
  31. package/dist/clis/twitter/block.js +1 -1
  32. package/dist/clis/twitter/bookmark.js +1 -1
  33. package/dist/clis/twitter/delete.js +1 -1
  34. package/dist/clis/twitter/follow.js +1 -1
  35. package/dist/clis/twitter/followers.js +2 -2
  36. package/dist/clis/twitter/following.js +2 -2
  37. package/dist/clis/twitter/hide-reply.js +1 -1
  38. package/dist/clis/twitter/like.js +1 -1
  39. package/dist/clis/twitter/notifications.js +1 -1
  40. package/dist/clis/twitter/profile.js +1 -1
  41. package/dist/clis/twitter/reply-dm.js +1 -1
  42. package/dist/clis/twitter/reply.js +1 -1
  43. package/dist/clis/twitter/search.js +1 -1
  44. package/dist/clis/twitter/unblock.js +1 -1
  45. package/dist/clis/twitter/unbookmark.js +1 -1
  46. package/dist/clis/twitter/unfollow.js +1 -1
  47. package/dist/clis/xiaohongshu/comments.test.js +1 -0
  48. package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
  49. package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
  50. package/dist/clis/xiaohongshu/publish.test.js +1 -0
  51. package/dist/clis/xiaohongshu/search.test.js +1 -0
  52. package/dist/daemon.js +14 -3
  53. package/dist/download/index.js +39 -33
  54. package/dist/download/index.test.js +15 -1
  55. package/dist/execution.js +3 -2
  56. package/dist/external-clis.yaml +16 -0
  57. package/dist/main.js +2 -0
  58. package/dist/node-network.d.ts +10 -0
  59. package/dist/node-network.js +174 -0
  60. package/dist/node-network.test.d.ts +1 -0
  61. package/dist/node-network.test.js +55 -0
  62. package/dist/pipeline/executor.test.js +1 -0
  63. package/dist/pipeline/steps/download.test.js +1 -0
  64. package/dist/pipeline/steps/intercept.js +4 -5
  65. package/dist/serialization.js +6 -1
  66. package/dist/serialization.test.d.ts +1 -0
  67. package/dist/serialization.test.js +23 -0
  68. package/dist/types.d.ts +2 -0
  69. package/dist/utils.d.ts +2 -0
  70. package/dist/utils.js +4 -0
  71. package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
  72. package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
  73. package/extension/dist/background.js +12 -5
  74. package/extension/manifest.json +2 -2
  75. package/extension/package-lock.json +2 -2
  76. package/extension/package.json +1 -1
  77. package/extension/src/background.ts +20 -6
  78. package/extension/src/protocol.ts +2 -1
  79. package/package.json +2 -1
  80. package/src/browser/cdp.ts +21 -0
  81. package/src/browser/daemon-client.ts +3 -2
  82. package/src/browser/dom-helpers.test.ts +100 -0
  83. package/src/browser/dom-helpers.ts +44 -0
  84. package/src/browser/index.ts +0 -15
  85. package/src/browser/mcp.ts +4 -3
  86. package/src/browser/page.ts +16 -0
  87. package/src/browser.test.ts +16 -12
  88. package/src/build-manifest.test.ts +117 -88
  89. package/src/build-manifest.ts +81 -180
  90. package/src/clis/36kr/hot.ts +1 -1
  91. package/src/clis/36kr/search.ts +1 -1
  92. package/src/clis/_shared/common.ts +11 -0
  93. package/src/clis/bloomberg/news.ts +1 -1
  94. package/src/clis/douban/utils.ts +3 -7
  95. package/src/clis/medium/utils.ts +1 -1
  96. package/src/clis/producthunt/browse.ts +1 -1
  97. package/src/clis/producthunt/hot.ts +1 -1
  98. package/src/clis/sinablog/utils.ts +6 -7
  99. package/src/clis/substack/utils.ts +2 -2
  100. package/src/clis/twitter/block.ts +1 -1
  101. package/src/clis/twitter/bookmark.ts +1 -1
  102. package/src/clis/twitter/delete.ts +1 -1
  103. package/src/clis/twitter/follow.ts +1 -1
  104. package/src/clis/twitter/followers.ts +2 -2
  105. package/src/clis/twitter/following.ts +2 -2
  106. package/src/clis/twitter/hide-reply.ts +1 -1
  107. package/src/clis/twitter/like.ts +1 -1
  108. package/src/clis/twitter/notifications.ts +1 -1
  109. package/src/clis/twitter/profile.ts +1 -1
  110. package/src/clis/twitter/reply-dm.ts +1 -1
  111. package/src/clis/twitter/reply.ts +1 -1
  112. package/src/clis/twitter/search.ts +1 -1
  113. package/src/clis/twitter/unblock.ts +1 -1
  114. package/src/clis/twitter/unbookmark.ts +1 -1
  115. package/src/clis/twitter/unfollow.ts +1 -1
  116. package/src/clis/xiaohongshu/comments.test.ts +1 -0
  117. package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
  118. package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
  119. package/src/clis/xiaohongshu/publish.test.ts +1 -0
  120. package/src/clis/xiaohongshu/search.test.ts +1 -0
  121. package/src/daemon.ts +16 -4
  122. package/src/download/index.test.ts +19 -1
  123. package/src/download/index.ts +50 -41
  124. package/src/execution.ts +3 -2
  125. package/src/external-clis.yaml +16 -0
  126. package/src/main.ts +3 -0
  127. package/src/node-network.test.ts +93 -0
  128. package/src/node-network.ts +213 -0
  129. package/src/pipeline/executor.test.ts +1 -0
  130. package/src/pipeline/steps/download.test.ts +1 -0
  131. package/src/pipeline/steps/intercept.ts +4 -5
  132. package/src/serialization.test.ts +26 -0
  133. package/src/serialization.ts +6 -1
  134. package/src/types.ts +2 -0
  135. package/src/utils.ts +5 -0
@@ -4,17 +4,13 @@
4
4
 
5
5
  import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
6
6
  import type { IPage } from '../../types.js';
7
+ import { clamp } from '../_shared/common.js';
7
8
 
8
9
  const DOUBAN_PHOTO_PAGE_SIZE = 30;
9
10
  const MAX_DOUBAN_PHOTOS = 500;
10
11
 
11
- function clampLimit(limit: number): number {
12
- return Math.max(1, Math.min(limit || 20, 50));
13
- }
14
-
15
- function clampPhotoLimit(limit: number): number {
16
- return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
17
- }
12
+ const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
13
+ const clampPhotoLimit = (limit: number) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
18
14
 
19
15
  async function ensureDoubanReady(page: IPage): Promise<void> {
20
16
  const state = await page.evaluate(`
@@ -16,7 +16,7 @@ export function buildMediumUserUrl(username: string): string {
16
16
  export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
17
17
  if (!page) throw new CommandExecutionError('Browser session required for medium posts');
18
18
  await page.goto(url);
19
- await page.wait(5);
19
+ await page.wait({ selector: 'article', timeout: 5 });
20
20
  const data = await page.evaluate(`
21
21
  (async () => {
22
22
  await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -32,7 +32,7 @@ cli({
32
32
 
33
33
  await page.installInterceptor('producthunt.com');
34
34
  await page.goto(`https://www.producthunt.com/categories/${slug}`);
35
- await page.wait(5);
35
+ await page.waitForCapture(5);
36
36
 
37
37
  const domItems: any = await page.evaluate(`
38
38
  (() => {
@@ -23,7 +23,7 @@ cli({
23
23
 
24
24
  await page.installInterceptor('producthunt.com');
25
25
  await page.goto('https://www.producthunt.com');
26
- await page.wait(5);
26
+ await page.waitForCapture(5);
27
27
 
28
28
  const domItems: any = await page.evaluate(`
29
29
  (() => {
@@ -1,8 +1,7 @@
1
1
  import type { IPage } from '../../types.js';
2
+ import { clamp } from '../_shared/common.js';
2
3
 
3
- function clampLimit(limit: number): number {
4
- return Math.max(1, Math.min(limit || 20, 50));
5
- }
4
+ const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
6
5
 
7
6
  export function buildSinaBlogSearchUrl(keyword: string): string {
8
7
  return `https://search.sina.com.cn/search?q=${encodeURIComponent(keyword)}&tp=mix`;
@@ -14,7 +13,7 @@ export function buildSinaBlogUserUrl(uid: string): string {
14
13
 
15
14
  export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any> {
16
15
  await page.goto(url);
17
- await page.wait(3);
16
+ await page.wait({ selector: 'h1', timeout: 3 });
18
17
  return page.evaluate(`
19
18
  (async () => {
20
19
  await new Promise((resolve) => setTimeout(resolve, 1500));
@@ -52,7 +51,7 @@ export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any
52
51
  export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]> {
53
52
  const safeLimit = clampLimit(limit);
54
53
  await page.goto('https://blog.sina.com.cn/');
55
- await page.wait(3);
54
+ await page.wait({ selector: 'h1', timeout: 3 });
56
55
  const data = await page.evaluate(`
57
56
  (async () => {
58
57
  await new Promise((resolve) => setTimeout(resolve, 1500));
@@ -122,7 +121,7 @@ export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]
122
121
  export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: number): Promise<any[]> {
123
122
  const safeLimit = clampLimit(limit);
124
123
  await page.goto(buildSinaBlogSearchUrl(keyword));
125
- await page.wait(5);
124
+ await page.wait({ selector: '.result-item', timeout: 5 });
126
125
  const data = await page.evaluate(`
127
126
  (async () => {
128
127
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -159,7 +158,7 @@ export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: nu
159
158
  export async function loadSinaBlogUser(page: IPage, uid: string, limit: number): Promise<any[]> {
160
159
  const safeLimit = clampLimit(limit);
161
160
  await page.goto(buildSinaBlogUserUrl(uid));
162
- await page.wait(3);
161
+ await page.wait({ selector: 'h1', timeout: 3 });
163
162
  const data = await page.evaluate(`
164
163
  (async () => {
165
164
  await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -10,7 +10,7 @@ export function buildSubstackBrowseUrl(category?: string): string {
10
10
  export async function loadSubstackFeed(page: IPage, url: string, limit: number): Promise<any[]> {
11
11
  if (!page) throw new CommandExecutionError('Browser session required for substack feed');
12
12
  await page.goto(url);
13
- await page.wait(5);
13
+ await page.wait({ selector: 'article', timeout: 5 });
14
14
  const data = await page.evaluate(`
15
15
  (async () => {
16
16
  await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -79,7 +79,7 @@ export async function loadSubstackFeed(page: IPage, url: string, limit: number):
79
79
  export async function loadSubstackArchive(page: IPage, baseUrl: string, limit: number): Promise<any[]> {
80
80
  if (!page) throw new CommandExecutionError('Browser session required for substack archive');
81
81
  await page.goto(`${baseUrl}/archive`);
82
- await page.wait(5);
82
+ await page.wait({ selector: 'article', timeout: 5 });
83
83
  const data = await page.evaluate(`
84
84
  (async () => {
85
85
  await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -18,7 +18,7 @@ cli({
18
18
  const username = kwargs.username.replace(/^@/, '');
19
19
 
20
20
  await page.goto(`https://x.com/${username}`);
21
- await page.wait(5);
21
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
22
22
 
23
23
  const result = await page.evaluate(`(async () => {
24
24
  try {
@@ -17,7 +17,7 @@ cli({
17
17
  if (!page) throw new CommandExecutionError('Browser session required for twitter bookmark');
18
18
 
19
19
  await page.goto(kwargs.url);
20
- await page.wait(5);
20
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
21
21
 
22
22
  const result = await page.evaluate(`(async () => {
23
23
  try {
@@ -17,7 +17,7 @@ cli({
17
17
  if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
18
18
 
19
19
  await page.goto(kwargs.url);
20
- await page.wait(5); // Wait for tweet to load completely
20
+ await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
21
21
 
22
22
  const result = await page.evaluate(`(async () => {
23
23
  try {
@@ -18,7 +18,7 @@ cli({
18
18
  const username = kwargs.username.replace(/^@/, '');
19
19
 
20
20
  await page.goto(`https://x.com/${username}`);
21
- await page.wait(5);
21
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
22
22
 
23
23
  const result = await page.evaluate(`(async () => {
24
24
  try {
@@ -19,7 +19,7 @@ cli({
19
19
  // If no user is specified, figure out the logged-in user's handle
20
20
  if (!targetUser) {
21
21
  await page.goto('https://x.com/home');
22
- await page.wait(5);
22
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
23
 
24
24
  const href = await page.evaluate(`() => {
25
25
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
@@ -58,7 +58,7 @@ cli({
58
58
  if (!clicked) {
59
59
  throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
60
60
  }
61
- await page.wait(5);
61
+ await page.waitForCapture(5);
62
62
 
63
63
  // 4. Scroll to trigger pagination API calls
64
64
  await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
@@ -19,7 +19,7 @@ cli({
19
19
  // If no user is specified, figure out the logged-in user's handle
20
20
  if (!targetUser) {
21
21
  await page.goto('https://x.com/home');
22
- await page.wait(5);
22
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
23
 
24
24
  const href = await page.evaluate(`() => {
25
25
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
@@ -51,7 +51,7 @@ cli({
51
51
  if (!clicked) {
52
52
  throw new SelectorError('Twitter following link', 'Twitter may have changed the layout.');
53
53
  }
54
- await page.wait(5);
54
+ await page.waitForCapture(5);
55
55
 
56
56
  // 4. Scroll to trigger pagination API calls
57
57
  await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
@@ -17,7 +17,7 @@ cli({
17
17
  if (!page) throw new CommandExecutionError('Browser session required for twitter hide-reply');
18
18
 
19
19
  await page.goto(kwargs.url);
20
- await page.wait(5);
20
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
21
21
 
22
22
  const result = await page.evaluate(`(async () => {
23
23
  try {
@@ -17,7 +17,7 @@ cli({
17
17
  if (!page) throw new CommandExecutionError('Browser session required for twitter like');
18
18
 
19
19
  await page.goto(kwargs.url);
20
- await page.wait(5); // Wait for tweet to load completely
20
+ await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
21
21
 
22
22
  const result = await page.evaluate(`(async () => {
23
23
  try {
@@ -25,7 +25,7 @@ cli({
25
25
  window.history.pushState({}, '', '/notifications');
26
26
  window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
27
27
  }`);
28
- await page.wait(5);
28
+ await page.waitForCapture(5);
29
29
 
30
30
  // Verify SPA navigation succeeded
31
31
  const currentUrl = await page.evaluate('() => window.location.pathname');
@@ -21,7 +21,7 @@ cli({
21
21
  // If no username, detect the logged-in user
22
22
  if (!username) {
23
23
  await page.goto('https://x.com/home');
24
- await page.wait(5);
24
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
25
25
  const href = await page.evaluate(`() => {
26
26
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
27
27
  return link ? link.getAttribute('href') : null;
@@ -27,7 +27,7 @@ cli({
27
27
 
28
28
  // Step 1: Navigate to messages to get conversation list
29
29
  await page.goto('https://x.com/messages');
30
- await page.wait(5);
30
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
31
31
 
32
32
  // Step 2: Collect conversations with scroll-to-load
33
33
  const needed = maxSend + 10; // extra buffer for skips
@@ -19,7 +19,7 @@ cli({
19
19
 
20
20
  // 1. Navigate to the tweet page
21
21
  await page.goto(kwargs.url);
22
- await page.wait(5); // Wait for the react application to hydrate
22
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
23
 
24
24
  // 2. Automate typing the reply and clicking reply
25
25
  const result = await page.evaluate(`(async () => {
@@ -20,7 +20,7 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
20
20
  window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
21
21
  })()
22
22
  `);
23
- await page.wait(5);
23
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
24
24
 
25
25
  lastPath = String(await page.evaluate('() => window.location.pathname') || '');
26
26
  if (lastPath.startsWith('/search')) {
@@ -18,7 +18,7 @@ cli({
18
18
  const username = kwargs.username.replace(/^@/, '');
19
19
 
20
20
  await page.goto(`https://x.com/${username}`);
21
- await page.wait(5);
21
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
22
22
 
23
23
  const result = await page.evaluate(`(async () => {
24
24
  try {
@@ -17,7 +17,7 @@ cli({
17
17
  if (!page) throw new CommandExecutionError('Browser session required for twitter unbookmark');
18
18
 
19
19
  await page.goto(kwargs.url);
20
- await page.wait(5);
20
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
21
21
 
22
22
  const result = await page.evaluate(`(async () => {
23
23
  try {
@@ -18,7 +18,7 @@ cli({
18
18
  const username = kwargs.username.replace(/^@/, '');
19
19
 
20
20
  await page.goto(`https://x.com/${username}`);
21
- await page.wait(5);
21
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
22
22
 
23
23
  const result = await page.evaluate(`(async () => {
24
24
  try {
@@ -26,6 +26,7 @@ function createPageMock(evaluateResult: any): IPage {
26
26
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
27
27
  getCookies: vi.fn().mockResolvedValue([]),
28
28
  screenshot: vi.fn().mockResolvedValue(''),
29
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
29
30
  };
30
31
  }
31
32
 
@@ -33,6 +33,7 @@ function createPageMock(evaluateResult: any): IPage {
33
33
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
34
34
  getCookies: vi.fn().mockResolvedValue([]),
35
35
  screenshot: vi.fn().mockResolvedValue(''),
36
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
36
37
  };
37
38
  }
38
39
 
@@ -37,6 +37,7 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
37
37
  getInterceptedRequests,
38
38
  getCookies: vi.fn().mockResolvedValue([]),
39
39
  screenshot: vi.fn().mockResolvedValue(''),
40
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
40
41
  };
41
42
  }
42
43
 
@@ -36,6 +36,7 @@ function createPageMock(evaluateResults: any[]): IPage {
36
36
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
37
37
  getCookies: vi.fn().mockResolvedValue([]),
38
38
  screenshot: vi.fn().mockResolvedValue(''),
39
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
39
40
  };
40
41
  }
41
42
 
@@ -31,6 +31,7 @@ function createPageMock(evaluateResults: any[]): IPage {
31
31
  getInterceptedRequests: vi.fn().mockResolvedValue([]),
32
32
  getCookies: vi.fn().mockResolvedValue([]),
33
33
  screenshot: vi.fn().mockResolvedValue(''),
34
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
34
35
  };
35
36
  }
36
37
 
package/src/daemon.ts CHANGED
@@ -102,7 +102,22 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
102
102
  return;
103
103
  }
104
104
 
105
- // Require custom header on all HTTP requests. Browsers cannot attach
105
+ const url = req.url ?? '/';
106
+ const pathname = url.split('?')[0];
107
+
108
+ // Health-check endpoint — no X-OpenCLI header required.
109
+ // Used by the extension to silently probe daemon reachability before
110
+ // attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
111
+ // Security note: this endpoint is reachable by any client that passes the
112
+ // origin check above (chrome-extension:// or no Origin header, e.g. curl).
113
+ // Timing side-channels can reveal daemon presence to local processes, which
114
+ // is an accepted risk given the daemon is loopback-only and short-lived.
115
+ if (req.method === 'GET' && pathname === '/ping') {
116
+ jsonResponse(res, 200, { ok: true });
117
+ return;
118
+ }
119
+
120
+ // Require custom header on all other HTTP requests. Browsers cannot attach
106
121
  // custom headers in "simple" requests, and our preflight returns no
107
122
  // Access-Control-Allow-Headers, so scripted fetch() from web pages is
108
123
  // blocked even if Origin check is somehow bypassed.
@@ -111,9 +126,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
111
126
  return;
112
127
  }
113
128
 
114
- const url = req.url ?? '/';
115
- const pathname = url.split('?')[0];
116
-
117
129
  if (req.method === 'GET' && pathname === '/status') {
118
130
  jsonResponse(res, 200, {
119
131
  ok: true,
@@ -2,13 +2,14 @@ import * as fs from 'node:fs';
2
2
  import * as http from 'node:http';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
- import { afterEach, describe, expect, it } from 'vitest';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
6
  import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
7
7
 
8
8
  const servers: http.Server[] = [];
9
9
  const tempDirs: string[] = [];
10
10
 
11
11
  afterEach(async () => {
12
+ vi.unstubAllEnvs();
12
13
  await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
13
14
  server.close((err) => (err ? reject(err) : resolve()));
14
15
  })));
@@ -114,4 +115,21 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
114
115
  expect(forwardedCookie).toBeUndefined();
115
116
  expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
116
117
  });
118
+
119
+ it('bypasses proxy settings for loopback downloads', async () => {
120
+ vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
121
+
122
+ const baseUrl = await startServer((_req, res) => {
123
+ res.statusCode = 200;
124
+ res.end('ok');
125
+ });
126
+
127
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
128
+ tempDirs.push(tempDir);
129
+ const destPath = path.join(tempDir, 'loopback.txt');
130
+ const result = await httpDownload(`${baseUrl}/ok`, destPath);
131
+
132
+ expect(result).toEqual({ success: true, size: 2 });
133
+ expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
134
+ });
117
135
  });
@@ -5,16 +5,16 @@
5
5
  import { spawn } from 'node:child_process';
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
- import * as https from 'node:https';
9
- import * as http from 'node:http';
10
8
  import * as os from 'node:os';
11
- import { Transform } from 'node:stream';
9
+ import { Readable, Transform } from 'node:stream';
10
+ import type { ReadableStream as WebReadableStream } from 'node:stream/web';
12
11
  import { pipeline } from 'node:stream/promises';
13
12
  import { URL } from 'node:url';
14
13
  import type { ProgressBar } from './progress.js';
15
14
  import { isBinaryInstalled } from '../external.js';
16
15
  import type { BrowserCookie } from '../types.js';
17
16
  import { getErrorMessage } from '../errors.js';
17
+ import { fetchWithNodeNetwork } from '../node-network.js';
18
18
 
19
19
  export type { BrowserCookie } from '../types.js';
20
20
 
@@ -89,9 +89,6 @@ export async function httpDownload(
89
89
  const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
90
90
 
91
91
  return new Promise((resolve) => {
92
- const parsedUrl = new URL(url);
93
- const protocol = parsedUrl.protocol === 'https:' ? https : http;
94
-
95
92
  const requestHeaders: Record<string, string> = {
96
93
  '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',
97
94
  ...headers,
@@ -118,37 +115,52 @@ export async function httpDownload(
118
115
  }
119
116
  };
120
117
 
121
- const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
122
- void (async () => {
118
+ void (async () => {
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), timeout);
121
+ try {
122
+ const response = await fetchWithNodeNetwork(url, {
123
+ headers: requestHeaders,
124
+ signal: controller.signal,
125
+ redirect: 'manual',
126
+ });
127
+ clearTimeout(timer);
128
+
123
129
  // Handle redirects before creating any file handles.
124
- if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
125
- response.resume();
126
- if (redirectCount >= maxRedirects) {
127
- finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
130
+ if (response.status >= 300 && response.status < 400) {
131
+ const location = response.headers.get('location');
132
+ if (location) {
133
+ if (redirectCount >= maxRedirects) {
134
+ finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
135
+ return;
136
+ }
137
+ const redirectUrl = resolveRedirectUrl(url, location);
138
+ const originalHost = new URL(url).hostname;
139
+ const redirectHost = new URL(redirectUrl).hostname;
140
+ const redirectOptions = originalHost === redirectHost
141
+ ? options
142
+ : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
143
+ finish(await httpDownload(
144
+ redirectUrl,
145
+ destPath,
146
+ redirectOptions,
147
+ redirectCount + 1,
148
+ ));
128
149
  return;
129
150
  }
130
- const redirectUrl = resolveRedirectUrl(url, response.headers.location);
131
- const originalHost = new URL(url).hostname;
132
- const redirectHost = new URL(redirectUrl).hostname;
133
- const redirectOptions = originalHost === redirectHost
134
- ? options
135
- : { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
136
- finish(await httpDownload(
137
- redirectUrl,
138
- destPath,
139
- redirectOptions,
140
- redirectCount + 1,
141
- ));
151
+ }
152
+
153
+ if (response.status !== 200) {
154
+ finish({ success: false, size: 0, error: `HTTP ${response.status}` });
142
155
  return;
143
156
  }
144
157
 
145
- if (response.statusCode !== 200) {
146
- response.resume();
147
- finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
158
+ if (!response.body) {
159
+ finish({ success: false, size: 0, error: 'Empty response body' });
148
160
  return;
149
161
  }
150
162
 
151
- const totalSize = parseInt(response.headers['content-length'] || '0', 10);
163
+ const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
152
164
  let received = 0;
153
165
  const progressStream = new Transform({
154
166
  transform(chunk, _encoding, callback) {
@@ -160,26 +172,23 @@ export async function httpDownload(
160
172
 
161
173
  try {
162
174
  await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
163
- await pipeline(response, progressStream, fs.createWriteStream(tempPath));
175
+ await pipeline(
176
+ Readable.fromWeb(response.body as unknown as WebReadableStream),
177
+ progressStream,
178
+ fs.createWriteStream(tempPath),
179
+ );
164
180
  await fs.promises.rename(tempPath, destPath);
165
181
  finish({ success: true, size: received });
166
182
  } catch (err) {
167
183
  await cleanupTempFile();
168
184
  finish({ success: false, size: 0, error: getErrorMessage(err) });
169
185
  }
170
- })();
171
- });
172
-
173
- request.on('error', (err) => {
174
- void (async () => {
186
+ } catch (err) {
187
+ clearTimeout(timer);
175
188
  await cleanupTempFile();
176
- finish({ success: false, size: 0, error: err.message });
177
- })();
178
- });
179
-
180
- request.on('timeout', () => {
181
- request.destroy(new Error('Timeout'));
182
- });
189
+ finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
190
+ }
191
+ })();
183
192
  });
184
193
  }
185
194
 
package/src/execution.ts CHANGED
@@ -19,6 +19,7 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
19
19
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
20
20
  import { emitHook, type HookContext } from './hooks.js';
21
21
  import { checkDaemonStatus } from './browser/discover.js';
22
+ import { log } from './logger.js';
22
23
 
23
24
  const _loadedModules = new Set<string>();
24
25
 
@@ -191,14 +192,14 @@ export async function executeCommand(
191
192
  if (preNavUrl) {
192
193
  const skip = await isAlreadyOnDomain(page, preNavUrl);
193
194
  if (skip) {
194
- if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
195
+ if (debug) log.debug('[pre-nav] Already on target domain, skipping navigation');
195
196
  } else {
196
197
  try {
197
198
  // goto() already includes smart DOM-settle detection (waitForDomStable).
198
199
  // No additional fixed sleep needed.
199
200
  await page.goto(preNavUrl);
200
201
  } catch (err) {
201
- if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
202
+ if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
202
203
  }
203
204
  }
204
205
  }
@@ -21,3 +21,19 @@
21
21
  tags: [docker, containers, devops]
22
22
  install:
23
23
  mac: "brew install --cask docker"
24
+
25
+ - name: lark-cli
26
+ binary: lark-cli
27
+ description: "Lark/Feishu CLI — messages, documents, spreadsheets, calendar, tasks and 200+ commands for AI agents"
28
+ homepage: "https://github.com/larksuite/cli"
29
+ tags: [lark, feishu, collaboration, productivity, ai-agent]
30
+ install:
31
+ default: "npm install -g @larksuite/cli"
32
+
33
+ - name: vercel
34
+ binary: vercel
35
+ description: "Vercel CLI — deploy projects, manage domains, env vars, logs and serverless functions"
36
+ homepage: "https://vercel.com/docs/cli"
37
+ tags: [vercel, deployment, serverless, frontend, devops]
38
+ install:
39
+ default: "npm install -g vercel"
package/src/main.ts CHANGED
@@ -20,8 +20,11 @@ import { discoverClis, discoverPlugins } from './discovery.js';
20
20
  import { getCompletions } from './completion.js';
21
21
  import { runCli } from './cli.js';
22
22
  import { emitHook } from './hooks.js';
23
+ import { installNodeNetwork } from './node-network.js';
23
24
  import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
24
25
 
26
+ installNodeNetwork();
27
+
25
28
  const __filename = fileURLToPath(import.meta.url);
26
29
  const __dirname = path.dirname(__filename);
27
30
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');