@agentrhq/webcmd 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +39 -24
  2. package/cli-manifest.json +2308 -650
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/bigbasket/add-to-cart.js +82 -0
  5. package/clis/bigbasket/bigbasket.test.js +255 -0
  6. package/clis/bigbasket/cart.js +81 -0
  7. package/clis/bigbasket/category.js +30 -0
  8. package/clis/bigbasket/checkout.js +71 -0
  9. package/clis/bigbasket/location.js +30 -0
  10. package/clis/bigbasket/product.js +79 -0
  11. package/clis/bigbasket/search.js +30 -0
  12. package/clis/bigbasket/utils.js +207 -0
  13. package/clis/blinkit/add-to-cart.js +123 -0
  14. package/clis/blinkit/auth.js +99 -0
  15. package/clis/blinkit/blinkit.test.js +168 -0
  16. package/clis/blinkit/cart.js +34 -0
  17. package/clis/blinkit/checkout.js +32 -0
  18. package/clis/blinkit/location.js +29 -0
  19. package/clis/blinkit/place-order.js +78 -0
  20. package/clis/blinkit/product.js +63 -0
  21. package/clis/blinkit/search.js +89 -0
  22. package/clis/blinkit/utils.js +223 -0
  23. package/clis/district/_lib.js +566 -0
  24. package/clis/district/auth.js +49 -0
  25. package/clis/district/checkout.js +348 -0
  26. package/clis/district/listings.js +158 -0
  27. package/clis/district/locations.js +211 -0
  28. package/clis/district/search.js +218 -0
  29. package/clis/district/seats.js +233 -0
  30. package/clis/district/set-location.js +82 -0
  31. package/clis/district/showtimes.js +433 -0
  32. package/clis/practo/appointment.js +21 -0
  33. package/clis/practo/appointments.js +27 -0
  34. package/clis/practo/book-confirm.js +35 -0
  35. package/clis/practo/book-preview.js +24 -0
  36. package/clis/practo/booking-link.js +24 -0
  37. package/clis/practo/cancel.js +29 -0
  38. package/clis/practo/contact.js +21 -0
  39. package/clis/practo/login.js +21 -0
  40. package/clis/practo/practo.test.js +136 -0
  41. package/clis/practo/profile.js +31 -0
  42. package/clis/practo/search.js +30 -0
  43. package/clis/practo/slots.js +19 -0
  44. package/clis/practo/utils.js +374 -0
  45. package/clis/practo/whoami.js +28 -0
  46. package/clis/reddit/popular.js +12 -1
  47. package/clis/reddit/popular.test.js +12 -3
  48. package/clis/zepto/add-to-cart.js +53 -0
  49. package/clis/zepto/auth.js +59 -0
  50. package/clis/zepto/cart.js +23 -0
  51. package/clis/zepto/checkout.js +60 -0
  52. package/clis/zepto/location.js +20 -0
  53. package/clis/zepto/place-order.js +47 -0
  54. package/clis/zepto/product.js +52 -0
  55. package/clis/zepto/search.js +30 -0
  56. package/clis/zepto/utils.js +228 -0
  57. package/clis/zepto/zepto.test.js +238 -0
  58. package/dist/src/browser/bridge.d.ts +1 -0
  59. package/dist/src/browser/bridge.js +1 -1
  60. package/dist/src/browser/page.d.ts +4 -1
  61. package/dist/src/browser/page.js +12 -1
  62. package/dist/src/browser/protocol.d.ts +2 -0
  63. package/dist/src/browser/runtime/local-cloak/actions.js +1 -0
  64. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +2 -0
  65. package/dist/src/browser/runtime/local-cloak/session-manager.js +12 -2
  66. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +58 -0
  67. package/dist/src/build-manifest.js +1 -0
  68. package/dist/src/build-manifest.test.js +34 -0
  69. package/dist/src/cli.js +111 -28
  70. package/dist/src/discovery.js +1 -0
  71. package/dist/src/engine.test.js +62 -0
  72. package/dist/src/execution.js +1 -1
  73. package/dist/src/generate-release-notes-cli.test.js +55 -1
  74. package/dist/src/manifest-types.d.ts +2 -0
  75. package/dist/src/registry.d.ts +10 -0
  76. package/dist/src/registry.js +5 -3
  77. package/dist/src/registry.test.js +25 -0
  78. package/dist/src/release-notes.d.ts +3 -1
  79. package/dist/src/release-notes.js +44 -1
  80. package/dist/src/release-notes.test.js +39 -1
  81. package/dist/src/runtime.d.ts +2 -0
  82. package/dist/src/runtime.js +1 -0
  83. package/dist/src/skills.d.ts +23 -5
  84. package/dist/src/skills.js +87 -45
  85. package/dist/src/skills.test.js +80 -23
  86. package/package.json +2 -2
  87. package/scripts/generate-release-notes.ts +31 -0
  88. package/skills/smart-search/SKILL.md +156 -0
  89. package/skills/smart-search/references/sources-ai.md +74 -0
  90. package/skills/smart-search/references/sources-info.md +43 -0
  91. package/skills/smart-search/references/sources-media.md +40 -0
  92. package/skills/smart-search/references/sources-other.md +32 -0
  93. package/skills/smart-search/references/sources-shopping.md +21 -0
  94. package/skills/smart-search/references/sources-social.md +36 -0
  95. package/skills/smart-search/references/sources-tech.md +38 -0
  96. package/skills/smart-search/references/sources-travel.md +26 -0
  97. package/skills/webcmd-adapter-author/SKILL.md +10 -0
  98. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  99. package/skills/webcmd-autofix/SKILL.md +8 -0
  100. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { ArgumentError, AuthRequiredError } from '@agentrhq/webcmd/errors';
