@jackwener/opencli 1.7.15 → 1.7.16

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 (94) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +161 -31
  4. package/clis/chatgpt/ask.js +2 -1
  5. package/clis/chatgpt/detail.js +6 -1
  6. package/clis/chatgpt/read.js +2 -1
  7. package/clis/chatgpt/send.js +2 -1
  8. package/clis/chatgpt/utils.js +54 -12
  9. package/clis/chatgpt/utils.test.js +36 -1
  10. package/clis/claude/ask.js +22 -7
  11. package/clis/claude/detail.js +9 -2
  12. package/clis/claude/new.js +8 -2
  13. package/clis/claude/read.js +2 -1
  14. package/clis/claude/send.js +8 -3
  15. package/clis/claude/utils.js +27 -4
  16. package/clis/deepseek/ask.js +21 -8
  17. package/clis/deepseek/detail.js +9 -1
  18. package/clis/deepseek/new.js +13 -2
  19. package/clis/deepseek/read.js +2 -1
  20. package/clis/deepseek/utils.js +8 -1
  21. package/clis/linkedin/search.js +8 -11
  22. package/clis/maimai/search-talents.js +10 -6
  23. package/clis/openreview/author.js +58 -0
  24. package/clis/openreview/openreview.test.js +83 -1
  25. package/clis/openreview/utils.js +14 -0
  26. package/clis/reddit/comment.js +1 -0
  27. package/clis/reddit/frontpage.js +1 -0
  28. package/clis/reddit/popular.js +1 -0
  29. package/clis/reddit/read.js +2 -0
  30. package/clis/reddit/read.test.js +4 -0
  31. package/clis/reddit/save.js +1 -0
  32. package/clis/reddit/saved.js +1 -0
  33. package/clis/reddit/search.js +1 -0
  34. package/clis/reddit/subreddit.js +1 -0
  35. package/clis/reddit/subscribe.js +1 -0
  36. package/clis/reddit/upvote.js +1 -0
  37. package/clis/reddit/upvoted.js +1 -0
  38. package/clis/reddit/user-comments.js +1 -0
  39. package/clis/reddit/user-posts.js +1 -0
  40. package/clis/reddit/user.js +1 -0
  41. package/clis/twitter/article.js +7 -4
  42. package/clis/twitter/bookmark-folder.js +3 -5
  43. package/clis/twitter/bookmark-folder.test.js +5 -2
  44. package/clis/twitter/bookmark-folders.js +3 -5
  45. package/clis/twitter/bookmark-folders.test.js +3 -1
  46. package/clis/twitter/bookmarks.js +3 -5
  47. package/clis/twitter/download.js +1 -0
  48. package/clis/twitter/followers.js +1 -0
  49. package/clis/twitter/following.js +3 -6
  50. package/clis/twitter/following.test.js +2 -1
  51. package/clis/twitter/likes.js +3 -5
  52. package/clis/twitter/list-add.js +4 -3
  53. package/clis/twitter/list-add.test.js +23 -1
  54. package/clis/twitter/list-remove.js +4 -3
  55. package/clis/twitter/list-remove.test.js +23 -1
  56. package/clis/twitter/list-tweets.js +3 -5
  57. package/clis/twitter/lists.js +3 -5
  58. package/clis/twitter/notifications.js +1 -0
  59. package/clis/twitter/profile.js +7 -4
  60. package/clis/twitter/search.js +1 -0
  61. package/clis/twitter/thread.js +5 -7
  62. package/clis/twitter/timeline.js +5 -7
  63. package/clis/twitter/trending.js +4 -4
  64. package/clis/twitter/tweets.js +3 -6
  65. package/clis/youtube/like.js +6 -2
  66. package/clis/youtube/subscribe.js +6 -2
  67. package/clis/youtube/unlike.js +6 -2
  68. package/clis/youtube/unsubscribe.js +6 -2
  69. package/clis/youtube/utils.js +19 -13
  70. package/clis/youtube/utils.test.js +17 -1
  71. package/dist/src/browser/bridge.d.ts +1 -0
  72. package/dist/src/browser/bridge.js +1 -1
  73. package/dist/src/browser/cdp.d.ts +1 -0
  74. package/dist/src/browser/daemon-client.d.ts +2 -2
  75. package/dist/src/browser/daemon-client.js +6 -3
  76. package/dist/src/browser/daemon-client.test.js +10 -0
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +5 -1
  79. package/dist/src/cli.js +70 -2
  80. package/dist/src/cli.test.js +139 -7
  81. package/dist/src/commanderAdapter.js +7 -0
  82. package/dist/src/doctor.js +2 -2
  83. package/dist/src/doctor.test.js +4 -4
  84. package/dist/src/execution.d.ts +2 -0
  85. package/dist/src/execution.js +31 -6
  86. package/dist/src/execution.test.js +43 -16
  87. package/dist/src/external-clis.yaml +24 -0
  88. package/dist/src/help.d.ts +1 -0
  89. package/dist/src/help.js +29 -0
  90. package/dist/src/main.js +4 -14
  91. package/dist/src/runtime.d.ts +3 -0
  92. package/dist/src/runtime.js +1 -0
  93. package/dist/src/types.d.ts +1 -1
  94. package/package.json +1 -1
