@jackwener/opencli 1.7.13 → 1.7.14

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.
@@ -0,0 +1,87 @@
1
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { parseTweetUrl } from './shared.js';
4
+
5
+ cli({
6
+ site: 'twitter',
7
+ name: 'unlike',
8
+ access: 'write',
9
+ description: 'Remove a like from a specific tweet',
10
+ domain: 'x.com',
11
+ strategy: Strategy.UI,
12
+ browser: true,
13
+ args: [
14
+ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unlike' },
15
+ ],
16
+ columns: ['status', 'message'],
17
+ func: async (page, kwargs) => {
18
+ if (!page)
19
+ throw new CommandExecutionError('Browser session required for twitter unlike');
20
+ const target = parseTweetUrl(kwargs.url);
21
+ await page.goto(target.url);
22
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
+ const result = await page.evaluate(`(async () => {
24
+ try {
25
+ const tweetId = ${JSON.stringify(target.id)};
26
+ const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
27
+ Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
28
+ try {
29
+ const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
30
+ return match?.[1] === tweetId;
31
+ } catch {
32
+ return false;
33
+ }
34
+ })
35
+ );
36
+ // Poll for the tweet to render
37
+ let attempts = 0;
38
+ let likeBtn = null;
39
+ let unlikeBtn = null;
40
+ let targetArticle = null;
41
+
42
+ while (attempts < 20) {
43
+ targetArticle = findTargetArticle();
44
+ likeBtn = targetArticle?.querySelector('[data-testid="like"]') || null;
45
+ unlikeBtn = targetArticle?.querySelector('[data-testid="unlike"]') || null;
46
+
47
+ if (likeBtn || unlikeBtn) break;
48
+
49
+ await new Promise(r => setTimeout(r, 500));
50
+ attempts++;
51
+ }
52
+
53
+ // Check if it's already not liked
54
+ if (likeBtn) {
55
+ return { ok: true, message: 'Tweet is not liked (already unliked).' };
56
+ }
57
+
58
+ if (!unlikeBtn) {
59
+ return { ok: false, message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?' };
60
+ }
61
+
62
+ // Click Unlike
63
+ unlikeBtn.click();
64
+ await new Promise(r => setTimeout(r, 1000));
65
+
66
+ // Verify success by checking if the 'like' button reappeared
67
+ const verifyArticle = findTargetArticle() || targetArticle;
68
+ const verifyBtn = verifyArticle?.querySelector('[data-testid="like"]');
69
+ if (verifyBtn) {
70
+ return { ok: true, message: 'Tweet successfully unliked.' };
71
+ } else {
72
+ return { ok: false, message: 'Unlike action was initiated but UI did not update as expected.' };
73
+ }
74
+ } catch (e) {
75
+ return { ok: false, message: e.toString() };
76
+ }
77
+ })()`);
78
+ if (result.ok) {
79
+ // Wait for the unlike network request to be processed
80
+ await page.wait(2);
81
+ }
82
+ return [{
83
+ status: result.ok ? 'success' : 'failed',
84
+ message: result.message
85
+ }];
86
+ }
87
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './unlike.js';
5
+ import { createPageMock } from '../test-utils.js';
6
+
7
+ describe('twitter unlike command', () => {
8
+ it('navigates to the tweet URL and reports success when the unlike script confirms', async () => {
9
+ const cmd = getRegistry().get('twitter/unlike');
10
+ expect(cmd?.func).toBeTypeOf('function');
11
+ const page = createPageMock([
12
+ { ok: true, message: 'Tweet successfully unliked.' },
13
+ ]);
14
+ const result = await cmd.func(page, {
15
+ url: 'https://x.com/alice/status/2040254679301718161',
16
+ });
17
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
18
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
19
+ // After ok:true the adapter waits an extra 2s for the network round-trip.
20
+ expect(page.wait).toHaveBeenNthCalledWith(2, 2);
21
+ const script = page.evaluate.mock.calls[0][0];
22
+ // Idempotency check: looks for the like button (already-not-liked path) before clicking unlike.
23
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
24
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
25
+ expect(script).toContain('unlikeBtn.click()');
26
+ expect(script).toContain("document.querySelectorAll('article')");
27
+ expect(script).toContain('match?.[1] === tweetId');
28
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
29
+ expect(result).toEqual([
30
+ { status: 'success', message: 'Tweet successfully unliked.' },
31
+ ]);
32
+ });
33
+
34
+ it('returns a failed row without re-waiting when the unlike script reports a UI mismatch', async () => {
35
+ const cmd = getRegistry().get('twitter/unlike');
36
+ expect(cmd?.func).toBeTypeOf('function');
37
+ const page = createPageMock([
38
+ {
39
+ ok: false,
40
+ message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
41
+ },
42
+ ]);
43
+ const result = await cmd.func(page, {
44
+ url: 'https://x.com/alice/status/2040254679301718161',
45
+ });
46
+ expect(result).toEqual([
47
+ {
48
+ status: 'failed',
49
+ message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
50
+ },
51
+ ]);
52
+ // Only the primaryColumn wait should run when ok is false.
53
+ expect(page.wait).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ it('throws CommandExecutionError when no page is provided', async () => {
57
+ const cmd = getRegistry().get('twitter/unlike');
58
+ await expect(cmd.func(undefined, {
59
+ url: 'https://x.com/alice/status/2040254679301718161',
60
+ })).rejects.toThrow(CommandExecutionError);
61
+ });
62
+
63
+ it('rejects invalid tweet URLs before navigation', async () => {
64
+ const cmd = getRegistry().get('twitter/unlike');
65
+ const page = createPageMock([]);
66
+ await expect(cmd.func(page, {
67
+ url: 'https://x.com/alice/status/2040254679301718161/photo/1',
68
+ })).rejects.toThrow(ArgumentError);
69
+ expect(page.goto).not.toHaveBeenCalled();
70
+ expect(page.evaluate).not.toHaveBeenCalled();
71
+ });
72
+ });
@@ -0,0 +1,99 @@
1
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { parseTweetUrl } from './shared.js';
4
+
5
+ cli({
6
+ site: 'twitter',
7
+ name: 'unretweet',
8
+ access: 'write',
9
+ description: 'Undo a retweet on a specific tweet',
10
+ domain: 'x.com',
11
+ strategy: Strategy.UI,
12
+ browser: true,
13
+ args: [
14
+ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unretweet' },
15
+ ],
16
+ columns: ['status', 'message'],
17
+ func: async (page, kwargs) => {
18
+ if (!page)
19
+ throw new CommandExecutionError('Browser session required for twitter unretweet');
20
+ const target = parseTweetUrl(kwargs.url);
21
+ await page.goto(target.url);
22
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
+ const result = await page.evaluate(`(async () => {
24
+ try {
25
+ const tweetId = ${JSON.stringify(target.id)};
26
+ const findTargetArticle = () => Array.from(document.querySelectorAll('article')).find((article) =>
27
+ Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
28
+ try {
29
+ const match = new URL(link.href, window.location.origin).pathname.match(/^\/(?:[^/]+|i)\/status\/(\d+)\/?$/);
30
+ return match?.[1] === tweetId;
31
+ } catch {
32
+ return false;
33
+ }
34
+ })
35
+ );
36
+ // Poll for the tweet to render
37
+ let attempts = 0;
38
+ let retweetBtn = null;
39
+ let unretweetBtn = null;
40
+ let targetArticle = null;
41
+
42
+ while (attempts < 20) {
43
+ targetArticle = findTargetArticle();
44
+ retweetBtn = targetArticle?.querySelector('[data-testid="retweet"]') || null;
45
+ unretweetBtn = targetArticle?.querySelector('[data-testid="unretweet"]') || null;
46
+
47
+ if (retweetBtn || unretweetBtn) break;
48
+
49
+ await new Promise(r => setTimeout(r, 500));
50
+ attempts++;
51
+ }
52
+
53
+ // Already not retweeted: idempotent success
54
+ if (retweetBtn) {
55
+ return { ok: true, message: 'Tweet is not retweeted (already removed).' };
56
+ }
57
+
58
+ if (!unretweetBtn) {
59
+ return { ok: false, message: 'Could not find the Unretweet button on this tweet after waiting 10 seconds. Are you logged in?' };
60
+ }
61
+
62
+ // Step 1: click Unretweet button → opens menu
63
+ unretweetBtn.click();
64
+
65
+ // Step 2: wait for the confirm menu item to appear, then click it
66
+ let confirmBtn = null;
67
+ for (let i = 0; i < 20; i++) {
68
+ await new Promise(r => setTimeout(r, 250));
69
+ confirmBtn = document.querySelector('[data-testid="unretweetConfirm"]');
70
+ if (confirmBtn) break;
71
+ }
72
+ if (!confirmBtn) {
73
+ return { ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' };
74
+ }
75
+ confirmBtn.click();
76
+ await new Promise(r => setTimeout(r, 1000));
77
+
78
+ // Verify success by checking if the 'retweet' button reappeared
79
+ const verifyArticle = findTargetArticle() || targetArticle;
80
+ const verifyBtn = verifyArticle?.querySelector('[data-testid="retweet"]');
81
+ if (verifyBtn) {
82
+ return { ok: true, message: 'Tweet successfully unretweeted.' };
83
+ } else {
84
+ return { ok: false, message: 'Unretweet action was initiated but UI did not update as expected.' };
85
+ }
86
+ } catch (e) {
87
+ return { ok: false, message: e.toString() };
88
+ }
89
+ })()`);
90
+ if (result.ok) {
91
+ // Wait for the unretweet network request to be processed
92
+ await page.wait(2);
93
+ }
94
+ return [{
95
+ status: result.ok ? 'success' : 'failed',
96
+ message: result.message
97
+ }];
98
+ }
99
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './unretweet.js';
5
+ import { createPageMock } from '../test-utils.js';
6
+
7
+ describe('twitter unretweet command', () => {
8
+ it('clicks the unretweet button then the confirm menu item and reports success', async () => {
9
+ const cmd = getRegistry().get('twitter/unretweet');
10
+ expect(cmd?.func).toBeTypeOf('function');
11
+ const page = createPageMock([
12
+ { ok: true, message: 'Tweet successfully unretweeted.' },
13
+ ]);
14
+ const result = await cmd.func(page, {
15
+ url: 'https://x.com/alice/status/2040254679301718161',
16
+ });
17
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
18
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
19
+ expect(page.wait).toHaveBeenNthCalledWith(2, 2);
20
+ const script = page.evaluate.mock.calls[0][0];
21
+ // Two-step UI flow must be present:
22
+ // 1) click the unretweet button
23
+ // 2) wait for and click the confirm menu item (data-testid="unretweetConfirm")
24
+ expect(script).toContain('unretweetBtn.click()');
25
+ expect(script).toContain("document.querySelector('[data-testid=\"unretweetConfirm\"]')");
26
+ expect(script).toContain('confirmBtn.click()');
27
+ expect(script).toContain("document.querySelectorAll('article')");
28
+ expect(script).toContain('match?.[1] === tweetId');
29
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
30
+ // Idempotency probe: when already not retweeted ([data-testid="retweet"] present),
31
+ // the script returns ok:true with an "already removed" message.
32
+ expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
33
+ expect(result).toEqual([
34
+ { status: 'success', message: 'Tweet successfully unretweeted.' },
35
+ ]);
36
+ });
37
+
38
+ it('returns a failed row when the confirm menu item never appears', async () => {
39
+ const cmd = getRegistry().get('twitter/unretweet');
40
+ expect(cmd?.func).toBeTypeOf('function');
41
+ const page = createPageMock([
42
+ { ok: false, message: 'Unretweet menu opened but the confirm option did not appear.' },
43
+ ]);
44
+ const result = await cmd.func(page, {
45
+ url: 'https://x.com/alice/status/2040254679301718161',
46
+ });
47
+ expect(result).toEqual([
48
+ { status: 'failed', message: 'Unretweet menu opened but the confirm option did not appear.' },
49
+ ]);
50
+ expect(page.wait).toHaveBeenCalledTimes(1);
51
+ });
52
+
53
+ it('throws CommandExecutionError when no page is provided', async () => {
54
+ const cmd = getRegistry().get('twitter/unretweet');
55
+ await expect(cmd.func(undefined, {
56
+ url: 'https://x.com/alice/status/2040254679301718161',
57
+ })).rejects.toThrow(CommandExecutionError);
58
+ });
59
+
60
+ it('rejects invalid tweet URLs before navigation', async () => {
61
+ const cmd = getRegistry().get('twitter/unretweet');
62
+ const page = createPageMock([]);
63
+ await expect(cmd.func(page, {
64
+ url: 'http://x.com/alice/status/2040254679301718161',
65
+ })).rejects.toThrow(ArgumentError);
66
+ expect(page.goto).not.toHaveBeenCalled();
67
+ expect(page.evaluate).not.toHaveBeenCalled();
68
+ });
69
+ });
@@ -54,62 +54,64 @@ export class BrowserBridge {
54
54
  const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
55
55
  const timeoutMs = effectiveSeconds * 1000;
56
56
  const health = await getDaemonHealth({ contextId });
57
+ // Detect stale daemon before any fast path. A stale daemon can still have
58
+ // the extension connected, so this cannot live only in the no-extension branch.
59
+ const daemonVersion = health.status?.daemonVersion;
60
+ const isStale = !!health.status && (!daemonVersion || daemonVersion !== PKG_VERSION);
61
+ let staleDaemonReplaced = false;
62
+ if (isStale) {
63
+ // Stale daemon — restart it so all browser commands run against the
64
+ // currently installed package code, not the old daemon binary.
65
+ const reason = daemonVersion
66
+ ? `v${daemonVersion} ≠ v${PKG_VERSION}`
67
+ : `pre-version daemon, CLI is v${PKG_VERSION}`;
68
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
69
+ process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
70
+ }
71
+ const shutdownAccepted = await requestDaemonShutdown();
72
+ const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
73
+ if (!portReleased) {
74
+ // Stale daemon replacement failed — don't blindly spawn on an occupied port
75
+ throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
76
+ ' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
77
+ }
78
+ // Port released — fall through to spawn a fresh daemon
79
+ staleDaemonReplaced = true;
80
+ }
57
81
  // Fast path: everything ready
58
- if (health.state === 'ready')
82
+ if (!staleDaemonReplaced && health.state === 'ready')
59
83
  return;
60
- if (health.state === 'profile-required') {
84
+ if (!staleDaemonReplaced && health.state === 'profile-required') {
61
85
  throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
62
86
  'Run opencli profile list to see connected profiles.', 'profile-required');
63
87
  }
64
- if (health.state === 'profile-disconnected') {
88
+ if (!staleDaemonReplaced && health.state === 'profile-disconnected') {
65
89
  const label = contextId ?? health.status.contextId ?? 'unknown';
66
90
  throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
67
91
  }
68
92
  // Daemon running but no extension
69
- if (health.state === 'no-extension') {
70
- // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
71
- const daemonVersion = health.status?.daemonVersion;
72
- const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;
73
- if (isStale) {
74
- // Stale daemon — restart it so extension gets a fresh WebSocket endpoint
75
- const reason = daemonVersion
76
- ? `v${daemonVersion} ≠ v${PKG_VERSION}`
77
- : `pre-version daemon, CLI is v${PKG_VERSION}`;
78
- if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
79
- process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
80
- }
81
- const shutdownAccepted = await requestDaemonShutdown();
82
- const portReleased = shutdownAccepted && await waitForDaemonStop(3000);
83
- if (!portReleased) {
84
- // Stale daemon replacement failed — don't blindly spawn on an occupied port
85
- throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
86
- ' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
87
- }
88
- // Port released — fall through to spawn a fresh daemon
93
+ if (!staleDaemonReplaced && health.state === 'no-extension') {
94
+ // Same version wait for extension to connect
95
+ if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
96
+ process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
97
+ process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
89
98
  }
90
- else {
91
- // Same version — wait for extension to connect
92
- if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
93
- process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
94
- process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
95
- }
96
- if (await this._pollUntilReady(timeoutMs, contextId))
97
- return;
98
- const finalHealth = await getDaemonHealth({ contextId });
99
- if (finalHealth.state === 'profile-required') {
100
- throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
101
- 'Run opencli profile list to see connected profiles.', 'profile-required');
102
- }
103
- if (finalHealth.state === 'profile-disconnected') {
104
- const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
105
- throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
106
- }
107
- throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
108
- 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
109
- 'If not installed:\n' +
110
- ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
111
- ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
99
+ if (await this._pollUntilReady(timeoutMs, contextId))
100
+ return;
101
+ const finalHealth = await getDaemonHealth({ contextId });
102
+ if (finalHealth.state === 'profile-required') {
103
+ throw new BrowserConnectError('Multiple Browser Bridge profiles are connected', 'Select one with --profile <name>, OPENCLI_PROFILE=<name>, or opencli profile use <name>.\n' +
104
+ 'Run opencli profile list to see connected profiles.', 'profile-required');
112
105
  }
106
+ if (finalHealth.state === 'profile-disconnected') {
107
+ const label = contextId ?? finalHealth.status.contextId ?? 'unknown';
108
+ throw new BrowserConnectError(`Browser profile "${label}" is not connected`, 'Open the matching Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.', 'profile-disconnected');
109
+ }
110
+ throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
111
+ 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
112
+ 'If not installed:\n' +
113
+ ' 1. Download: https://github.com/jackwener/opencli/releases\n' +
114
+ ' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
113
115
  }
114
116
  // No daemon — spawn one
115
117
  if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
@@ -165,6 +165,24 @@ describe('BrowserBridge state', () => {
165
165
  const bridge = new BrowserBridge();
166
166
  await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
167
167
  });
168
+ it('attempts stale daemon replacement even when extension is connected', async () => {
169
+ vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({
170
+ state: 'ready',
171
+ status: {
172
+ ok: true,
173
+ pid: 1,
174
+ uptime: 0,
175
+ daemonVersion: '0.0.1',
176
+ extensionConnected: true,
177
+ pending: 0,
178
+ memoryMB: 0,
179
+ port: 0,
180
+ },
181
+ });
182
+ vi.spyOn(daemonClient, 'requestDaemonShutdown').mockResolvedValue(false);
183
+ const bridge = new BrowserBridge();
184
+ await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
185
+ });
168
186
  });
169
187
  describe('stealth anti-detection', () => {
170
188
  it('generates non-empty JS string', () => {
@@ -223,6 +223,7 @@ describe('createProgram root help descriptions', () => {
223
223
  strategy: Strategy.PUBLIC,
224
224
  browser: false,
225
225
  args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
226
+ columns: ['title', 'url'],
226
227
  });
227
228
  const program = createProgram('', '');
228
229
  const site = program.commands.find(cmd => cmd.name() === 'bilibili');
@@ -235,10 +236,99 @@ describe('createProgram root help descriptions', () => {
235
236
  name: 'hot',
236
237
  access: 'read',
237
238
  description: 'Bilibili hot videos',
239
+ browser: false,
238
240
  example: 'opencli bilibili hot -f yaml',
239
- args: [{ name: 'limit', type: 'int', default: 20 }],
241
+ command_options: [{ name: 'limit', type: 'int', default: 20 }],
242
+ columns: ['title', 'url'],
240
243
  },
241
244
  ]);
245
+ expect(data.commands[0]).not.toHaveProperty('args');
246
+ }
247
+ finally {
248
+ process.argv = argv;
249
+ registry.clear();
250
+ for (const [key, value] of snapshot)
251
+ registry.set(key, value);
252
+ }
253
+ });
254
+ it('renders per-site text help without per-command common option noise', () => {
255
+ const registry = getRegistry();
256
+ const snapshot = new Map(registry);
257
+ registry.clear();
258
+ try {
259
+ cli({
260
+ site: 'bilibili',
261
+ name: 'hot',
262
+ access: 'read',
263
+ description: 'Bilibili hot videos',
264
+ strategy: Strategy.PUBLIC,
265
+ browser: false,
266
+ args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
267
+ });
268
+ cli({
269
+ site: 'bilibili',
270
+ name: 'video',
271
+ access: 'read',
272
+ description: 'Read one video',
273
+ domain: 'www.bilibili.com',
274
+ strategy: Strategy.PUBLIC,
275
+ browser: true,
276
+ args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }],
277
+ });
278
+ const program = createProgram('', '');
279
+ const site = program.commands.find(cmd => cmd.name() === 'bilibili');
280
+ expect(site).toBeTruthy();
281
+ const help = site.helpInformation();
282
+ expect(help).toContain('hot [options] [read] Bilibili hot videos');
283
+ expect(help).toContain('video <bvid> [read] Read one video');
284
+ expect(help).toContain('hot [options]');
285
+ expect(help).not.toContain('video <bvid> [options]');
286
+ expect(help).not.toContain('\nOptions:');
287
+ expect(help).toContain('Common options:');
288
+ expect(help).toContain('-f, --format <fmt>');
289
+ expect(help).toContain('--trace <mode>');
290
+ expect(help).toContain('get all command args/options in one structured response');
291
+ }
292
+ finally {
293
+ registry.clear();
294
+ for (const [key, value] of snapshot)
295
+ registry.set(key, value);
296
+ }
297
+ });
298
+ it('separates command args from common options in structured help', () => {
299
+ const registry = getRegistry();
300
+ const snapshot = new Map(registry);
301
+ const argv = process.argv;
302
+ registry.clear();
303
+ try {
304
+ cli({
305
+ site: 'bilibili',
306
+ name: 'video',
307
+ access: 'read',
308
+ description: 'Read one video',
309
+ strategy: Strategy.PUBLIC,
310
+ domain: 'www.bilibili.com',
311
+ browser: true,
312
+ args: [
313
+ { name: 'bvid', positional: true, required: true, help: 'Video id' },
314
+ { name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' },
315
+ ],
316
+ columns: ['title', 'url'],
317
+ });
318
+ const program = createProgram('', '');
319
+ const site = program.commands.find(cmd => cmd.name() === 'bilibili');
320
+ const command = site.commands.find(cmd => cmd.name() === 'video');
321
+ expect(command).toBeTruthy();
322
+ process.argv = ['node', 'opencli', 'bilibili', 'video', '--help', '-f', 'yaml'];
323
+ const data = yaml.load(command.helpInformation());
324
+ expect(data.usage).toBe('opencli bilibili video <bvid> [options]');
325
+ expect(data.browser).toBe(true);
326
+ expect(data.domain).toBe('www.bilibili.com');
327
+ expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]);
328
+ expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]);
329
+ expect(data.common_options.map((option) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']);
330
+ expect(data.columns).toEqual(['title', 'url']);
331
+ expect(data).not.toHaveProperty('args');
242
332
  }
243
333
  finally {
244
334
  process.argv = argv;
@@ -12,10 +12,9 @@
12
12
  import { log } from './logger.js';
13
13
  import yaml from 'js-yaml';
14
14
  import { fullName, getRegistry } from './registry.js';
15
- import { formatRegistryHelpText } from './serialization.js';
16
15
  import { render as renderOutput } from './output.js';
17
16
  import { executeCommand, prepareCommandArgs } from './execution.js';
18
- import { commandHelpData, formatSiteCommandDescription, installStructuredHelp, siteHelpData, } from './help.js';
17
+ import { commandHelpData, formatCommandHelpText, formatCommandListTerm, formatSiteCommandDescription, formatSiteHelpText, getRequestedHelpFormat, renderStructuredHelp, siteHelpData, } from './help.js';
19
18
  import { CliError, EXIT_CODES, toEnvelope, } from './errors.js';
20
19
  /**
21
20
  * Register a single CliCommand as a Commander subcommand.
@@ -49,7 +48,16 @@ export function registerCommandToProgram(siteCmd, cmd) {
49
48
  .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
50
49
  .option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
51
50
  .option('-v, --verbose', 'Debug output', false);
52
- installStructuredHelp(subCmd, () => commandHelpData(cmd), () => formatRegistryHelpText(cmd));
51
+ const originalHelpInformation = subCmd.helpInformation.bind(subCmd);
52
+ subCmd.helpInformation = ((contextOptions) => {
53
+ const format = getRequestedHelpFormat();
54
+ if (format)
55
+ return renderStructuredHelp(commandHelpData(cmd), format);
56
+ // Keep a fallback reference so future Commander upgrades still initialize
57
+ // internal help state before we render the cleaner grouped command help.
58
+ void originalHelpInformation(contextOptions);
59
+ return formatCommandHelpText(cmd);
60
+ });
53
61
  subCmd.action(async (...actionArgs) => {
54
62
  const actionOpts = actionArgs[positionalArgs.length] ?? {};
55
63
  const optionsRecord = typeof actionOpts === 'object' && actionOpts !== null ? actionOpts : {};
@@ -174,7 +182,18 @@ export function registerAllCommands(program, siteGroups) {
174
182
  for (const cmd of commands) {
175
183
  registerCommandToProgram(siteCmd, cmd);
176
184
  }
177
- installStructuredHelp(siteCmd, () => siteHelpData(site, commands));
185
+ const commandTerms = new Map(commands.map(cmd => [cmd.name, formatCommandListTerm(cmd)]));
186
+ siteCmd.configureHelp({
187
+ subcommandTerm: command => commandTerms.get(command.name()) ?? command.name(),
188
+ });
189
+ const originalSiteHelpInformation = siteCmd.helpInformation.bind(siteCmd);
190
+ siteCmd.helpInformation = ((contextOptions) => {
191
+ const format = getRequestedHelpFormat();
192
+ if (format)
193
+ return renderStructuredHelp(siteHelpData(site, commands), format);
194
+ void originalSiteHelpInformation(contextOptions);
195
+ return formatSiteHelpText(site, commands);
196
+ });
178
197
  }
179
198
  return [...commandsBySite.keys()].sort((a, b) => a.localeCompare(b));
180
199
  }
@@ -29,8 +29,12 @@ export interface RootAdapterGroups {
29
29
  sites: readonly string[];
30
30
  }
31
31
  export declare function formatRootAdapterHelpText(groups: RootAdapterGroups): string;
32
+ export declare function formatCommandListTerm(cmd: CliCommand): string;
32
33
  export declare function rootHelpData(program: Command, groups: RootAdapterGroups): Record<string, unknown>;
33
34
  export declare function siteHelpData(site: string, commands: readonly CliCommand[]): Record<string, unknown>;
34
35
  export declare function commandHelpData(cmd: CliCommand): Record<string, unknown>;
36
+ export declare function formatCommonOptionsHelpText(): string;
37
+ export declare function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string;
38
+ export declare function formatCommandHelpText(cmd: CliCommand): string;
35
39
  export declare function installStructuredHelp(command: Command, data: () => unknown, textSuffix?: string | (() => string)): void;
36
40
  export declare function formatSiteCommandDescription(cmd: CliCommand): string;