4
+ import { getRegistry } from '@agentrhq/webcmd/registry';
5
+ import './auth.js';
6
+ import './location.js';
7
+ import './search.js';
8
+ import './product.js';
9
+ import './add-to-cart.js';
10
+ import './cart.js';
11
+ import './checkout.js';
12
+ import './place-order.js';
13
+ import {
14
+ CART_EVALUATE,
15
+ buildSearchUrl,
16
+ normalizeLocationState,
17
+ normalizeProductRow,
18
+ parseLimitArg,
19
+ parseQuantityArg,
20
+ productCardsEvaluate,
21
+ resolveProductInput,
22
+ safeGoto,
23
+ } from './utils.js';
24
+
25
+ describe('zepto helpers', () => {
26
+ it('builds search URLs and validates numeric args', () => {
27
+ expect(buildSearchUrl('amul milk')).toBe('https://www.zepto.com/search?query=amul%20milk');
28
+ expect(() => buildSearchUrl(' ')).toThrow(ArgumentError);
29
+ expect(() => parseLimitArg(0, 20, 50)).toThrow(ArgumentError);
30
+ expect(() => parseLimitArg(51, 20, 50)).toThrow(ArgumentError);
31
+ expect(() => parseQuantityArg(0, 1, 12)).toThrow(ArgumentError);
32
+ });
33
+
34
+ it('parses product pvids and URLs', () => {
35
+ expect(resolveProductInput('https://www.zepto.com/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089')).toMatchObject({
36
+ productId: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
37
+ url: 'https://www.zepto.com/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089',
38
+ });
39
+ expect(() => resolveProductInput('5f54bb83-f3e0-4d8d-89b0-6339f3312089')).toThrow(ArgumentError);
40
+ });
41
+
42
+ it('normalizes product rows', () => {
43
+ expect(normalizeProductRow({
44
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
45
+ title: 'Rin Matic Top Load Detergent Liquid | Pouch',
46
+ pack_size: '1 pack (2 kg)',
47
+ price: '₹179',
48
+ mrp: '₹260',
49
+ url: '/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089',
50
+ }, 0)).toMatchObject({
51
+ rank: 1,
52
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
53
+ price: 179,
54
+ mrp: 260,
55
+ url: 'https://www.zepto.com/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089',
56
+ });
57
+ });
58
+
59
+ it('extracts rendered product cards', () => {
60
+ const dom = new JSDOM(`
61
+ <a href="/pn/rin/pvid/5f54bb83-f3e0-4d8d-89b0-6339f3312089">
62
+ <div>
63
+ <button>ADD</button>
64
+ <span>₹ 179</span><span>₹ 260</span>
65
+ <span>Rin Matic Top Load Detergent Liquid | Pouch</span>
66
+ <span>1 pack (2 kg)</span>
67
+ </div>
68
+ </a>
69
+ `, { runScripts: 'outside-only', url: 'https://www.zepto.com/search?query=rin' });
70
+
71
+ const result = dom.window.eval(productCardsEvaluate(5));
72
+
73
+ expect(result.rows[0]).toMatchObject({
74
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
75
+ title: 'Rin Matic Top Load Detergent Liquid | Pouch',
76
+ price: '₹ 179',
77
+ mrp: '₹ 260',
78
+ });
79
+ });
80
+
81
+ it('normalizes selected location without leaking exact coordinates', () => {
82
+ expect(normalizeLocationState({
83
+ addressText: 'Home: HSR Layout, Bengaluru',
84
+ userPosition: { latitude: 12.911, longitude: 77.629 },
85
+ })).toEqual({
86
+ selected: true,
87
+ label: 'Home',
88
+ area: '',
89
+ city: 'Bengaluru',
90
+ pincode: '',
91
+ hasCoordinates: true,
92
+ source: 'browser',
93
+ });
94
+ });
95
+
96
+ it('redacts exact house and floor details from location output', () => {
97
+ expect(normalizeLocationState({
98
+ addressText: 'home - House number - 5, Floor - 5th, HSR Layout, Bengaluru, India',
99
+ userPosition: { latitude: 12.911, longitude: 77.629 },
100
+ })).toEqual({
101
+ selected: true,
102
+ label: 'Home',
103
+ area: '',
104
+ city: 'Bengaluru',
105
+ pincode: '',
106
+ hasCoordinates: true,
107
+ source: 'browser',
108
+ });
109
+ });
110
+
111
+ it('does not treat the Select Location placeholder as selected', () => {
112
+ expect(normalizeLocationState({ addressText: 'Select Location' })).toEqual({
113
+ selected: false,
114
+ label: '',
115
+ area: '',
116
+ city: '',
117
+ pincode: '',
118
+ hasCoordinates: false,
119
+ source: 'browser',
120
+ });
121
+ });
122
+
123
+ it('tolerates Zepto SPA navigation aborts but keeps real navigation failures', async () => {
124
+ await expect(safeGoto({
125
+ goto: async () => { throw new Error('page.goto: net::ERR_ABORTED at https://www.zepto.com/'); },
126
+ }, 'https://www.zepto.com/', 'zepto cart')).resolves.toBeUndefined();
127
+
128
+ await expect(safeGoto({
129
+ goto: async () => { throw new Error('page.goto: net::ERR_NAME_NOT_RESOLVED'); },
130
+ }, 'https://www.zepto.com/', 'zepto cart')).rejects.toThrow('zepto cart navigation failed');
131
+ });
132
+
133
+ it('extracts cart items from Zepto cart storage without recommendations', () => {
134
+ const cart = {
135
+ state: {
136
+ cartContent: {
137
+ '5f54bb83-f3e0-4d8d-89b0-6339f3312089': {
138
+ quantity: 2,
139
+ productVariant: {
140
+ id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
141
+ name: 'Rin Matic Top Load Detergent Liquid | Pouch',
142
+ formattedPacksize: '1 pack (2 kg)',
143
+ price: 17900,
144
+ mrp: 26000,
145
+ },
146
+ },
147
+ },
148
+ },
149
+ };
150
+ const dom = new JSDOM('', { runScripts: 'outside-only', url: 'https://www.zepto.com/' });
151
+ dom.window.localStorage.setItem('cart', JSON.stringify(cart));
152
+
153
+ const result = dom.window.eval(CART_EVALUATE);
154
+
155
+ expect(result.rows).toEqual([expect.objectContaining({
156
+ product_id: '5f54bb83-f3e0-4d8d-89b0-6339f3312089',
157
+ title: 'Rin Matic Top Load Detergent Liquid | Pouch',
158
+ quantity: 2,
159
+ price: 179,
160
+ })]);
161
+ });
162
+
163
+ it('extracts top-level Zepto cart item fields', () => {
164
+ const cart = {
165
+ state: {
166
+ cartContent: {
167
+ 'ba77f9b3-0525-4ce8-bc4b-a2480419b780': {
168
+ quantity: 1,
169
+ title: 'Godrej Jersey Toned Fresh Milk | Pouch',
170
+ productVariantId: 'ba77f9b3-0525-4ce8-bc4b-a2480419b780',
171
+ quantityText: '1 pack (490 ml or 500 ml)',
172
+ superSaverSellingPrice: 2600,
173
+ mrp: 2700,
174
+ },
175
+ },
176
+ },
177
+ };
178
+ const dom = new JSDOM('', { runScripts: 'outside-only', url: 'https://www.zepto.com/' });
179
+ dom.window.localStorage.setItem('cart', JSON.stringify(cart));
180
+
181
+ const result = dom.window.eval(CART_EVALUATE);
182
+
183
+ expect(result.rows).toEqual([expect.objectContaining({
184
+ product_id: 'ba77f9b3-0525-4ce8-bc4b-a2480419b780',
185
+ title: 'Godrej Jersey Toned Fresh Milk | Pouch',
186
+ pack_size: '1 pack (490 ml or 500 ml)',
187
+ quantity: 1,
188
+ price: 26,
189
+ mrp: 27,
190
+ })]);
191
+ });
192
+ });
193
+
194
+ describe('zepto registry shape', () => {
195
+ it('registers the buying-path commands', () => {
196
+ for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
197
+ expect(getRegistry().get(`zepto/${name}`)).toBeDefined();
198
+ }
199
+ });
200
+
201
+ it('marks only cart-changing commands as write', () => {
202
+ expect(getRegistry().get('zepto/location').access).toBe('read');
203
+ expect(getRegistry().get('zepto/search').access).toBe('read');
204
+ expect(getRegistry().get('zepto/product').access).toBe('read');
205
+ expect(getRegistry().get('zepto/cart').access).toBe('read');
206
+ expect(getRegistry().get('zepto/login').access).toBe('write');
207
+ expect(getRegistry().get('zepto/add-to-cart').access).toBe('write');
208
+ expect(getRegistry().get('zepto/checkout').access).toBe('write');
209
+ expect(getRegistry().get('zepto/place-order').access).toBe('write');
210
+ });
211
+
212
+ it('lets commands own Zepto navigation instead of framework pre-navigation', () => {
213
+ for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
214
+ expect(getRegistry().get(`zepto/${name}`).navigateBefore).toBe(false);
215
+ }
216
+ });
217
+ });
218
+
219
+ describe('zepto command behavior', () => {
220
+ it('search reports auth-required when Zepto blocks web search', async () => {
221
+ const command = getRegistry().get('zepto/search');
222
+ const calls = [];
223
+ const fakePage = {
224
+ goto: async (...args) => { calls.push(args); },
225
+ wait: async () => {},
226
+ evaluate: async () => ({ authRequired: true, rows: [] }),
227
+ };
228
+
229
+ await expect(command.func(fakePage, { query: 'milk' })).rejects.toThrow(AuthRequiredError);
230
+ expect(calls[0]).toEqual(['https://www.zepto.com/search?query=milk', { waitUntil: 'none', settleMs: 1500 }]);
231
+ });
232
+
233
+ it('keeps place-order no-op unless --confirm is passed', async () => {
234
+ const command = getRegistry().get('zepto/place-order');
235
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
236
+ await expect(command.func(fakePage, {})).resolves.toMatchObject([{ status: 'no-op', confirmed: false }]);
237
+ });
238
+ });
@@ -21,6 +21,7 @@ export declare class BrowserBridge implements IBrowserFactory {
21
21
  windowMode?: 'foreground' | 'background';
22
22
  surface?: 'browser' | 'adapter';
23
23
  siteSession?: 'ephemeral' | 'persistent';
24
+ freshPage?: boolean;
24
25
  }): Promise<IPage>;