package/dist/src/cli.js CHANGED
@@ -313,7 +313,7 @@ async function resolveStoredBrowserTarget(page, scope = DEFAULT_BROWSER_WORKSPAC
313
313
  return resolveBrowserTargetInSession(page, defaultPage, { scope, source: 'saved' });
314
314
  }
315
315
  /** Create a browser page for browser commands. Uses a dedicated browser workspace for session persistence. */
316
- async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE, contextId) {
316
+ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE, contextId, opts = {}) {
317
317
  const { BrowserBridge } = await import('./browser/index.js');
318
318
  const bridge = new BrowserBridge();
319
319
  // Idle timeout: how long the browser workspace lease stays alive between commands
@@ -325,6 +325,7 @@ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE,
325
325
  workspace,
326
326
  ...(contextId && { contextId }),
327
327
  ...(idleTimeout && idleTimeout > 0 && { idleTimeout }),
328
+ windowMode: opts.windowMode ?? getBrowserWindowMode(undefined, 'foreground'),
328
329
  });
329
330
  const targetScope = getBrowserScope(workspace, contextId);
330
331
  const resolvedTargetPage = targetPage
@@ -338,6 +339,43 @@ async function getBrowserPage(targetPage, workspace = DEFAULT_BROWSER_WORKSPACE,
338
339
  }
339
340
  return page;
340
341
  }
342
+ function getBrowserWindowMode(command, defaultMode) {
343
+ const optionRaw = getCommandOption(command, 'window');
344
+ if (optionRaw !== undefined && optionRaw !== '') {
345
+ if (optionRaw === 'foreground' || optionRaw === 'background')
346
+ return optionRaw;
347
+ throw new Error(`--window must be one of: foreground, background. Received: "${String(optionRaw)}"`);
348
+ }
349
+ const envRaw = process.env.OPENCLI_WINDOW;
350
+ if (envRaw !== undefined && envRaw !== '') {
351
+ if (envRaw === 'foreground' || envRaw === 'background')
352
+ return envRaw;
353
+ throw new Error(`OPENCLI_WINDOW must be one of: foreground, background. Received: "${envRaw}"`);
354
+ }
355
+ return defaultMode;
356
+ }
357
+ function parseBrowserBoolean(name, raw) {
358
+ if (raw === undefined || raw === '')
359
+ return undefined;
360
+ if (raw === 'true')
361
+ return true;
362
+ if (raw === 'false')
363
+ return false;
364
+ throw new Error(`${name} must be one of: true, false. Received: "${String(raw)}"`);
365
+ }
366
+ function getBrowserKeepTab(command, defaultValue) {
367
+ return parseBrowserBoolean('--keep-tab', getCommandOption(command, 'keepTab'))
368
+ ?? parseBrowserBoolean('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
369
+ ?? defaultValue;
370
+ }
371
+ function hasExplicitBrowserWindowOption(command) {
372
+ const raw = getCommandOption(command, 'window');
373
+ return raw !== undefined && raw !== '';
374
+ }
375
+ function hasExplicitBrowserKeepTabOption(command) {
376
+ const raw = getCommandOption(command, 'keepTab');
377
+ return raw !== undefined && raw !== '';
378
+ }
341
379
  function addBrowserTabOption(command) {
342
380
  return command.option('--tab <targetId>', BROWSER_TAB_OPTION_DESCRIPTION);
343
381
  }
@@ -583,6 +621,8 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
583
621
  const browser = program
584
622
  .command('browser')
585
623
  .option('--workspace <name>', 'Browser workspace to use (default: browser:default; bound tabs use bound:<name>)')
624
+ .option('--window <mode>', 'Browser window mode: foreground or background')
625
+ .option('--keep-tab <bool>', 'Keep the browser tab lease after the command finishes')
586
626
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
587
627
  const originalBrowserDescription = browser.description();
588
628
  /**
@@ -646,12 +686,20 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
646
686
  /** Wrap browser actions with error handling and optional --json output */
647
687
  function browserAction(fn) {
648
688
  return async (...args) => {
689
+ let page = null;
690
+ let shouldReleasePage = false;
649
691
  try {
650
692
  const command = args.at(-1) instanceof Command ? args.at(-1) : undefined;
651
693
  const targetPage = getBrowserTargetId(command);
652
694
  const workspace = getBrowserWorkspace(command);
653
695
  const contextId = getBrowserContextId(command);
654
- const page = await getBrowserPage(targetPage, workspace, contextId);
696
+ const windowMode = getBrowserWindowMode(command, 'foreground');
697
+ const keepTab = getBrowserKeepTab(command, true);
698
+ shouldReleasePage = !keepTab && !workspace.startsWith('bound:');
699
+ if (workspace.startsWith('bound:') && (hasExplicitBrowserWindowOption(command) || hasExplicitBrowserKeepTabOption(command))) {
700
+ log.warn('--window/--keep-tab ignored for bound:* workspaces; bound tabs are user-owned.');
701
+ }
702
+ page = await getBrowserPage(targetPage, workspace, contextId, { windowMode });
655
703
  await fn(page, ...args);
656
704
  }
657
705
  catch (err) {
@@ -699,6 +747,14 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
699
747
  }
700
748
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
701
749
  }
750
+ finally {
751
+ if (shouldReleasePage && page?.closeWindow) {
752
+ await page.closeWindow().catch((err) => {
753
+ if (process.env.OPENCLI_VERBOSE)
754
+ log.warn(`[browser] Failed to release tab lease: ${getErrorMessage(err)}`);
755
+ });
756
+ }
757
+ }
702
758
  };
703
759
  }
