@jackwener/opencli 1.3.1 → 1.3.2

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 (217) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +44 -5
  3. package/README.zh-CN.md +44 -5
  4. package/SKILL.md +317 -5
  5. package/TESTING.md +4 -4
  6. package/dist/browser/errors.d.ts +2 -1
  7. package/dist/browser/errors.js +9 -10
  8. package/dist/build-manifest.js +1 -3
  9. package/dist/cli-manifest.json +2573 -989
  10. package/dist/cli.js +42 -2
  11. package/dist/clis/bilibili/download.js +20 -65
  12. package/dist/clis/bilibili/utils.js +2 -1
  13. package/dist/clis/chaoxing/assignments.js +2 -1
  14. package/dist/clis/doubao/ask.d.ts +1 -0
  15. package/dist/clis/doubao/ask.js +35 -0
  16. package/dist/clis/doubao/common.d.ts +23 -0
  17. package/dist/clis/doubao/common.js +564 -0
  18. package/dist/clis/doubao/new.d.ts +1 -0
  19. package/dist/clis/doubao/new.js +20 -0
  20. package/dist/clis/doubao/read.d.ts +1 -0
  21. package/dist/clis/doubao/read.js +19 -0
  22. package/dist/clis/doubao/send.d.ts +1 -0
  23. package/dist/clis/doubao/send.js +22 -0
  24. package/dist/clis/doubao/status.d.ts +1 -0
  25. package/dist/clis/doubao/status.js +24 -0
  26. package/dist/clis/doubao-app/ask.d.ts +1 -0
  27. package/dist/clis/doubao-app/ask.js +53 -0
  28. package/dist/clis/doubao-app/common.d.ts +37 -0
  29. package/dist/clis/doubao-app/common.js +110 -0
  30. package/dist/clis/doubao-app/dump.d.ts +1 -0
  31. package/dist/clis/doubao-app/dump.js +24 -0
  32. package/dist/clis/doubao-app/new.d.ts +1 -0
  33. package/dist/clis/doubao-app/new.js +20 -0
  34. package/dist/clis/doubao-app/read.d.ts +1 -0
  35. package/dist/clis/doubao-app/read.js +18 -0
  36. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  37. package/dist/clis/doubao-app/screenshot.js +18 -0
  38. package/dist/clis/doubao-app/send.d.ts +1 -0
  39. package/dist/clis/doubao-app/send.js +27 -0
  40. package/dist/clis/doubao-app/status.d.ts +1 -0
  41. package/dist/clis/doubao-app/status.js +16 -0
  42. package/dist/clis/hackernews/ask.yaml +38 -0
  43. package/dist/clis/hackernews/best.yaml +38 -0
  44. package/dist/clis/hackernews/jobs.yaml +36 -0
  45. package/dist/clis/hackernews/new.yaml +38 -0
  46. package/dist/clis/hackernews/search.yaml +44 -0
  47. package/dist/clis/hackernews/show.yaml +38 -0
  48. package/dist/clis/hackernews/top.yaml +3 -1
  49. package/dist/clis/hackernews/user.yaml +25 -0
  50. package/dist/clis/twitter/download.js +13 -97
  51. package/dist/clis/twitter/thread.js +2 -1
  52. package/dist/clis/v2ex/member.yaml +29 -0
  53. package/dist/clis/v2ex/node.yaml +34 -0
  54. package/dist/clis/v2ex/nodes.yaml +31 -0
  55. package/dist/clis/v2ex/replies.yaml +32 -0
  56. package/dist/clis/v2ex/user.yaml +34 -0
  57. package/dist/clis/weibo/search.d.ts +1 -0
  58. package/dist/clis/weibo/search.js +73 -0
  59. package/dist/clis/weixin/download.d.ts +12 -0
  60. package/dist/clis/weixin/download.js +183 -0
  61. package/dist/clis/xiaohongshu/download.js +12 -60
  62. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  63. package/dist/clis/xiaohongshu/publish.js +352 -0
  64. package/dist/clis/xiaohongshu/search.js +47 -15
  65. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  66. package/dist/clis/xiaohongshu/search.test.js +114 -0
  67. package/dist/clis/yollomi/background.d.ts +4 -0
  68. package/dist/clis/yollomi/background.js +45 -0
  69. package/dist/clis/yollomi/edit.d.ts +5 -0
  70. package/dist/clis/yollomi/edit.js +56 -0
  71. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  72. package/dist/clis/yollomi/face-swap.js +43 -0
  73. package/dist/clis/yollomi/generate.d.ts +9 -0
  74. package/dist/clis/yollomi/generate.js +100 -0
  75. package/dist/clis/yollomi/models.d.ts +1 -0
  76. package/dist/clis/yollomi/models.js +33 -0
  77. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  78. package/dist/clis/yollomi/object-remover.js +42 -0
  79. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  80. package/dist/clis/yollomi/remove-bg.js +38 -0
  81. package/dist/clis/yollomi/restore.d.ts +4 -0
  82. package/dist/clis/yollomi/restore.js +38 -0
  83. package/dist/clis/yollomi/try-on.d.ts +4 -0
  84. package/dist/clis/yollomi/try-on.js +46 -0
  85. package/dist/clis/yollomi/upload.d.ts +7 -0
  86. package/dist/clis/yollomi/upload.js +71 -0
  87. package/dist/clis/yollomi/upscale.d.ts +4 -0
  88. package/dist/clis/yollomi/upscale.js +53 -0
  89. package/dist/clis/yollomi/utils.d.ts +45 -0
  90. package/dist/clis/yollomi/utils.js +180 -0
  91. package/dist/clis/yollomi/video.d.ts +5 -0
  92. package/dist/clis/yollomi/video.js +56 -0
  93. package/dist/clis/zhihu/download.d.ts +1 -5
  94. package/dist/clis/zhihu/download.js +20 -126
  95. package/dist/clis/zhihu/download.test.js +7 -5
  96. package/dist/clis/zhihu/question.js +2 -1
  97. package/dist/commanderAdapter.js +4 -6
  98. package/dist/daemon.js +5 -2
  99. package/dist/discovery.js +10 -10
  100. package/dist/download/article-download.d.ts +59 -0
  101. package/dist/download/article-download.js +178 -0
  102. package/dist/download/media-download.d.ts +49 -0
  103. package/dist/download/media-download.js +112 -0
  104. package/dist/errors.d.ts +23 -2
  105. package/dist/errors.js +58 -2
  106. package/dist/errors.test.d.ts +1 -0
  107. package/dist/errors.test.js +59 -0
  108. package/dist/execution.js +9 -10
  109. package/dist/explore.js +4 -2
  110. package/dist/external.d.ts +15 -0
  111. package/dist/external.js +48 -2
  112. package/dist/external.test.d.ts +1 -0
  113. package/dist/external.test.js +64 -0
  114. package/dist/main.js +10 -0
  115. package/dist/plugin.d.ts +4 -0
  116. package/dist/plugin.js +45 -23
  117. package/dist/plugin.test.js +6 -1
  118. package/dist/record.d.ts +47 -0
  119. package/dist/record.js +545 -0
  120. package/dist/registry.d.ts +7 -2
  121. package/dist/registry.js +2 -6
  122. package/dist/runtime.d.ts +3 -1
  123. package/dist/runtime.js +10 -3
  124. package/dist/validate.js +1 -3
  125. package/docs/.vitepress/config.mts +1 -0
  126. package/docs/adapters/browser/doubao.md +35 -0
  127. package/docs/adapters/browser/hackernews.md +20 -4
  128. package/docs/adapters/browser/tiktok.md +1 -1
  129. package/docs/adapters/browser/v2ex.md +31 -10
  130. package/docs/adapters/browser/weibo.md +4 -0
  131. package/docs/adapters/browser/weixin.md +33 -0
  132. package/docs/adapters/browser/xiaohongshu.md +8 -6
  133. package/docs/adapters/browser/yollomi.md +69 -0
  134. package/docs/adapters/desktop/doubao-app.md +35 -0
  135. package/docs/adapters/index.md +16 -5
  136. package/docs/advanced/download.md +4 -0
  137. package/package.json +3 -1
  138. package/src/browser/errors.ts +17 -11
  139. package/src/build-manifest.ts +2 -3
  140. package/src/cli.ts +45 -2
  141. package/src/clis/bilibili/download.ts +25 -83
  142. package/src/clis/bilibili/utils.ts +2 -1
  143. package/src/clis/chaoxing/assignments.ts +2 -1
  144. package/src/clis/doubao/ask.ts +40 -0
  145. package/src/clis/doubao/common.ts +619 -0
  146. package/src/clis/doubao/new.ts +22 -0
  147. package/src/clis/doubao/read.ts +20 -0
  148. package/src/clis/doubao/send.ts +25 -0
  149. package/src/clis/doubao/status.ts +27 -0
  150. package/src/clis/doubao-app/ask.ts +60 -0
  151. package/src/clis/doubao-app/common.ts +116 -0
  152. package/src/clis/doubao-app/dump.ts +28 -0
  153. package/src/clis/doubao-app/new.ts +21 -0
  154. package/src/clis/doubao-app/read.ts +21 -0
  155. package/src/clis/doubao-app/screenshot.ts +19 -0
  156. package/src/clis/doubao-app/send.ts +30 -0
  157. package/src/clis/doubao-app/status.ts +17 -0
  158. package/src/clis/hackernews/ask.yaml +38 -0
  159. package/src/clis/hackernews/best.yaml +38 -0
  160. package/src/clis/hackernews/jobs.yaml +36 -0
  161. package/src/clis/hackernews/new.yaml +38 -0
  162. package/src/clis/hackernews/search.yaml +44 -0
  163. package/src/clis/hackernews/show.yaml +38 -0
  164. package/src/clis/hackernews/top.yaml +3 -1
  165. package/src/clis/hackernews/user.yaml +25 -0
  166. package/src/clis/twitter/download.ts +13 -111
  167. package/src/clis/twitter/thread.ts +2 -1
  168. package/src/clis/v2ex/member.yaml +29 -0
  169. package/src/clis/v2ex/node.yaml +34 -0
  170. package/src/clis/v2ex/nodes.yaml +31 -0
  171. package/src/clis/v2ex/replies.yaml +32 -0
  172. package/src/clis/v2ex/user.yaml +34 -0
  173. package/src/clis/weibo/search.ts +78 -0
  174. package/src/clis/weixin/download.ts +199 -0
  175. package/src/clis/xiaohongshu/download.ts +12 -71
  176. package/src/clis/xiaohongshu/publish.ts +392 -0
  177. package/src/clis/xiaohongshu/search.test.ts +134 -0
  178. package/src/clis/xiaohongshu/search.ts +49 -15
  179. package/src/clis/yollomi/background.ts +48 -0
  180. package/src/clis/yollomi/edit.ts +58 -0
  181. package/src/clis/yollomi/face-swap.ts +45 -0
  182. package/src/clis/yollomi/generate.ts +95 -0
  183. package/src/clis/yollomi/models.ts +38 -0
  184. package/src/clis/yollomi/object-remover.ts +44 -0
  185. package/src/clis/yollomi/remove-bg.ts +40 -0
  186. package/src/clis/yollomi/restore.ts +40 -0
  187. package/src/clis/yollomi/try-on.ts +48 -0
  188. package/src/clis/yollomi/upload.ts +78 -0
  189. package/src/clis/yollomi/upscale.ts +49 -0
  190. package/src/clis/yollomi/utils.ts +202 -0
  191. package/src/clis/yollomi/video.ts +61 -0
  192. package/src/clis/zhihu/download.test.ts +7 -5
  193. package/src/clis/zhihu/download.ts +23 -158
  194. package/src/clis/zhihu/question.ts +2 -1
  195. package/src/commanderAdapter.ts +4 -7
  196. package/src/daemon.ts +5 -2
  197. package/src/discovery.ts +26 -26
  198. package/src/download/article-download.ts +272 -0
  199. package/src/download/media-download.ts +178 -0
  200. package/src/errors.test.ts +79 -0
  201. package/src/errors.ts +92 -2
  202. package/src/execution.ts +14 -10
  203. package/src/explore.ts +4 -2
  204. package/src/external.test.ts +88 -0
  205. package/src/external.ts +56 -2
  206. package/src/generate.ts +2 -1
  207. package/src/main.ts +10 -0
  208. package/src/plugin.test.ts +7 -1
  209. package/src/plugin.ts +49 -25
  210. package/src/record.ts +617 -0
  211. package/src/registry.ts +9 -5
  212. package/src/runtime.ts +16 -4
  213. package/src/validate.ts +2 -3
  214. package/tests/e2e/browser-auth.test.ts +10 -1
  215. package/tests/e2e/browser-public.test.ts +13 -8
  216. package/tests/e2e/public-commands.test.ts +209 -21
  217. package/tests/smoke/api-health.test.ts +65 -6
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CliError, BrowserConnectError, AdapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, SelectorError, } from './errors.js';
3
+ describe('Error type hierarchy', () => {
4
+ it('all error types extend CliError', () => {
5
+ const errors = [
6
+ new BrowserConnectError('test'),
7
+ new AdapterLoadError('test'),
8
+ new CommandExecutionError('test'),
9
+ new ConfigError('test'),
10
+ new AuthRequiredError('example.com'),
11
+ new TimeoutError('test', 30),
12
+ new ArgumentError('test'),
13
+ new EmptyResultError('test/cmd'),
14
+ new SelectorError('.btn'),
15
+ ];
16
+ for (const err of errors) {
17
+ expect(err).toBeInstanceOf(CliError);
18
+ expect(err).toBeInstanceOf(Error);
19
+ }
20
+ });
21
+ it('AuthRequiredError has correct code, domain, and auto-generated hint', () => {
22
+ const err = new AuthRequiredError('bilibili.com');
23
+ expect(err.code).toBe('AUTH_REQUIRED');
24
+ expect(err.domain).toBe('bilibili.com');
25
+ expect(err.message).toBe('Not logged in to bilibili.com');
26
+ expect(err.hint).toContain('https://bilibili.com');
27
+ });
28
+ it('AuthRequiredError accepts custom message', () => {
29
+ const err = new AuthRequiredError('x.com', 'No ct0 cookie found');
30
+ expect(err.message).toBe('No ct0 cookie found');
31
+ expect(err.hint).toContain('https://x.com');
32
+ });
33
+ it('TimeoutError has correct code and hint', () => {
34
+ const err = new TimeoutError('bilibili/hot', 60);
35
+ expect(err.code).toBe('TIMEOUT');
36
+ expect(err.message).toBe('bilibili/hot timed out after 60s');
37
+ expect(err.hint).toContain('timeout');
38
+ });
39
+ it('ArgumentError has correct code', () => {
40
+ const err = new ArgumentError('Argument "limit" must be a valid number');
41
+ expect(err.code).toBe('ARGUMENT');
42
+ });
43
+ it('EmptyResultError has default hint', () => {
44
+ const err = new EmptyResultError('hackernews/top');
45
+ expect(err.code).toBe('EMPTY_RESULT');
46
+ expect(err.message).toBe('hackernews/top returned no data');
47
+ expect(err.hint).toBeTruthy();
48
+ });
49
+ it('SelectorError has default hint about page changes', () => {
50
+ const err = new SelectorError('.submit-btn');
51
+ expect(err.code).toBe('SELECTOR');
52
+ expect(err.message).toContain('.submit-btn');
53
+ expect(err.hint).toContain('report');
54
+ });
55
+ it('BrowserConnectError has correct code', () => {
56
+ const err = new BrowserConnectError('Cannot connect');
57
+ expect(err.code).toBe('BROWSER_CONNECT');
58
+ });
59
+ });
package/dist/execution.js CHANGED
@@ -11,14 +11,11 @@
11
11
  import { Strategy, getRegistry, fullName } from './registry.js';
