@jackwener/opencli 1.7.1 → 1.7.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 (122) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +6 -3
  3. package/cli-manifest.json +1085 -73
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/feed.js +202 -48
  9. package/clis/binance/asks.js +21 -0
  10. package/clis/binance/commands.test.js +70 -0
  11. package/clis/binance/depth.js +21 -0
  12. package/clis/binance/gainers.js +22 -0
  13. package/clis/binance/klines.js +21 -0
  14. package/clis/binance/losers.js +22 -0
  15. package/clis/binance/pairs.js +21 -0
  16. package/clis/binance/price.js +18 -0
  17. package/clis/binance/prices.js +19 -0
  18. package/clis/binance/ticker.js +21 -0
  19. package/clis/binance/top.js +21 -0
  20. package/clis/binance/trades.js +20 -0
  21. package/clis/boss/utils.js +2 -1
  22. package/clis/chatgpt/image.js +97 -0
  23. package/clis/chatgpt/utils.js +297 -0
  24. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  25. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  26. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  27. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  28. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  29. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  30. package/clis/discord-app/delete.js +114 -0
  31. package/clis/douban/utils.js +29 -2
  32. package/clis/douban/utils.test.js +121 -1
  33. package/clis/ke/chengjiao.js +77 -0
  34. package/clis/ke/ershoufang.js +100 -0
  35. package/clis/ke/utils.js +104 -0
  36. package/clis/ke/xiaoqu.js +77 -0
  37. package/clis/ke/zufang.js +94 -0
  38. package/clis/maimai/search-talents.js +172 -0
  39. package/clis/mubu/doc.js +40 -0
  40. package/clis/mubu/docs.js +43 -0
  41. package/clis/mubu/notes.js +244 -0
  42. package/clis/mubu/recent.js +27 -0
  43. package/clis/mubu/search.js +62 -0
  44. package/clis/mubu/utils.js +304 -0
  45. package/clis/reuters/search.js +1 -1
  46. package/clis/twitter/lists-parser.js +77 -0
  47. package/clis/twitter/lists.d.ts +5 -0
  48. package/clis/twitter/lists.js +62 -0
  49. package/clis/twitter/lists.test.js +50 -0
  50. package/clis/weibo/feed.js +18 -5
  51. package/clis/xiaohongshu/comments.js +18 -6
  52. package/clis/xiaohongshu/comments.test.js +36 -0
  53. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  54. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  55. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  56. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  57. package/clis/xiaohongshu/creator-notes.js +1 -0
  58. package/clis/xiaohongshu/creator-profile.js +1 -0
  59. package/clis/xiaohongshu/creator-stats.js +1 -0
  60. package/clis/xiaohongshu/download.js +12 -0
  61. package/clis/xiaohongshu/download.test.js +30 -0
  62. package/clis/xiaohongshu/navigation.test.js +34 -0
  63. package/clis/xiaohongshu/note.js +14 -5
  64. package/clis/xiaohongshu/note.test.js +28 -0
  65. package/clis/xiaohongshu/publish.js +1 -0
  66. package/clis/xiaohongshu/search.js +1 -0
  67. package/clis/xiaohongshu/user.js +1 -0
  68. package/clis/yahoo-finance/quote.js +1 -1
  69. package/clis/zsxq/topic.js +5 -3
  70. package/clis/zsxq/topic.test.js +4 -3
  71. package/clis/zsxq/utils.js +1 -1
  72. package/dist/src/browser/base-page.d.ts +9 -0
  73. package/dist/src/browser/base-page.js +19 -0
  74. package/dist/src/browser/cdp.js +10 -2
  75. package/dist/src/browser/daemon-client.d.ts +1 -0
  76. package/dist/src/cli.js +112 -2
  77. package/dist/src/daemon.js +5 -0
  78. package/dist/src/discovery.d.ts +5 -2
  79. package/dist/src/discovery.js +7 -35
  80. package/dist/src/doctor.d.ts +1 -0
  81. package/dist/src/doctor.js +51 -2
  82. package/dist/src/electron-apps.js +1 -1
  83. package/dist/src/engine.test.js +29 -1
  84. package/dist/src/errors.d.ts +1 -0
  85. package/dist/src/errors.js +13 -0
  86. package/dist/src/execution.js +36 -9
  87. package/dist/src/execution.test.js +23 -0
  88. package/dist/src/logger.d.ts +2 -2
  89. package/dist/src/logger.js +4 -9
  90. package/dist/src/main.js +6 -5
  91. package/dist/src/registry.js +3 -4
  92. package/dist/src/types.d.ts +2 -0
  93. package/dist/src/update-check.d.ts +14 -0
  94. package/dist/src/update-check.js +48 -3
  95. package/dist/src/update-check.test.js +31 -0
  96. package/package.json +3 -3
  97. package/scripts/fetch-adapters.js +92 -34
  98. package/dist/src/clis/binance/asks.js +0 -20
  99. package/dist/src/clis/binance/commands.test.d.ts +0 -3
  100. package/dist/src/clis/binance/commands.test.js +0 -58
  101. package/dist/src/clis/binance/depth.d.ts +0 -1
  102. package/dist/src/clis/binance/depth.js +0 -20
  103. package/dist/src/clis/binance/gainers.d.ts +0 -1
  104. package/dist/src/clis/binance/gainers.js +0 -21
  105. package/dist/src/clis/binance/klines.d.ts +0 -1
  106. package/dist/src/clis/binance/klines.js +0 -20
  107. package/dist/src/clis/binance/losers.d.ts +0 -1
  108. package/dist/src/clis/binance/losers.js +0 -21
  109. package/dist/src/clis/binance/pairs.d.ts +0 -1
  110. package/dist/src/clis/binance/pairs.js +0 -20
  111. package/dist/src/clis/binance/price.d.ts +0 -1
  112. package/dist/src/clis/binance/price.js +0 -17
  113. package/dist/src/clis/binance/prices.d.ts +0 -1
  114. package/dist/src/clis/binance/prices.js +0 -18
  115. package/dist/src/clis/binance/ticker.d.ts +0 -1
  116. package/dist/src/clis/binance/ticker.js +0 -20
  117. package/dist/src/clis/binance/top.d.ts +0 -1
  118. package/dist/src/clis/binance/top.js +0 -20
  119. package/dist/src/clis/binance/trades.d.ts +0 -1
  120. package/dist/src/clis/binance/trades.js +0 -19
  121. /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
  122. /package/dist/src/{clis/binance/asks.d.ts → update-check.test.d.ts} +0 -0