25
26
  close(): Promise<void>;
26
27
  private _ensureDaemon;
@@ -32,7 +32,7 @@ export class BrowserBridge {
32
32
  await this._ensureDaemon(opts.timeout, routing.contextId);
33
33
  if (!opts.session?.trim())
34
34
  throw new Error('Browser session is required');
35
- this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId);
35
+ this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage);
36
36
  this._state = 'connected';
37
37
  return this._page;
38
38
  }
@@ -21,7 +21,10 @@ export declare class Page extends BasePage {
21
21
  private readonly siteSession?;
22
22
  readonly preferredContextId?: string | undefined;
23
23
  private readonly _idleTimeout;
24
- constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined, preferredContextId?: string | undefined);
24
+ constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined, preferredContextId?: string | undefined, freshPage?: boolean);
25
+ /** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
26
+ private _freshPagePending;
27
+ private _freshPageOpts;
25
28
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
26
29
  private _page;
27
30
  private _networkCaptureUnsupported;
@@ -42,7 +42,7 @@ export class Page extends BasePage {
42
42
  siteSession;
43
43
  preferredContextId;
44
44
  _idleTimeout;
45
- constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession, preferredContextId) {
45
+ constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession, preferredContextId, freshPage) {
46
46
  super();
47
47
  this.session = session;
48
48
  this.contextId = contextId;
@@ -51,6 +51,15 @@ export class Page extends BasePage {
51
51
  this.siteSession = siteSession;
52
52
  this.preferredContextId = preferredContextId;
53
53
  this._idleTimeout = idleTimeout;
54
+ this._freshPagePending = freshPage === true;
55
+ }
56
+ /** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
57
+ _freshPagePending;
58
+ _freshPageOpts() {
59
+ if (!this._freshPagePending)
60
+ return {};
61
+ this._freshPagePending = false;
62
+ return { freshPage: true };
54
63
  }
55
64
  /** Active page identity (targetId), set after navigate and used in all subsequent commands */
