@jackwener/opencli 1.7.17 → 1.7.19
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.
- package/README.md +10 -8
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +585 -9
- package/clis/ctrip/ctrip.test.js +486 -1
- package/clis/ctrip/flight.js +136 -0
- package/clis/ctrip/hotel-search.js +132 -0
- package/clis/ctrip/utils.js +298 -0
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/google/search.js +16 -6
- package/clis/google-scholar/search.js +20 -5
- package/clis/google-scholar/search.test.js +35 -2
- package/clis/reddit/home.js +117 -0
- package/clis/reddit/home.test.js +127 -0
- package/clis/reddit/read.js +400 -54
- package/clis/reddit/read.test.js +315 -12
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/reddit/subreddit-info.js +117 -0
- package/clis/reddit/subreddit-info.test.js +163 -0
- package/clis/reddit/whoami.js +84 -0
- package/clis/reddit/whoami.test.js +105 -0
- package/clis/rednote/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +101 -0
- package/clis/rednote/user.js +55 -0
- package/clis/twitter/bookmark-folder.js +3 -1
- package/clis/twitter/bookmarks.js +3 -1
- package/clis/twitter/followers.js +20 -5
- package/clis/twitter/followers.test.js +44 -0
- package/clis/twitter/following.js +36 -20
- package/clis/twitter/following.test.js +60 -8
- package/clis/twitter/likes.js +28 -13
- package/clis/twitter/likes.test.js +111 -1
- package/clis/twitter/list-add.js +128 -204
- package/clis/twitter/list-add.test.js +97 -1
- package/clis/twitter/list-tweets.js +13 -4
- package/clis/twitter/list-tweets.test.js +48 -0
- package/clis/twitter/lists.js +5 -2
- package/clis/twitter/post.js +23 -4
- package/clis/twitter/post.test.js +30 -0
- package/clis/twitter/profile.js +16 -8
- package/clis/twitter/profile.test.js +39 -0
- package/clis/twitter/reply.js +133 -10
- package/clis/twitter/reply.test.js +55 -0
- package/clis/twitter/search.js +188 -170
- package/clis/twitter/search.test.js +96 -258
- package/clis/twitter/shared.js +167 -16
- package/clis/twitter/shared.test.js +102 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/tweets.js +147 -51
- package/clis/twitter/tweets.test.js +238 -1
- package/clis/xiaohongshu/comments.js +57 -26
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +201 -37
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- package/clis/xueqiu/earnings-date.js +2 -2
- package/clis/xueqiu/kline.js +2 -2
- package/clis/xueqiu/utils.js +19 -0
- package/clis/xueqiu/utils.test.js +26 -0
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/clis/zhihu/answer-detail.js +233 -0
- package/clis/zhihu/answer-detail.test.js +330 -0
- package/clis/zhihu/question.js +44 -10
- package/clis/zhihu/question.test.js +78 -1
- package/clis/zhihu/recommend.js +103 -0
- package/clis/zhihu/recommend.test.js +143 -0
- package/dist/src/browser/base-page.d.ts +3 -2
- package/dist/src/browser/base-page.test.js +2 -2
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/page.d.ts +3 -2
- package/dist/src/browser/page.js +4 -4
- package/dist/src/browser/page.test.js +31 -0
- package/dist/src/browser/utils.d.ts +10 -0
- package/dist/src/browser/utils.js +37 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/cli-argv-preprocess.d.ts +37 -0
- package/dist/src/cli-argv-preprocess.js +131 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +130 -0
- package/dist/src/cli.js +123 -86
- package/dist/src/cli.test.js +32 -22
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +21 -17
- package/dist/src/doctor.test.js +2 -0
- package/dist/src/download/progress.js +15 -11
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +25 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/help.d.ts +11 -0
- package/dist/src/help.js +46 -5
- package/dist/src/logger.js +8 -9
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +4 -5
- package/dist/src/runtime-detect.d.ts +1 -1
- package/dist/src/runtime-detect.js +1 -1
- package/dist/src/runtime-detect.test.js +3 -2
- package/dist/src/tui.d.ts +0 -1
- package/dist/src/tui.js +9 -22
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.js +4 -5
- package/package.json +5 -4
package/dist/src/cli.test.js
CHANGED
|
@@ -349,18 +349,20 @@ describe('createProgram root help descriptions', () => {
|
|
|
349
349
|
expect(data.command).toBe('opencli browser');
|
|
350
350
|
expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
351
351
|
expect(data.command_count).toBeGreaterThan(20);
|
|
352
|
+
// `--session` is now a hidden internal option; user-facing surface is the
|
|
353
|
+
// <session> positional declared via `.usage()`. Structured help drops
|
|
354
|
+
// hidden options, so namespace_options shouldn't expose it.
|
|
355
|
+
expect(data.namespace_options).not.toEqual(expect.arrayContaining([
|
|
356
|
+
expect.objectContaining({ name: 'session' }),
|
|
357
|
+
]));
|
|
352
358
|
expect(data.namespace_options).toEqual(expect.arrayContaining([
|
|
353
|
-
expect.objectContaining({
|
|
354
|
-
name: 'session',
|
|
355
|
-
flags: '--session <name>',
|
|
356
|
-
takes_value: 'required',
|
|
357
|
-
}),
|
|
358
359
|
expect.objectContaining({
|
|
359
360
|
name: 'window',
|
|
360
361
|
flags: '--window <mode>',
|
|
361
362
|
takes_value: 'required',
|
|
362
363
|
}),
|
|
363
364
|
]));
|
|
365
|
+
expect(data.usage).toBe('opencli browser <session> <command> [options]');
|
|
364
366
|
expect(data.global_options).toEqual(expect.arrayContaining([
|
|
365
367
|
expect.objectContaining({
|
|
366
368
|
name: 'version',
|
|
@@ -373,21 +375,24 @@ describe('createProgram root help descriptions', () => {
|
|
|
373
375
|
}),
|
|
374
376
|
]));
|
|
375
377
|
const click = data.commands.find((cmd) => cmd.name === 'click');
|
|
378
|
+
// Structured help command/usage paths include the <session> positional so
|
|
379
|
+
// agents construct the correct full invocation. `name` is the leaf
|
|
380
|
+
// identifier (placeholder positionals are stripped).
|
|
376
381
|
expect(click).toMatchObject({
|
|
377
|
-
command: 'opencli browser click',
|
|
378
|
-
usage: 'opencli browser click [target] [options]',
|
|
382
|
+
command: 'opencli browser <session> click',
|
|
383
|
+
usage: 'opencli browser <session> click [target] [options]',
|
|
379
384
|
positionals: [{ name: 'target' }],
|
|
380
385
|
});
|
|
381
386
|
expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
|
|
382
387
|
const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
|
|
383
388
|
expect(tabList).toMatchObject({
|
|
384
|
-
command: 'opencli browser tab list',
|
|
385
|
-
usage: 'opencli browser tab list [options]',
|
|
389
|
+
command: 'opencli browser <session> tab list',
|
|
390
|
+
usage: 'opencli browser <session> tab list [options]',
|
|
386
391
|
command_options: [],
|
|
387
392
|
});
|
|
388
393
|
const getText = data.commands.find((cmd) => cmd.name === 'get text');
|
|
389
394
|
expect(getText).toMatchObject({
|
|
390
|
-
command: 'opencli browser get text',
|
|
395
|
+
command: 'opencli browser <session> get text',
|
|
391
396
|
positionals: [{ name: 'target' }],
|
|
392
397
|
});
|
|
393
398
|
expect(data.structured_help).toMatchObject({
|
|
@@ -411,8 +416,8 @@ describe('createProgram root help descriptions', () => {
|
|
|
411
416
|
expect(data).toMatchObject({
|
|
412
417
|
namespace: 'browser',
|
|
413
418
|
group: 'tab',
|
|
414
|
-
command: 'opencli browser tab',
|
|
415
|
-
usage: 'opencli browser tab <command> [args] [options]',
|
|
419
|
+
command: 'opencli browser <session> tab',
|
|
420
|
+
usage: 'opencli browser <session> tab <command> [args] [options]',
|
|
416
421
|
command_count: 4,
|
|
417
422
|
});
|
|
418
423
|
expect(data.commands.map((cmd) => cmd.name)).toEqual([
|
|
@@ -422,13 +427,15 @@ describe('createProgram root help descriptions', () => {
|
|
|
422
427
|
'tab select',
|
|
423
428
|
]);
|
|
424
429
|
expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
|
|
425
|
-
command: 'opencli browser tab close',
|
|
426
|
-
usage: 'opencli browser tab close [targetId] [options]',
|
|
430
|
+
command: 'opencli browser <session> tab close',
|
|
431
|
+
usage: 'opencli browser <session> tab close [targetId] [options]',
|
|
427
432
|
positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
|
|
428
433
|
});
|
|
429
|
-
|
|
434
|
+
// session is now a hidden internal option (consumed from the <session> positional).
|
|
435
|
+
// namespace_options should only list user-facing options.
|
|
436
|
+
expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
|
|
430
437
|
expect(data.structured_help).toMatchObject({
|
|
431
|
-
usage: 'opencli browser tab --help -f yaml',
|
|
438
|
+
usage: 'opencli browser <session> tab --help -f yaml',
|
|
432
439
|
});
|
|
433
440
|
}
|
|
434
441
|
finally {
|
|
@@ -447,15 +454,16 @@ describe('createProgram root help descriptions', () => {
|
|
|
447
454
|
expect(data).toMatchObject({
|
|
448
455
|
namespace: 'browser',
|
|
449
456
|
name: 'click',
|
|
450
|
-
command: 'opencli browser click',
|
|
451
|
-
usage: 'opencli browser click [target] [options]',
|
|
457
|
+
command: 'opencli browser <session> click',
|
|
458
|
+
usage: 'opencli browser <session> click [target] [options]',
|
|
452
459
|
positionals: [{ name: 'target' }],
|
|
453
460
|
structured_help: {
|
|
454
|
-
usage: 'opencli browser click --help -f yaml',
|
|
461
|
+
usage: 'opencli browser <session> click --help -f yaml',
|
|
455
462
|
},
|
|
456
463
|
});
|
|
457
464
|
expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
|
|
458
|
-
|
|
465
|
+
// session is hidden; only `window` surfaces as a namespace option.
|
|
466
|
+
expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
|
|
459
467
|
expect(data.global_options.map((option) => option.name)).toContain('profile');
|
|
460
468
|
}
|
|
461
469
|
finally {
|
|
@@ -896,10 +904,12 @@ describe('browser tab targeting commands', () => {
|
|
|
896
904
|
});
|
|
897
905
|
it('requires an explicit session for browser commands', async () => {
|
|
898
906
|
const program = createProgram('', '');
|
|
907
|
+
// --session is now a hidden internal flag; commander no longer guards it.
|
|
908
|
+
// The action body throws via getBrowserSession(), surfacing the
|
|
909
|
+
// <session> positional in the error message.
|
|
899
910
|
await program.parseAsync(['node', 'opencli', 'browser', 'state']);
|
|
900
911
|
expect(mockBrowserConnect).not.toHaveBeenCalled();
|
|
901
|
-
expect(stderrSpy.mock.calls.flat().join('')).toContain('
|
|
902
|
-
expect(process.exitCode).toBeDefined();
|
|
912
|
+
expect(stderrSpy.mock.calls.flat().join('')).toContain('<session> is a required positional argument');
|
|
903
913
|
});
|
|
904
914
|
it('runs browser commands against an explicit session', async () => {
|
|
905
915
|
const program = createProgram('', '');
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* opencli daemon stop — graceful shutdown
|
|
5
5
|
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
|
6
6
|
*/
|
|
7
|
-
import { styleText } from 'node:util';
|
|
8
7
|
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
9
8
|
import { restartDaemon } from '../browser/daemon-lifecycle.js';
|
|
10
9
|
import { formatDuration } from '../download/progress.js';
|
|
@@ -14,18 +13,18 @@ import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js
|
|
|
14
13
|
export async function daemonStatus() {
|
|
15
14
|
const status = await fetchDaemonStatus();
|
|
16
15
|
if (!status) {
|
|
17
|
-
console.log(
|
|
16
|
+
console.log('Daemon: not running');
|
|
18
17
|
return;
|
|
19
18
|
}
|
|
20
19
|
const extensionLabel = !status.extensionConnected
|
|
21
|
-
?
|
|
20
|
+
? 'disconnected'
|
|
22
21
|
: status.extensionVersion
|
|
23
|
-
?
|
|
24
|
-
:
|
|
22
|
+
? `connected (v${status.extensionVersion})`
|
|
23
|
+
: 'connected (version unknown)';
|
|
25
24
|
const daemonVersion = formatDaemonVersion(status);
|
|
26
25
|
const stale = isDaemonStale(status, PKG_VERSION);
|
|
27
|
-
console.log(`Daemon: ${stale ?
|
|
28
|
-
console.log(`Version: ${daemonVersion}${stale ?
|
|
26
|
+
console.log(`Daemon: ${stale ? 'stale' : 'running'} (PID ${status.pid})`);
|
|
27
|
+
console.log(`Version: ${daemonVersion}${stale ? ` (CLI v${PKG_VERSION}; run: opencli daemon restart)` : ''}`);
|
|
29
28
|
console.log(`Uptime: ${formatDuration(Math.round(status.uptime * 1000))}`);
|
|
30
29
|
console.log(`Extension: ${extensionLabel}`);
|
|
31
30
|
if (status.profiles && status.profiles.length > 0) {
|
package/dist/src/doctor.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Simplified for the daemon-based architecture.
|
|
5
5
|
*/
|
|
6
|
-
import { styleText } from 'node:util';
|
|
7
6
|
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
|
8
7
|
import { BrowserBridge } from './browser/index.js';
|
|
9
8
|
import { getDaemonHealth } from './browser/daemon-client.js';
|
|
@@ -14,6 +13,7 @@ import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
|
|
14
13
|
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
|
|
15
14
|
import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
|
|
16
15
|
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
|
|
16
|
+
const DOCTOR_SESSION = '__doctor__';
|
|
17
17
|
/** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
|
|
18
18
|
function parseSemver(v) {
|
|
19
19
|
const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
|
|
@@ -50,7 +50,11 @@ export async function checkConnectivity(opts) {
|
|
|
50
50
|
const start = Date.now();
|
|
51
51
|
try {
|
|
52
52
|
const bridge = new BrowserBridge();
|
|
53
|
-
const page = await bridge.connect({
|
|
53
|
+
const page = await bridge.connect({
|
|
54
|
+
timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS,
|
|
55
|
+
session: DOCTOR_SESSION,
|
|
56
|
+
surface: 'browser',
|
|
57
|
+
});
|
|
54
58
|
try {
|
|
55
59
|
// Try a simple eval to verify end-to-end connectivity.
|
|
56
60
|
await page.evaluate('1 + 1');
|
|
@@ -163,13 +167,13 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
163
167
|
};
|
|
164
168
|
}
|
|
165
169
|
export function renderBrowserDoctorReport(report) {
|
|
166
|
-
const lines = [
|
|
170
|
+
const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor` + ` (${getRuntimeLabel()})`, ''];
|
|
167
171
|
// Daemon status
|
|
168
172
|
const daemonIcon = report.daemonFlaky
|
|
169
|
-
?
|
|
173
|
+
? '[WARN]'
|
|
170
174
|
: report.daemonStale
|
|
171
|
-
?
|
|
172
|
-
: report.daemonRunning ?
|
|
175
|
+
? '[WARN]'
|
|
176
|
+
: report.daemonRunning ? '[OK]' : '[MISSING]';
|
|
173
177
|
const daemonLabel = report.daemonFlaky
|
|
174
178
|
? 'unstable (running during live check, then stopped)'
|
|
175
179
|
: report.daemonRunning
|
|
@@ -180,47 +184,47 @@ export function renderBrowserDoctorReport(report) {
|
|
|
180
184
|
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);
|
|
181
185
|
// Extension status
|
|
182
186
|
const extIcon = report.extensionFlaky || (report.extensionConnected && !report.extensionVersion)
|
|
183
|
-
?
|
|
184
|
-
: report.extensionConnected ?
|
|
187
|
+
? '[WARN]'
|
|
188
|
+
: report.extensionConnected ? '[OK]' : '[MISSING]';
|
|
185
189
|
const extUpdateHint = report.extensionVersion && report.latestExtensionVersion && isNewerVersion(report.latestExtensionVersion, report.extensionVersion)
|
|
186
|
-
?
|
|
190
|
+
? ` → v${report.latestExtensionVersion} available`
|
|
187
191
|
: '';
|
|
188
192
|
const extVersion = !report.extensionConnected
|
|
189
193
|
? ''
|
|
190
194
|
: report.extensionVersion
|
|
191
|
-
?
|
|
192
|
-
:
|
|
195
|
+
? ` (v${report.extensionVersion})` + extUpdateHint
|
|
196
|
+
: ' (version unknown)';
|
|
193
197
|
const extLabel = report.extensionFlaky
|
|
194
198
|
? 'unstable (connected during live check, then disconnected)'
|
|
195
199
|
: report.extensionConnected ? 'connected' : 'not connected';
|
|
196
200
|
lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);
|
|
197
201
|
if (report.profiles && report.profiles.length > 0) {
|
|
198
202
|
const config = loadProfileConfig();
|
|
199
|
-
lines.push('',
|
|
203
|
+
lines.push('', 'Profiles:');
|
|
200
204
|
for (const profile of report.profiles) {
|
|
201
205
|
const alias = aliasForContextId(config, profile.contextId);
|
|
202
206
|
const aliasText = alias ? ` (${alias})` : '';
|
|
203
207
|
const defaultText = config.defaultContextId === profile.contextId ? ', default' : '';
|
|
204
208
|
const version = profile.extensionVersion ? `v${profile.extensionVersion}` : 'version unknown';
|
|
205
|
-
lines.push(
|
|
209
|
+
lines.push(` • ${profile.contextId}${aliasText}: connected ${version}${defaultText}`);
|
|
206
210
|
}
|
|
207
211
|
}
|
|
208
212
|
// Connectivity
|
|
209
213
|
if (report.connectivity) {
|
|
210
|
-
const connIcon = report.connectivity.ok ?
|
|
214
|
+
const connIcon = report.connectivity.ok ? '[OK]' : '[FAIL]';
|
|
211
215
|
const detail = report.connectivity.ok
|
|
212
216
|
? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
213
217
|
: `failed (${report.connectivity.error ?? 'unknown'})`;
|
|
214
218
|
lines.push(`${connIcon} Connectivity: ${detail}`);
|
|
215
219
|
}
|
|
216
220
|
if (report.issues.length) {
|
|
217
|
-
lines.push('',
|
|
221
|
+
lines.push('', 'Issues:');
|
|
218
222
|
for (const issue of report.issues) {
|
|
219
|
-
lines.push(
|
|
223
|
+
lines.push(` • ${issue}`);
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
226
|
else if (report.daemonRunning && report.extensionConnected) {
|
|
223
|
-
lines.push('',
|
|
227
|
+
lines.push('', 'Everything looks good!');
|
|
224
228
|
}
|
|
225
229
|
return lines.join('\n');
|
|
226
230
|
}
|
package/dist/src/doctor.test.js
CHANGED
|
@@ -173,6 +173,8 @@ describe('doctor report rendering', () => {
|
|
|
173
173
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
174
174
|
mockConnect.mockImplementationOnce(async (opts) => {
|
|
175
175
|
timeoutSeen = opts?.timeout;
|
|
176
|
+
expect(opts?.session).toBe('__doctor__');
|
|
177
|
+
expect(opts?.surface).toBe('browser');
|
|
176
178
|
return {
|
|
177
179
|
evaluate: vi.fn().mockResolvedValue(2),
|
|
178
180
|
closeWindow,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Download progress display: terminal progress bars, status updates.
|
|
3
3
|
*/
|
|
4
|
-
import { styleText } from 'node:util';
|
|
5
4
|
/**
|
|
6
5
|
* Format bytes as human-readable string (KB, MB, GB).
|
|
7
6
|
*/
|
|
@@ -35,33 +34,38 @@ export function formatDuration(ms) {
|
|
|
35
34
|
* Create a simple progress bar for terminal display.
|
|
36
35
|
*/
|
|
37
36
|
export function createProgressBar(filename, index, total) {
|
|
38
|
-
const prefix =
|
|
37
|
+
const prefix = `[${index + 1}/${total}]`;
|
|
39
38
|
const truncatedName = filename.length > 40 ? filename.slice(0, 37) + '...' : filename;
|
|
40
39
|
return {
|
|
41
40
|
update(current, totalBytes, label) {
|
|
42
|
-
const percent = totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0;
|
|
41
|
+
const percent = clampPercent(totalBytes > 0 ? Math.round((current / totalBytes) * 100) : 0);
|
|
43
42
|
const bar = createBar(percent);
|
|
44
43
|
const size = totalBytes > 0 ? formatBytes(totalBytes) : '';
|
|
45
44
|
const extra = label ? ` ${label}` : '';
|
|
46
45
|
process.stderr.write(`\r${prefix} ${truncatedName} ${bar} ${percent}% ${size}${extra}`);
|
|
47
46
|
},
|
|
48
47
|
complete(success, message) {
|
|
49
|
-
const icon = success ?
|
|
50
|
-
const msg = message ? ` ${
|
|
48
|
+
const icon = success ? '✓' : '✗';
|
|
49
|
+
const msg = message ? ` ${message}` : '';
|
|
51
50
|
process.stderr.write(`\r${prefix} ${icon} ${truncatedName}${msg}\n`);
|
|
52
51
|
},
|
|
53
52
|
fail(error) {
|
|
54
|
-
process.stderr.write(`\r${prefix}
|
|
53
|
+
process.stderr.write(`\r${prefix} ✗ ${truncatedName} ${error}\n`);
|
|
55
54
|
},
|
|
56
55
|
};
|
|
57
56
|
}
|
|
57
|
+
function clampPercent(percent) {
|
|
58
|
+
if (!Number.isFinite(percent))
|
|
59
|
+
return 0;
|
|
60
|
+
return Math.max(0, Math.min(100, percent));
|
|
61
|
+
}
|
|
58
62
|
/**
|
|
59
63
|
* Create a progress bar string.
|
|
60
64
|
*/
|
|
61
65
|
function createBar(percent, width = 20) {
|
|
62
66
|
const filled = Math.round((percent / 100) * width);
|
|
63
67
|
const empty = width - filled;
|
|
64
|
-
return
|
|
68
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
65
69
|
}
|
|
66
70
|
/**
|
|
67
71
|
* Multi-file download progress tracker.
|
|
@@ -98,19 +102,19 @@ export class DownloadProgressTracker {
|
|
|
98
102
|
const elapsed = formatDuration(Date.now() - this.startTime);
|
|
99
103
|
const parts = [];
|
|
100
104
|
if (this.completed > 0) {
|
|
101
|
-
parts.push(
|
|
105
|
+
parts.push(`${this.completed} downloaded`);
|
|
102
106
|
}
|
|
103
107
|
if (this.skipped > 0) {
|
|
104
|
-
parts.push(
|
|
108
|
+
parts.push(`${this.skipped} skipped`);
|
|
105
109
|
}
|
|
106
110
|
if (this.failed > 0) {
|
|
107
|
-
parts.push(
|
|
111
|
+
parts.push(`${this.failed} failed`);
|
|
108
112
|
}
|
|
109
113
|
return `${parts.join(', ')} in ${elapsed}`;
|
|
110
114
|
}
|
|
111
115
|
finish() {
|
|
112
116
|
if (this.verbose) {
|
|
113
|
-
process.stderr.write(`\
|
|
117
|
+
process.stderr.write(`\nDownload complete: ${this.getSummary()}\n`);
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createProgressBar } from './progress.js';
|
|
3
|
+
describe('download progress display', () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
it('clamps percentages above 100 to keep the progress bar renderable', () => {
|
|
8
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
9
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
10
|
+
expect(() => progress.update(150, 100)).not.toThrow();
|
|
11
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('100%'));
|
|
12
|
+
});
|
|
13
|
+
it('clamps negative percentages to zero', () => {
|
|
14
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
15
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
16
|
+
expect(() => progress.update(-10, 100)).not.toThrow();
|
|
17
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
|
|
18
|
+
});
|
|
19
|
+
it('renders zero percent when the total size is unknown', () => {
|
|
20
|
+
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
21
|
+
const progress = createProgressBar('file.bin', 0, 1);
|
|
22
|
+
expect(() => progress.update(50, 0)).not.toThrow();
|
|
23
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining('0%'));
|
|
24
|
+
});
|
|
25
|
+
});
|
package/dist/src/execution.js
CHANGED
|
@@ -467,9 +467,7 @@ function normalizeBooleanOption(name, raw) {
|
|
|
467
467
|
function resolveKeepTab(siteSession, rawOption) {
|
|
468
468
|
if (siteSession === 'persistent')
|
|
469
469
|
return true;
|
|
470
|
-
return normalizeBooleanOption('--keep-tab', rawOption)
|
|
471
|
-
?? normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
|
|
472
|
-
?? false;
|
|
470
|
+
return normalizeBooleanOption('--keep-tab', rawOption) ?? false;
|
|
473
471
|
}
|
|
474
472
|
function normalizeWindowMode(name, raw) {
|
|
475
473
|
if (raw === undefined || raw === '')
|
|
@@ -370,13 +370,11 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
370
370
|
expect(closeWindow).toHaveBeenCalledTimes(1);
|
|
371
371
|
vi.restoreAllMocks();
|
|
372
372
|
});
|
|
373
|
-
it('skips closeWindow when
|
|
373
|
+
it('skips closeWindow when --keep-tab=true (success path)', async () => {
|
|
374
374
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
375
375
|
const mockPage = { closeWindow };
|
|
376
376
|
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
377
377
|
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
378
|
-
const prev = process.env.OPENCLI_KEEP_TAB;
|
|
379
|
-
process.env.OPENCLI_KEEP_TAB = 'true';
|
|
380
378
|
try {
|
|
381
379
|
const cmd = cli({
|
|
382
380
|
site: 'test-execution',
|
|
@@ -386,24 +384,18 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
386
384
|
strategy: Strategy.PUBLIC,
|
|
387
385
|
func: async () => [{ ok: true }],
|
|
388
386
|
});
|
|
389
|
-
await executeCommand(cmd, {});
|
|
387
|
+
await executeCommand(cmd, {}, false, { keepTab: 'true' });
|
|
390
388
|
expect(closeWindow).not.toHaveBeenCalled();
|
|
391
389
|
}
|
|
392
390
|
finally {
|
|
393
|
-
if (prev === undefined)
|
|
394
|
-
delete process.env.OPENCLI_KEEP_TAB;
|
|
395
|
-
else
|
|
396
|
-
process.env.OPENCLI_KEEP_TAB = prev;
|
|
397
391
|
vi.restoreAllMocks();
|
|
398
392
|
}
|
|
399
393
|
});
|
|
400
|
-
it('skips closeWindow when
|
|
394
|
+
it('skips closeWindow when --keep-tab=true (failure path)', async () => {
|
|
401
395
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
402
396
|
const mockPage = { closeWindow };
|
|
403
397
|
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
404
398
|
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
405
|
-
const prev = process.env.OPENCLI_KEEP_TAB;
|
|
406
|
-
process.env.OPENCLI_KEEP_TAB = 'true';
|
|
407
399
|
try {
|
|
408
400
|
const cmd = cli({
|
|
409
401
|
site: 'test-execution',
|
|
@@ -413,14 +405,10 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
413
405
|
strategy: Strategy.PUBLIC,
|
|
414
406
|
func: async () => { throw new Error('adapter failure'); },
|
|
415
407
|
});
|
|
416
|
-
await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
|
|
408
|
+
await expect(executeCommand(cmd, {}, false, { keepTab: 'true' })).rejects.toThrow('adapter failure');
|
|
417
409
|
expect(closeWindow).not.toHaveBeenCalled();
|
|
418
410
|
}
|
|
419
411
|
finally {
|
|
420
|
-
if (prev === undefined)
|
|
421
|
-
delete process.env.OPENCLI_KEEP_TAB;
|
|
422
|
-
else
|
|
423
|
-
process.env.OPENCLI_KEEP_TAB = prev;
|
|
424
412
|
vi.restoreAllMocks();
|
|
425
413
|
}
|
|
426
414
|
});
|
package/dist/src/help.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ export interface RootAdapterGroups {
|
|
|
47
47
|
sites: readonly string[];
|
|
48
48
|
}
|
|
49
49
|
export declare function formatRootAdapterHelpText(groups: RootAdapterGroups): string;
|
|
50
|
+
/**
|
|
51
|
+
* Extracts a positional placeholder that should appear immediately after this
|
|
52
|
+
* command's name in user-facing path strings. Reads the leading positional
|
|
53
|
+
* (e.g. `<session>`) from a `.usage()` override; commands without a positional
|
|
54
|
+
* override return `null` so the path stays as-is.
|
|
55
|
+
*
|
|
56
|
+
* Example: `browser` declares `.usage('<session> <command> [options]')`,
|
|
57
|
+
* so `commanderPath(browserClickCmd)` becomes
|
|
58
|
+
* `['opencli', 'browser', '<session>', 'click']`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function leadingPositionalFromUsage(command: Command): string | null;
|
|
50
61
|
export declare function commanderNamespaceHelpData(namespaceRoot: Command, opts?: {
|
|
51
62
|
globalCommand?: Command;
|
|
52
63
|
description?: string;
|
package/dist/src/help.js
CHANGED
|
@@ -175,13 +175,42 @@ function compactCommanderOptions(options) {
|
|
|
175
175
|
.map(compactCommanderOption)
|
|
176
176
|
.filter((option) => option !== null);
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Extracts a positional placeholder that should appear immediately after this
|
|
180
|
+
* command's name in user-facing path strings. Reads the leading positional
|
|
181
|
+
* (e.g. `<session>`) from a `.usage()` override; commands without a positional
|
|
182
|
+
* override return `null` so the path stays as-is.
|
|
183
|
+
*
|
|
184
|
+
* Example: `browser` declares `.usage('<session> <command> [options]')`,
|
|
185
|
+
* so `commanderPath(browserClickCmd)` becomes
|
|
186
|
+
* `['opencli', 'browser', '<session>', 'click']`.
|
|
187
|
+
*/
|
|
188
|
+
export function leadingPositionalFromUsage(command) {
|
|
189
|
+
const usage = command._usage;
|
|
190
|
+
if (!usage)
|
|
191
|
+
return null;
|
|
192
|
+
const match = usage.match(/^\s*(<[^>]+>)/);
|
|
193
|
+
return match ? match[1] : null;
|
|
194
|
+
}
|
|
178
195
|
function commanderPath(command) {
|
|
179
196
|
const parts = [];
|
|
180
197
|
let current = command;
|
|
181
198
|
while (current) {
|
|
182
199
|
const name = current.name();
|
|
183
|
-
if (name)
|
|
200
|
+
if (name) {
|
|
184
201
|
parts.push(name);
|
|
202
|
+
// If this command declares a leading-positional usage override AND we
|
|
203
|
+
// have already collected a child name below it, the positional must
|
|
204
|
+
// appear between this command and the child (i.e. before the names
|
|
205
|
+
// already collected). parts is in reverse order, so push to the end.
|
|
206
|
+
const positional = leadingPositionalFromUsage(current);
|
|
207
|
+
if (positional && parts.length > 1) {
|
|
208
|
+
// We collected child names first (reverse order). Move them up by one
|
|
209
|
+
// and put the positional at index `parts.length - 2` so reverse()
|
|
210
|
+
// places it between this command and the first child name.
|
|
211
|
+
parts.splice(parts.length - 1, 0, positional);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
185
214
|
current = current.parent;
|
|
186
215
|
}
|
|
187
216
|
return parts.reverse();
|
|
@@ -189,7 +218,10 @@ function commanderPath(command) {
|
|
|
189
218
|
function commandPathFromRoot(namespaceRoot, command) {
|
|
190
219
|
const rootPath = commanderPath(namespaceRoot);
|
|
191
220
|
const commandPath = commanderPath(command);
|
|
192
|
-
|
|
221
|
+
// Strip placeholder positional segments (e.g. `<session>`) from the relative
|
|
222
|
+
// name so agents can still address subcommands by their leaf name. Display
|
|
223
|
+
// paths in `command` / `usage` still include the placeholders.
|
|
224
|
+
return commandPath.slice(rootPath.length).filter(part => !/^<.+>$/.test(part));
|
|
193
225
|
}
|
|
194
226
|
function collectLeafCommands(command) {
|
|
195
227
|
if (command.commands.length === 0)
|
|
@@ -232,10 +264,19 @@ export function commanderNamespaceHelpData(namespaceRoot, opts = {}) {
|
|
|
232
264
|
const leaves = collectLeafCommands(namespaceRoot)
|
|
233
265
|
.filter(command => command !== namespaceRoot)
|
|
234
266
|
.sort((a, b) => commandPathFromRoot(namespaceRoot, a).join(' ').localeCompare(commandPathFromRoot(namespaceRoot, b).join(' ')));
|
|
267
|
+
// Respect commander's `.usage()` override (e.g. `<session> <command> [options]`
|
|
268
|
+
// on `browser`); fall back to the generic `<command> [args] [options]` form.
|
|
269
|
+
// Read the private `_usage` field directly because `.usage()` returns the
|
|
270
|
+
// auto-generated form if no override was set.
|
|
271
|
+
const commandPath = commanderPath(namespaceRoot).join(' ');
|
|
272
|
+
const usageOverride = namespaceRoot._usage;
|
|
273
|
+
const usage = usageOverride
|
|
274
|
+
? `${commandPath} ${usageOverride}`
|
|
275
|
+
: `${commandPath} <command> [args] [options]`;
|
|
235
276
|
return {
|
|
236
277
|
namespace: namespaceRoot.name(),
|
|
237
|
-
command:
|
|
238
|
-
usage
|
|
278
|
+
command: commandPath,
|
|
279
|
+
usage,
|
|
239
280
|
description: opts.description ?? namespaceRoot.description(),
|
|
240
281
|
command_count: leaves.length,
|
|
241
282
|
commands: leaves.map(command => compactCommanderCommand(namespaceRoot, command, opts)),
|
|
@@ -243,7 +284,7 @@ export function commanderNamespaceHelpData(namespaceRoot, opts = {}) {
|
|
|
243
284
|
...(opts.globalCommand ? { global_options: compactCommanderOptions(opts.globalCommand.options) } : {}),
|
|
244
285
|
structured_help: {
|
|
245
286
|
formats: ['yaml', 'json'],
|
|
246
|
-
usage: `${
|
|
287
|
+
usage: `${commandPath} --help -f yaml`,
|
|
247
288
|
},
|
|
248
289
|
};
|
|
249
290
|
}
|
package/dist/src/logger.js
CHANGED
|
@@ -4,35 +4,34 @@
|
|
|
4
4
|
* All framework output (warnings, debug info, errors) should go through
|
|
5
5
|
* this module so that verbosity levels are respected consistently.
|
|
6
6
|
*/
|
|
7
|
-
import { styleText } from 'node:util';
|
|
8
7
|
function isVerbose() {
|
|
9
8
|
return !!process.env.OPENCLI_VERBOSE;
|
|
10
9
|
}
|
|
11
10
|
export const log = {
|
|
12
11
|
/** Informational message (always shown) */
|
|
13
12
|
info(msg) {
|
|
14
|
-
process.stderr.write(
|
|
13
|
+
process.stderr.write(`ℹ ${msg}\n`);
|
|
15
14
|
},
|
|
16
15
|
/** Lightweight status line for adapter progress updates */
|
|
17
16
|
status(msg) {
|
|
18
|
-
process.stderr.write(`${
|
|
17
|
+
process.stderr.write(`${msg}\n`);
|
|
19
18
|
},
|
|
20
19
|
/** Positive completion/status line without the heavier info prefix */
|
|
21
20
|
success(msg) {
|
|
22
|
-
process.stderr.write(`${
|
|
21
|
+
process.stderr.write(`${msg}\n`);
|
|
23
22
|
},
|
|
24
23
|
/** Warning (always shown) */
|
|
25
24
|
warn(msg) {
|
|
26
|
-
process.stderr.write(
|
|
25
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
27
26
|
},
|
|
28
27
|
/** Error (always shown) */
|
|
29
28
|
error(msg) {
|
|
30
|
-
process.stderr.write(
|
|
29
|
+
process.stderr.write(`✖ ${msg}\n`);
|
|
31
30
|
},
|
|
32
31
|
/** Verbose output (shown when -v flag or OPENCLI_VERBOSE is set) */
|
|
33
32
|
verbose(msg) {
|
|
34
33
|
if (isVerbose()) {
|
|
35
|
-
process.stderr.write(
|
|
34
|
+
process.stderr.write(`[verbose] ${msg}\n`);
|
|
36
35
|
}
|
|
37
36
|
},
|
|
38
37
|
/** Alias for verbose output. */
|
|
@@ -41,10 +40,10 @@ export const log = {
|
|
|
41
40
|
},
|
|
42
41
|
/** Step-style debug (for pipeline steps, etc.) */
|
|
43
42
|
step(stepNum, total, op, preview = '') {
|
|
44
|
-
process.stderr.write(`
|
|
43
|
+
process.stderr.write(` [${stepNum}/${total}] ${op}${preview}\n`);
|
|
45
44
|
},
|
|
46
45
|
/** Step result summary */
|
|
47
46
|
stepResult(summary) {
|
|
48
|
-
process.stderr.write(`
|
|
47
|
+
process.stderr.write(` → ${summary}\n`);
|
|
49
48
|
},
|
|
50
49
|
};
|
package/dist/src/main.js
CHANGED
|
@@ -139,5 +139,21 @@ if (getCompIdx !== -1) {
|
|
|
139
139
|
process.stdout.write(candidates.join('\n') + '\n');
|
|
140
140
|
process.exit(EXIT_CODES.SUCCESS);
|
|
141
141
|
}
|
|
142
|
+
// Rewrite `opencli browser <session> <subcommand> ...` so commander (which
|
|
143
|
+
// can't combine a parent positional with subcommand dispatch) sees the internal
|
|
144
|
+
// `--session <name>` flag form. Also refuses the retired `opencli browser
|
|
145
|
+
// --session foo ...` user form with a friendly usage error.
|
|
146
|
+
const { rewriteBrowserArgv, BrowserSessionArgvError } = await import('./cli-argv-preprocess.js');
|
|
147
|
+
try {
|
|
148
|
+
const rewritten = rewriteBrowserArgv(process.argv.slice(2));
|
|
149
|
+
process.argv.splice(2, process.argv.length - 2, ...rewritten);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err instanceof BrowserSessionArgvError) {
|
|
153
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
154
|
+
process.exit(EXIT_CODES.GENERIC_ERROR);
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
142
158
|
await emitHook('onStartup', { command: '__startup__', args: {} });
|
|
143
159
|
runCli(BUILTIN_CLIS, USER_CLIS);
|