12
12
  import { pathToFileURL } from 'node:url';
13
13
  import { executePipeline } from './pipeline/index.js';
14
- import { AdapterLoadError } from './errors.js';
14
+ import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
15
15
  import { shouldUseBrowserSession } from './capabilityRouting.js';
16
16
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
17
17
  /** Set of TS module paths that have been loaded */
18
18
  const _loadedModules = new Set();
19
- function getErrorMessage(error) {
20
- return error instanceof Error ? error.message : String(error);
21
- }
22
19
  /**
23
20
  * Validates and coerces arguments based on the command's Arg definitions.
24
21
  */
@@ -28,14 +25,14 @@ export function coerceAndValidateArgs(cmdArgs, kwargs) {
28
25
  const val = result[argDef.name];
29
26
  // 1. Check required
30
27
  if (argDef.required && (val === undefined || val === null || val === '')) {
31
- throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
28
+ throw new ArgumentError(`Argument "${argDef.name}" is required.`, argDef.help ?? `Provide a value for --${argDef.name}`);
32
29
  }
33
30
  if (val !== undefined && val !== null) {
34
31
  // 2. Type coercion
35
32
  if (argDef.type === 'int' || argDef.type === 'number') {
36
33
  const num = Number(val);
37
34
  if (Number.isNaN(num)) {
38
- throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
35
+ throw new ArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
39
36
  }
40
37
  result[argDef.name] = num;
41
38
  }
@@ -47,7 +44,7 @@ export function coerceAndValidateArgs(cmdArgs, kwargs) {
47
44
  else if (lower === 'false' || lower === '0')
48
45
  result[argDef.name] = false;
49
46
  else
50
- throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
47
+ throw new ArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
51
48
  }
52
49
  else {
53
50
  result[argDef.name] = Boolean(val);
@@ -57,7 +54,7 @@ export function coerceAndValidateArgs(cmdArgs, kwargs) {
57
54
  const coercedVal = result[argDef.name];
58
55
  if (argDef.choices && argDef.choices.length > 0) {
59
56
  if (!argDef.choices.map(String).includes(String(coercedVal))) {
60
- throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
57
+ throw new ArgumentError(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
61
58
  }
62
59
  }
63
60
  }
@@ -95,7 +92,7 @@ async function runCommand(cmd, page, kwargs, debug) {
95
92
  return cmd.func(page, kwargs, debug);
96
93
  if (cmd.pipeline)
97
94
  return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
98
- throw new Error(`Command ${fullName(cmd)} has no func or pipeline`);
95
+ throw new CommandExecutionError(`Command ${fullName(cmd)} has no func or pipeline`, 'This is likely a bug in the adapter definition. Please report this issue.');
99
96
  }
100
97
  /**
101
98
  * Resolve the pre-navigation URL for a command, or null to skip.
@@ -127,7 +124,9 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
127
124
  kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
128
125
  }
129
126
  catch (err) {
130
- throw new Error(`[Argument Validation Error]\n${getErrorMessage(err)}`);
127
+ if (err instanceof ArgumentError)
128
+ throw err;
129
+ throw new ArgumentError(getErrorMessage(err));
131
130
  }
132
131
  if (shouldUseBrowserSession(cmd)) {
133
132
  const BrowserFactory = getBrowserFactory();
package/dist/explore.js CHANGED
@@ -12,6 +12,7 @@ import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_
12
12
  import { detectFramework } from './scripts/framework.js';
13
13
  import { discoverStores } from './scripts/store.js';
14
14
  import { interactFuzz } from './scripts/interact.js';
15
+ import { log } from './logger.js';
15
16
  // ── Site name detection ────────────────────────────────────────────────────
16
17
  const KNOWN_SITE_ALIASES = {
17
18
  'x.com': 'twitter', 'twitter.com': 'twitter',
@@ -148,7 +149,8 @@ function flattenFields(obj, prefix, maxDepth) {
148
149
  return names;
149
150
  }
150
151
  function isBooleanRecord(value) {
151
- return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
153
+ && Object.values(value).every(v => typeof v === 'boolean');
152
154
  }
153
155
  function scoreEndpoint(ep) {
154
156
  let s = 0;
@@ -360,7 +362,7 @@ export async function exploreUrl(url, opts) {
360
362
  await page.wait(2); // wait for XHRs to settle
361
363
  }
362
364
  catch (e) {
363
- // fuzzing is best-effort, don't fail the whole explore
365
+ log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
364
366
  }
365
367
  }
366
368
  // Step 3: Read page metadata
@@ -15,6 +15,21 @@ export interface ExternalCliConfig {
15
15
  export declare function loadExternalClis(): ExternalCliConfig[];
16
16
  export declare function isBinaryInstalled(binary: string): boolean;
17
17
  export declare function getInstallCmd(installConfig?: ExternalCliInstall): string | null;
18
+ /**
19
+ * Safely parses a command string into a binary and argument list.
20
+ * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
21
+ * cannot be safely expressed as execFileSync arguments.
22
+ *
23
+ * Args:
24
+ * cmd: Raw command string from YAML config (e.g. "brew install gh")
25
+ *
26
+ * Returns:
27
+ * Object with `binary` and `args` fields, or throws on unsafe input.
28
+ */
29
+ export declare function parseCommand(cmd: string): {
30
+ binary: string;
31
+ args: string[];
32
+ };
18
33
  export declare function installExternalCli(cli: ExternalCliConfig): boolean;
19
34
  export declare function executeExternalCli(name: string, args: string[], preloaded?: ExternalCliConfig[]): void;
20
35
  export interface RegisterOptions {
package/dist/external.js CHANGED
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { spawnSync, execSync, execFileSync } from 'node:child_process';
5
+ import { spawnSync, execFileSync } from 'node:child_process';
6
6
  import yaml from 'js-yaml';
7
7
  import chalk from 'chalk';
8
8
  import { log } from './logger.js';
@@ -66,6 +66,52 @@ export function getInstallCmd(installConfig) {
66
66
  return installConfig.default;
67
67
  return null;
68
68
  }
69
+ /**
70
+ * Safely parses a command string into a binary and argument list.
71
+ * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
72
+ * cannot be safely expressed as execFileSync arguments.
73
+ *
74
+ * Args:
75
+ * cmd: Raw command string from YAML config (e.g. "brew install gh")
76
+ *
77
+ * Returns:
78
+ * Object with `binary` and `args` fields, or throws on unsafe input.
79
+ */
80
+ export function parseCommand(cmd) {
81
+ const shellOperators = /&&|\|\|?|;|[><`]/;
82
+ if (shellOperators.test(cmd)) {
83
+ throw new Error(`Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
84
+ `Please install the tool manually.`);
85
+ }
86
+ // Tokenise respecting single- and double-quoted segments (no variable expansion).
87
+ const tokens = [];
88
+ const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
89
+ let match;
90
+ while ((match = re.exec(cmd)) !== null) {
91
+ tokens.push(match[1] ?? match[2] ?? match[3]);
92
+ }
93
+ if (tokens.length === 0) {
94
+ throw new Error(`Install command is empty.`);
95
+ }
96
+ const [binary, ...args] = tokens;
97
+ return { binary, args };
98
+ }
99
+ function shouldRetryWithCmdShim(binary, err) {
100
+ return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
101
+ }
102
+ function runInstallCommand(cmd) {
103
+ const { binary, args } = parseCommand(cmd);
104
+ try {
105
+ execFileSync(binary, args, { stdio: 'inherit' });
106
+ }
107
+ catch (err) {
108
+ if (shouldRetryWithCmdShim(binary, err)) {
109
+ execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
110
+ return;
111
+ }
112
+ throw err;
113
+ }
114
+ }
69
115
  export function installExternalCli(cli) {
70
116
  if (!cli.install) {
71
117
  console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
@@ -82,7 +128,7 @@ export function installExternalCli(cli) {
82
128
  console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
83
129
  console.log(chalk.dim(`$ ${cmd}`));
84
130
  try {
85
- execSync(cmd, { stdio: 'inherit' });
131
+ runInstallCommand(cmd);
86
132
  console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
87
133
  return true;
88
134
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
3
+ mockExecFileSync: vi.fn(),
4
+ mockPlatform: vi.fn(() => 'darwin'),
5
+ }));
6
+ vi.mock('node:child_process', () => ({
7
+ spawnSync: vi.fn(),
8
+ execFileSync: mockExecFileSync,
9
+ }));
10
+ vi.mock('node:os', async () => {
11
+ const actual = await vi.importActual('node:os');
12
+ return {
13
+ ...actual,
14
+ platform: mockPlatform,
15
+ };
16
+ });
17
+ import { installExternalCli, parseCommand } from './external.js';
18
+ describe('parseCommand', () => {
19
+ it('splits binaries and quoted arguments without invoking a shell', () => {
20
+ expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
21
+ binary: 'npm',
22
+ args: ['install', '-g', '@scope/tool name'],
23
+ });
24
+ });
25
+ it('rejects shell operators', () => {
26
+ expect(() => parseCommand('brew install gh && rm -rf /')).toThrow('Install command contains unsafe shell operators');
27
+ });
28
+ });
29
+ describe('installExternalCli', () => {
30
+ const cli = {
31
+ name: 'readwise',
32
+ binary: 'readwise',
33
+ install: {
34
+ default: 'npm install -g @readwiseio/readwise-cli',
35
+ },
36
+ };
37
+ beforeEach(() => {
38
+ mockExecFileSync.mockReset();
39
+ mockPlatform.mockReturnValue('darwin');
40
+ });
41
+ it('retries with .cmd on Windows when the bare binary is unavailable', () => {
42
+ mockPlatform.mockReturnValue('win32');
43
+ mockExecFileSync
44
+ .mockImplementationOnce(() => {
45
+ const err = new Error('not found');
46
+ err.code = 'ENOENT';
47
+ throw err;
48
+ })
49
+ .mockReturnValueOnce(Buffer.from(''));
50
+ expect(installExternalCli(cli)).toBe(true);
51
+ expect(mockExecFileSync).toHaveBeenNthCalledWith(1, 'npm', ['install', '-g', '@readwiseio/readwise-cli'], { stdio: 'inherit' });
52
+ expect(mockExecFileSync).toHaveBeenNthCalledWith(2, 'npm.cmd', ['install', '-g', '@readwiseio/readwise-cli'], { stdio: 'inherit' });
53
+ });
54
+ it('does not mask non-ENOENT failures', () => {
55
+ mockPlatform.mockReturnValue('win32');
56
+ mockExecFileSync.mockImplementationOnce(() => {
57
+ const err = new Error('permission denied');
58
+ err.code = 'EACCES';
59
+ throw err;
60
+ });
61
+ expect(installExternalCli(cli)).toBe(false);
62
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
63
+ });
64
+ });
package/dist/main.js CHANGED
@@ -2,6 +2,16 @@
2
2
  /**
3
3
  * opencli — Make any website your CLI. AI-powered.
4
4
  */
5
+ // Ensure standard system paths are available for child processes.
6
+ // Some environments (GUI apps, cron, IDE terminals) launch with a minimal PATH
7
+ // that excludes /usr/local/bin, /usr/sbin, etc., causing external CLIs to fail.
8
+ if (process.platform !== 'win32') {
9
+ const std = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
10
+ const cur = new Set((process.env.PATH ?? '').split(':').filter(Boolean));
11
+ for (const p of std)
12
+ cur.add(p);
13
+ process.env.PATH = [...cur].join(':');
14
+ }
5
15
  import * as os from 'node:os';
6
16
  import * as path from 'node:path';
7
17
  import { fileURLToPath } from 'node:url';
package/dist/plugin.d.ts CHANGED
@@ -19,6 +19,10 @@ export declare function installPlugin(source: string): string;
19
19
  * Uninstall a plugin by name.
20
20
  */
21
21
  export declare function uninstallPlugin(name: string): void;
22
+ /**
23
+ * Update a plugin by name (git pull + re-install lifecycle).
24
+ */
25
+ export declare function updatePlugin(name: string): void;
22
26
  /**
23
27
  * List all installed plugins.
24
28
  */
package/dist/plugin.js CHANGED
@@ -9,6 +9,30 @@ import * as path from 'node:path';
9
9
  import { execSync, execFileSync } from 'node:child_process';
10
10
  import { PLUGINS_DIR } from './discovery.js';
11
11
  import { log } from './logger.js';
12
+ /**
13
+ * Shared post-install lifecycle: npm install → host symlink → TS transpile.
14
+ * Called by both installPlugin() and updatePlugin().
15
+ */
16
+ function postInstallLifecycle(pluginDir) {
17
+ const pkgJsonPath = path.join(pluginDir, 'package.json');
18
+ if (!fs.existsSync(pkgJsonPath))
19
+ return;
20
+ try {
21
+ execFileSync('npm', ['install', '--omit=dev'], {
22
+ cwd: pluginDir,
23
+ encoding: 'utf-8',
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ });
26
+ }
27
+ catch {
28
+ // Non-fatal: npm install may fail if no real deps
29
+ }
30
+ // Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
31
+ // against the running host, not a stale npm-published version.
32
+ linkHostOpencli(pluginDir);
33
+ // Transpile .ts → .js via esbuild (production node can't load .ts directly).
34
+ transpilePluginTs(pluginDir);
35
+ }
12
36
  /**
13
37
  * Install a plugin from a source.
14
38
  * Currently supports "github:user/repo" format (git clone wrapper).
@@ -37,29 +61,7 @@ export function installPlugin(source) {
37
61
  catch (err) {
38
62
  throw new Error(`Failed to clone plugin: ${err.message}`);
39
63
  }
40
- // If the plugin has a package.json, run npm install for regular deps,
41
- // then symlink the host opencli into node_modules for peerDep resolution.
42
- const pkgJsonPath = path.join(targetDir, 'package.json');
43
- if (fs.existsSync(pkgJsonPath)) {
44
- try {
45
- execFileSync('npm', ['install', '--omit=dev'], {
46
- cwd: targetDir,
47
- encoding: 'utf-8',
48
- stdio: ['pipe', 'pipe', 'pipe'],
49
- });
50
- }
51
- catch {
52
- // Non-fatal: npm install may fail if no real deps
53
- }
54
- // Symlink host opencli into plugin's node_modules so TS plugins
55
- // can resolve '@jackwener/opencli/registry' against the running host.
56
- // This is more reliable than depending on the npm-published version
57
- // which may lag behind the local installation.
58
- linkHostOpencli(targetDir);
59
- // Transpile TS plugin files to JS so they work in production mode
60
- // (node cannot load .ts files directly without tsx).
61
- transpilePluginTs(targetDir);
62
- }
64
+ postInstallLifecycle(targetDir);
63
65
  return name;
64
66
  }
65
67
  /**
@@ -72,6 +74,26 @@ export function uninstallPlugin(name) {
72
74
  }
73
75
  fs.rmSync(targetDir, { recursive: true, force: true });
74
76
  }
77
+ /**
78
+ * Update a plugin by name (git pull + re-install lifecycle).
79
+ */
80
+ export function updatePlugin(name) {
81
+ const targetDir = path.join(PLUGINS_DIR, name);
82
+ if (!fs.existsSync(targetDir)) {
83
+ throw new Error(`Plugin "${name}" is not installed.`);
84
+ }
85
+ try {
86
+ execFileSync('git', ['pull', '--ff-only'], {
87
+ cwd: targetDir,
88
+ encoding: 'utf-8',
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ });
91
+ }
92
+ catch (err) {
93
+ throw new Error(`Failed to update plugin: ${err.message}`);
94
+ }
95
+ postInstallLifecycle(targetDir);
96
+ }
75
97
  /**
76
98
  * List all installed plugins.
77
99
  */
@@ -5,7 +5,7 @@ import { describe, it, expect, afterEach } from 'vitest';
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
7
  import { PLUGINS_DIR } from './discovery.js';
8
- import { listPlugins, uninstallPlugin, _parseSource } from './plugin.js';
8
+ import { listPlugins, uninstallPlugin, updatePlugin, _parseSource } from './plugin.js';
9
9
  describe('parseSource', () => {
10
10
  it('parses github:user/repo format', () => {
11
11
  const result = _parseSource('github:ByteYue/opencli-plugin-github-trending');
@@ -74,3 +74,8 @@ describe('uninstallPlugin', () => {
74
74
  expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed');
75
75
  });
76
76
  });
77
+ describe('updatePlugin', () => {
78
+ it('throws for non-existent plugin', () => {
79
+ expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
80
+ });
81
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Record mode — capture API calls from a live browser session.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to the target URL in an automation tab
6
+ * 2. Inject a full-capture fetch/XHR interceptor (records url + method + body)
7
+ * 3. Poll every 2s and print newly captured requests
8
+ * 4. User operates the page; press Enter to stop
9
+ * 5. Analyze captured requests → infer capabilities → write YAML candidates
10
+ *
11
+ * Design: no new daemon endpoints, no extension changes.
12
+ * Uses existing exec + navigate actions only.
13
+ */
14
+ import type { IPage } from './types.js';
15
+ export interface RecordedRequest {
16
+ url: string;
17
+ method: string;
18
+ status: number | null;
19
+ contentType: string;
20
+ body: unknown;
21
+ capturedAt: number;
22
+ }
23
+ export interface RecordResult {
24
+ site: string;
25
+ url: string;
26
+ requests: RecordedRequest[];
27
+ outDir: string;
28
+ candidateCount: number;
29
+ candidates: Array<{
30
+ name: string;
31
+ path: string;
32
+ strategy: string;
33
+ }>;
34
+ }
35
+ export interface RecordOptions {
36
+ BrowserFactory: new () => {
37
+ connect(o?: unknown): Promise<IPage>;
38
+ close(): Promise<void>;
39
+ };
40
+ site?: string;
41
+ url: string;
42
+ outDir?: string;
43
+ pollMs?: number;
44
+ timeoutMs?: number;
45
+ }
46
+ export declare function recordSession(opts: RecordOptions): Promise<RecordResult>;
47
+ export declare function renderRecordSummary(result: RecordResult): string;