@jackwener/opencli 1.3.1 → 1.3.3

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 (241) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +48 -9
  3. package/README.zh-CN.md +48 -9
  4. package/SKILL.md +317 -6
  5. package/TESTING.md +4 -4
  6. package/dist/browser/cdp.js +10 -1
  7. package/dist/browser/daemon-client.js +2 -1
  8. package/dist/browser/discover.js +2 -1
  9. package/dist/browser/errors.d.ts +2 -1
  10. package/dist/browser/errors.js +10 -10
  11. package/dist/browser/index.d.ts +1 -0
  12. package/dist/browser/index.js +1 -0
  13. package/dist/browser/page.js +12 -0
  14. package/dist/browser/stealth.d.ts +18 -0
  15. package/dist/browser/stealth.js +140 -0
  16. package/dist/browser.test.js +47 -1
  17. package/dist/build-manifest.js +1 -3
  18. package/dist/cli-manifest.json +2573 -989
  19. package/dist/cli.js +42 -2
  20. package/dist/clis/bilibili/download.js +20 -65
  21. package/dist/clis/bilibili/utils.js +2 -1
  22. package/dist/clis/chaoxing/assignments.js +2 -1
  23. package/dist/clis/doubao/ask.d.ts +1 -0
  24. package/dist/clis/doubao/ask.js +35 -0
  25. package/dist/clis/doubao/common.d.ts +23 -0
  26. package/dist/clis/doubao/common.js +564 -0
  27. package/dist/clis/doubao/new.d.ts +1 -0
  28. package/dist/clis/doubao/new.js +20 -0
  29. package/dist/clis/doubao/read.d.ts +1 -0
  30. package/dist/clis/doubao/read.js +19 -0
  31. package/dist/clis/doubao/send.d.ts +1 -0
  32. package/dist/clis/doubao/send.js +22 -0
  33. package/dist/clis/doubao/status.d.ts +1 -0
  34. package/dist/clis/doubao/status.js +24 -0
  35. package/dist/clis/doubao-app/ask.d.ts +1 -0
  36. package/dist/clis/doubao-app/ask.js +53 -0
  37. package/dist/clis/doubao-app/common.d.ts +37 -0
  38. package/dist/clis/doubao-app/common.js +110 -0
  39. package/dist/clis/doubao-app/dump.d.ts +1 -0
  40. package/dist/clis/doubao-app/dump.js +24 -0
  41. package/dist/clis/doubao-app/new.d.ts +1 -0
  42. package/dist/clis/doubao-app/new.js +20 -0
  43. package/dist/clis/doubao-app/read.d.ts +1 -0
  44. package/dist/clis/doubao-app/read.js +18 -0
  45. package/dist/clis/doubao-app/screenshot.d.ts +1 -0
  46. package/dist/clis/doubao-app/screenshot.js +18 -0
  47. package/dist/clis/doubao-app/send.d.ts +1 -0
  48. package/dist/clis/doubao-app/send.js +27 -0
  49. package/dist/clis/doubao-app/status.d.ts +1 -0
  50. package/dist/clis/doubao-app/status.js +16 -0
  51. package/dist/clis/hackernews/ask.yaml +38 -0
  52. package/dist/clis/hackernews/best.yaml +38 -0
  53. package/dist/clis/hackernews/jobs.yaml +36 -0
  54. package/dist/clis/hackernews/new.yaml +38 -0
  55. package/dist/clis/hackernews/search.yaml +44 -0
  56. package/dist/clis/hackernews/show.yaml +38 -0
  57. package/dist/clis/hackernews/top.yaml +3 -1
  58. package/dist/clis/hackernews/user.yaml +25 -0
  59. package/dist/clis/twitter/download.js +13 -97
  60. package/dist/clis/twitter/thread.js +2 -1
  61. package/dist/clis/v2ex/member.yaml +29 -0
  62. package/dist/clis/v2ex/node.yaml +34 -0
  63. package/dist/clis/v2ex/nodes.yaml +31 -0
  64. package/dist/clis/v2ex/replies.yaml +32 -0
  65. package/dist/clis/v2ex/user.yaml +34 -0
  66. package/dist/clis/weibo/search.d.ts +1 -0
  67. package/dist/clis/weibo/search.js +73 -0
  68. package/dist/clis/weixin/download.d.ts +12 -0
  69. package/dist/clis/weixin/download.js +183 -0
  70. package/dist/clis/xiaohongshu/download.js +12 -60
  71. package/dist/clis/xiaohongshu/publish.d.ts +18 -0
  72. package/dist/clis/xiaohongshu/publish.js +352 -0
  73. package/dist/clis/xiaohongshu/search.js +47 -15
  74. package/dist/clis/xiaohongshu/search.test.d.ts +1 -0
  75. package/dist/clis/xiaohongshu/search.test.js +114 -0
  76. package/dist/clis/yollomi/background.d.ts +4 -0
  77. package/dist/clis/yollomi/background.js +45 -0
  78. package/dist/clis/yollomi/edit.d.ts +5 -0
  79. package/dist/clis/yollomi/edit.js +56 -0
  80. package/dist/clis/yollomi/face-swap.d.ts +5 -0
  81. package/dist/clis/yollomi/face-swap.js +43 -0
  82. package/dist/clis/yollomi/generate.d.ts +9 -0
  83. package/dist/clis/yollomi/generate.js +100 -0
  84. package/dist/clis/yollomi/models.d.ts +1 -0
  85. package/dist/clis/yollomi/models.js +33 -0
  86. package/dist/clis/yollomi/object-remover.d.ts +4 -0
  87. package/dist/clis/yollomi/object-remover.js +42 -0
  88. package/dist/clis/yollomi/remove-bg.d.ts +4 -0
  89. package/dist/clis/yollomi/remove-bg.js +38 -0
  90. package/dist/clis/yollomi/restore.d.ts +4 -0
  91. package/dist/clis/yollomi/restore.js +38 -0
  92. package/dist/clis/yollomi/try-on.d.ts +4 -0
  93. package/dist/clis/yollomi/try-on.js +46 -0
  94. package/dist/clis/yollomi/upload.d.ts +7 -0
  95. package/dist/clis/yollomi/upload.js +71 -0
  96. package/dist/clis/yollomi/upscale.d.ts +4 -0
  97. package/dist/clis/yollomi/upscale.js +53 -0
  98. package/dist/clis/yollomi/utils.d.ts +45 -0
  99. package/dist/clis/yollomi/utils.js +180 -0
  100. package/dist/clis/yollomi/video.d.ts +5 -0
  101. package/dist/clis/yollomi/video.js +56 -0
  102. package/dist/clis/zhihu/download.d.ts +1 -5
  103. package/dist/clis/zhihu/download.js +20 -126
  104. package/dist/clis/zhihu/download.test.js +7 -5
  105. package/dist/clis/zhihu/question.js +2 -1
  106. package/dist/commanderAdapter.js +4 -6
  107. package/dist/constants.d.ts +2 -0
  108. package/dist/constants.js +2 -0
  109. package/dist/daemon.js +7 -3
  110. package/dist/discovery.js +10 -10
  111. package/dist/doctor.js +2 -1
  112. package/dist/download/article-download.d.ts +59 -0
  113. package/dist/download/article-download.js +178 -0
  114. package/dist/download/media-download.d.ts +49 -0
  115. package/dist/download/media-download.js +112 -0
  116. package/dist/errors.d.ts +23 -2
  117. package/dist/errors.js +58 -2
  118. package/dist/errors.test.d.ts +1 -0
  119. package/dist/errors.test.js +59 -0
  120. package/dist/execution.js +9 -10
  121. package/dist/explore.js +4 -2
  122. package/dist/external.d.ts +15 -0
  123. package/dist/external.js +48 -2
  124. package/dist/external.test.d.ts +1 -0
  125. package/dist/external.test.js +64 -0
  126. package/dist/main.js +10 -0
  127. package/dist/plugin.d.ts +4 -0
  128. package/dist/plugin.js +45 -23
  129. package/dist/plugin.test.js +6 -1
  130. package/dist/record.d.ts +47 -0
  131. package/dist/record.js +545 -0
  132. package/dist/registry.d.ts +7 -2
  133. package/dist/registry.js +2 -6
  134. package/dist/runtime.d.ts +3 -1
  135. package/dist/runtime.js +10 -3
  136. package/dist/validate.js +1 -3
  137. package/docs/.vitepress/config.mts +1 -0
  138. package/docs/adapters/browser/douban.md +18 -8
  139. package/docs/adapters/browser/doubao.md +35 -0
  140. package/docs/adapters/browser/hackernews.md +20 -4
  141. package/docs/adapters/browser/tiktok.md +1 -1
  142. package/docs/adapters/browser/v2ex.md +31 -10
  143. package/docs/adapters/browser/weibo.md +4 -0
  144. package/docs/adapters/browser/weixin.md +33 -0
  145. package/docs/adapters/browser/wikipedia.md +0 -9
  146. package/docs/adapters/browser/xiaohongshu.md +8 -6
  147. package/docs/adapters/browser/yollomi.md +69 -0
  148. package/docs/adapters/desktop/antigravity.md +0 -3
  149. package/docs/adapters/desktop/doubao-app.md +35 -0
  150. package/docs/adapters/index.md +19 -8
  151. package/docs/advanced/download.md +4 -0
  152. package/package.json +3 -1
  153. package/src/browser/cdp.ts +9 -1
  154. package/src/browser/daemon-client.ts +4 -3
  155. package/src/browser/discover.ts +2 -1
  156. package/src/browser/errors.ts +18 -11
  157. package/src/browser/index.ts +1 -0
  158. package/src/browser/page.ts +11 -0
  159. package/src/browser/stealth.ts +142 -0
  160. package/src/browser.test.ts +51 -1
  161. package/src/build-manifest.ts +1 -3
  162. package/src/cli.ts +45 -2
  163. package/src/clis/bilibili/download.ts +25 -83
  164. package/src/clis/bilibili/utils.ts +2 -1
  165. package/src/clis/chaoxing/assignments.ts +2 -1
  166. package/src/clis/doubao/ask.ts +40 -0
  167. package/src/clis/doubao/common.ts +619 -0
  168. package/src/clis/doubao/new.ts +22 -0
  169. package/src/clis/doubao/read.ts +20 -0
  170. package/src/clis/doubao/send.ts +25 -0
  171. package/src/clis/doubao/status.ts +27 -0
  172. package/src/clis/doubao-app/ask.ts +60 -0
  173. package/src/clis/doubao-app/common.ts +116 -0
  174. package/src/clis/doubao-app/dump.ts +28 -0
  175. package/src/clis/doubao-app/new.ts +21 -0
  176. package/src/clis/doubao-app/read.ts +21 -0
  177. package/src/clis/doubao-app/screenshot.ts +19 -0
  178. package/src/clis/doubao-app/send.ts +30 -0
  179. package/src/clis/doubao-app/status.ts +17 -0
  180. package/src/clis/hackernews/ask.yaml +38 -0
  181. package/src/clis/hackernews/best.yaml +38 -0
  182. package/src/clis/hackernews/jobs.yaml +36 -0
  183. package/src/clis/hackernews/new.yaml +38 -0
  184. package/src/clis/hackernews/search.yaml +44 -0
  185. package/src/clis/hackernews/show.yaml +38 -0
  186. package/src/clis/hackernews/top.yaml +3 -1
  187. package/src/clis/hackernews/user.yaml +25 -0
  188. package/src/clis/twitter/download.ts +13 -111
  189. package/src/clis/twitter/thread.ts +2 -1
  190. package/src/clis/v2ex/member.yaml +29 -0
  191. package/src/clis/v2ex/node.yaml +34 -0
  192. package/src/clis/v2ex/nodes.yaml +31 -0
  193. package/src/clis/v2ex/replies.yaml +32 -0
  194. package/src/clis/v2ex/user.yaml +34 -0
  195. package/src/clis/weibo/search.ts +78 -0
  196. package/src/clis/weixin/download.ts +199 -0
  197. package/src/clis/xiaohongshu/download.ts +12 -71
  198. package/src/clis/xiaohongshu/publish.ts +392 -0
  199. package/src/clis/xiaohongshu/search.test.ts +134 -0
  200. package/src/clis/xiaohongshu/search.ts +49 -15
  201. package/src/clis/yollomi/background.ts +48 -0
  202. package/src/clis/yollomi/edit.ts +58 -0
  203. package/src/clis/yollomi/face-swap.ts +45 -0
  204. package/src/clis/yollomi/generate.ts +95 -0
  205. package/src/clis/yollomi/models.ts +38 -0
  206. package/src/clis/yollomi/object-remover.ts +44 -0
  207. package/src/clis/yollomi/remove-bg.ts +40 -0
  208. package/src/clis/yollomi/restore.ts +40 -0
  209. package/src/clis/yollomi/try-on.ts +48 -0
  210. package/src/clis/yollomi/upload.ts +78 -0
  211. package/src/clis/yollomi/upscale.ts +49 -0
  212. package/src/clis/yollomi/utils.ts +202 -0
  213. package/src/clis/yollomi/video.ts +61 -0
  214. package/src/clis/zhihu/download.test.ts +7 -5
  215. package/src/clis/zhihu/download.ts +23 -158
  216. package/src/clis/zhihu/question.ts +2 -1
  217. package/src/commanderAdapter.ts +4 -7
  218. package/src/constants.ts +3 -0
  219. package/src/daemon.ts +7 -3
  220. package/src/discovery.ts +26 -26
  221. package/src/doctor.ts +2 -1
  222. package/src/download/article-download.ts +272 -0
  223. package/src/download/media-download.ts +178 -0
  224. package/src/errors.test.ts +79 -0
  225. package/src/errors.ts +92 -2
  226. package/src/execution.ts +14 -10
  227. package/src/explore.ts +4 -2
  228. package/src/external.test.ts +88 -0
  229. package/src/external.ts +56 -2
  230. package/src/generate.ts +2 -1
  231. package/src/main.ts +10 -0
  232. package/src/plugin.test.ts +7 -1
  233. package/src/plugin.ts +49 -25
  234. package/src/record.ts +617 -0
  235. package/src/registry.ts +9 -5
  236. package/src/runtime.ts +16 -4
  237. package/src/validate.ts +1 -3
  238. package/tests/e2e/browser-auth.test.ts +10 -1
  239. package/tests/e2e/browser-public.test.ts +13 -8
  240. package/tests/e2e/public-commands.test.ts +209 -21
  241. package/tests/smoke/api-health.test.ts +65 -6
