@agentrhq/webcmd 0.3.4 → 0.4.1

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 (67) hide show
  1. package/README.md +43 -268
  2. package/cli-manifest.json +184 -311
  3. package/clis/_shared/site-auth.js +37 -40
  4. package/clis/_shared/site-auth.test.js +109 -22
  5. package/clis/amazon/auth.js +0 -6
  6. package/clis/band/auth.js +0 -6
  7. package/clis/blinkit/auth.js +13 -40
  8. package/clis/blinkit/blinkit.test.js +39 -2
  9. package/clis/chatgpt/auth.js +0 -6
  10. package/clis/claude/auth.js +0 -6
  11. package/clis/coupang/auth.js +0 -6
  12. package/clis/district/auth.js +0 -2
  13. package/clis/facebook/auth.js +0 -6
  14. package/clis/gemini/auth.js +0 -6
  15. package/clis/github/auth.js +0 -6
  16. package/clis/grok/auth.js +0 -6
  17. package/clis/hackernews/hackernews.test.js +20 -1
  18. package/clis/hackernews/jobs.js +1 -1
  19. package/clis/hf/auth.js +2 -7
  20. package/clis/instagram/auth.js +0 -6
  21. package/clis/linkedin/auth.js +0 -6
  22. package/clis/linkedin-learning/auth.js +0 -6
  23. package/clis/manus/auth.js +0 -6
  24. package/clis/notebooklm/auth.js +0 -6
  25. package/clis/pixiv/auth.js +1 -7
  26. package/clis/practo/login.js +12 -38
  27. package/clis/practo/practo.test.js +13 -9
  28. package/clis/reddit/auth.js +0 -6
  29. package/clis/reuters/auth.js +0 -14
  30. package/clis/suno/auth.js +0 -6
  31. package/clis/tiktok/auth.js +0 -6
  32. package/clis/twitter/auth.js +0 -6
  33. package/clis/upwork/auth.js +0 -6
  34. package/clis/youtube/auth.js +0 -6
  35. package/clis/zepto/auth.js +16 -29
  36. package/clis/zepto/zepto.test.js +45 -2
  37. package/dist/src/browser/cdp.js +3 -1
  38. package/dist/src/browser/cdp.test.js +12 -0
  39. package/dist/src/browser/page.js +6 -2
  40. package/dist/src/browser/page.test.js +1 -0
  41. package/dist/src/browser/protocol.d.ts +2 -0
  42. package/dist/src/browser/runtime/local-cloak/actions.js +42 -21
  43. package/dist/src/browser/runtime/local-cloak/provider.test.js +33 -5
  44. package/dist/src/build-manifest.js +3 -3
  45. package/dist/src/build-manifest.test.js +14 -0
  46. package/dist/src/completion-shared.js +1 -1
  47. package/dist/src/docs-sync-review.js +15 -7
  48. package/dist/src/docs-sync-review.test.js +38 -2
  49. package/dist/src/generate-release-notes-cli.test.js +1 -1
  50. package/dist/src/hosted/client.d.ts +14 -2
  51. package/dist/src/hosted/client.js +46 -5
  52. package/dist/src/hosted/client.test.js +73 -20
  53. package/dist/src/hosted/main-lifecycle.test.js +2 -1
  54. package/dist/src/hosted/manifest.test.js +4 -2
  55. package/dist/src/hosted/output-parity.test.js +2 -1
  56. package/dist/src/hosted/root-command-surface.test.js +8 -1
  57. package/dist/src/hosted/runner.js +93 -0
  58. package/dist/src/hosted/runner.test.js +134 -5
  59. package/dist/src/hosted/types.d.ts +9 -2
  60. package/dist/src/skills.test.js +54 -0
  61. package/hosted-contract.json +191 -366
  62. package/package.json +4 -4
  63. package/skills/webcmd-adapter-author/SKILL.md +2 -0
  64. package/skills/webcmd-autofix/SKILL.md +5 -3
  65. package/skills/webcmd-browser/SKILL.md +12 -11
  66. package/skills/webcmd-usage/SKILL.md +8 -0
  67. package/clis/practo/whoami.js +0 -28