package/dist/src/cli.js CHANGED
@@ -369,9 +369,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
369
369
  await page.wait(0.3);
370
370
  await page.typeText(index, text);
371
371
  // Detect autocomplete/combobox fields and wait for dropdown suggestions
372
+ const safeIndex = JSON.stringify(String(index));
372
373
  const isAutocomplete = await page.evaluate(`
373
374
  (() => {
374
- const el = document.querySelector('[data-opencli-ref="${index}"]');
375
+ const el = document.querySelector('[data-opencli-ref="' + ${safeIndex} + '"]');
375
376
  if (!el) return false;
376
377
  const role = el.getAttribute('role');
377
378
  const ac = el.getAttribute('aria-autocomplete');
@@ -390,9 +391,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
390
391
  browser.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
391
392
  .description('Select dropdown option')
392
393
  .action(browserAction(async (page, index, option) => {
394
+ const safeIdx = JSON.stringify(String(index));
393
395
  const result = await page.evaluate(`
394
396
  (function() {
395
- var sel = document.querySelector('[data-opencli-ref="${index}"]');
397
+ var sel = document.querySelector('[data-opencli-ref="' + ${safeIdx} + '"]');
396
398
  if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
397
399
  var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
398
400
  if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
@@ -891,6 +893,114 @@ cli({
891
893
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
892
894
  }
893
895
  });
896
+ // ── Built-in: adapter management ─────────────────────────────────────────
897
+ const adapterCmd = program.command('adapter').description('Manage CLI adapters');
898
+ adapterCmd
899
+ .command('status')
900
+ .description('Show which sites have local overrides vs using official baseline')
901
+ .action(async () => {
902
+ const os = await import('node:os');
903
+ const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
904
+ const builtinClisDir = BUILTIN_CLIS;
905
+ try {
906
+ const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
907
+ const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
908
+ let builtinSites = [];
909
+ try {
910
+ const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true });
911
+ builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
912
+ }
913
+ catch { /* no builtin dir */ }
914
+ if (userSites.length === 0) {
915
+ console.log('No local adapter overrides. All sites use the official baseline.');
916
+ return;
917
+ }
918
+ console.log(`Local overrides in ~/.opencli/clis/ (${userSites.length} sites):\n`);
919
+ for (const site of userSites) {
920
+ const isOfficial = builtinSites.includes(site);
921
+ const label = isOfficial ? 'override' : 'custom';
922
+ console.log(` ${site} [${label}]`);
923
+ }
924
+ console.log(`\nOfficial baseline: ${builtinSites.length} sites in package`);
925
+ }
926
+ catch {
927
+ console.log('No local adapter overrides. All sites use the official baseline.');
928
+ }
929
+ });
930
+ adapterCmd
931
+ .command('eject')
932
+ .description('Copy an official adapter to ~/.opencli/clis/ for local editing')
933
+ .argument('<site>', 'Site name (e.g. twitter, bilibili)')
934
+ .action(async (site) => {
935
+ const os = await import('node:os');
936
+ const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
937
+ const builtinSiteDir = path.join(BUILTIN_CLIS, site);
938
+ const userSiteDir = path.join(userClisDir, site);
939
+ try {
940
+ await fs.promises.access(builtinSiteDir);
941
+ }
942
+ catch {
943
+ console.error(styleText('red', `Error: Site "${site}" not found in official adapters.`));
944
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
945
+ return;
946
+ }
947
+ try {
948
+ await fs.promises.access(userSiteDir);
949
+ console.error(styleText('yellow', `Site "${site}" already exists in ~/.opencli/clis/. Use "opencli adapter reset ${site}" first to restore official version.`));
950
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
951
+ return;
952
+ }
953
+ catch { /* good, doesn't exist yet */ }
954
+ fs.cpSync(builtinSiteDir, userSiteDir, { recursive: true });
955
+ console.log(styleText('green', `✅ Ejected "${site}" to ~/.opencli/clis/${site}/`));
956
+ console.log('You can now edit the adapter files. Changes take effect immediately.');
957
+ console.log(styleText('yellow', 'Note: Official updates to this adapter will overwrite your changes.'));
958
+ });
959
+ adapterCmd
960
+ .command('reset')
961
+ .description('Remove local override and restore official adapter version')
962
+ .argument('[site]', 'Site name (e.g. twitter, bilibili)')
963
+ .option('--all', 'Reset all local overrides')
964
+ .action(async (site, opts) => {
965
+ const os = await import('node:os');
966
+ const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
967
+ if (opts.all) {
968
+ try {
969
+ const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
970
+ const dirs = userEntries.filter(e => e.isDirectory());
971
+ if (dirs.length === 0) {
972
+ console.log('No local sites to reset.');
973
+ return;
974
+ }
975
+ for (const dir of dirs) {
976
+ fs.rmSync(path.join(userClisDir, dir.name), { recursive: true, force: true });
977
+ }
978
+ console.log(styleText('green', `✅ Reset ${dirs.length} site(s). All adapters now use official baseline.`));
979
+ }
980
+ catch {
981
+ console.log('No local sites to reset.');
982
+ }
983
+ return;
984
+ }
985
+ if (!site) {
986
+ console.error(styleText('red', 'Error: Please specify a site name or use --all.'));
987
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
988
+ return;
989
+ }
990
+ const userSiteDir = path.join(userClisDir, site);
991
+ try {
992
+ await fs.promises.access(userSiteDir);
993
+ }
994
+ catch {
995
+ console.error(styleText('yellow', `Site "${site}" has no local override.`));
996
+ return;
997
+ }
998
+ const isOfficial = fs.existsSync(path.join(BUILTIN_CLIS, site));
999
+ fs.rmSync(userSiteDir, { recursive: true, force: true });
1000
+ console.log(styleText('green', isOfficial
1001
+ ? `✅ Reset "${site}". Now using official baseline.`
1002
+ : `✅ Removed custom site "${site}".`));
1003
+ });
894
1004
  // ── Built-in: daemon ──────────────────────────────────────────────────────