56
65
  _page;
@@ -66,6 +75,7 @@ export class Page extends BasePage {
66
75
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
67
76
  ...(this.windowMode && { windowMode: this.windowMode }),
68
77
  ...(this.siteSession && { siteSession: this.siteSession }),
78
+ ...this._freshPageOpts(),
69
79
  };
70
80
  }
71
81
  /** Helper: spread session + page identity into command params */
@@ -79,6 +89,7 @@ export class Page extends BasePage {
79
89
  ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
80
90
  ...(this.windowMode && { windowMode: this.windowMode }),
81
91
  ...(this.siteSession && { siteSession: this.siteSession }),
92
+ ...this._freshPageOpts(),
82
93
  };
83
94
  }
84
95
  async goto(url, options) {
@@ -10,6 +10,8 @@ export interface BrowserRuntimeCommand {
10
10
  session?: string;
11
11
  surface?: BrowserSurface;
12
12
  siteSession?: SiteSessionMode;
13
+ /** Close any existing leased page and start on a new one (sent on the first action of a command run). */
14
+ freshPage?: boolean;
13
15
  url?: string;
14
16
  op?: string;
15
17
  index?: number;
@@ -43,6 +43,7 @@ async function resolveLease(manager, command) {
43
43
  surface: command.surface,
44
44
  siteSession: command.siteSession,
45
45
  idleTimeout: command.idleTimeout,
46
+ freshPage: command.freshPage,
46
47
  });
47
48
  }