@@ -1,32 +1,37 @@
1
- import { AuthRequiredError, TimeoutError, getErrorMessage } from '@agentrhq/webcmd/errors';
1
+ import { AuthRequiredError } from '@agentrhq/webcmd/errors';
2
2
  import { cli, Strategy } from '@agentrhq/webcmd/registry';
3
3
 
4
- const DEFAULT_TIMEOUT_SECONDS = 300;
5
- const POLL_INTERVAL_MS = 2000;
4
+ const LOGIN_ACTION = 'Complete sign-in in the opened Webcmd browser, then tell the agent when you are done.';
6
5
 
7
- function normalizeIdentity(site, identity) {
6
+ function normalizeIdentity(config, identity) {
8
7
  const row = identity && typeof identity === 'object' && !Array.isArray(identity)
9
8
  ? identity
10
9
  : {};
11
- return { logged_in: true, site, ...row };
10
+ return { ...blankIdentity(config), ...row, logged_in: true, site: config.site };
12
11
  }
13
12
 
14
13
  function isAuthRequired(error) {
15
14
  return error instanceof AuthRequiredError;
16
15
  }
17
16
 
18
- async function tryProbe(config, page, phase) {
19
- const probe = phase === 'poll' && config.poll ? config.poll : config.verify;
20
- return normalizeIdentity(config.site, await probe(page, { phase }));
17
+ async function tryProbe(config, page) {
18
+ return normalizeIdentity(config, await config.verify(page, { phase: 'identity' }));
21
19
  }
22
20
 
23
- function authHint(config) {
24
- return `Run \`webcmd ${config.site} login\` to open the login page, then retry.`;
21
+ function identityColumns(config) {
22
+ return config.columns ?? ['id', 'username', 'name'];
23
+ }
24
+
25
+ function blankIdentity(config) {
26
+ return Object.fromEntries(identityColumns(config).map((column) => [column, '']));
25
27
  }
26
28
 
27
29
  function commandColumns(config) {
28
- const identityColumns = config.columns ?? ['id', 'username', 'name'];
29
- return ['logged_in', 'site', ...identityColumns];
30
+ return ['logged_in', 'site', ...identityColumns(config)];
31
+ }
32
+
33
+ function loginColumns(config) {
34
+ return ['status', ...commandColumns(config), 'action', 'verify_command'];
30
35
  }
31
36
 
32
37
  function normalizeQuickCheck(result) {
@@ -62,6 +67,7 @@ export function registerSiteAuthCommands(config) {
62
67
  browser: true,
63
68
  navigateBefore: false,
64
69
  siteSession: 'persistent',
70
+ aliases: config.whoamiAliases ?? [],
65
71
  args: [],
66
72
  columns: commandColumns(config),
67
73
  authStatus: {
@@ -72,52 +78,43 @@ export function registerSiteAuthCommands(config) {
72
78
  ? { refresh: async (page, kwargs) => normalizeRefreshResult(await config.refresh(page, kwargs)) }
73
79
  : {}),
74
80
  },
75
- func: async (page) => [await tryProbe(config, page, 'identity')],
81
+ func: async (page) => [await tryProbe(config, page)],
76
82
  });
77
83
 
78
84
  cli({
79
85
  site: config.site,
80
86
  name: 'login',
81
87
  access: 'write',
82
- description: config.loginDescription ?? `Open ${config.site} login and wait until the browser session is authenticated`,
88
+ description: config.loginDescription ?? `Open ${config.site} login`,
83
89
  domain: config.domain,
84
90
  strategy: Strategy.COOKIE,
85
91
  browser: true,
86
92
  navigateBefore: false,
87
93
  defaultWindowMode: 'foreground',
88
94
  siteSession: 'persistent',
89
- args: [
90
- { name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
91
- ],
92
- columns: ['status', ...commandColumns(config)],
93
- func: async (page, kwargs) => {
95
+ args: [],
96
+ columns: loginColumns(config),
97
+ func: async (page) => {
94
98
  try {
95
- return [{ status: 'already_logged_in', ...await tryProbe(config, page, 'identity') }];
99
+ return [{
100
+ status: 'already_logged_in',
101
+ ...await tryProbe(config, page),
102
+ action: '',
103
+ verify_command: '',
104
+ }];
96
105
  } catch (error) {
97
106
  if (!isAuthRequired(error)) throw error;
98
107
  }
99
108
 
100
109
  await openLogin(page);
101
- const timeoutSeconds = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
102
- const deadline = Date.now() + timeoutSeconds * 1000;
103
- let lastAuthMessage = '';
104
-
105
- while (Date.now() < deadline) {
106
- await page.wait(Math.min(POLL_INTERVAL_MS / 1000, Math.max(0.2, (deadline - Date.now()) / 1000)));
107
- try {
108
- const identity = await tryProbe(config, page, 'poll');
109
- return [{ status: 'login_complete', ...identity }];
110
- } catch (error) {
111
- if (!isAuthRequired(error)) throw error;
112
- lastAuthMessage = getErrorMessage(error);
113
- }
114
- }
115
-
116
- throw new TimeoutError(
117
- `${config.site} login`,
118
- timeoutSeconds,
119
- lastAuthMessage ? `${authHint(config)} Last auth check: ${lastAuthMessage}` : authHint(config),
120
- );
110
+ return [{
111
+ status: 'action_required',
112
+ logged_in: false,
113
+ site: config.site,
114
+ ...blankIdentity(config),
115
+ action: LOGIN_ACTION,
116
+ verify_command: `webcmd ${config.site} whoami`,
117
+ }];
121
118
  },
122
119
  });
123
120
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
2
+ import { AuthRequiredError } from '@agentrhq/webcmd/errors';
3
3
  import { getRegistry } from '@agentrhq/webcmd/registry';
4
4
  import { registerSiteAuthCommands } from './site-auth.js';
5
5
 
@@ -11,12 +11,13 @@ function pageMock() {
11
11
  }
12
12
 
13
13
  describe('site auth command helper', () => {
14
- it('registers whoami and foreground login commands', () => {
14
+ it('registers whoami aliases and foreground login columns', () => {
15
15
  registerSiteAuthCommands({
16
16
  site: 'auth-helper-registration',
17
17
  domain: 'example.com',
18
18
  loginUrl: 'https://example.com/login',
19
19
  columns: ['username'],
20
+ whoamiAliases: ['auth-status'],
20
21
  verify: async () => ({ username: 'alice' }),
21
22
  });
22
23
 
@@ -24,16 +25,23 @@ describe('site auth command helper', () => {
24
25
  access: 'read',
25
26
  browser: true,
26
27
  navigateBefore: false,
28
+ aliases: ['auth-status'],
27
29
  columns: ['logged_in', 'site', 'username'],
28
30
  });
29
- expect(getRegistry().get('auth-helper-registration/login')).toMatchObject({
31
+ expect(getRegistry().get('auth-helper-registration/auth-status'))
32
+ .toBe(getRegistry().get('auth-helper-registration/whoami'));
33
+ const login = getRegistry().get('auth-helper-registration/login');
34
+ expect(login).toMatchObject({
30
35
  access: 'write',
31
36
  browser: true,
32
37
  navigateBefore: false,
33
38
  defaultWindowMode: 'foreground',
34
39
  siteSession: 'persistent',
35
- columns: ['status', 'logged_in', 'site', 'username'],
36
40
  });
41
+ expect(login.args).toEqual([]);
42
+ expect(login.columns).toEqual([
43
+ 'status', 'logged_in', 'site', 'username', 'action', 'verify_command',
44
+ ]);
37
45
  });
38
46
 
39
47
  it('whoami returns normalized identity without opening login', async () => {
@@ -55,44 +63,123 @@ describe('site auth command helper', () => {
55
63
  expect(page.goto).not.toHaveBeenCalled();
56
64
  });
57
65
 
58
- it('login opens the login URL and polls until authenticated', async () => {
59
- const poll = vi.fn()
60
- .mockRejectedValueOnce(new AuthRequiredError('example.com', 'not yet'))
61
- .mockResolvedValueOnce({ username: 'alice' });
66
+ it('login returns the existing authenticated identity', async () => {
67
+ registerSiteAuthCommands({
68
+ site: 'auth-helper-authenticated',
69
+ domain: 'example.com',
70
+ loginUrl: 'https://example.com/login',
71
+ columns: ['username'],
72
+ verify: async () => ({ username: 'alice' }),
73
+ });
74
+ const login = getRegistry().get('auth-helper-authenticated/login');
75
+ const page = pageMock();
76
+
77
+ await expect(login.func(page, {})).resolves.toEqual([{
78
+ status: 'already_logged_in',
79
+ logged_in: true,
80
+ site: 'auth-helper-authenticated',
81
+ username: 'alice',
82
+ action: '',
83
+ verify_command: '',
84
+ }]);
85
+ expect(page.goto).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('completes and canonicalizes successful identity rows', async () => {
89
+ registerSiteAuthCommands({
90
+ site: 'auth-helper-canonical',
91
+ domain: 'example.com',
92
+ loginUrl: 'https://example.com/login',
93
+ columns: ['username', 'name'],
94
+ verify: async () => ({
95
+ logged_in: false,
96
+ site: 'wrong-site',
97
+ username: 'alice',
98
+ extra: 'preserved',
99
+ }),
100
+ });
101
+ const page = pageMock();
102
+ const identity = {
103
+ logged_in: true,
104
+ site: 'auth-helper-canonical',
105
+ username: 'alice',
106
+ name: '',
107
+ extra: 'preserved',
108
+ };
109
+
110
+ await expect(getRegistry().get('auth-helper-canonical/whoami').func(page, {}))
111
+ .resolves.toEqual([identity]);
112
+ await expect(getRegistry().get('auth-helper-canonical/login').func(page, {}))
113
+ .resolves.toEqual([{
114
+ status: 'already_logged_in',
115
+ ...identity,
116
+ action: '',
117
+ verify_command: '',
118
+ }]);
119
+ });
120
+
121
+ it('opens the default login URL and returns an immediate handoff', async () => {
62
122
  registerSiteAuthCommands({
63
123
  site: 'auth-helper-login',
64
124
  domain: 'example.com',
65
125
  loginUrl: 'https://example.com/login',
66
126
  columns: ['username'],
67
127
  verify: async () => { throw new AuthRequiredError('example.com', 'missing'); },
68
- poll,
69
128
  });
70
- const cmd = getRegistry().get('auth-helper-login/login');
129
+ const login = getRegistry().get('auth-helper-login/login');
71
130
  const page = pageMock();
72
131
 
73
- await expect(cmd.func(page, { timeout: 1 })).resolves.toEqual([{
74
- status: 'login_complete',
75
- logged_in: true,
132
+ expect(login.args).toEqual([]);
133
+ expect(login.columns).toEqual([
134
+ 'status', 'logged_in', 'site', 'username', 'action', 'verify_command',
135
+ ]);
136
+ await expect(login.func(page, {})).resolves.toEqual([{
137
+ status: 'action_required',
138
+ logged_in: false,
76
139
  site: 'auth-helper-login',
77
- username: 'alice',
140
+ username: '',
141
+ action: 'Complete sign-in in the opened Webcmd browser, then tell the agent when you are done.',
142
+ verify_command: 'webcmd auth-helper-login whoami',
78
143
  }]);
79
144
  expect(page.goto).toHaveBeenCalledWith('https://example.com/login');
80
- expect(page.wait).toHaveBeenCalled();
81
- expect(poll).toHaveBeenCalledTimes(2);
145
+ expect(page.wait).not.toHaveBeenCalled();
82
146
  });
83
147
 
84
- it('login times out when auth never completes', async () => {
148
+ it('uses a custom opener for the immediate handoff', async () => {
149
+ const openLogin = vi.fn().mockResolvedValue(undefined);
85
150
  registerSiteAuthCommands({
86
- site: 'auth-helper-timeout',
151
+ site: 'auth-helper-custom-login',
87
152
  domain: 'example.com',
88
153
  loginUrl: 'https://example.com/login',
89
154
  verify: async () => { throw new AuthRequiredError('example.com', 'missing'); },
90
- poll: async () => { throw new AuthRequiredError('example.com', 'still missing'); },
155
+ openLogin,
91
156
  });
92
- const cmd = getRegistry().get('auth-helper-timeout/login');
157
+ const login = getRegistry().get('auth-helper-custom-login/login');
93
158
  const page = pageMock();
94
159
 
95
- await expect(cmd.func(page, { timeout: 0 })).rejects.toBeInstanceOf(TimeoutError);
96
- expect(page.goto).toHaveBeenCalledWith('https://example.com/login');
160
+ await login.func(page, {});
161
+ expect(openLogin).toHaveBeenCalledOnce();
162
+ expect(page.goto).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('propagates non-auth probe and opener errors', async () => {
166
+ registerSiteAuthCommands({
167
+ site: 'auth-helper-probe-error',
168
+ domain: 'example.com',
169
+ loginUrl: 'https://example.com/login',
170
+ verify: async () => { throw new Error('probe broke'); },
171
+ });
172
+ registerSiteAuthCommands({
173
+ site: 'auth-helper-open-error',
174
+ domain: 'example.com',
175
+ loginUrl: 'https://example.com/login',
176
+ verify: async () => { throw new AuthRequiredError('example.com', 'missing'); },
177
+ openLogin: async () => { throw new Error('open broke'); },
178
+ });
179
+
180
+ await expect(getRegistry().get('auth-helper-probe-error/login').func(pageMock(), {}))
181
+ .rejects.toThrow('probe broke');
182
+ await expect(getRegistry().get('auth-helper-open-error/login').func(pageMock(), {}))
183
+ .rejects.toThrow('open broke');
97
184
  });
98
185
  });
@@ -44,10 +44,4 @@ registerSiteAuthCommands({
44
44
  columns: ['user_name'],
45
45
  quickCheck: hasAmazonSessionCookies,
46
46
  verify: verifyAmazonIdentity,
47
- poll: async (page) => {
48
- if (!await hasAmazonSessionCookies(page)) {
49
- throw new AuthRequiredError('amazon.com', 'Waiting for Amazon at-main / x-main cookie');
50
- }
51
- return verifyAmazonIdentity(page);
52
- },
53
47
  });
package/clis/band/auth.js CHANGED
@@ -53,10 +53,4 @@ registerSiteAuthCommands({
53
53
  columns: ['user_id'],
54
54
  quickCheck: hasBandSessionCookie,
55
55
  verify: verifyBandIdentity,
56
- poll: async (page) => {
57
- if (!await hasBandSessionCookie(page)) {
58
- throw new AuthRequiredError('band.us', 'Waiting for Band band_session cookie');
59
- }
60
- return verifyBandIdentity(page);
61
- },
62
56
  });
@@ -1,9 +1,7 @@
1
- import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
2
- import { cli, Strategy } from '@agentrhq/webcmd/registry';
1
+ import { AuthRequiredError } from '@agentrhq/webcmd/errors';
2
+ import { registerSiteAuthCommands } from '../_shared/site-auth.js';
3
3
  import { BASE, DOMAIN } from './utils.js';
4
4
 
5
- const DEFAULT_TIMEOUT_SECONDS = 300;
6
-
7
5
  async function probeBlinkitIdentity(page) {
8
6
  const probe = await page.evaluate(`
9
7
  (() => {
@@ -50,46 +48,21 @@ function buildOpenLoginEvaluate() {
50
48
  `;
51
49
  }
52
50
 
53
- cli({
51
+ registerSiteAuthCommands({
54
52
  site: 'blinkit',
55
- name: 'login',
56
- access: 'write',
57
- description: 'Open Blinkit login and wait until the browser session is authenticated',
58
53
  domain: DOMAIN,
59
- strategy: Strategy.COOKIE,
60
- browser: true,
61
- navigateBefore: false,
62
- defaultWindowMode: 'foreground',
63
- siteSession: 'persistent',
64
- args: [
65
- { name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
66
- ],
67
- columns: ['status', 'logged_in', 'phone', 'user_id'],
68
- func: async (page, kwargs) => {
54
+ loginUrl: BASE,
55
+ columns: ['phone', 'user_id'],
56
+ verify: async (page) => {
69
57
  await page.goto(BASE);
70
58
  await page.wait(1);
71
- try {
72
- const identity = await probeBlinkitIdentity(page);
73
- return [{ status: 'already_logged_in', logged_in: true, ...identity }];
74
- } catch (error) {
75
- if (!(error instanceof AuthRequiredError)) throw error;
76
- }
77
-
78
- const opened = await page.evaluate(buildOpenLoginEvaluate()).catch(() => null);
79
- const timeoutSeconds = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
80
- const deadline = Date.now() + timeoutSeconds * 1000;
81
- let lastMessage = opened?.opened ? '' : opened?.detail || '';
82
- while (Date.now() < deadline) {
83
- await page.wait(2);
84
- try {
85
- const identity = await probeBlinkitIdentity(page);
86
- return [{ status: 'login_complete', logged_in: true, ...identity }];
87
- } catch (error) {
88
- if (!(error instanceof AuthRequiredError)) throw error;
89
- lastMessage = error.message;
90
- }
91
- }
92
- throw new TimeoutError('blinkit login', timeoutSeconds, lastMessage || 'Finish OTP login in the opened Blinkit tab and retry.');
59
+ return probeBlinkitIdentity(page);
60
+ },
61
+ openLogin: async (page) => {
62
+ await page.goto(BASE);
63
+ await page.wait(1);
64
+ const result = await page.evaluate(buildOpenLoginEvaluate());
65
+ if (!result?.opened) throw new Error(`Blinkit login dialog did not open: ${result?.detail || 'unknown reason'}`);
93
66
  },
94
67
  });
95
68
 
@@ -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 { __test__ as authTest } from './auth.js';
@@ -143,11 +143,48 @@ describe('blinkit helpers', () => {
143
143
 
144
144
  describe('blinkit registry shape', () => {
145
145
  it('registers the buying-path commands', () => {
146
- for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
146
+ for (const name of ['login', 'whoami', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
147
147
  expect(getRegistry().get(`blinkit/${name}`)).toBeDefined();
148
148
  }
149
149
  });
150
150
 
151
+ it('opens login without waiting for manual authentication', async () => {
152
+ const login = getRegistry().get('blinkit/login');
153
+ const whoami = getRegistry().get('blinkit/whoami');
154
+ const page = {
155
+ goto: vi.fn().mockResolvedValue(undefined),
156
+ wait: vi.fn().mockResolvedValue(undefined),
157
+ evaluate: vi.fn()
158
+ .mockResolvedValueOnce({ kind: 'auth', detail: 'Blinkit login button is still visible' })
159
+ .mockResolvedValueOnce({ opened: true }),
160
+ };
161
+
162
+ expect(login.args).toEqual([]);
163
+ expect(login.columns).toEqual(expect.arrayContaining(['action', 'verify_command']));
164
+ expect(whoami).toBeDefined();
165
+ await expect(login.func(page, {})).resolves.toEqual([expect.objectContaining({
166
+ status: 'action_required',
167
+ logged_in: false,
168
+ site: 'blinkit',
169
+ verify_command: 'webcmd blinkit whoami',
170
+ })]);
171
+ expect(page.wait).not.toHaveBeenCalledWith(2);
172
+ });
173
+
174
+ it('rejects when the Blinkit login dialog cannot be opened', async () => {
175
+ const login = getRegistry().get('blinkit/login');
176
+ const page = {
177
+ goto: vi.fn().mockResolvedValue(undefined),
178
+ wait: vi.fn().mockResolvedValue(undefined),
179
+ evaluate: vi.fn()
180
+ .mockResolvedValueOnce({ kind: 'auth', detail: 'login required' })
181
+ .mockResolvedValueOnce({ opened: false, detail: 'login_button_missing' }),
182
+ };
183
+
184
+ await expect(login.func(page, {}))
185
+ .rejects.toThrow('Blinkit login dialog did not open: login_button_missing');
186
+ });
187
+
151
188
  it('marks only cart-changing commands as write', () => {
152
189
  expect(getRegistry().get('blinkit/search').access).toBe('read');
153
190
  expect(getRegistry().get('blinkit/product').access).toBe('read');
@@ -43,10 +43,4 @@ registerSiteAuthCommands({
43
43
  columns: ['user_id', 'name'],
44
44
  quickCheck: hasChatgptSessionCookie,
45
45
  verify: verifyChatgptIdentity,
46
- poll: async (page) => {
47
- if (!await hasChatgptSessionCookie(page)) {
48
- throw new AuthRequiredError('chatgpt.com', 'Waiting for ChatGPT session cookie');
49
- }
50
- return verifyChatgptIdentity(page);
51
- },
52
46
  });
@@ -46,10 +46,4 @@ registerSiteAuthCommands({
46
46
  columns: ['user_id', 'org_name', 'org_uuid'],
47
47
  quickCheck: hasClaudeSessionCookie,
48
48
  verify: verifyClaudeIdentity,
49
- poll: async (page) => {
50
- if (!await hasClaudeSessionCookie(page)) {
51
- throw new AuthRequiredError('claude.ai', 'Waiting for Claude sessionKey cookie');
52
- }
53
- return verifyClaudeIdentity(page);
54
- },
55
49
  });
@@ -39,10 +39,4 @@ registerSiteAuthCommands({
39
39
  loginUrl: 'https://login.coupang.com/login/login.pang',
40
40
  columns: ['name'],
41
41
  verify: verifyCoupangIdentity,
42
- poll: async (page) => {
43
- if (!await hasCoupangSessionCookie(page)) {
44
- throw new AuthRequiredError('coupang.com', 'Waiting for Coupang session cookies');
45
- }
46
- return verifyCoupangIdentity(page);
47
- },
48
42
  });
@@ -37,8 +37,6 @@ registerSiteAuthCommands({
37
37
  await page.wait(1);
38
38
  return profileProbe(page);
39
39
  },
40
- // While the OTP modal is open, probe without navigating away from it.
41
- poll: async (page) => profileProbe(page),
42
40
  quickCheck: async (page) => {
43
41
  try {
44
42
  return { logged_in: true, ...await profileProbe(page) };
@@ -34,10 +34,4 @@ registerSiteAuthCommands({
34
34
  columns: ['user_id', 'vanity', 'profile_url'],
35
35
  quickCheck: hasFacebookCUserCookie,
36
36
  verify: verifyFacebookIdentity,
37
- poll: async (page) => {
38
- if (!await hasFacebookCUserCookie(page)) {
39
- throw new AuthRequiredError('www.facebook.com', 'Waiting for Facebook c_user cookie');
40
- }
41
- return verifyFacebookIdentity(page);
42
- },
43
37
  });
@@ -39,10 +39,4 @@ registerSiteAuthCommands({
39
39
  columns: ['name'],
40
40
  quickCheck: hasGoogleSessionCookie,
41
41
  verify: verifyGeminiIdentity,
42
- poll: async (page) => {
43
- if (!await hasGoogleSessionCookie(page)) {
44
- throw new AuthRequiredError('gemini.google.com', 'Waiting for Google session cookies');
45
- }
46
- return verifyGeminiIdentity(page);
47
- },
48
42
  });
@@ -35,10 +35,4 @@ registerSiteAuthCommands({
35
35
  columns: ['id', 'username', 'name', 'url'],
36
36
  quickCheck: hasGithubSessionCookies,
37
37
  verify: verifyGithubIdentity,
38
- poll: async (page) => {
39
- if (!await hasGithubSessionCookies(page)) {
40
- throw new AuthRequiredError('github.com', 'Waiting for GitHub session cookies');
41
- }
42
- return verifyGithubIdentity(page);
43
- },
44
38
  });
package/clis/grok/auth.js CHANGED
@@ -43,10 +43,4 @@ registerSiteAuthCommands({
43
43
  columns: ['user_id', 'name'],
44
44
  quickCheck: hasGrokSessionCookie,
45
45
  verify: verifyGrokIdentity,
46
- poll: async (page) => {
47
- if (!await hasGrokSessionCookie(page)) {
48
- throw new AuthRequiredError('grok.com', 'Waiting for Grok session cookie');
49
- }
50
- return verifyGrokIdentity(page);
51
- },
52
46
  });
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@agentrhq/webcmd/registry';
3
3
  import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors';
4
+ import { executePipeline } from '@agentrhq/webcmd/pipeline';
4
5
  import './top.js';
5
6
  import './best.js';
6
7
  import './ask.js';
@@ -34,10 +35,28 @@ describe('hackernews listing adapters expose item id', () => {
34
35
  expect(cmd?.columns).toEqual(['rank', 'id', 'title', 'author', 'url']);
35
36
  expect(cmd?.pipeline?.[5]?.map).toMatchObject({
36
37
  id: '${{ item.id }}',
37
- url: '${{ item.url }}',
38
+ url: '${{ item.url || "https://news.ycombinator.com/item?id=" + item.id }}',
38
39
  });
39
40
  });
40
41
 
42
+ it('hackernews/jobs preserves external URLs and falls back to item pages', async () => {
43
+ const jobs = {
44
+ 48995037: { id: 48995037, title: 'Text-only job', by: 'author' },
45
+ 48954074: { id: 48954074, title: 'External job', by: 'author', url: 'https://example.com/job' },
46
+ };
47
+ vi.stubGlobal('fetch', vi.fn(async (url) => new Response(JSON.stringify(
48
+ String(url).endsWith('jobstories.json') ? [48995037, 48954074] : jobs[String(url).match(/item\/(\d+)\.json$/)?.[1]],
49
+ ), { status: 200 })));
50
+
51
+ const cmd = getRegistry().get('hackernews/jobs');
52
+ const result = await executePipeline(null, cmd?.pipeline ?? [], { args: { limit: 2 } });
53
+
54
+ expect(result).toEqual([
55
+ { rank: 1, id: 48995037, title: 'Text-only job', author: 'author', url: 'https://news.ycombinator.com/item?id=48995037' },
56
+ { rank: 2, id: 48954074, title: 'External job', author: 'author', url: 'https://example.com/job' },
57
+ ]);
58
+ });
59
+
41
60
  it('hackernews/search surfaces id (algolia objectID) alongside the existing columns', () => {
42
61
  const cmd = getRegistry().get('hackernews/search');
43
62
  expect(cmd?.columns).toEqual(['rank', 'id', 'title', 'score', 'author', 'comments', 'url']);
@@ -22,7 +22,7 @@ cli({
22
22
  id: '${{ item.id }}',
23
23
  title: '${{ item.title }}',
24
24
  author: '${{ item.by }}',
25
- url: '${{ item.url }}',
25
+ url: '${{ item.url || "https://news.ycombinator.com/item?id=" + item.id }}',
26
26
  } },
27
27
  { limit: '${{ args.limit }}' },
28
28
  ],
package/clis/hf/auth.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { AuthRequiredError, CommandExecutionError } from '@agentrhq/webcmd/errors';
2
2
  import { registerSiteAuthCommands } from '../_shared/site-auth.js';
3
3
 
4
- // Hugging Face's `token` cookie is httpOnly; gate the login poll on the
5
- // documented /api/whoami-v2 endpoint (401 when anonymous) via a no-nav probe.
4
+ // Hugging Face's `token` cookie is httpOnly; use the documented
5
+ // /api/whoami-v2 endpoint (401 when anonymous) via a no-nav probe.
6
6
  const WHOAMI_PROBE = `(async () => {
7
7
  try {
8
8
  const r = await fetch('/api/whoami-v2', { credentials: 'include', headers: { Accept: 'application/json' } });
@@ -33,9 +33,4 @@ registerSiteAuthCommands({
33
33
  loginUrl: 'https://huggingface.co/login',
34
34
  columns: ['username', 'fullname', 'type'],
35
35
  verify: verifyHfIdentity,
36
- poll: async (page) => {
37
- const probe = await page.evaluate(WHOAMI_PROBE);
38
- if (!probe?.ok) throw new AuthRequiredError('huggingface.co', 'Waiting for Hugging Face login');
39
- return { username: probe.username, fullname: probe.fullname, type: probe.type };
40
- },
41
36
  });
@@ -48,10 +48,4 @@ registerSiteAuthCommands({
48
48
  columns: ['user_id', 'username', 'full_name'],
49
49
  quickCheck: hasInstagramSessionCookie,
50
50
  verify: verifyInstagramIdentity,
51
- poll: async (page) => {
52
- if (!await hasInstagramSessionCookie(page)) {
53
- throw new AuthRequiredError('www.instagram.com', 'Waiting for Instagram sessionid cookie');
54
- }
55
- return verifyInstagramIdentity(page);
56
- },
57
51
  });
@@ -53,10 +53,4 @@ registerSiteAuthCommands({
53
53
  columns: ['public_id', 'plain_id', 'name'],
54
54
  quickCheck: hasLinkedinSessionCookie,
55
55
  verify: verifyLinkedinIdentity,
56
- poll: async (page) => {
57
- if (!await hasLinkedinSessionCookie(page)) {
58
- throw new AuthRequiredError('linkedin.com', 'Waiting for LinkedIn li_at cookie');
59
- }
60
- return verifyLinkedinIdentity(page);
61
- },
62
56
  });