package/src/errors.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  * Unified error types for opencli.
3
3
  *
4
4
  * All errors thrown by the framework should extend CliError so that
5
- * the top-level handler in main.ts can render consistent, helpful output.
5
+ * the top-level handler in commanderAdapter.ts can render consistent,
6
+ * helpful output with emoji-coded severity and actionable hints.
6
7
  */
7
8
 
8
9
  export class CliError extends Error {
9
- /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */
10
+ /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */
10
11
  readonly code: string;
11
12
  /** Human-readable hint on how to fix the problem */
12
13
  readonly hint?: string;
@@ -19,6 +20,8 @@ export class CliError extends Error {
19
20
  }
20
21
  }
21
22
 
23
+ // ── Browser / Connection ────────────────────────────────────────────────────
24
+
22
25
  export class BrowserConnectError extends CliError {
23
26
  constructor(message: string, hint?: string) {
24
27
  super('BROWSER_CONNECT', message, hint);
@@ -26,6 +29,8 @@ export class BrowserConnectError extends CliError {
26
29
  }
27
30
  }
28
31
 
32
+ // ── Adapter loading ─────────────────────────────────────────────────────────
33
+
29
34
  export class AdapterLoadError extends CliError {
30
35
  constructor(message: string, hint?: string) {
31
36
  super('ADAPTER_LOAD', message, hint);
@@ -33,6 +38,8 @@ export class AdapterLoadError extends CliError {
33
38
  }
34
39
  }
35
40
 
41
+ // ── Command execution ───────────────────────────────────────────────────────
42
+
36
43
  export class CommandExecutionError extends CliError {
37
44
  constructor(message: string, hint?: string) {
38
45
  super('COMMAND_EXEC', message, hint);
@@ -40,9 +47,92 @@ export class CommandExecutionError extends CliError {
40
47
  }
41
48
  }
42
49
 
50
+ // ── Configuration ───────────────────────────────────────────────────────────
51
+
43
52
  export class ConfigError extends CliError {
44
53
  constructor(message: string, hint?: string) {
45
54
  super('CONFIG', message, hint);
46
55
  this.name = 'ConfigError';
47
56
  }
48
57
  }
58
+
59
+ // ── Authentication / Login ──────────────────────────────────────────────────
60
+
61
+ export class AuthRequiredError extends CliError {
62
+ readonly domain: string;
63
+
64
+ constructor(domain: string, message?: string) {
65
+ super(
66
+ 'AUTH_REQUIRED',
67
+ message ?? `Not logged in to ${domain}`,
68
+ `Please open Chrome and log in to https://${domain}`,
69
+ );
70
+ this.name = 'AuthRequiredError';
71
+ this.domain = domain;
72
+ }
73
+ }
74
+
75
+ // ── Timeout ─────────────────────────────────────────────────────────────────
76
+
77
+ export class TimeoutError extends CliError {
78
+ constructor(label: string, seconds: number) {
79
+ super(
80
+ 'TIMEOUT',
81
+ `${label} timed out after ${seconds}s`,
82
+ 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
83
+ );
84
+ this.name = 'TimeoutError';
85
+ }
86
+ }
87
+
88
+ // ── Argument validation ─────────────────────────────────────────────────────
89
+
90
+ export class ArgumentError extends CliError {
91
+ constructor(message: string, hint?: string) {
92
+ super('ARGUMENT', message, hint);
93
+ this.name = 'ArgumentError';
94
+ }
95
+ }
96
+
97
+ // ── Empty result ────────────────────────────────────────────────────────────
98
+
99
+ export class EmptyResultError extends CliError {
100
+ constructor(command: string, hint?: string) {
101
+ super(
102
+ 'EMPTY_RESULT',
103
+ `${command} returned no data`,
104
+ hint ?? 'The page structure may have changed, or you may need to log in',
105
+ );
106
+ this.name = 'EmptyResultError';
107
+ }
108
+ }
109
+
110
+ // ── Selector / DOM ──────────────────────────────────────────────────────────
111
+
112
+ export class SelectorError extends CliError {
113
+ constructor(selector: string, hint?: string) {
114
+ super(
115
+ 'SELECTOR',
116
+ `Could not find element: ${selector}`,
117
+ hint ?? 'The page UI may have changed. Please report this issue.',
118
+ );
119
+ this.name = 'SelectorError';
120
+ }
121
+ }
122
+
123
+ // ── Utilities ───────────────────────────────────────────────────────────
124
+
125
+ /** Extract a human-readable message from an unknown caught value. */
126
+ export function getErrorMessage(error: unknown): string {
127
+ return error instanceof Error ? error.message : String(error);
128
+ }
129
+
130
+ /** Error code → emoji mapping for CLI output rendering. */
131
+ export const ERROR_ICONS: Record<string, string> = {
132
+ AUTH_REQUIRED: '🔒',
133
+ BROWSER_CONNECT: '🔌',
134
+ TIMEOUT: '⏱ ',
135
+ ARGUMENT: '❌',
136
+ EMPTY_RESULT: '📭',
137
+ SELECTOR: '🔍',
138
+ };
package/src/execution.ts CHANGED
@@ -13,7 +13,7 @@ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, getRegist
13
13
  import type { IPage } from './types.js';
14
14
  import { pathToFileURL } from 'node:url';
15
15
  import { executePipeline } from './pipeline/index.js';
16
- import { AdapterLoadError } from './errors.js';
16
+ import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
17
17
  import { shouldUseBrowserSession } from './capabilityRouting.js';
18
18
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
19
19
 
@@ -21,9 +21,6 @@ import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMM
21
21
  const _loadedModules = new Set<string>();
22
22
  type CommandArgs = Record<string, unknown>;
23
23
 
24
- function getErrorMessage(error: unknown): string {
25
- return error instanceof Error ? error.message : String(error);
26
- }
27
24
 
28
25
  /**
29
26
  * Validates and coerces arguments based on the command's Arg definitions.
@@ -36,7 +33,10 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
36
33
 
37
34
  // 1. Check required
38
35
  if (argDef.required && (val === undefined || val === null || val === '')) {
39
- throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
36
+ throw new ArgumentError(
37
+ `Argument "${argDef.name}" is required.`,
38
+ argDef.help ?? `Provide a value for --${argDef.name}`,
39
+ );
40
40
  }
41
41
 
42
42
  if (val !== undefined && val !== null) {
@@ -44,7 +44,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
44
44
  if (argDef.type === 'int' || argDef.type === 'number') {
45
45
  const num = Number(val);
46
46
  if (Number.isNaN(num)) {
47
- throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
47
+ throw new ArgumentError(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
48
48
  }
49
49
  result[argDef.name] = num;
50
50
  } else if (argDef.type === 'boolean' || argDef.type === 'bool') {
@@ -52,7 +52,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
52
52
  const lower = val.toLowerCase();
53
53
  if (lower === 'true' || lower === '1') result[argDef.name] = true;
54
54
  else if (lower === 'false' || lower === '0') result[argDef.name] = false;
55
- else throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
55
+ else throw new ArgumentError(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
56
56
  } else {
57
57
  result[argDef.name] = Boolean(val);
58
58
  }
@@ -62,7 +62,7 @@ export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): Comm
62
62
  const coercedVal = result[argDef.name];
63
63
  if (argDef.choices && argDef.choices.length > 0) {
64
64
  if (!argDef.choices.map(String).includes(String(coercedVal))) {
65
- throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
65
+ throw new ArgumentError(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
66
66
  }
67
67
  }
68
68
  } else if (argDef.default !== undefined) {
@@ -104,7 +104,10 @@ async function runCommand(
104
104
 
105
105
  if (cmd.func) return cmd.func(page!, kwargs, debug);
106
106
  if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
107
- throw new Error(`Command ${fullName(cmd)} has no func or pipeline`);
107
+ throw new CommandExecutionError(
108
+ `Command ${fullName(cmd)} has no func or pipeline`,
109
+ 'This is likely a bug in the adapter definition. Please report this issue.',
110
+ );
108
111
  }
109
112
 
110
113
  /**
@@ -140,7 +143,8 @@ export async function executeCommand(
140
143
  try {
141
144
  kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
142
145
  } catch (err) {
143
- throw new Error(`[Argument Validation Error]\n${getErrorMessage(err)}`);
146
+ if (err instanceof ArgumentError) throw err;
147
+ throw new ArgumentError(getErrorMessage(err));
144
148
  }
145
149
 
146
150
  if (shouldUseBrowserSession(cmd)) {
package/src/explore.ts CHANGED
@@ -15,6 +15,7 @@ import { detectFramework } from './scripts/framework.js';
15
15
  import { discoverStores } from './scripts/store.js';
16
16
  import { interactFuzz } from './scripts/interact.js';
17
17
  import type { IPage } from './types.js';
18
+ import { log } from './logger.js';
18
19
 
19
20
  // ── Site name detection ────────────────────────────────────────────────────
20
21
 
@@ -224,7 +225,8 @@ function flattenFields(obj: unknown, prefix: string, maxDepth: number): string[]
224
225
  }
225
226
 
226
227
  function isBooleanRecord(value: unknown): value is Record<string, boolean> {
227
- return typeof value === 'object' && value !== null && !Array.isArray(value);
228
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
229
+ && Object.values(value as Record<string, unknown>).every(v => typeof v === 'boolean');
228
230
  }
229
231
 
230
232
  function scoreEndpoint(ep: { contentType: string; responseAnalysis: AnalyzedEndpoint['responseAnalysis']; pattern: string; status: number | null; hasSearchParam: boolean; hasPaginationParam: boolean; hasLimitParam: boolean }): number {
@@ -453,7 +455,7 @@ export async function exploreUrl(
453
455
  const clicks = await page.evaluate(INTERACT_FUZZ_JS);
454
456
  await page.wait(2); // wait for XHRs to settle
455
457
  } catch (e) {
456
- // fuzzing is best-effort, don't fail the whole explore
458
+ log.debug(`Interactive fuzzing skipped: ${e instanceof Error ? e.message : String(e)}`);
457
459
  }
458
460
  }
459
461
 
@@ -0,0 +1,88 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockExecFileSync, mockPlatform } = vi.hoisted(() => ({
4
+ mockExecFileSync: vi.fn(),
5
+ mockPlatform: vi.fn(() => 'darwin'),
6
+ }));
7
+
8
+ vi.mock('node:child_process', () => ({
9
+ spawnSync: vi.fn(),
10
+ execFileSync: mockExecFileSync,
11
+ }));
12
+
13
+ vi.mock('node:os', async () => {
14
+ const actual = await vi.importActual<typeof import('node:os')>('node:os');
15
+ return {
16
+ ...actual,
17
+ platform: mockPlatform,
18
+ };
19
+ });
20
+
21
+ import { installExternalCli, parseCommand, type ExternalCliConfig } from './external.js';
22
+
23
+ describe('parseCommand', () => {
24
+ it('splits binaries and quoted arguments without invoking a shell', () => {
25
+ expect(parseCommand('npm install -g "@scope/tool name"')).toEqual({
26
+ binary: 'npm',
27
+ args: ['install', '-g', '@scope/tool name'],
28
+ });
29
+ });
30
+
31
+ it('rejects shell operators', () => {
32
+ expect(() => parseCommand('brew install gh && rm -rf /')).toThrow(
33
+ 'Install command contains unsafe shell operators',
34
+ );
35
+ });
36
+ });
37
+
38
+ describe('installExternalCli', () => {
39
+ const cli: ExternalCliConfig = {
40
+ name: 'readwise',
41
+ binary: 'readwise',
42
+ install: {
43
+ default: 'npm install -g @readwiseio/readwise-cli',
44
+ },
45
+ };
46
+
47
+ beforeEach(() => {
48
+ mockExecFileSync.mockReset();
49
+ mockPlatform.mockReturnValue('darwin');
50
+ });
51
+
52
+ it('retries with .cmd on Windows when the bare binary is unavailable', () => {
53
+ mockPlatform.mockReturnValue('win32');
54
+ mockExecFileSync
55
+ .mockImplementationOnce(() => {
56
+ const err = new Error('not found') as NodeJS.ErrnoException;
57
+ err.code = 'ENOENT';
58
+ throw err;
59
+ })
60
+ .mockReturnValueOnce(Buffer.from(''));
61
+
62
+ expect(installExternalCli(cli)).toBe(true);
63
+ expect(mockExecFileSync).toHaveBeenNthCalledWith(
64
+ 1,
65
+ 'npm',
66
+ ['install', '-g', '@readwiseio/readwise-cli'],
67
+ { stdio: 'inherit' },
68
+ );
69
+ expect(mockExecFileSync).toHaveBeenNthCalledWith(
70
+ 2,
71
+ 'npm.cmd',
72
+ ['install', '-g', '@readwiseio/readwise-cli'],
73
+ { stdio: 'inherit' },
74
+ );
75
+ });
76
+
77
+ it('does not mask non-ENOENT failures', () => {
78
+ mockPlatform.mockReturnValue('win32');
79
+ mockExecFileSync.mockImplementationOnce(() => {
80
+ const err = new Error('permission denied') as NodeJS.ErrnoException;
81
+ err.code = 'EACCES';
82
+ throw err;
83
+ });
84
+
85
+ expect(installExternalCli(cli)).toBe(false);
86
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
87
+ });
88
+ });
package/src/external.ts 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';
@@ -82,6 +82,60 @@ export function getInstallCmd(installConfig?: ExternalCliInstall): string | null
82
82
  return null;
83
83
  }
84
84
 
85
+ /**
86
+ * Safely parses a command string into a binary and argument list.
87
+ * Rejects commands containing shell operators (&&, ||, |, ;, >, <, `) that
88
+ * cannot be safely expressed as execFileSync arguments.
89
+ *
90
+ * Args:
91
+ * cmd: Raw command string from YAML config (e.g. "brew install gh")
92
+ *
93
+ * Returns:
94
+ * Object with `binary` and `args` fields, or throws on unsafe input.
95
+ */
96
+ export function parseCommand(cmd: string): { binary: string; args: string[] } {
97
+ const shellOperators = /&&|\|\|?|;|[><`]/;
98
+ if (shellOperators.test(cmd)) {
99
+ throw new Error(
100
+ `Install command contains unsafe shell operators and cannot be executed securely: "${cmd}". ` +
101
+ `Please install the tool manually.`
102
+ );
103
+ }
104
+
105
+ // Tokenise respecting single- and double-quoted segments (no variable expansion).
106
+ const tokens: string[] = [];
107
+ const re = /(?:"([^"]*)")|(?:'([^']*)')|(\S+)/g;
108
+ let match: RegExpExecArray | null;
109
+ while ((match = re.exec(cmd)) !== null) {
110
+ tokens.push(match[1] ?? match[2] ?? match[3]);
111
+ }
112
+
113
+ if (tokens.length === 0) {
114
+ throw new Error(`Install command is empty.`);
115
+ }
116
+
117
+ const [binary, ...args] = tokens;
118
+ return { binary, args };
119
+ }
120
+
121
+ function shouldRetryWithCmdShim(binary: string, err: NodeJS.ErrnoException): boolean {
122
+ return os.platform() === 'win32' && !path.extname(binary) && err.code === 'ENOENT';
123
+ }
124
+
125
+ function runInstallCommand(cmd: string): void {
126
+ const { binary, args } = parseCommand(cmd);
127
+
128
+ try {
129
+ execFileSync(binary, args, { stdio: 'inherit' });
130
+ } catch (err: any) {
131
+ if (shouldRetryWithCmdShim(binary, err)) {
132
+ execFileSync(`${binary}.cmd`, args, { stdio: 'inherit' });
133
+ return;
134
+ }
135
+ throw err;
136
+ }
137
+ }
138
+
85
139
  export function installExternalCli(cli: ExternalCliConfig): boolean {
86
140
  if (!cli.install) {
87
141
  console.error(chalk.red(`No auto-install command configured for '${cli.name}'.`));
@@ -99,7 +153,7 @@ export function installExternalCli(cli: ExternalCliConfig): boolean {
99
153
  console.log(chalk.cyan(`🔹 '${cli.name}' is not installed. Auto-installing...`));
100
154
  console.log(chalk.dim(`$ ${cmd}`));
101
155
  try {
102
- execSync(cmd, { stdio: 'inherit' });
156
+ runInstallCommand(cmd);
103
157
  console.log(chalk.green(`✅ Installed '${cli.name}' successfully.\n`));
104
158
  return true;
105
159
  } catch (err: any) {
package/src/generate.ts CHANGED
@@ -12,7 +12,8 @@ import { exploreUrl } from './explore.js';
12
12
  import type { IBrowserFactory } from './runtime.js';
13
13
  import { synthesizeFromExplore, type SynthesizeCandidateSummary, type SynthesizeResult } from './synthesize.js';
14
14
 
15
- // TODO: implement real CLI registration (copy candidate YAML to user clis dir)
15
+ // Registration is a no-op stub candidates are written to disk by synthesize,
16
+ // but not yet auto-copied into the user clis dir.
16
17
  interface RegisterCandidatesOptions {
17
18
  target: string;
18
19
  builtinClis?: string;
package/src/main.ts CHANGED
@@ -3,6 +3,16 @@
3
3
  * opencli — Make any website your CLI. AI-powered.
4
4
  */
5
5
 
6
+ // Ensure standard system paths are available for child processes.
7
+ // Some environments (GUI apps, cron, IDE terminals) launch with a minimal PATH
8
+ // that excludes /usr/local/bin, /usr/sbin, etc., causing external CLIs to fail.
9
+ if (process.platform !== 'win32') {
10
+ const std = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
11
+ const cur = new Set((process.env.PATH ?? '').split(':').filter(Boolean));
12
+ for (const p of std) cur.add(p);
13
+ process.env.PATH = [...cur].join(':');
14
+ }
15
+
6
16
  import * as os from 'node:os';
7
17
  import * as path from 'node:path';
8
18
  import { fileURLToPath } from 'node:url';
@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import { PLUGINS_DIR } from './discovery.js';
9
- import { listPlugins, uninstallPlugin, _parseSource } from './plugin.js';
9
+ import { listPlugins, uninstallPlugin, updatePlugin, _parseSource } from './plugin.js';
10
10
 
11
11
  describe('parseSource', () => {
12
12
  it('parses github:user/repo format', () => {
@@ -84,3 +84,9 @@ describe('uninstallPlugin', () => {
84
84
  expect(() => uninstallPlugin('__nonexistent__')).toThrow('not installed');
85
85
  });
86
86
  });
87
+
88
+ describe('updatePlugin', () => {
89
+ it('throws for non-existent plugin', () => {
90
+ expect(() => updatePlugin('__nonexistent__')).toThrow('not installed');
91
+ });
92
+ });
package/src/plugin.ts CHANGED
@@ -18,6 +18,32 @@ export interface PluginInfo {
18
18
  source?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Shared post-install lifecycle: npm install → host symlink → TS transpile.
23
+ * Called by both installPlugin() and updatePlugin().
24
+ */
25
+ function postInstallLifecycle(pluginDir: string): void {
26
+ const pkgJsonPath = path.join(pluginDir, 'package.json');
27
+ if (!fs.existsSync(pkgJsonPath)) return;
28
+
29
+ try {
30
+ execFileSync('npm', ['install', '--omit=dev'], {
31
+ cwd: pluginDir,
32
+ encoding: 'utf-8',
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ });
35
+ } catch {
36
+ // Non-fatal: npm install may fail if no real deps
37
+ }
38
+
39
+ // Symlink host opencli so TS plugins resolve '@jackwener/opencli/registry'
40
+ // against the running host, not a stale npm-published version.
41
+ linkHostOpencli(pluginDir);
42
+
43
+ // Transpile .ts → .js via esbuild (production node can't load .ts directly).
44
+ transpilePluginTs(pluginDir);
45
+ }
46
+
21
47
  /**
22
48
  * Install a plugin from a source.
23
49
  * Currently supports "github:user/repo" format (git clone wrapper).
@@ -52,31 +78,7 @@ export function installPlugin(source: string): string {
52
78
  throw new Error(`Failed to clone plugin: ${err.message}`);
53
79
  }
54
80
 
55
- // If the plugin has a package.json, run npm install for regular deps,
56
- // then symlink the host opencli into node_modules for peerDep resolution.
57
- const pkgJsonPath = path.join(targetDir, 'package.json');
58
- if (fs.existsSync(pkgJsonPath)) {
59
- try {
60
- execFileSync('npm', ['install', '--omit=dev'], {
61
- cwd: targetDir,
62
- encoding: 'utf-8',
63
- stdio: ['pipe', 'pipe', 'pipe'],
64
- });
65
- } catch {
66
- // Non-fatal: npm install may fail if no real deps
67
- }
68
-
69
- // Symlink host opencli into plugin's node_modules so TS plugins
70
- // can resolve '@jackwener/opencli/registry' against the running host.
71
- // This is more reliable than depending on the npm-published version
72
- // which may lag behind the local installation.
73
- linkHostOpencli(targetDir);
74
-
75
- // Transpile TS plugin files to JS so they work in production mode
76
- // (node cannot load .ts files directly without tsx).
77
- transpilePluginTs(targetDir);
78
- }
79
-
81
+ postInstallLifecycle(targetDir);
80
82
  return name;
81
83
  }
82
84
 
@@ -91,6 +93,28 @@ export function uninstallPlugin(name: string): void {
91
93
  fs.rmSync(targetDir, { recursive: true, force: true });
92
94
  }
93
95
 
96
+ /**
97
+ * Update a plugin by name (git pull + re-install lifecycle).
98
+ */
99
+ export function updatePlugin(name: string): void {
100
+ const targetDir = path.join(PLUGINS_DIR, name);
101
+ if (!fs.existsSync(targetDir)) {
102
+ throw new Error(`Plugin "${name}" is not installed.`);
103
+ }
104
+
105
+ try {
106
+ execFileSync('git', ['pull', '--ff-only'], {
107
+ cwd: targetDir,
108
+ encoding: 'utf-8',
109
+ stdio: ['pipe', 'pipe', 'pipe'],
110
+ });
111
+ } catch (err: any) {
112
+ throw new Error(`Failed to update plugin: ${err.message}`);
113
+ }
114
+
115
+ postInstallLifecycle(targetDir);
116
+ }
117
+
94
118
  /**
95
119
  * List all installed plugins.
96
120
  */