704
760
  browser.command('bind')
@@ -2611,6 +2667,8 @@ cli({
2611
2667
  });
2612
2668
  // ── Plugin management ──────────────────────────────────────────────────────
2613
2669
  const pluginCmd = program.command('plugin').description('Manage opencli plugins');
2670
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
2671
+ const originalPluginDescription = pluginCmd.description();
2614
2672
  pluginCmd
2615
2673
  .command('install')
2616
2674
  .description('Install a plugin from a git repository')
@@ -2797,6 +2855,8 @@ cli({
2797
2855
  });
2798
2856
  // ── Built-in: adapter management ─────────────────────────────────────────
2799
2857
  const adapterCmd = program.command('adapter').description('Manage CLI adapters');
2858
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
2859
+ const originalAdapterDescription = adapterCmd.description();
2800
2860
  adapterCmd
2801
2861
  .command('status')
2802
2862
  .description('Show which sites have local overrides vs using official baseline')
@@ -2905,6 +2965,8 @@ cli({
2905
2965
  });
2906
2966
  // ── Built-in: browser profile selection ──────────────────────────────────
2907
2967
  const profileCmd = program.command('profile').description('Manage Browser Bridge Chrome profiles');
2968
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
2969
+ const originalProfileDescription = profileCmd.description();
2908
2970
  profileCmd
2909
2971
  .command('list')
2910
2972
  .description('List Chrome profiles connected through the Browser Bridge extension')
@@ -2982,6 +3044,8 @@ cli({
2982
3044
  });
2983
3045
  // ── Built-in: daemon ──────────────────────────────────────────────────────
2984
3046
  const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
3047
+ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
3048
+ const originalDaemonDescription = daemonCmd.description();
2985
3049
  daemonCmd
2986
3050
  .command('status')
2987
3051
  .description('Show daemon status')
@@ -3107,6 +3171,10 @@ cli({
3107
3171
  const adapterGroups = { external: externalNames, apps, sites };
3108
3172
  const adapterNameSet = new Set([...externalNames, ...siteNames]);
3109
3173
  installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
3174
+ installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
3175
+ installCommanderNamespaceStructuredHelp(pluginCmd, { globalCommand: program, description: originalPluginDescription });
3176
+ installCommanderNamespaceStructuredHelp(adapterCmd, { globalCommand: program, description: originalAdapterDescription });
3177
+ installCommanderNamespaceStructuredHelp(profileCmd, { globalCommand: program, description: originalProfileDescription });
3110
3178
  program.configureHelp({
3111
3179
  visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())),
3112
3180
  });