48
49
  function execTarget(page, frameIndex, pageId) {
@@ -9,6 +9,8 @@ export interface SessionKeyInput {
9
9
  surface?: BrowserSurface;
10
10
  siteSession?: SiteSessionMode;
11
11
  idleTimeout?: number;
12
+ /** Discard the existing leased page (if any) and create a new one under the same lease. */
13
+ freshPage?: boolean;
12
14
  }
13
15
  export interface CloakPageLease {
14
16
  profileId: string;
@@ -40,15 +40,25 @@ export class CloakSessionManager {
40
40
  const surface = normalizeSurface(input.surface);
41
41
  const leaseKey = resolveLeaseKey(input);
42
42
  const runtime = await this.getProfileRuntime(profileId);
43
+ const freshPage = input.freshPage === true;
43
44
  const existing = runtime.pages.get(leaseKey);
44
- if (existing && !pageIsClosed(existing.page)) {
45
+ if (existing && !pageIsClosed(existing.page) && !freshPage) {
45
46
  runtime.lastSeenAt = Date.now();
46
47
  existing.idleTimeout = input.idleTimeout;
47
48
  this.refreshIdleTimer(runtime, leaseKey, existing);
48
49
  return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId };
49
50
  }
51
+ if (existing && freshPage) {
52
+ runtime.pages.delete(leaseKey);
53
+ this.clearIdleTimer(existing);
54
+ if (runtime.selectedPageId === existing.pageId)
55
+ runtime.selectedPageId = undefined;
56
+ if (!pageIsClosed(existing.page))
57
+ await existing.page.close().catch(() => { });
58
+ }
50
59
  const existingPages = runtime.context.pages();
51
- const page = existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
60
+ // freshPage must never adopt a leftover tab its whole point is a clean DOM.
61
+ const page = !freshPage && existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
52
62
  const pageId = nextPageId();
53
63
  const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
54
64
  runtime.pages.set(leaseKey, entry);
@@ -42,6 +42,64 @@ describe('CloakSessionManager', () => {
42
42
  expect(launchPersistentContext).toHaveBeenCalledTimes(1);
43
43
  expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false });
44
44
  });
45
+ it('freshPage closes the existing persistent lease page and creates a new one', async () => {
46
+ const makePage = () => ({
47
+ goto: vi.fn().mockResolvedValue(undefined),
48
+ evaluate: vi.fn().mockResolvedValue('ok'),
49
+ title: vi.fn().mockResolvedValue('Title'),
50
+ url: vi.fn().mockReturnValue('about:blank'),
51
+ isClosed: vi.fn().mockReturnValue(false),
52
+ close: vi.fn().mockResolvedValue(undefined),
53
+ });
54
+ const openPages = [];
55
+ const context = {
56
+ pages: vi.fn(() => openPages),
57
+ newPage: vi.fn(async () => {
58
+ const page = makePage();
59
+ openPages.push(page);
60
+ return page;
61
+ }),
62
+ cookies: vi.fn().mockResolvedValue([]),
63
+ close: vi.fn().mockResolvedValue(undefined),
64
+ };
65
+ const manager = new CloakSessionManager({
66
+ baseDir: '/tmp/webcmd-test',
67
+ launchPersistentContext: vi.fn().mockResolvedValue(context),
68
+ });
69
+ const key = { profileId: 'default', session: 'site:district', surface: 'adapter', siteSession: 'persistent' };
70
+ const first = await manager.getPage(key);
71
+ expect((await manager.getPage(key)).page).toBe(first.page);
72
+ const fresh = await manager.getPage({ ...key, freshPage: true });
73
+ expect(first.page.close).toHaveBeenCalled();
74
+ expect(fresh.page).not.toBe(first.page);
75
+ const reused = await manager.getPage(key);
76
+ expect(reused.page).toBe(fresh.page);
77
+ });
78
+ it('freshPage never adopts a leftover context tab', async () => {
79
+ const leftover = {
80
+ goto: vi.fn(),
81
+ isClosed: vi.fn().mockReturnValue(false),
82
+ close: vi.fn().mockResolvedValue(undefined),
83
+ };
84
+ const created = {
85
+ goto: vi.fn(),
86
+ isClosed: vi.fn().mockReturnValue(false),
87
+ close: vi.fn().mockResolvedValue(undefined),
88
+ };
89
+ const context = {
90
+ pages: vi.fn().mockReturnValue([leftover]),
91
+ newPage: vi.fn().mockResolvedValue(created),
92
+ cookies: vi.fn().mockResolvedValue([]),
93
+ close: vi.fn().mockResolvedValue(undefined),
94
+ };
95
+ const manager = new CloakSessionManager({
96
+ baseDir: '/tmp/webcmd-test',
97
+ launchPersistentContext: vi.fn().mockResolvedValue(context),
98
+ });
99
+ const lease = await manager.getPage({ profileId: 'default', session: 'site:district', surface: 'adapter', siteSession: 'persistent', freshPage: true });
100
+ expect(lease.page).toBe(created);
101
+ expect(context.newPage).toHaveBeenCalled();
102
+ });
45
103
  it('closes ephemeral adapter sessions when released', async () => {
46
104
  const launched = fakeContext();
47
105
  const manager = new CloakSessionManager({
@@ -103,6 +103,7 @@ function toManifestEntry(cmd, modulePath, sourceFile) {
103
103
  sourceFile,
104
104
  navigateBefore: cmd.navigateBefore,
105
105
  siteSession: cmd.siteSession,
106
+ freshPage: cmd.freshPage,
106
107
  defaultWindowMode: cmd.defaultWindowMode,
107
108
  };
108
109
  }
@@ -81,6 +81,40 @@ describe('manifest helper rules', () => {
81
81
  expect(entries[0].sourceFile).not.toContain('\\');
82
82
  getRegistry().delete(key);
83
83
  });
84
+ it('serializes freshPage for persistent browser commands', async () => {
85
+ const site = `manifest-fresh-page-${Date.now()}`;
86
+ const key = `${site}/checkout`;
87
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-manifest-'));
88
+ tempDirs.push(dir);
89
+ const file = path.join(dir, `${site}.ts`);
90
+ fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'checkout', access: 'write' });`);
91
+ const entries = await loadManifestEntries(file, site, async () => ({
92
+ command: cli({
93
+ site,
94
+ name: 'checkout',
95
+ access: 'write',
96
+ description: 'checkout command',
97
+ strategy: Strategy.COOKIE,
98
+ browser: true,
99
+ siteSession: 'persistent',
100
+ freshPage: true,
101
+ }),
102
+ }));
103
+ expect(entries).toMatchObject([
104
+ {
105
+ site,
106
+ name: 'checkout',
107
+ access: 'write',
108
+ strategy: 'cookie',
109
+ browser: true,
110
+ siteSession: 'persistent',
111
+ freshPage: true,
112
+ type: 'js',
113
+ modulePath: `${site}/${site}.js`,
114
+ },
115
+ ]);
116
+ getRegistry().delete(key);
117
+ });
84
118
  it('falls back to registry delta for side-effect-only cli modules', async () => {
85
119
  const site = `manifest-side-effect-${Date.now()}`;
86
120
  const key = `${site}/legacy`;
package/dist/src/cli.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import * as fs from 'node:fs';
8
8
  import * as os from 'node:os';
9
9
  import * as path from 'node:path';
10
+ import * as readline from 'node:readline/promises';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { Command, InvalidArgumentError, Option } from 'commander';
12
13
  import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js';
@@ -16,10 +17,10 @@ import { render as renderOutput } from './output.js';
16
17
  import { PKG_VERSION } from './version.js';
17
18
  import { printCompletionScript } from './completion.js';
18
19
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
19
- import { listWebcmdSkills, readWebcmdSkill } from './skills.js';
20
+ import { installWebcmdSkill, listWebcmdSkills, updateWebcmdSkill } from './skills.js';
20
21
  import { registerAllCommands } from './commanderAdapter.js';
21
22
  import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
22
- import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError } from './errors.js';
23
+ import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError, ArgumentError } from './errors.js';
23
24
  import { TargetError } from './browser/target-errors.js';
24
25
  import { resolveTargetJs, getTextResolvedJs, getValueResolvedJs, getAttributesResolvedJs, selectResolvedJs, isAutocompleteResolvedJs } from './browser/target-resolver.js';
25
26
  import { buildFindJs, buildSemanticFindJs, isFindError } from './browser/find.js';
@@ -58,6 +59,70 @@ function parseDurationMs(raw, flagName) {
58
59
  function timestampFromRaw(value) {
59
60
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
60
61
  }
62
+ function isInteractiveInstall(opts) {
63
+ return !opts.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
64
+ }
65
+ async function resolveSkillInstallOptions(opts) {
66
+ if (!isInteractiveInstall(opts))
67
+ return opts;
68
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
69
+ try {
70
+ const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd install skills?', [
71
+ { key: '1', label: 'Global', value: 'user', aliases: ['global', 'user', 'g'] },
72
+ { key: '2', label: 'Local project', value: 'project', aliases: ['local', 'project', 'l'] },
73
+ ], '1');
74
+ const provider = opts.provider ?? (opts.path ? undefined : await choosePrompt(rl, 'Which coding agent should use them?', [
75
+ { key: '1', label: 'Agents', value: 'agents', aliases: ['agents', 'agent', 'a'] },
76
+ { key: '2', label: 'Codex', value: 'codex', aliases: ['codex', 'c'] },
77
+ { key: '3', label: 'Claude', value: 'claude', aliases: ['claude', 'claude-code'] },
78
+ { key: '4', label: 'Custom path', value: 'custom', aliases: ['custom', 'path'] },
79
+ ], '1'));
80
+ const customPath = opts.path ?? (provider === 'custom' ? await nonEmptyPrompt(rl, 'Skills directory path: ') : undefined);
81
+ return { ...opts, scope, provider: provider === 'custom' ? undefined : provider, path: customPath };
82
+ }
83
+ finally {
84
+ rl.close();
85
+ }
86
+ }
87
+ async function choosePrompt(rl, question, choices, defaultKey) {
88
+ console.log(question);
89
+ for (const choice of choices)
90
+ console.log(` ${choice.key}) ${choice.label}`);
91
+ while (true) {
92
+ const answer = (await rl.question(`Choose [${defaultKey}]: `)).trim().toLowerCase() || defaultKey;
93
+ const choice = choices.find((item) => item.key === answer || item.aliases.includes(answer));
94
+ if (choice)
95
+ return choice.value;
96
+ console.log(`Choose one of: ${choices.map((item) => item.key).join(', ')}`);
97
+ }
98
+ }
99
+ async function nonEmptyPrompt(rl, question) {
100
+ while (true) {
101
+ const answer = (await rl.question(question)).trim();
102
+ if (answer)
103
+ return answer;
104
+ console.log('Path is required.');
105
+ }
106
+ }
107
+ async function handleSkillLinkCommand(action, json, verb) {
108
+ try {
109
+ const result = await action();
110
+ if (json) {
111
+ console.log(JSON.stringify(result, null, 2));
112
+ return;
113
+ }
114
+ console.log(`Webcmd skills ${verb}: ${result.skills.length}`);
115
+ for (const skill of result.skills) {
116
+ console.log(`${skill.name}: ${skill.destination ? `${skill.destination} -> ` : ''}${skill.stableLink}`);
117
+ }
118
+ }
119
+ catch (err) {
120
+ console.error(`Error: ${getErrorMessage(err)}`);
121
+ if (err instanceof CliError && err.hint)
122
+ console.error(`Hint: ${err.hint}`);
123
+ process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
124
+ }
125
+ }
61
126
  function toIsoTimestamp(timestamp) {
62
127
  if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0)
63
128
  return undefined;
@@ -711,10 +776,20 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
711
776
  });
712
777
  const skillsCmd = program
713
778
  .command('skills')
714
- .description('Read bundled Webcmd skills');
779
+ .description('List, install, and update bundled Webcmd skills')
780
+ .action(() => {
781
+ const rows = listWebcmdSkills();
782
+ renderOutput(rows, {
783
+ fmt: 'table',
784
+ fmtExplicit: false,
785
+ columns: ['name', 'description', 'version', 'path'],
786
+ title: 'webcmd/skills/list',
787
+ source: 'webcmd skills',
788
+ });
789
+ });
715
790
  skillsCmd
716
791
  .command('list')
717
- .description('List bundled webcmd-* skills')
792
+ .description('List bundled Webcmd skills')
718
793
  .option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
719
794
  .action((opts) => {
720
795
  const rows = listWebcmdSkills();
@@ -727,30 +802,38 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
727
802
  });
728
803
  });
729
804
  skillsCmd
730
- .command('read')
731
- .description("Print a bundled webcmd-* skill's SKILL.md or reference file")
732
- .argument('<skill>', 'Skill name, or skill/path like webcmd-browser/references/foo.md')
733
- .argument('[path]', 'Path under the skill directory')
734
- .option('--json', 'Output a JSON envelope instead of raw markdown', false)
735
- .action((skill, skillPath, opts) => {
736
- let result;
737
- try {
738
- result = readWebcmdSkill(skill, skillPath ?? '');
739
- }
740
- catch (err) {
741
- console.error(`Error: ${getErrorMessage(err)}`);
742
- if (err instanceof CliError && err.hint)
743
- console.error(`Hint: ${err.hint}`);
744
- process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
745
- return;
746
- }
747
- if (opts.json) {
748
- console.log(JSON.stringify(result, null, 2));
749
- return;
750
- }
751
- process.stdout.write(result.content);
752
- if (!result.content.endsWith('\n'))
753
- process.stdout.write('\n');
805
+ .command('install')
806
+ .description('Install bundled Webcmd skills into an agent skills folder')
807
+ .option('-p, --provider <provider>', 'Agent provider: agents, codex, claude')
808
+ .option('-s, --scope <scope>', 'Install scope: user/global or project/local')
809
+ .option('--path <path>', 'Custom agent skills directory')
810
+ .option('--json', 'Output a JSON envelope', false)
811
+ .action(async (opts) => {
812
+ await handleSkillLinkCommand(async () => {
813
+ const resolved = await resolveSkillInstallOptions(opts);
814
+ if (resolved.provider === 'custom' && !resolved.path) {
815
+ throw new ArgumentError('Custom skill provider requires --path.', 'Pass --path <skills-dir> or run interactively.');
816
+ }
817
+ return installWebcmdSkill({
818
+ provider: resolved.provider,
819
+ scope: resolved.scope,
820
+ customPath: resolved.path,
821
+ });
822
+ }, opts.json, 'installed');
823
+ });
824
+ skillsCmd
825
+ .command('update')
826
+ .description('Refresh bundled Webcmd skill symlinks after updating the package')
827
+ .option('-p, --provider <provider>', 'Also repair provider link: agents, codex, claude')
828
+ .option('-s, --scope <scope>', 'Also repair scoped link: user/global or project/local')
829
+ .option('--path <path>', 'Also repair a custom agent skills directory')
830
+ .option('--json', 'Output a JSON envelope', false)
831
+ .action(async (opts) => {
832
+ await handleSkillLinkCommand(() => updateWebcmdSkill({
833
+ provider: opts.provider,
834
+ scope: opts.scope,
835
+ customPath: opts.path,
836
+ }), opts.json, 'updated');
754
837
  });
755
838
  const authCmd = registerAuthCommands(program);
756
839
  program