@agentrhq/webcmd 0.2.3 → 0.2.5

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 CHANGED
@@ -258,7 +258,7 @@ webcmd plugin uninstall <name>
258
258
  webcmd plugin create <name>
259
259
  ```
260
260
 
261
- Use plugins for private company workflows, community adapters, or experiments that are not ready for the built-in registry.
261
+ Use plugins for private company workflows, community adapters, or experiments that are not ready for the built-in registry. This repo is also a plugin monorepo: community CLIs promoted here live under [`plugins/`](./plugins/) and are advertised through [`webcmd-plugin.json`](./webcmd-plugin.json).
262
262
 
263
263
  ## Writing Adapters
264
264
 
@@ -278,7 +278,7 @@ import { cli, Strategy } from '@agentrhq/webcmd/registry';
278
278
  import { CommandExecutionError } from '@agentrhq/webcmd/errors';
279
279
  ```
280
280
 
281
- Private adapters can live in `~/.webcmd/clis/<site>/<command>.js`; upstream adapters live in [`clis/`](./clis/). For the full authoring workflow, install and use [`webcmd-adapter-author`](./skills/webcmd-adapter-author/SKILL.md).
281
+ Private adapters can live in `~/.webcmd/clis/<site>/<command>.js`; bundled upstream adapters live in [`clis/`](./clis/), while community upstream adapters live as plugins under [`plugins/`](./plugins/). For the full authoring workflow, install and use [`webcmd-adapter-author`](./skills/webcmd-adapter-author/SKILL.md).
282
282
 
283
283
  ## Configuration
284
284
 