895
1005
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
896
1006
  daemonCmd
@@ -27,6 +27,7 @@ const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_P
27
27
  // ─── State ───────────────────────────────────────────────────────────
28
28
  let extensionWs = null;
29
29
  let extensionVersion = null;
30
+ let extensionCompatRange = null;
30
31
  const pending = new Map();
31
32
  const LOG_BUFFER_SIZE = 200;
32
33
  const logBuffer = [];
@@ -111,6 +112,7 @@ async function handleRequest(req, res) {
111
112
  uptime,
112
113
  extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
113
114
  extensionVersion,
115
+ extensionCompatRange,
114
116
  pending: pending.size,
115
117
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
116
118
  port: PORT,
@@ -188,6 +190,7 @@ wss.on('connection', (ws) => {
188
190
  log.info('[daemon] Extension connected');
189
191
  extensionWs = ws;
190
192
  extensionVersion = null; // cleared until hello message arrives
193
+ extensionCompatRange = null;
191
194
  // ── Heartbeat: ping every 15s, close if 2 pongs missed ──
192
195
  let missedPongs = 0;
193
196
  const heartbeatInterval = setInterval(() => {
@@ -213,6 +216,7 @@ wss.on('connection', (ws) => {
213
216
  // Handle hello message from extension (version handshake)
214
217
  if (msg.type === 'hello') {
215
218
  extensionVersion = typeof msg.version === 'string' ? msg.version : null;
219
+ extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null;
216
220
  return;
217
221
  }
218
222
  // Handle log messages from extension
@@ -244,6 +248,7 @@ wss.on('connection', (ws) => {
244
248
  if (extensionWs === ws) {
245
249
  extensionWs = null;
246
250
  extensionVersion = null;
251
+ extensionCompatRange = null;
247
252
  // Reject all pending requests since the extension is gone
248
253
  for (const [id, p] of pending) {
249
254
  clearTimeout(p.timer);
@@ -23,8 +23,11 @@ export declare const PLUGINS_DIR: string;
23
23
  */
24
24
  export declare function ensureUserCliCompatShims(baseDir?: string): Promise<void>;
25
25
  /**
26
- * First-run fallback: if postinstall was skipped (--ignore-scripts) or failed,
27
- * trigger adapter fetch on first CLI invocation when ~/.opencli/clis/ is empty.
26
+ * Ensure the user adapters directory exists.
27
+ *
28
+ * With smart sync, ~/.opencli/clis/ only holds files that differ from the
29
+ * package baseline (upstream-synced cache + autofix output + user overrides).
30
+ * Built-in adapters are loaded directly from the installed package.
28
31
  */
29
32
  export declare function ensureUserAdapters(): Promise<void>;
30
33
  /**
@@ -14,7 +14,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
14
14
  import { Strategy, registerCommand } from './registry.js';
15
15
  import { getErrorMessage } from './errors.js';
16
16
  import { log } from './logger.js';
17
- import { findPackageRoot, getCliManifestPath, getFetchAdaptersScriptPath } from './package-paths.js';
17
+ import { findPackageRoot, getCliManifestPath } from './package-paths.js';
18
18
  /** User runtime directory: ~/.opencli */
19
19
  export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
20
20
  /** User CLIs directory: ~/.opencli/clis */
@@ -77,43 +77,15 @@ export async function ensureUserCliCompatShims(baseDir = USER_OPENCLI_DIR) {
77
77
  log.warn(`Could not create symlink at ${symlinkPath}: ${getErrorMessage(err)}`);
78
78
  }
79
79
  }
80
- const ADAPTER_MANIFEST_PATH = path.join(USER_OPENCLI_DIR, 'adapter-manifest.json');
81
80
  /**
82
- * First-run fallback: if postinstall was skipped (--ignore-scripts) or failed,
83
- * trigger adapter fetch on first CLI invocation when ~/.opencli/clis/ is empty.
81
+ * Ensure the user adapters directory exists.
82
+ *
83
+ * With smart sync, ~/.opencli/clis/ only holds files that differ from the
84
+ * package baseline (upstream-synced cache + autofix output + user overrides).
85
+ * Built-in adapters are loaded directly from the installed package.
84
86
  */
85
87
  export async function ensureUserAdapters() {
86
- // If adapter manifest already exists, adapters were fetched — nothing to do
87
- try {
88
- await fs.promises.access(ADAPTER_MANIFEST_PATH);
89
- return;
90
- }
91
- catch {
92
- // No manifest — first run or postinstall was skipped
93
- }
94
- // Check if clis dir has any content (could be manually populated)
95
- try {
96
- const entries = await fs.promises.readdir(USER_CLIS_DIR);
97
- if (entries.length > 0)
98
- return;
99
- }
100
- catch {
101
- // Dir doesn't exist — needs fetch
102
- }
103
- log.info('First run detected — copying adapters (one-time setup)...');
104
- try {
105
- const { execFileSync } = await import('node:child_process');
106
- const scriptPath = getFetchAdaptersScriptPath(PACKAGE_ROOT);
107
- execFileSync(process.execPath, [scriptPath], {
108
- stdio: 'inherit',
109
- env: { ...process.env, _OPENCLI_FIRST_RUN: '1' },
110
- timeout: 120_000,
111
- });
112
- }
113
- catch (err) {
114
- log.warn(`Could not fetch adapters on first run: ${getErrorMessage(err)}`);
115
- log.warn('Built-in adapters from the package will be used.');
116
- }
88
+ await fs.promises.mkdir(USER_CLIS_DIR, { recursive: true });
117
89
  }
118
90
  /**
119
91
  * Discover and register CLI commands.
@@ -21,6 +21,7 @@ export type DoctorReport = {
21
21
  extensionConnected: boolean;
22
22
  extensionFlaky?: boolean;
23
23
  extensionVersion?: string;
24
+ latestExtensionVersion?: string;
24
25
  connectivity?: ConnectivityResult;
25
26
  sessions?: Array<{
26
27
  workspace: string;
@@ -9,7 +9,37 @@ import { BrowserBridge } from './browser/index.js';
9
9
  import { getDaemonHealth, listSessions } from './browser/daemon-client.js';
10
10
  import { getErrorMessage } from './errors.js';
11
11
  import { getRuntimeLabel } from './runtime-detect.js';
12
+ import { getCachedLatestExtensionVersion } from './update-check.js';
12
13
  const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
14
+ /** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
15
+ function parseSemver(v) {
16
+ const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
17
+ if (parts.length < 3 || parts.some(isNaN))
18
+ return null;
19
+ return [parts[0], parts[1], parts[2]];
20
+ }
21
+ /** Returns true if `a` is strictly newer than `b`. */
22
+ function isNewerVersion(a, b) {
23
+ const va = parseSemver(a);
24
+ const vb = parseSemver(b);
25
+ if (!va || !vb)
26
+ return false;
27
+ const cmp = va[0] - vb[0] || va[1] - vb[1] || va[2] - vb[2];
28
+ return cmp > 0;
29
+ }
30
+ /** Check if version satisfies a simple range like ">=1.7.0". */
31
+ function satisfiesRange(version, range) {
32
+ const match = range.match(/^(>=?)\s*(\S+)$/);
33
+ if (!match)
34
+ return true; // Unknown range format — don't block
35
+ const [, op, rangeVer] = match;
36
+ const v = parseSemver(version);
37
+ const r = parseSemver(rangeVer);
38
+ if (!v || !r)
39
+ return true;
40
+ const cmp = v[0] - r[0] || v[1] - r[1] || v[2] - r[2];
41
+ return op === '>=' ? cmp >= 0 : cmp > 0;
42
+ }
13
43
  /**
14
44
  * Test connectivity by attempting a real browser command.
15
45
  */
@@ -80,7 +110,16 @@ export async function runBrowserDoctor(opts = {}) {
80
110
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
81
111
  }
82
112
  const extensionVersion = health.status?.extensionVersion;
83
- if (extensionVersion && opts.cliVersion) {
113
+ const extensionCompatRange = health.status?.extensionCompatRange;
114
+ if (extensionVersion && opts.cliVersion && extensionCompatRange) {
115
+ if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) {
116
+ issues.push(`CLI version incompatible with extension: extension v${extensionVersion} requires CLI ${extensionCompatRange}, but CLI is v${opts.cliVersion}\n` +
117
+ ' Update the CLI: npm install -g @jackwener/opencli\n' +
118
+ ' Or download a compatible extension from: https://github.com/jackwener/opencli/releases');
119
+ }
120
+ }
121
+ else if (extensionVersion && opts.cliVersion) {
122
+ // Fallback for older extensions that don't send compatRange
84
123
  const extMajor = extensionVersion.split('.')[0];
85
124
  const cliMajor = opts.cliVersion.split('.')[0];
86
125
  if (extMajor !== cliMajor) {
@@ -88,6 +127,12 @@ export async function runBrowserDoctor(opts = {}) {
88
127
  ' Download the latest extension from: https://github.com/jackwener/opencli/releases');
89
128
  }
90
129
  }
130
+ // Extension update check (from cached background fetch)
131
+ const latestExtensionVersion = getCachedLatestExtensionVersion();
132
+ if (extensionVersion && latestExtensionVersion && isNewerVersion(latestExtensionVersion, extensionVersion)) {
133
+ issues.push(`Extension update available: v${extensionVersion} → v${latestExtensionVersion}\n` +
134
+ ' Download from: https://github.com/jackwener/opencli/releases');
135
+ }
91
136
  return {
92
137
  cliVersion: opts.cliVersion,
93
138
  daemonRunning,
@@ -95,6 +140,7 @@ export async function runBrowserDoctor(opts = {}) {
95
140
  extensionConnected,
96
141
  extensionFlaky,
97
142
  extensionVersion,
143
+ latestExtensionVersion,
98
144
  connectivity,
99
145
  sessions,
100
146
  issues,
@@ -114,7 +160,10 @@ export function renderBrowserDoctorReport(report) {
114
160
  const extIcon = report.extensionFlaky
115
161
  ? styleText('yellow', '[WARN]')
116
162
  : report.extensionConnected ? styleText('green', '[OK]') : styleText('yellow', '[MISSING]');
117
- const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) : '';
163
+ const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
164
+ ? styleText('yellow', ` → v${report.latestExtensionVersion} available`)
165
+ : '';
166
+ const extVersion = report.extensionVersion ? styleText('dim', ` (v${report.extensionVersion})`) + extUpdateHint : '';
118
167
  const extLabel = report.extensionFlaky
119
168
  ? 'unstable (connected during live check, then disconnected)'
120
169
  : report.extensionConnected ? 'connected' : 'not connected';
@@ -22,7 +22,7 @@ export const builtinApps = {
22
22
  bundleId: 'dev.antigravity.app',
23
23
  displayName: 'Antigravity',
24
24
  },
25
- chatgpt: { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' },
25
+ 'chatgpt-app': { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' },
26
26
  };
27
27
  /** Merge builtin + user-defined apps. User entries are additive only. */
28
28
  export function loadApps(userApps) {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { discoverClis, discoverPlugins, ensureUserCliCompatShims, PLUGINS_DIR } from './discovery.js';
2
+ import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js';
3
3
  import { executeCommand } from './execution.js';
4
4
  import { getRegistry, cli, Strategy } from './registry.js';
5
5
  import { clearAllHooks, onAfterExecute } from './hooks.js';
@@ -103,6 +103,34 @@ cli({
103
103
  }
104
104
  });
105
105
  });
106
+ describe('ensureUserAdapters', () => {
107
+ it('creates user clis directory without triggering full copy', async () => {
108
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-ensure-'));
109
+ const clisDir = path.join(tempDir, 'clis');
110
+ try {
111
+ // Patch USER_CLIS_DIR is not easy, so we test the function behavior indirectly:
112
+ // ensureUserAdapters should not throw and should be very fast (no fetch script)
113
+ const start = Date.now();
114
+ await ensureUserAdapters();
115
+ const elapsed = Date.now() - start;
116
+ // Should complete quickly (< 1s) since it only creates a directory
117
+ expect(elapsed).toBeLessThan(1000);
118
+ }
119
+ finally {
120
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
121
+ }
122
+ });
123
+ it('discoverClis handles empty user directory gracefully', async () => {
124
+ const emptyDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-empty-'));
125
+ try {
126
+ // Should not throw for an empty directory (no adapters to discover)
127
+ await expect(discoverClis(emptyDir)).resolves.not.toThrow();
128
+ }
129
+ finally {
130
+ await fs.promises.rm(emptyDir, { recursive: true, force: true });
131
+ }
132
+ });
133
+ });
106
134
  describe('discoverPlugins', () => {
107
135
  const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
108
136
  const yamlPath = path.join(testPluginDir, 'greeting.yaml');
@@ -82,6 +82,7 @@ export interface ErrorEnvelope {
82
82
  help?: string;
83
83
  exitCode: number;
84
84
  stack?: string;
85
+ cause?: string;
85
86
  };
86
87
  }
87
88
  /** Extract a human-readable message from an unknown caught value. */
@@ -106,8 +106,19 @@ export class PluginError extends CliError {
106
106
  export function getErrorMessage(error) {
107
107
  return error instanceof Error ? error.message : String(error);
108
108
  }
109
+ /** Serialize an error cause chain into a readable string. */
110
+ function serializeCause(cause) {
111
+ if (cause instanceof Error) {
112
+ const parts = [cause.message];
113
+ if (cause.cause)
114
+ parts.push(` caused by: ${serializeCause(cause.cause)}`);
115
+ return parts.join('\n');
116
+ }
117
+ return String(cause);
118
+ }
109
119
  /** Build an ErrorEnvelope from any caught value. */
110
120
  export function toEnvelope(err) {
121
+ const cause = err instanceof Error && err.cause ? serializeCause(err.cause) : undefined;
111
122
  if (err instanceof CliError) {
112
123
  return {
113
124
  ok: false,
@@ -116,6 +127,7 @@ export function toEnvelope(err) {
116
127
  message: err.message,
117
128
  ...(err.hint ? { help: err.hint } : {}),
118
129
  exitCode: err.exitCode,
130
+ ...(cause ? { cause } : {}),
119
131
  },
120
132
  };
121
133
  }
@@ -126,6 +138,7 @@ export function toEnvelope(err) {
126
138
  code: 'UNKNOWN',
127
139
  message: msg,
128
140
  exitCode: EXIT_CODES.GENERIC_ERROR,
141
+ ...(cause ? { cause } : {}),
129
142
  },
130
143
  };
131
144
  }
@@ -11,16 +11,20 @@
11
11
  */
12
12
  import { getRegistry, fullName } from './registry.js';
13
13
  import { pathToFileURL } from 'node:url';
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
14
16
  import { executePipeline } from './pipeline/index.js';
15
17
  import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
16
18
  import { isDiagnosticEnabled, collectDiagnostic, emitDiagnostic } from './diagnostic.js';
17
19
  import { shouldUseBrowserSession } from './capabilityRouting.js';
18
20
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
19
21
  import { emitHook } from './hooks.js';
20
- import { log } from './logger.js';
21
22
  import { isElectronApp } from './electron-apps.js';
22
23
  import { probeCDP, resolveElectronEndpoint } from './launcher.js';
23
- const _loadedModules = new Set();
24
+ const _loadedModules = new Map();
25
+ /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */
26
+ const _moduleMtimes = new Map();
27
+ const _userClisDir = `${os.homedir()}/.opencli/clis/`;
24
28
  export function coerceAndValidateArgs(cmdArgs, kwargs) {
25
29
  const result = { ...kwargs };
26
30
  for (const argDef of cmdArgs) {
@@ -67,15 +71,34 @@ async function runCommand(cmd, page, kwargs, debug) {
67
71
  const internal = cmd;
68
72
  if (internal._lazy && internal._modulePath) {
69
73
  const modulePath = internal._modulePath;
70
- if (!_loadedModules.has(modulePath)) {
74
+ // Hot-reload: if a user adapter's file has changed on disk, invalidate cache
75
+ const isUserAdapter = modulePath.startsWith(_userClisDir);
76
+ if (isUserAdapter && _loadedModules.has(modulePath)) {
71
77
  try {
72
- await import(pathToFileURL(modulePath).href);
73
- _loadedModules.add(modulePath);
78
+ const stat = fs.statSync(modulePath);
79
+ const prevMtime = _moduleMtimes.get(modulePath);
80
+ if (prevMtime !== undefined && stat.mtimeMs !== prevMtime) {
81
+ _loadedModules.delete(modulePath);
82
+ _moduleMtimes.delete(modulePath);
83
+ }
74
84
  }
75
- catch (err) {
85
+ catch { /* file may have been deleted; let import below handle it */ }
86
+ }
87
+ if (!_loadedModules.has(modulePath)) {
88
+ const url = pathToFileURL(modulePath).href;
89
+ const importUrl = _moduleMtimes.has(modulePath) ? `${url}?t=${Date.now()}` : url;
90
+ const loadPromise = import(importUrl).then(() => {
91
+ try {
92
+ _moduleMtimes.set(modulePath, fs.statSync(modulePath).mtimeMs);
93
+ }
94
+ catch { }
95
+ }, (err) => {
96
+ _loadedModules.delete(modulePath);
76
97
  throw new AdapterLoadError(`Failed to load adapter module ${modulePath}: ${getErrorMessage(err)}`, 'Check that the adapter file exists and has no syntax errors.');
77
- }
98
+ });
99
+ _loadedModules.set(modulePath, loadPromise);
78
100
  }
101
+ await _loadedModules.get(modulePath);
79
102
  const updated = getRegistry().get(fullName(cmd));
80
103
  if (updated?.func) {
81
104
  if (!page && updated.browser !== false) {
@@ -159,7 +182,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
159
182
  await page.goto(preNavUrl);
160
183
  }
161
184
  catch (err) {
162
- log.warn(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`);
185
+ throw new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
163
186
  }
164
187
  }
165
188
  try {
@@ -173,13 +196,17 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
173
196
  return result;
174
197
  }
175
198
  catch (err) {
176
- // Collect diagnostic while page is still alive (before browserSession closes it).
199
+ // Collect diagnostic while page is still alive (before closing the window).
177
200
  if (isDiagnosticEnabled()) {
178
201
  const internal = cmd;
179
202
  const ctx = await collectDiagnostic(err, internal, page);
180
203
  emitDiagnostic(ctx);
181
204
  diagnosticEmitted = true;
182
205
  }
206
+ // Close the automation window on failure too — without this, the window
207
+ // lingers until the extension's idle timer fires (unreliable on Windows
208
+ // where MV3 service workers may be suspended before setTimeout triggers).
209
+ await page.closeWindow?.().catch(() => { });
183
210
  throw err;
184
211
  }
185
212
  }, { workspace: `site:${cmd.site}`, cdpEndpoint });
@@ -3,6 +3,8 @@ import { executeCommand, prepareCommandArgs } from './execution.js';
3
3
  import { TimeoutError } from './errors.js';
4
4
  import { cli, Strategy } from './registry.js';
5
5
  import { withTimeoutMs } from './runtime.js';
6
+ import * as runtime from './runtime.js';
7
+ import * as capRouting from './capabilityRouting.js';
6
8
  describe('executeCommand — non-browser timeout', () => {
7
9
  it('applies timeoutSeconds to non-browser commands', async () => {
8
10
  const cmd = cli({
@@ -37,6 +39,27 @@ describe('executeCommand — non-browser timeout', () => {
37
39
  // With timeout guard skipped, the sentinel fires instead.
38
40
  await expect(withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout')).rejects.toThrow('sentinel timeout');
39
41
  });
42
+ it('calls closeWindow on browser command failure', async () => {
43
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
44
+ const mockPage = { closeWindow };
45
+ // Mock shouldUseBrowserSession to return true
46
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
47
+ // Mock browserSession to invoke the callback with our mock page
48
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => {
49
+ return fn(mockPage);
50
+ });
51
+ const cmd = cli({
52
+ site: 'test-execution',
53
+ name: 'browser-close-on-error',
54
+ description: 'test closeWindow on failure',
55
+ browser: true,
56
+ strategy: Strategy.PUBLIC,
57
+ func: async () => { throw new Error('adapter failure'); },
58
+ });
59
+ await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
60
+ expect(closeWindow).toHaveBeenCalledTimes(1);
61
+ vi.restoreAllMocks();
62
+ });
40
63
  it('does not re-run custom validation when args are already prepared', async () => {
41
64
  const validateArgs = vi.fn();
42
65
  const cmd = {
@@ -15,9 +15,9 @@ export declare const log: {
15
15
  warn(msg: string): void;
16
16
  /** Error (always shown) */
17
17
  error(msg: string): void;
18
- /** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
18
+ /** Verbose output (shown when -v flag, OPENCLI_VERBOSE, or DEBUG=opencli is set) */
19
19
  verbose(msg: string): void;
20
- /** Debug output (only when DEBUG includes 'opencli') */
20
+ /** @deprecated Use log.verbose() instead. Kept as alias for backward compatibility. */
21
21
  debug(msg: string): void;
22
22
  /** Step-style debug (for pipeline steps, etc.) */
23
23
  step(stepNum: number, total: number, op: string, preview?: string): void;
@@ -6,10 +6,7 @@
6
6
  */
7
7
  import { styleText } from 'node:util';
8
8
  function isVerbose() {
9
- return !!process.env.OPENCLI_VERBOSE;
10
- }
11
- function isDebug() {
12
- return !!process.env.DEBUG?.includes('opencli');
9
+ return !!process.env.OPENCLI_VERBOSE || !!process.env.DEBUG?.includes('opencli');
13
10
  }
14
11
  export const log = {
15
12
  /** Informational message (always shown) */
@@ -32,17 +29,15 @@ export const log = {
32
29
  error(msg) {
33
30
  process.stderr.write(`${styleText('red', '✖')} ${msg}\n`);
34
31
  },
35
- /** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */
32
+ /** Verbose output (shown when -v flag, OPENCLI_VERBOSE, or DEBUG=opencli is set) */
36
33
  verbose(msg) {
37
34
  if (isVerbose()) {
38
35
  process.stderr.write(`${styleText('dim', '[verbose]')} ${msg}\n`);
39
36
  }
40
37
  },
41
- /** Debug output (only when DEBUG includes 'opencli') */
38
+ /** @deprecated Use log.verbose() instead. Kept as alias for backward compatibility. */
42
39
  debug(msg) {
43
- if (isDebug()) {
44
- process.stderr.write(`${styleText('dim', '[debug]')} ${msg}\n`);
45
- }
40
+ this.verbose(msg);
46
41
  },
47
42
  /** Step-style debug (for pipeline steps, etc.) */
48
43
  step(stepNum, total, op, preview = '') {
package/dist/src/main.js CHANGED
@@ -45,14 +45,15 @@ if (argv[0] === 'completion' && argv.length >= 2) {
45
45
  // Fast path: --get-completions — read from manifest, skip discovery
46
46
  const getCompIdx = process.argv.indexOf('--get-completions');
47
47
  if (getCompIdx !== -1) {
48
- // Only require manifest for directories that actually exist.
49
- // If user clis dir doesn't exist, there are no user adapters to miss.
48
+ // Only include manifests that actually exist on disk.
49
+ // With sparse override, the user clis dir may exist but have no manifest.
50
50
  const manifestPaths = [getCliManifestPath(BUILTIN_CLIS)];
51
+ const userManifest = getCliManifestPath(USER_CLIS);
51
52
  try {
52
- fs.accessSync(USER_CLIS);
53
- manifestPaths.push(getCliManifestPath(USER_CLIS));
53
+ fs.accessSync(userManifest);
54
+ manifestPaths.push(userManifest);
54
55
  }
55
- catch { /* no user dir */ }
56
+ catch { /* no user manifest */ }
56
57
  if (hasAllManifests(manifestPaths)) {
57
58
  const rest = process.argv.slice(getCompIdx + 1);
58
59
  let cursor;