@@ -349,13 +349,23 @@ 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
- expect(data.namespace_options).toMatchObject([
353
- {
352
+ expect(data.namespace_options).toEqual(expect.arrayContaining([
353
+ expect.objectContaining({
354
354
  name: 'workspace',
355
355
  flags: '--workspace <name>',
356
356
  takes_value: 'required',
357
- },
358
- ]);
357
+ }),
358
+ expect.objectContaining({
359
+ name: 'window',
360
+ flags: '--window <mode>',
361
+ takes_value: 'required',
362
+ }),
363
+ expect.objectContaining({
364
+ name: 'keepTab',
365
+ flags: '--keep-tab <bool>',
366
+ takes_value: 'required',
367
+ }),
368
+ ]));
359
369
  expect(data.global_options).toEqual(expect.arrayContaining([
360
370
  expect.objectContaining({
361
371
  name: 'version',
@@ -421,7 +431,7 @@ describe('createProgram root help descriptions', () => {
421
431
  usage: 'opencli browser tab close [targetId] [options]',
422
432
  positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
423
433
  });
424
- expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace']);
434
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace', 'window', 'keepTab']);
425
435
  expect(data.structured_help).toMatchObject({
426
436
  usage: 'opencli browser tab --help -f yaml',
427
437
  });
@@ -450,13 +460,113 @@ describe('createProgram root help descriptions', () => {
450
460
  },
451
461
  });
452
462
  expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
453
- expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace']);
463
+ expect(data.namespace_options.map((option) => option.name)).toEqual(['workspace', 'window', 'keepTab']);
454
464
  expect(data.global_options.map((option) => option.name)).toContain('profile');
455
465
  }
456
466
  finally {
457
467
  process.argv = argv;
458
468
  }
459
469
  });
470
+ it('renders daemon namespace structured help with leaves and global options', () => {
471
+ const argv = process.argv;
472
+ try {
473
+ const program = createProgram('', '');
474
+ const daemon = program.commands.find(cmd => cmd.name() === 'daemon');
475
+ expect(daemon).toBeTruthy();
476
+ process.argv = ['node', 'opencli', 'daemon', '--help', '-f', 'yaml'];
477
+ const data = yaml.load(daemon.helpInformation());
478
+ expect(data).toMatchObject({
479
+ namespace: 'daemon',
480
+ command: 'opencli daemon',
481
+ usage: 'opencli daemon <command> [args] [options]',
482
+ description: 'Manage the opencli daemon',
483
+ command_count: 3,
484
+ namespace_options: [],
485
+ structured_help: { usage: 'opencli daemon --help -f yaml' },
486
+ });
487
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop']);
488
+ expect(data.global_options.map((option) => option.name)).toEqual(expect.arrayContaining(['version', 'profile']));
489
+ }
490
+ finally {
491
+ process.argv = argv;
492
+ }
493
+ });
494
+ it('renders plugin namespace structured help with positional + option leaves', () => {
495
+ const argv = process.argv;
496
+ try {
497
+ const program = createProgram('', '');
498
+ const plugin = program.commands.find(cmd => cmd.name() === 'plugin');
499
+ expect(plugin).toBeTruthy();
500
+ process.argv = ['node', 'opencli', 'plugin', '--help', '-f', 'yaml'];
501
+ const data = yaml.load(plugin.helpInformation());
502
+ expect(data).toMatchObject({
503
+ namespace: 'plugin',
504
+ command: 'opencli plugin',
505
+ description: 'Manage opencli plugins',
506
+ namespace_options: [],
507
+ });
508
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['create', 'install', 'list', 'uninstall', 'update']);
509
+ const update = data.commands.find((cmd) => cmd.name === 'update');
510
+ expect(update).toMatchObject({
511
+ usage: 'opencli plugin update [name] [options]',
512
+ positionals: [{ name: 'name' }],
513
+ });
514
+ expect(update.command_options.map((option) => option.name)).toEqual(['all']);
515
+ }
516
+ finally {
517
+ process.argv = argv;
518
+ }
519
+ });
520
+ it('renders adapter namespace structured help preserving original description after applyRootSubcommandSummaries', () => {
521
+ const argv = process.argv;
522
+ try {
523
+ const program = createProgram('', '');
524
+ const adapter = program.commands.find(cmd => cmd.name() === 'adapter');
525
+ expect(adapter).toBeTruthy();
526
+ process.argv = ['node', 'opencli', 'adapter', '--help', '-f', 'yaml'];
527
+ const data = yaml.load(adapter.helpInformation());
528
+ // applyRootSubcommandSummaries() rewrites .description() to a child-name listing;
529
+ // structured help must surface the original product description via the snapshot.
530
+ expect(data.description).toBe('Manage CLI adapters');
531
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['eject', 'reset', 'status']);
532
+ const reset = data.commands.find((cmd) => cmd.name === 'reset');
533
+ expect(reset).toMatchObject({
534
+ usage: 'opencli adapter reset [site] [options]',
535
+ positionals: [{ name: 'site' }],
536
+ });
537
+ expect(reset.command_options.map((option) => option.name)).toEqual(['all']);
538
+ }
539
+ finally {
540
+ process.argv = argv;
541
+ }
542
+ });
543
+ it('renders profile namespace structured help including required positionals', () => {
544
+ const argv = process.argv;
545
+ try {
546
+ const program = createProgram('', '');
547
+ const profile = program.commands.find(cmd => cmd.name() === 'profile');
548
+ expect(profile).toBeTruthy();
549
+ process.argv = ['node', 'opencli', 'profile', '--help', '-f', 'yaml'];
550
+ const data = yaml.load(profile.helpInformation());
551
+ expect(data).toMatchObject({
552
+ namespace: 'profile',
553
+ description: 'Manage Browser Bridge Chrome profiles',
554
+ command_count: 3,
555
+ });
556
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['list', 'rename', 'use']);
557
+ const rename = data.commands.find((cmd) => cmd.name === 'rename');
558
+ expect(rename).toMatchObject({
559
+ usage: 'opencli profile rename <contextId> <alias> [options]',
560
+ positionals: [
561
+ { name: 'contextId', required: true },
562
+ { name: 'alias', required: true },
563
+ ],
564
+ });
565
+ }
566
+ finally {
567
+ process.argv = argv;
568
+ }
569
+ });
460
570
  });