package/cli-manifest.json CHANGED
@@ -16873,16 +16873,25 @@
16873
16873
  {
16874
16874
  "site": "practo",
16875
16875
  "name": "login",
16876
- "description": "Open Practo login for manual sign-in",
16876
+ "description": "Open Practo login and wait until the browser session is authenticated",
16877
16877
  "access": "write",
16878
16878
  "domain": "www.practo.com",
16879
16879
  "strategy": "cookie",
16880
16880
  "browser": true,
16881
- "args": [],
16881
+ "args": [
16882
+ {
16883
+ "name": "timeout",
16884
+ "type": "int",
16885
+ "default": 300,
16886
+ "required": false,
16887
+ "help": "Maximum seconds to wait for the user to finish login"
16888
+ }
16889
+ ],
16882
16890
  "columns": [
16883
16891
  "status",
16892
+ "logged_in",
16884
16893
  "site",
16885
- "message"
16894
+ "name"
16886
16895
  ],
16887
16896
  "type": "js",
16888
16897
  "modulePath": "practo/login.js",
@@ -1,21 +1,48 @@
1
+ import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
1
2
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
- import { PRACTO } from './utils.js';
3
+ import { PRACTO, probeIdentity } from './utils.js';
4
+
5
+ const DEFAULT_TIMEOUT_SECONDS = 300;
6
+
7
+ function isAuthRequired(error) {
8
+ return error instanceof AuthRequiredError;
9
+ }
3
10
 
4
11
  cli({
5
12
  site: 'practo',
6
13
  name: 'login',
7
14
  access: 'write',
8
- description: 'Open Practo login for manual sign-in',
15
+ description: 'Open Practo login and wait until the browser session is authenticated',
9
16
  domain: 'www.practo.com',
10
17
  strategy: Strategy.COOKIE,
11
18
  browser: true,
12
19
  navigateBefore: false,
13
20
  defaultWindowMode: 'foreground',
14
21
  siteSession: 'persistent',
15
- args: [],
16
- columns: ['status', 'site', 'message'],
17
- func: async (page) => {
18
- await page.goto(`${PRACTO}/login`);
19
- return [{ status: 'opened', site: 'practo', message: 'Finish login in the browser, then run `webcmd practo whoami`.' }];
22
+ args: [
23
+ { name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
24
+ ],
25
+ columns: ['status', 'logged_in', 'site', 'name'],
26
+ func: async (page, kwargs) => {
27
+ try {
28
+ const rows = await probeIdentity(page);
29
+ return [{ status: 'already_logged_in', ...rows[0] }];
30
+ } catch (error) {
31
+ if (!isAuthRequired(error)) throw error;
32
+ }
33
+
34
+ await page.goto(`${PRACTO}/login`, { waitUntil: 'none', settleMs: 1500 });
35
+ const timeout = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
36
+ const deadline = Date.now() + timeout * 1000;
37
+ while (Date.now() < deadline) {
38
+ await page.wait(2);
39
+ try {
40
+ const rows = await probeIdentity(page);
41
+ return [{ status: 'login_complete', ...rows[0] }];
42
+ } catch (error) {
43
+ if (!isAuthRequired(error)) throw error;
44
+ }
45
+ }
46
+ throw new TimeoutError('practo login', timeout, 'Complete Practo login in the browser, then retry.');
20
47
  },
21
48
  });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { ArgumentError } from '@agentrhq/webcmd/errors';
3
3
  import { getRegistry } from '@agentrhq/webcmd/registry';
4
4
  import './appointment.js';
@@ -124,6 +124,24 @@ describe('practo command registry', () => {
124
124
  expect(cancel.args.find((arg) => arg.name === 'confirm')?.type).toBe('boolean');
125
125
  });
126
126
 
127
+ it('waits for manual login to complete', async () => {
128
+ const login = getRegistry().get('practo/login');
129
+ const page = {
130
+ goto: vi.fn().mockResolvedValue(undefined),
131
+ wait: vi.fn().mockResolvedValue(undefined),
132
+ evaluate: vi.fn()
133
+ .mockResolvedValueOnce({ __error: 'HTTP 401', status: 401 })
134
+ .mockResolvedValueOnce({ name: 'Ada' }),
135
+ };
136
+
137
+ await expect(login.func(page, { timeout: 1 })).resolves.toEqual([{
138
+ status: 'login_complete',
139
+ logged_in: true,
140
+ site: 'practo',
141
+ name: 'Ada',
142
+ }]);
143
+ });
144
+
127
145
  it('refuses real booking without --confirm true', async () => {
128
146
  const book = getRegistry().get('practo/book-confirm');
129
147
  await expect(book.func({}, { practice_doctor_id: '859054', time: '2026-07-10 10:30:00' })).rejects.toThrow(ArgumentError);
@@ -23,8 +23,10 @@ export declare function waitForDaemonStatus(timeoutMs: number): Promise<DaemonSt
23
23
  export declare const daemonLifecycleHooks: {
24
24
  requestDaemonShutdown: typeof requestDaemonShutdown;
25
25
  spawnDaemonProcess: typeof spawnDaemonProcess;
26
+ signalDaemonProcessTree: typeof signalDaemonProcessTree;
26
27
  waitForDaemonStop: typeof waitForDaemonStop;
27
28
  };
29
+ export declare function signalDaemonProcessTree(pid: number, signal: NodeJS.Signals): boolean;
28
30
  export declare function restartDaemon(opts?: {
29
31
  stopTimeoutMs?: number;
30
32
  startTimeoutMs?: number;
@@ -53,8 +53,31 @@ export async function waitForDaemonStatus(timeoutMs) {
53
53
  export const daemonLifecycleHooks = {
54
54
  requestDaemonShutdown,
55
55
  spawnDaemonProcess,
56
+ signalDaemonProcessTree,
56
57
  waitForDaemonStop,
57
58
  };
59
+ export function signalDaemonProcessTree(pid, signal) {
60
+ let signaled = false;
61
+ if (process.platform !== 'win32') {
62
+ try {
63
+ process.kill(-pid, signal);
64
+ signaled = true;
65
+ }
66
+ catch {
67
+ // Fall back to the daemon PID below.
68
+ }
69
+ }
70
+ if (!signaled) {
71
+ try {
72
+ process.kill(pid, signal);
73
+ signaled = true;
74
+ }
75
+ catch {
76
+ // The process may have already exited; the caller polls the daemon port.
77
+ }
78
+ }
79
+ return signaled;
80
+ }
58
81
  export async function restartDaemon(opts = {}) {
59
82
  const previousStatus = await fetchDaemonStatus();
60
83
  let stopped = previousStatus === null;
@@ -91,13 +114,12 @@ export async function ensureBrowserBridgeReady(opts = {}) {
91
114
  if (!portReleased) {
92
115
  const stalePid = health.status?.pid;
93
116
  if (typeof stalePid === 'number' && Number.isInteger(stalePid) && stalePid > 0) {
94
- try {
95
- process.kill(stalePid, 'SIGKILL');
96
- }
97
- catch {
98
- // EPERM / ESRCH are both resolved by polling the fixed daemon port.
117
+ daemonLifecycleHooks.signalDaemonProcessTree(stalePid, 'SIGTERM');
118
+ portReleased = await daemonLifecycleHooks.waitForDaemonStop(1000);
119
+ if (!portReleased) {
120
+ daemonLifecycleHooks.signalDaemonProcessTree(stalePid, 'SIGKILL');
121
+ portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000);
99
122
  }
100
- portReleased = await daemonLifecycleHooks.waitForDaemonStop(2000);
101
123
  }
102
124
  }
103
125
  if (!portReleased) {
@@ -3,6 +3,7 @@ import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbr
3
3
  import type { BrowserSurface, SiteSessionMode } from '../../protocol.js';
4
4
  import { CloakNetworkCapture } from './network.js';
5
5
  export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
6
+ export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
6
7
  export interface SessionKeyInput {
7
8
  profileId?: string;
8
9
  session?: string;
@@ -33,13 +34,16 @@ export interface CloakTabInfo {
33
34
  export interface CloakSessionManagerOptions {
34
35
  baseDir?: string;
35
36
  launchPersistentContext?: LaunchPersistentContext;
37
+ recoverLockedProfile?: RecoverLockedProfile;
36
38
  }
37
39
  export declare function resolveLeaseKey(input: SessionKeyInput): string;
38
40
  export declare class CloakSessionManager {
39
41
  private readonly opts;
40
42
  readonly networkCapture: CloakNetworkCapture;
41
43
  private readonly launchPersistentContext;
44
+ private readonly recoverLockedProfile;
42
45
  private readonly profiles;
46
+ private readonly profileLaunches;
43
47
  constructor(opts?: CloakSessionManagerOptions);
44
48
  profileStatuses(): {
45
49
  contextId: string;
@@ -72,6 +76,7 @@ export declare class CloakSessionManager {
72
76
  release(input: SessionKeyInput): Promise<void>;
73
77
  shutdown(): Promise<void>;
74
78
  private getProfileRuntime;
79
+ private launchProfileRuntime;
75
80
  private openEntries;
76
81
  private findEntryByPageId;
77
82
  private refreshIdleTimer;
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { execFile } from 'node:child_process';
2
3
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
3
4
  import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
4
5
  import { CloakNetworkCapture } from './network.js';
@@ -17,10 +18,13 @@ export class CloakSessionManager {
17
18
  opts;
18
19
  networkCapture = new CloakNetworkCapture();
19
20
  launchPersistentContext;
21
+ recoverLockedProfile;
20
22
  profiles = new Map();
23
+ profileLaunches = new Map();
21
24
  constructor(opts = {}) {
22
25
  this.opts = opts;
23
26
  this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext;
27
+ this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile;
24
28
  }
25
29
  profileStatuses() {
26
30
  return [...this.profiles.entries()].map(([contextId, runtime]) => ({
@@ -235,13 +239,35 @@ export class CloakSessionManager {
235
239
  const existing = this.profiles.get(profileId);
236
240
  if (existing)
237
241
  return existing;
242
+ const pending = this.profileLaunches.get(profileId);
243
+ if (pending)
244
+ return pending;
245
+ const launch = this.launchProfileRuntime(profileId);
246
+ this.profileLaunches.set(profileId, launch);
247
+ try {
248
+ return await launch;
249
+ }
250
+ finally {
251
+ this.profileLaunches.delete(profileId);
252
+ }
253
+ }
254
+ async launchProfileRuntime(profileId) {
238
255
  const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir });
239
256
  fs.mkdirSync(userDataDir, { recursive: true });
240
- const context = await this.launchPersistentContext({
257
+ const launchOptions = {
241
258
  userDataDir,
242
259
  headless: false,
243
260
  humanize: true,
244
- });
261
+ };
262
+ let context;
263
+ try {
264
+ context = await this.launchPersistentContext(launchOptions);
265
+ }
266
+ catch (err) {
267
+ if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir)))
268
+ throw err;
269
+ context = await this.launchPersistentContext(launchOptions);
270
+ }
245
271
  const runtime = { context, pages: new Map(), lastSeenAt: Date.now() };
246
272
  this.profiles.set(profileId, runtime);
247
273
  return runtime;
@@ -291,3 +317,91 @@ function requireSession(session) {
291
317
  throw new Error('Browser session is required.');
292
318
  return normalized;
293
319
  }
320
+ function isProfileAlreadyInUseError(err) {
321
+ const message = err instanceof Error ? err.message : String(err);
322
+ return message.includes('Opening in existing browser session')
323
+ || message.includes('Failed to create a ProcessSingleton for your profile directory');
324
+ }
325
+ async function recoverLockedCloakProfile(userDataDir) {
326
+ if (process.platform === 'win32')
327
+ return false;
328
+ const initial = await findCloakProfileProcesses(userDataDir);
329
+ if (initial.length === 0)
330
+ return false;
331
+ signalPids(initial, 'SIGTERM');
332
+ if (await waitForProfileProcessesToExit(userDataDir, 2500))
333
+ return true;
334
+ signalPids(await findCloakProfileProcesses(userDataDir), 'SIGKILL');
335
+ return waitForProfileProcessesToExit(userDataDir, 1500);
336
+ }
337
+ async function waitForProfileProcessesToExit(userDataDir, timeoutMs) {
338
+ const deadline = Date.now() + timeoutMs;
339
+ while (Date.now() < deadline) {
340
+ await new Promise((resolve) => setTimeout(resolve, 100));
341
+ if ((await findCloakProfileProcesses(userDataDir)).length === 0)
342
+ return true;
343
+ }
344
+ return (await findCloakProfileProcesses(userDataDir)).length === 0;
345
+ }
346
+ function signalPids(pids, signal) {
347
+ for (const pid of pids) {
348
+ try {
349
+ process.kill(pid, signal);
350
+ }
351
+ catch {
352
+ // Already exited or not signalable; the follow-up poll decides recovery.
353
+ }
354
+ }
355
+ }
356
+ async function findCloakProfileProcesses(userDataDir) {
357
+ const profileDirs = profileDirAliases(userDataDir);
358
+ const stdout = await psOutput();
359
+ const pids = [];
360
+ for (const line of stdout.split('\n')) {
361
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
362
+ if (!match)
363
+ continue;
364
+ const pid = Number(match[1]);
365
+ const command = match[2];
366
+ if (!Number.isInteger(pid) || pid === process.pid)
367
+ continue;
368
+ if (!isCloakBrowserCommand(command))
369
+ continue;
370
+ if (!commandUsesProfileDir(command, profileDirs))
371
+ continue;
372
+ pids.push(pid);
373
+ }
374
+ return [...new Set(pids)];
375
+ }
376
+ function commandUsesProfileDir(command, profileDirs) {
377
+ for (const dir of profileDirs) {
378
+ const marker = `--user-data-dir=${dir}`;
379
+ const index = command.indexOf(marker);
380
+ if (index < 0)
381
+ continue;
382
+ const next = command[index + marker.length];
383
+ if (next === undefined || /\s/.test(next))
384
+ return true;
385
+ }
386
+ return false;
387
+ }
388
+ function profileDirAliases(userDataDir) {
389
+ const aliases = new Set([userDataDir]);
390
+ try {
391
+ aliases.add(fs.realpathSync.native(userDataDir));
392
+ }
393
+ catch {
394
+ // The launch path is still useful even if realpath cannot resolve it.
395
+ }
396
+ return [...aliases];
397
+ }
398
+ function isCloakBrowserCommand(command) {
399
+ return command.includes('/.cloakbrowser/') || command.includes('\\.cloakbrowser\\');
400
+ }
401
+ function psOutput() {
402
+ return new Promise((resolve) => {
403
+ execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => {
404
+ resolve(err ? '' : String(stdout));
405
+ });
406
+ });
407
+ }
@@ -42,6 +42,42 @@ describe('CloakSessionManager', () => {
42
42
  expect(launchPersistentContext).toHaveBeenCalledTimes(1);
43
43
  expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false });
44
44
  });
45
+ it('coalesces concurrent persistent context launches for the same profile', async () => {
46
+ const launched = fakeContext();
47
+ let resolveLaunch;
48
+ const launchPersistentContext = vi.fn(() => new Promise((resolve) => {
49
+ resolveLaunch = resolve;
50
+ }));
51
+ const manager = new CloakSessionManager({
52
+ baseDir: '/tmp/webcmd-test',
53
+ launchPersistentContext,
54
+ });
55
+ const firstPage = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
56
+ const secondPage = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
57
+ await Promise.resolve();
58
+ expect(launchPersistentContext).toHaveBeenCalledTimes(1);
59
+ resolveLaunch(launched.context);
60
+ const [first, second] = await Promise.all([firstPage, secondPage]);
61
+ expect(first.context).toBe(launched.context);
62
+ expect(second.context).toBe(launched.context);
63
+ expect(first.page).toBe(second.page);
64
+ });
65
+ it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => {
66
+ const launched = fakeContext();
67
+ const launchPersistentContext = vi.fn()
68
+ .mockRejectedValueOnce(new Error('browserType.launchPersistentContext: Opening in existing browser session.'))
69
+ .mockResolvedValueOnce(launched.context);
70
+ const recoverLockedProfile = vi.fn().mockResolvedValue(true);
71
+ const manager = new CloakSessionManager({
72
+ baseDir: '/tmp/webcmd-test',
73
+ launchPersistentContext,
74
+ recoverLockedProfile,
75
+ });
76
+ const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
77
+ expect(lease.context).toBe(launched.context);
78
+ expect(recoverLockedProfile).toHaveBeenCalledWith(expectedProfileDir('default'));
79
+ expect(launchPersistentContext).toHaveBeenCalledTimes(2);
80
+ });
45
81
  it('freshPage closes the existing persistent lease page and creates a new one', async () => {
46
82
  const makePage = () => ({
47
83
  goto: vi.fn().mockResolvedValue(undefined),
@@ -196,7 +196,7 @@ describe('BrowserBridge state', () => {
196
196
  const bridge = new BrowserBridge();
197
197
  await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
198
198
  });
199
- it('falls back to SIGKILL when stale daemon refuses graceful shutdown', async () => {
199
+ it('falls back to process-group SIGKILL when stale daemon refuses graceful shutdown', async () => {
200
200
  vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
201
201
  state: 'no-runtime',
202
202
  status: {
@@ -213,8 +213,10 @@ describe('BrowserBridge state', () => {
213
213
  });
214
214
  vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
215
215
  // Graceful shutdown short-circuits to false (requestDaemonShutdown -> false).
216
- // After SIGKILL the port is released, so the second waitForDaemonStop returns true.
217
- vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop').mockResolvedValue(true);
216
+ // SIGTERM does not release the port; process-group SIGKILL does.
217
+ vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop')
218
+ .mockResolvedValueOnce(false)
219
+ .mockResolvedValueOnce(true);
218
220
  vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'spawnDaemonProcess').mockReturnValue(null);
219
221
  const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
220
222
  const bridge = new BrowserBridge();
@@ -222,7 +224,9 @@ describe('BrowserBridge state', () => {
222
224
  // no-runtime wait (which times out with `timeout: 0.1`), producing the
223
225
  // runtime-not-ready error rather than the stale-daemon error.
224
226
  await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser runtime is not ready');
225
- expect(killSpy).toHaveBeenCalledWith(99999, 'SIGKILL');
227
+ const signalPid = process.platform === 'win32' ? 99999 : -99999;
228
+ expect(killSpy).toHaveBeenCalledWith(signalPid, 'SIGTERM');
229
+ expect(killSpy).toHaveBeenCalledWith(signalPid, 'SIGKILL');
226
230
  });
227
231
  it('reports stale daemon error when SIGKILL fails to release the port', async () => {
228
232
  vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
package/dist/src/cli.js CHANGED
@@ -2999,6 +2999,102 @@ cli({
2999
2999
  console.log(` ${plugins.length} plugin(s) installed`);
3000
3000
  console.log();
3001
3001
  });
3002
+ const catalogCmd = pluginCmd
3003
+ .command('catalog')
3004
+ .description('Manage plugin marketplace sources');
3005
+ catalogCmd
3006
+ .command('list')
3007
+ .description('List configured plugin marketplace sources')
3008
+ .option('-f, --format <fmt>', 'Output format: table, json', 'table')
3009
+ .action(async (opts) => {
3010
+ const { readCatalog } = await import('./plugin-catalog.js');
3011
+ try {
3012
+ const catalog = readCatalog();
3013
+ if (opts.format === 'json') {
3014
+ renderOutput(catalog, { fmt: 'json' });
3015
+ return;
3016
+ }
3017
+ renderOutput(catalog.sources, {
3018
+ fmt: opts.format,
3019
+ columns: ['id', 'source', 'manifestUrl'],
3020
+ title: `${CLI_COMMAND}/plugin-catalog`,
3021
+ source: `${CLI_COMMAND} plugin catalog list`,
3022
+ });
3023
+ }
3024
+ catch (err) {
3025
+ console.error(`Error: ${getErrorMessage(err)}`);
3026
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
3027
+ }
3028
+ });
3029
+ catalogCmd
3030
+ .command('add')
3031
+ .description('Add a plugin marketplace source')
3032
+ .argument('<source>', 'Marketplace source, e.g. github:owner/repo')
3033
+ .option('-f, --format <fmt>', 'Output format: table, json', 'table')
3034
+ .action(async (source, opts) => {
3035
+ const { addCatalogSource } = await import('./plugin-catalog.js');
3036
+ try {
3037
+ const added = await addCatalogSource(source);
3038
+ renderOutput(opts.format === 'json' ? added : [added], {
3039
+ fmt: opts.format,
3040
+ columns: ['id', 'source', 'manifestUrl'],
3041
+ title: `${CLI_COMMAND}/plugin-catalog`,
3042
+ source: `${CLI_COMMAND} plugin catalog add`,
3043
+ });
3044
+ }
3045
+ catch (err) {
3046
+ console.error(`Error: ${getErrorMessage(err)}`);
3047
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
3048
+ }
3049
+ });
3050
+ catalogCmd
3051
+ .command('remove')
3052
+ .description('Remove a plugin marketplace source')
3053
+ .argument('<id>', 'Catalog source id')
3054
+ .action(async (id) => {
3055
+ const { removeCatalogSource } = await import('./plugin-catalog.js');
3056
+ try {
3057
+ removeCatalogSource(id);
3058
+ console.log(`✅ Catalog source "${id}" removed.`);
3059
+ }
3060
+ catch (err) {
3061
+ console.error(`Error: ${getErrorMessage(err)}`);
3062
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
3063
+ }
3064
+ });
3065
+ pluginCmd
3066
+ .command('search')
3067
+ .description('Search installable marketplace plugins')
3068
+ .argument('[query]', 'Search query matched against plugin name and description')
3069
+ .option('-f, --format <fmt>', 'Output format: table, json', 'table')
3070
+ .action(async (query, opts) => {
3071
+ const { readCatalog, searchCatalogPlugins } = await import('./plugin-catalog.js');
3072
+ try {
3073
+ const catalog = readCatalog();
3074
+ const result = await searchCatalogPlugins(catalog, { query });
3075
+ if (opts.format === 'json') {
3076
+ renderOutput(result, { fmt: 'json' });
3077
+ }
3078
+ else {
3079
+ for (const err of result.errors) {
3080
+ console.error(`Warning: ${err.sourceId}: ${err.message}`);
3081
+ }
3082
+ renderOutput(result.plugins, {
3083
+ fmt: opts.format,
3084
+ columns: ['name', 'description', 'version', 'sourceId', 'installSource', 'webcmd'],
3085
+ title: `${CLI_COMMAND}/plugin-search`,
3086
+ source: `${CLI_COMMAND} plugin search`,
3087
+ });
3088
+ }
3089
+ if (catalog.sources.length > 0 && result.errors.length === catalog.sources.length) {
3090
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
3091
+ }
3092
+ }
3093
+ catch (err) {
3094
+ console.error(`Error: ${getErrorMessage(err)}`);
3095
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
3096
+ }
3097
+ });
3002
3098
  pluginCmd
3003
3099
  .command('create')
3004
3100
  .description('Create a new plugin scaffold')
@@ -52,7 +52,7 @@ describe('createProgram root help descriptions', () => {
52
52
  expect(descriptionFor(program, 'browser')).toContain('verify');
53
53
  expect(descriptionFor(program, 'browser')).not.toContain('Browser control');
54
54
  expect(descriptionFor(program, 'auth')).toBe('refresh, status');
55
- expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
55
+ expect(descriptionFor(program, 'plugin')).toBe('catalog, create, install, list, search, uninstall, update');
56
56
  expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
57
57
  expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
58
58
  expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
@@ -648,7 +648,7 @@ describe('createProgram root help descriptions', () => {
648
648
  description: 'Manage webcmd plugins',
649
649
  namespace_options: [],
650
650
  });
651
- expect(data.commands.map((cmd) => cmd.name)).toEqual(['create', 'install', 'list', 'uninstall', 'update']);
651
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['catalog add', 'catalog list', 'catalog remove', 'create', 'install', 'list', 'search', 'uninstall', 'update']);
652
652
  const update = data.commands.find((cmd) => cmd.name === 'update');
653
653
  expect(update).toMatchObject({
654
654
  usage: 'webcmd plugin update [name] [options]',
@@ -68,25 +68,45 @@ describe('runGenerateReleaseNotes', () => {
68
68
  '## Highlights',
69
69
  '- Better summaries.',
70
70
  '',
71
- '## Improvements',
72
- 'None.',
73
- '',
74
- '## Fixes',
75
- 'None.',
76
- '',
77
- '## Adapters',
78
- 'None.',
79
- '',
80
- '## Contributors',
81
- '- @alice',
82
- '',
83
- '## Reverts',
84
- 'None.',
85
- '',
86
71
  ].join('\n'),
87
72
  stderr: '',
88
73
  });
89
74
  });
75
+ it('prints nothing when generated notes only contain empty placeholders', async () => {
76
+ const { io, read } = createIo();
77
+ const context = {
78
+ tag: 'v1.2.3',
79
+ previousTag: 'v1.2.2',
80
+ currentRef: 'abcdef1',
81
+ pullRequests: [
82
+ {
83
+ number: 42,
84
+ title: 'chore: no user visible release changes',
85
+ author: { login: 'alice' },
86
+ labels: [],
87
+ files: [],
88
+ url: 'https://example.com/42',
89
+ },
90
+ ],
91
+ };
92
+ const loadContext = vi.fn(async () => context);
93
+ const generateText = vi.fn(async () => [
94
+ '## Highlights',
95
+ 'None.',
96
+ '',
97
+ '## Reverts',
98
+ 'There are no reverts in this release.',
99
+ '',
100
+ '## Contributors',
101
+ '- @alice',
102
+ ].join('\n'));
103
+ const exitCode = await runGenerateReleaseNotes(['node', 'script', 'v1.2.3'], { GEMINI_API_KEY: 'test-key' }, { loadContext, generateText }, io);
104
+ expect(exitCode).toBe(0);
105
+ expect(read()).toEqual({
106
+ stdout: '',
107
+ stderr: '',
108
+ });
109
+ });
90
110
  it('swallows generator errors and keeps release-please notes intact', async () => {
91
111
  const { io, read } = createIo();
92
112
  const loadContext = vi.fn(async () => {