461
571
  describe('resolveBrowserVerifyInvocation', () => {
462
572
  it('prefers the built entry declared in package metadata', () => {
@@ -727,6 +837,8 @@ describe('browser tab targeting commands', () => {
727
837
  stderrSpy.mockClear();
728
838
  mockBrowserConnect.mockClear();
729
839
  mockBrowserClose.mockReset().mockResolvedValue(undefined);
840
+ delete process.env.OPENCLI_WINDOW;
841
+ delete process.env.OPENCLI_KEEP_TAB;
730
842
  mockBindTab.mockReset().mockResolvedValue({
731
843
  workspace: 'bound:default',
732
844
  page: 'tab-2',
@@ -759,6 +871,7 @@ describe('browser tab targeting commands', () => {
759
871
  screenshot: vi.fn().mockResolvedValue('base64-shot'),
760
872
  annotatedScreenshot: vi.fn().mockResolvedValue('annotated-base64-shot'),
761
873
  readNetworkCapture: vi.fn().mockResolvedValue([]),
874
+ closeWindow: vi.fn().mockResolvedValue(undefined),
762
875
  waitForDownload: vi.fn().mockResolvedValue({
763
876
  downloaded: true,
764
877
  filename: '/tmp/receipt.pdf',
@@ -801,8 +914,27 @@ describe('browser tab targeting commands', () => {
801
914
  it('runs browser commands against an explicit bound workspace', async () => {
802
915
  const program = createProgram('', '');
803
916
  await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', 'state']);
804
- expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default' });
917
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'bound:default', windowMode: 'foreground' });
918
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
919
+ });
920
+ it('passes browser --window through Commander options without relying on env pre-processing', async () => {
921
+ const program = createProgram('', '');
922
+ await program.parseAsync(['node', 'opencli', 'browser', '--window', 'background', 'state']);
923
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 30, workspace: 'browser:default', windowMode: 'background' });
924
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
925
+ });
926
+ it('releases non-bound browser tab leases when --keep-tab=false', async () => {
927
+ const program = createProgram('', '');
928
+ await program.parseAsync(['node', 'opencli', 'browser', '--keep-tab', 'false', 'state']);
929
+ expect(browserState.page?.snapshot).toHaveBeenCalled();
930
+ expect(browserState.page?.closeWindow).toHaveBeenCalled();
931
+ });
932
+ it('does not auto-release explicit bound workspaces when --keep-tab=false', async () => {
933
+ const program = createProgram('', '');
934
+ await program.parseAsync(['node', 'opencli', 'browser', '--workspace', 'bound:default', '--keep-tab', 'false', 'state']);
805
935
  expect(browserState.page?.snapshot).toHaveBeenCalled();
936
+ expect(browserState.page?.closeWindow).not.toHaveBeenCalled();
937
+ expect(stderrSpy.mock.calls.flat().join('')).toContain('--window/--keep-tab ignored for bound:* workspaces');
806
938
  });
807
939
  it('passes the opt-in AX source to browser state', async () => {
808
940
  const program = createProgram('', '');
@@ -48,6 +48,11 @@ export function registerCommandToProgram(siteCmd, cmd) {
48
48
  .option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
49
49
  .option('--trace <mode>', 'Trace capture: off, on, retain-on-failure', 'off')
50
50
  .option('-v, --verbose', 'Debug output', false);
51
+ if (cmd.browser) {
52
+ subCmd
53
+ .option('--window <mode>', 'Browser window mode: foreground or background')
54
+ .option('--keep-tab <bool>', 'Keep the browser tab lease after the command finishes');
55
+ }
51
56
  const originalHelpInformation = subCmd.helpInformation.bind(subCmd);
52
57
  subCmd.helpInformation = ((contextOptions) => {
53
58
  const format = getRequestedHelpFormat();
@@ -102,6 +107,8 @@ export function registerCommandToProgram(siteCmd, cmd) {
102
107
  prepared: true,
103
108
  ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}),
104
109
  ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}),
110
+ ...(cmd.browser && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}),
111
+ ...(cmd.browser && typeof optionsRecord.keepTab === 'string' ? { keepTab: optionsRecord.keepTab } : {}),
105
112
  });
106
113
  if (result === null || result === undefined) {
107
114
  return;
@@ -271,8 +271,8 @@ export function renderBrowserDoctorReport(report) {
271
271
  ? `tab ${session.preferredTabId}`
272
272
  : `window ${session.windowId ?? 'unknown'}`;
273
273
  const mode = session.ownership ?? (session.owned === false ? 'borrowed' : 'owned');
274
- const surface = session.surface ? `, surface=${session.surface}` : '';
275
- lines.push(styleText('dim', ` • ${session.workspace ?? 'default'} → ${target}, mode=${mode}${surface}, tabs=${session.tabCount ?? 0}, idle=${idle}`));
274
+ const windowRole = session.windowRole ? `, window=${session.windowRole}` : '';
275
+ lines.push(styleText('dim', ` • ${session.workspace ?? 'default'} → ${target}, mode=${mode}${windowRole}, tabs=${session.tabCount ?? 0}, idle=${idle}`));
276
276
  }
277
277
  }
278
278
  }
@@ -116,13 +116,13 @@ describe('doctor report rendering', () => {
116
116
  windowId: 2,
117
117
  preferredTabId: 42,
118
118
  ownership: 'borrowed',
119
- surface: 'borrowed-user-tab',
119
+ windowRole: 'borrowed-user',
120
120
  tabCount: 1,
121
121
  idleMsRemaining: null,
122
122
  },
123
123
  ],
124
124
  }));
125
- expect(text).toContain('bound:default → tab 42, mode=borrowed, surface=borrowed-user-tab, tabs=1, idle=none');
125
+ expect(text).toContain('bound:default → tab 42, mode=borrowed, window=borrowed-user, tabs=1, idle=none');
126
126
  });
127
127
  it('renders connected profiles and groups sessions by profile', () => {
128
128
  const text = strip(renderBrowserDoctorReport({
@@ -140,7 +140,7 @@ describe('doctor report rendering', () => {
140
140
  windowId: 2,
141
141
  preferredTabId: 42,
142
142
  ownership: 'borrowed',
143
- surface: 'borrowed-user-tab',
143
+ windowRole: 'borrowed-user',
144
144
  tabCount: 1,
145
145
  idleMsRemaining: null,
146
146
  },
@@ -150,7 +150,7 @@ describe('doctor report rendering', () => {
150
150
  windowId: 1,
151
151
  preferredTabId: 10,
152
152
  ownership: 'owned',
153
- surface: 'dedicated-container',
153
+ windowRole: 'automation',
154
154
  tabCount: 1,
155
155
  idleMsRemaining: 1000,
156
156
  },
@@ -16,6 +16,8 @@ export declare function executeCommand(cmd: CliCommand, rawKwargs: CommandArgs,
16
16
  prepared?: boolean;
17
17
  profile?: string;
18
18
  trace?: string;
19
+ keepTab?: string;
20
+ windowMode?: string;
19
21
  onTraceExport?: (trace: ObservationExportResult) => void;
20
22
  }): Promise<unknown>;
21
23
  export declare function prepareCommandArgs(cmd: CliCommand, rawKwargs: CommandArgs): CommandArgs;
@@ -215,6 +215,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
215
215
  const browserReuse = resolveBrowserSessionReuse(cmd);
216
216
  const workspace = resolveBrowserWorkspace(cmd, browserReuse);
217
217
  const idleTimeout = browserReuse === 'site' ? INTERACTIVE_BROWSER_IDLE_TIMEOUT_SECONDS : undefined;
218
+ const keepTab = resolveKeepTab(browserReuse, opts.keepTab);
219
+ const windowMode = resolveBrowserWindowMode('background', opts.windowMode);
218
220
  result = await browserSession(BrowserFactory, async (page) => {
219
221
  const observation = traceMode === 'off'
220
222
  ? null
@@ -281,9 +283,6 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
281
283
  throw wrapped;
282
284
  }
283
285
  }
284
- // --live / OPENCLI_LIVE=1 keeps the current automation tab lease after
285
- // the command finishes, so agents (or humans) can inspect the page state.
286
- const keepOpen = browserReuse !== 'none' || process.env.OPENCLI_LIVE === '1' || process.env.OPENCLI_LIVE === 'true';
287
286
  try {
288
287
  const browserTimeout = userTimeoutSec !== null
289
288
  ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
@@ -304,7 +303,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
304
303
  // Adapter commands are one-shot — release the current tab lease immediately
305
304
  // instead of waiting for the 30s idle timeout. The automation container
306
305
  // window stays open for reuse.
307
- if (!keepOpen)
306
+ if (!keepTab)
308
307
  await page.closeWindow?.().catch(() => { });
309
308
  return result;
310
309
  }
@@ -329,11 +328,11 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
329
328
  // Release the tab lease on failure too — without this, the lease lingers
330
329
  // until the extension's idle timer fires (unreliable on Windows where
331
330
  // MV3 service workers may be suspended before setTimeout triggers).
332
- if (!keepOpen)
331
+ if (!keepTab)
333
332
  await page.closeWindow?.().catch(() => { });
334
333
  throw err;
335
334
  }
336
- }, { workspace, cdpEndpoint, contextId, idleTimeout });
335
+ }, { workspace, cdpEndpoint, contextId, idleTimeout, windowMode });
337
336
  }
338
337
  else {
339
338
  // Non-browser commands: enforce a timeout only when the command exposes
@@ -459,6 +458,32 @@ function resolveBrowserWorkspace(cmd, reuse) {
459
458
  return `site:${cmd.site}`;
460
459
  return `site:${cmd.site}:${crypto.randomUUID()}`;
461
460
  }
461
+ function normalizeBooleanOption(name, raw) {
462
+ if (raw === undefined || raw === '')
463
+ return null;
464
+ if (raw === 'true')
465
+ return true;
466
+ if (raw === 'false')
467
+ return false;
468
+ throw new ArgumentError(`${name} must be one of: true, false. Received: "${String(raw)}"`);
469
+ }
470
+ function resolveKeepTab(reuse, rawOption) {
471
+ return normalizeBooleanOption('--keep-tab', rawOption)
472
+ ?? normalizeBooleanOption('OPENCLI_KEEP_TAB', process.env.OPENCLI_KEEP_TAB)
473
+ ?? reuse !== 'none';
474
+ }
475
+ function normalizeWindowMode(name, raw) {
476
+ if (raw === undefined || raw === '')
477
+ return null;
478
+ if (raw === 'foreground' || raw === 'background')
479
+ return raw;
480
+ throw new ArgumentError(`${name} must be one of: foreground, background. Received: "${String(raw)}"`);
481
+ }
482
+ function resolveBrowserWindowMode(defaultMode = 'background', rawOption) {
483
+ return normalizeWindowMode('--window', rawOption)
484
+ ?? normalizeWindowMode('OPENCLI_WINDOW', process.env.OPENCLI_WINDOW)
485
+ ?? defaultMode;
486
+ }
462
487
  /**
463
488
  * Resolve the user-controllable `--timeout` arg, in seconds.
464
489
  *
@@ -157,8 +157,8 @@ describe('executeCommand — non-browser timeout', () => {
157
157
  await executeCommand(cmd, {});
158
158
  await executeCommand(cmd, {});
159
159
  expect(sessionOpts).toHaveLength(2);
160
- expect(sessionOpts[0]).toMatchObject({ workspace: 'site:test-execution', idleTimeout: 600 });
161
- expect(sessionOpts[1]).toMatchObject({ workspace: 'site:test-execution', idleTimeout: 600 });
160
+ expect(sessionOpts[0]).toMatchObject({ workspace: 'site:test-execution', idleTimeout: 600, windowMode: 'background' });
161
+ expect(sessionOpts[1]).toMatchObject({ workspace: 'site:test-execution', idleTimeout: 600, windowMode: 'background' });
162
162
  expect(closeWindow).not.toHaveBeenCalled();
163
163
  vi.restoreAllMocks();
164
164
  });
@@ -187,6 +187,8 @@ describe('executeCommand — non-browser timeout', () => {
187
187
  expect(sessionOpts[0]?.workspace).not.toBe(sessionOpts[1]?.workspace);
188
188
  expect(sessionOpts[0]?.idleTimeout).toBeUndefined();
189
189
  expect(sessionOpts[1]?.idleTimeout).toBeUndefined();
190
+ expect(sessionOpts[0]?.windowMode).toBe('background');
191
+ expect(sessionOpts[1]?.windowMode).toBe('background');
190
192
  expect(closeWindow).toHaveBeenCalledTimes(2);
191
193
  vi.restoreAllMocks();
192
194
  });
@@ -372,18 +374,18 @@ describe('executeCommand — non-browser timeout', () => {
372
374
  expect(closeWindow).toHaveBeenCalledTimes(1);
373
375
  vi.restoreAllMocks();
374
376
  });
375
- it('skips closeWindow when OPENCLI_LIVE=1 (success path)', async () => {
377
+ it('skips closeWindow when OPENCLI_KEEP_TAB=true (success path)', async () => {
376
378
  const closeWindow = vi.fn().mockResolvedValue(undefined);
377
379
  const mockPage = { closeWindow };
378
380
  vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
379
381
  vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
380
- const prev = process.env.OPENCLI_LIVE;
381
- process.env.OPENCLI_LIVE = '1';
382
+ const prev = process.env.OPENCLI_KEEP_TAB;
383
+ process.env.OPENCLI_KEEP_TAB = 'true';
382
384
  try {
383
385
  const cmd = cli({
384
386
  site: 'test-execution',
385
- name: 'browser-live-success', access: 'read',
386
- description: 'test closeWindow skipped with --live on success',
387
+ name: 'browser-keep-tab-success', access: 'read',
388
+ description: 'test closeWindow skipped with --keep-tab on success',
387
389
  browser: true,
388
390
  strategy: Strategy.PUBLIC,
389
391
  func: async () => [{ ok: true }],
@@ -393,24 +395,24 @@ describe('executeCommand — non-browser timeout', () => {
393
395
  }
394
396
  finally {
395
397
  if (prev === undefined)
396
- delete process.env.OPENCLI_LIVE;
398
+ delete process.env.OPENCLI_KEEP_TAB;
397
399
  else
398
- process.env.OPENCLI_LIVE = prev;
400
+ process.env.OPENCLI_KEEP_TAB = prev;
399
401
  vi.restoreAllMocks();
400
402
  }
401
403
  });
402
- it('skips closeWindow when OPENCLI_LIVE=1 (failure path)', async () => {
404
+ it('skips closeWindow when OPENCLI_KEEP_TAB=true (failure path)', async () => {
403
405
  const closeWindow = vi.fn().mockResolvedValue(undefined);
404
406
  const mockPage = { closeWindow };
405
407
  vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
406
408
  vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
407
- const prev = process.env.OPENCLI_LIVE;
408
- process.env.OPENCLI_LIVE = '1';
409
+ const prev = process.env.OPENCLI_KEEP_TAB;
410
+ process.env.OPENCLI_KEEP_TAB = 'true';
409
411
  try {
410
412
  const cmd = cli({
411
413
  site: 'test-execution',
412
- name: 'browser-live-failure', access: 'read',
413
- description: 'test closeWindow skipped with --live on failure',
414
+ name: 'browser-keep-tab-failure', access: 'read',
415
+ description: 'test closeWindow skipped with --keep-tab on failure',
414
416
  browser: true,
415
417
  strategy: Strategy.PUBLIC,
416
418
  func: async () => { throw new Error('adapter failure'); },
@@ -420,12 +422,37 @@ describe('executeCommand — non-browser timeout', () => {
420
422
  }
421
423
  finally {
422
424
  if (prev === undefined)
423
- delete process.env.OPENCLI_LIVE;
425
+ delete process.env.OPENCLI_KEEP_TAB;
424
426
  else
425
- process.env.OPENCLI_LIVE = prev;
427
+ process.env.OPENCLI_KEEP_TAB = prev;
426
428
  vi.restoreAllMocks();
427
429
  }
428
430
  });
431
+ it('lets browser common options override adapter window and keep-tab defaults', async () => {
432
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
433
+ const mockPage = { closeWindow };
434
+ const sessionOpts = [];
435
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
436
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
437
+ sessionOpts.push(opts ?? {});
438
+ return fn(mockPage);
439
+ });
440
+ const cmd = cli({
441
+ site: 'test-execution',
442
+ name: 'browser-window-options', access: 'read',
443
+ description: 'test browser common options',
444
+ browser: true,
445
+ strategy: Strategy.PUBLIC,
446
+ func: async () => [{ ok: true }],
447
+ });
448
+ await executeCommand(cmd, {}, false, {
449
+ windowMode: 'foreground',
450
+ keepTab: 'true',
451
+ });
452
+ expect(sessionOpts[0]).toMatchObject({ windowMode: 'foreground' });
453
+ expect(closeWindow).not.toHaveBeenCalled();
454
+ vi.restoreAllMocks();
455
+ });
429
456
  it('does not re-run custom validation when args are already prepared', async () => {
430
457
  const validateArgs = vi.fn();
431
458
  const cmd = {
@@ -54,3 +54,27 @@
54
54
  tags: [vercel, deployment, serverless, frontend, devops]
55
55
  install:
56
56
  default: "npm install -g vercel"
57
+
58
+ - name: tg-cli
59
+ binary: tg
60
+ description: "Telegram CLI — local-first sync, search, export via MTProto for AI agents"
61
+ homepage: "https://github.com/jackwener/tg-cli"
62
+ tags: [telegram, messaging, search, export, ai-agent]
63
+ install:
64
+ default: "uv tool install kabi-tg-cli"
65
+
66
+ - name: discord-cli
67
+ binary: discord
68
+ description: "Discord CLI — local-first sync, search, export via SQLite for AI agents"
69
+ homepage: "https://github.com/jackwener/discord-cli"
70
+ tags: [discord, messaging, search, export, ai-agent]
71
+ install:
72
+ default: "uv tool install kabi-discord-cli"
73
+
74
+ - name: wx-cli
75
+ binary: wx
76
+ description: "WeChat local data CLI — sessions, messages, search, contacts, export for AI agents"
77
+ homepage: "https://github.com/jackwener/wx-cli"
78
+ tags: [wechat, messaging, search, export, ai-agent]
79
+ install:
80
+ default: "npm install -g @jackwener/wx-cli"
@@ -66,6 +66,7 @@ export declare function rootHelpData(program: Command, groups: RootAdapterGroups
66
66
  export declare function siteHelpData(site: string, commands: readonly CliCommand[]): Record<string, unknown>;
67
67
  export declare function commandHelpData(cmd: CliCommand): Record<string, unknown>;
68
68
  export declare function formatCommonOptionsHelpText(): string;
69
+ export declare function formatBrowserCommonOptionsHelpText(): string;
69
70
  export declare function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string;
70
71
  export declare function formatCommandHelpText(cmd: CliCommand): string;
71
72
  export declare function installStructuredHelp(command: Command, data: () => unknown, textSuffix?: string | (() => string)): void;