@agentrhq/webcmd 0.3.0 → 0.3.2

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 (183) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +228 -24
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/producthunt/hot.js +1 -2
  26. package/clis/spotify/spotify.js +11 -11
  27. package/clis/twitter/article-evaluate.test.js +27 -0
  28. package/clis/twitter/article.js +73 -16
  29. package/clis/twitter/article.test.js +209 -0
  30. package/clis/twitter/download.js +6 -1
  31. package/clis/twitter/post.js +14 -1
  32. package/clis/twitter/profile.js +1 -1
  33. package/clis/twitter/profile.test.js +8 -0
  34. package/clis/twitter/quote.js +11 -1
  35. package/clis/twitter/reply.js +11 -1
  36. package/dist/src/browser/base-page.d.ts +26 -0
  37. package/dist/src/browser/base-page.js +23 -8
  38. package/dist/src/browser/base-page.test.js +45 -12
  39. package/dist/src/browser/command-catalog.d.ts +6 -0
  40. package/dist/src/browser/command-catalog.js +259 -0
  41. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  42. package/dist/src/browser/command-catalog.test.js +79 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  45. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  47. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  48. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  49. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  50. package/dist/src/browser/target-resolver.d.ts +1 -0
  51. package/dist/src/browser/target-resolver.js +99 -4
  52. package/dist/src/browser/target-resolver.test.js +141 -1
  53. package/dist/src/build-manifest.d.ts +6 -0
  54. package/dist/src/build-manifest.js +18 -1
  55. package/dist/src/build-manifest.test.js +57 -1
  56. package/dist/src/builtin-command-surface.d.ts +9 -0
  57. package/dist/src/builtin-command-surface.js +16 -0
  58. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  59. package/dist/src/check-hosted-contract.test.js +56 -0
  60. package/dist/src/cli.js +58 -106
  61. package/dist/src/cli.test.js +71 -1
  62. package/dist/src/command-presentation.d.ts +95 -0
  63. package/dist/src/command-presentation.js +486 -0
  64. package/dist/src/command-presentation.test.d.ts +1 -0
  65. package/dist/src/command-presentation.test.js +97 -0
  66. package/dist/src/command-surface.d.ts +43 -0
  67. package/dist/src/command-surface.js +205 -0
  68. package/dist/src/command-surface.test.d.ts +1 -0
  69. package/dist/src/command-surface.test.js +406 -0
  70. package/dist/src/commanderAdapter.d.ts +5 -1
  71. package/dist/src/commanderAdapter.js +12 -55
  72. package/dist/src/commanderAdapter.test.js +15 -3
  73. package/dist/src/community-plugin-sync.d.ts +11 -0
  74. package/dist/src/community-plugin-sync.js +138 -0
  75. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  76. package/dist/src/community-plugin-sync.test.js +123 -0
  77. package/dist/src/completion-fast.d.ts +4 -4
  78. package/dist/src/completion-fast.js +15 -34
  79. package/dist/src/completion-shared.d.ts +4 -0
  80. package/dist/src/completion-shared.js +38 -0
  81. package/dist/src/completion.js +3 -27
  82. package/dist/src/completion.test.js +31 -2
  83. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  84. package/dist/src/docs-sync-review-cli.test.js +322 -0
  85. package/dist/src/docs-sync-review.d.ts +117 -0
  86. package/dist/src/docs-sync-review.js +475 -0
  87. package/dist/src/docs-sync-review.test.d.ts +1 -0
  88. package/dist/src/docs-sync-review.test.js +324 -0
  89. package/dist/src/errors.d.ts +12 -3
  90. package/dist/src/errors.js +11 -7
  91. package/dist/src/errors.test.js +14 -1
  92. package/dist/src/execution.d.ts +1 -2
  93. package/dist/src/execution.js +4 -45
  94. package/dist/src/generate-release-notes-cli.test.js +7 -2
  95. package/dist/src/help.d.ts +4 -0
  96. package/dist/src/help.js +50 -255
  97. package/dist/src/help.test.js +27 -1
  98. package/dist/src/hosted/args.d.ts +2 -7
  99. package/dist/src/hosted/args.js +2 -99
  100. package/dist/src/hosted/args.test.js +15 -1
  101. package/dist/src/hosted/availability.d.ts +13 -0
  102. package/dist/src/hosted/availability.js +16 -0
  103. package/dist/src/hosted/availability.test.d.ts +1 -0
  104. package/dist/src/hosted/availability.test.js +191 -0
  105. package/dist/src/hosted/browser-args.d.ts +18 -0
  106. package/dist/src/hosted/browser-args.js +152 -0
  107. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  108. package/dist/src/hosted/browser-args.test.js +182 -0
  109. package/dist/src/hosted/client.d.ts +31 -2
  110. package/dist/src/hosted/client.js +441 -53
  111. package/dist/src/hosted/client.test.js +651 -32
  112. package/dist/src/hosted/config.d.ts +7 -2
  113. package/dist/src/hosted/config.js +26 -4
  114. package/dist/src/hosted/config.test.js +34 -1
  115. package/dist/src/hosted/contract.d.ts +94 -0
  116. package/dist/src/hosted/contract.js +208 -0
  117. package/dist/src/hosted/contract.test.d.ts +1 -0
  118. package/dist/src/hosted/contract.test.js +361 -0
  119. package/dist/src/hosted/credentials.d.ts +38 -0
  120. package/dist/src/hosted/credentials.js +248 -0
  121. package/dist/src/hosted/credentials.test.d.ts +1 -0
  122. package/dist/src/hosted/credentials.test.js +93 -0
  123. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  124. package/dist/src/hosted/file-contract.test.js +174 -0
  125. package/dist/src/hosted/files.d.ts +29 -0
  126. package/dist/src/hosted/files.js +296 -0
  127. package/dist/src/hosted/files.test.d.ts +1 -0
  128. package/dist/src/hosted/files.test.js +231 -0
  129. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  130. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  131. package/dist/src/hosted/manifest.d.ts +5 -0
  132. package/dist/src/hosted/manifest.js +21 -66
  133. package/dist/src/hosted/manifest.test.js +120 -2
  134. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  135. package/dist/src/hosted/output-parity.test.js +108 -0
  136. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  137. package/dist/src/hosted/root-command-surface.test.js +646 -0
  138. package/dist/src/hosted/runner.d.ts +7 -0
  139. package/dist/src/hosted/runner.js +554 -253
  140. package/dist/src/hosted/runner.test.js +1471 -53
  141. package/dist/src/hosted/setup.d.ts +3 -2
  142. package/dist/src/hosted/setup.js +45 -15
  143. package/dist/src/hosted/setup.test.js +133 -6
  144. package/dist/src/hosted/types.d.ts +101 -23
  145. package/dist/src/main.js +120 -108
  146. package/dist/src/manifest-types.d.ts +2 -0
  147. package/dist/src/output.d.ts +17 -2
  148. package/dist/src/output.js +88 -81
  149. package/dist/src/output.test.js +141 -51
  150. package/dist/src/pipeline/executor.test.js +1 -0
  151. package/dist/src/pipeline/steps/download.test.js +1 -0
  152. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  153. package/dist/src/plugin-create-cli.test.js +37 -0
  154. package/dist/src/plugin-manifest.d.ts +12 -0
  155. package/dist/src/plugin-manifest.js +16 -0
  156. package/dist/src/plugin-manifest.test.js +19 -1
  157. package/dist/src/plugin-scaffold.d.ts +4 -1
  158. package/dist/src/plugin-scaffold.js +4 -1
  159. package/dist/src/plugin-scaffold.test.js +23 -8
  160. package/dist/src/plugin.js +4 -1
  161. package/dist/src/plugin.test.js +13 -0
  162. package/dist/src/registry.d.ts +2 -0
  163. package/dist/src/release-notes.js +12 -4
  164. package/dist/src/release-notes.test.js +27 -15
  165. package/dist/src/root-command-surface.d.ts +31 -0
  166. package/dist/src/root-command-surface.js +106 -0
  167. package/dist/src/serialization.d.ts +1 -16
  168. package/dist/src/serialization.js +5 -55
  169. package/dist/src/stream-write.d.ts +12 -0
  170. package/dist/src/stream-write.js +91 -0
  171. package/dist/src/stream-write.test.d.ts +1 -0
  172. package/dist/src/stream-write.test.js +186 -0
  173. package/dist/src/types.d.ts +5 -0
  174. package/dist/src/utils.d.ts +1 -1
  175. package/dist/src/utils.js +2 -8
  176. package/dist/src/utils.test.js +50 -0
  177. package/hosted-contract.json +36759 -0
  178. package/package.json +6 -1
  179. package/scripts/check-hosted-contract.mjs +108 -0
  180. package/scripts/docs-sync-review.ts +332 -0
  181. package/scripts/sync-community-plugins.ts +14 -0
  182. package/skills/webcmd-autofix/SKILL.md +26 -37
  183. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -0,0 +1,153 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
3
+ import {
4
+ LINKEDIN_DOMAIN,
5
+ assertLinkedInAuthenticated,
6
+ normalizeWhitespace,
7
+ unwrapEvaluateResult,
8
+ } from './shared.js';
9
+
10
+ const SLUG_RE = /^[A-Za-z0-9%._-]+$/;
11
+ const COMPANY_URL_RE = /^\/company\/([^/?#]+)/;
12
+ const LINKEDIN_COMPANY_HOSTS = new Set(['linkedin.com', LINKEDIN_DOMAIN]);
13
+
14
+ function normalizeCompanyUrl(value) {
15
+ const raw = normalizeWhitespace(value);
16
+ if (!raw) throw new ArgumentError('LinkedIn company requires a company universal name or URL');
17
+
18
+ let slug = raw;
19
+ if (/^https?:\/\//i.test(raw) || raw.startsWith('/company/')) {
20
+ let parsed;
21
+ try {
22
+ parsed = raw.startsWith('/') ? new URL(raw, `https://${LINKEDIN_DOMAIN}`) : new URL(raw);
23
+ } catch {
24
+ throw new ArgumentError(`LinkedIn company received a malformed URL: ${raw}`);
25
+ }
26
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port
27
+ || !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) {
28
+ throw new ArgumentError('LinkedIn company URL must point to linkedin.com');
29
+ }
30
+ const match = parsed.pathname.match(COMPANY_URL_RE);
31
+ if (!match) throw new ArgumentError('LinkedIn company URL must look like /company/<name>');
32
+ try {
33
+ slug = decodeURIComponent(match[1]);
34
+ } catch {
35
+ throw new ArgumentError(`LinkedIn company URL has a malformed company slug: ${match[1]}`);
36
+ }
37
+ }
38
+ if (!SLUG_RE.test(slug)) {
39
+ throw new ArgumentError(`LinkedIn company name has unexpected characters: ${slug}`);
40
+ }
41
+ return `https://www.linkedin.com/company/${encodeURIComponent(slug)}/about/`;
42
+ }
43
+
44
+ function buildCompanyExtractionScript() {
45
+ return String.raw`(() => {
46
+ const clean = (s) => String(s || '').replace(/[  ]+/g, ' ').replace(/\s+/g, ' ').trim();
47
+ const facts = {};
48
+ for (const dt of Array.from(document.querySelectorAll('dt'))) {
49
+ const key = clean(dt.innerText || dt.textContent || '').toLowerCase().replace(/:$/, '');
50
+ const dd = dt.nextElementSibling;
51
+ const val = dd ? clean(dd.innerText || dd.textContent || '') : '';
52
+ if (key && val && !(key in facts)) facts[key] = val;
53
+ }
54
+ const name = clean((document.querySelector('main h1') || document.querySelector('h1'))?.innerText || '');
55
+ const bodyText = clean(document.body ? (document.body.innerText || '') : '');
56
+ const followersMatch = bodyText.match(/([\d,]+)\s+followers/i);
57
+ const aboutHeading = Array.from(document.querySelectorAll('main h2, section h2'))
58
+ .find((el) => /^About$|^Overview$/i.test(clean(el.innerText || '')));
59
+ const aboutSection = aboutHeading ? aboutHeading.closest('section') : null;
60
+ const about = aboutSection
61
+ ? clean((aboutSection.innerText || '').replace(/^About\s*/i, '').replace(/^Overview\s*/i, ''))
62
+ : '';
63
+ return {
64
+ url: window.location.href,
65
+ name,
66
+ industry: facts.industry || '',
67
+ size: facts['company size'] || '',
68
+ headquarters: facts.headquarters || '',
69
+ founded: facts.founded || '',
70
+ website: facts.website || '',
71
+ specialties: facts.specialties || '',
72
+ followers: followersMatch ? followersMatch[1].replace(/,/g, '') : '',
73
+ about: about.slice(0, 2000),
74
+ };
75
+ })()`;
76
+ }
77
+
78
+ function normalizeCompanyOutputUrl(value, fallbackUrl) {
79
+ const raw = normalizeWhitespace(value || fallbackUrl);
80
+ let parsed;
81
+ try {
82
+ parsed = new URL(raw, `https://${LINKEDIN_DOMAIN}`);
83
+ } catch {
84
+ throw new CommandExecutionError('LinkedIn company extraction returned a malformed current URL');
85
+ }
86
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port
87
+ || !LINKEDIN_COMPANY_HOSTS.has(parsed.hostname.toLowerCase())) {
88
+ throw new CommandExecutionError('LinkedIn company extraction ended on a non-LinkedIn page');
89
+ }
90
+ const match = parsed.pathname.match(COMPANY_URL_RE);
91
+ if (!match?.[1]) {
92
+ throw new CommandExecutionError('LinkedIn company extraction ended outside a company page');
93
+ }
94
+ let slug;
95
+ try {
96
+ slug = decodeURIComponent(match[1]);
97
+ } catch {
98
+ throw new CommandExecutionError('LinkedIn company extraction returned a malformed company slug');
99
+ }
100
+ return `https://${LINKEDIN_DOMAIN}/company/${encodeURIComponent(slug)}/about/`;
101
+ }
102
+
103
+ function normalizeCompanyInfo(info, targetUrl) {
104
+ if (!info || typeof info !== 'object' || Array.isArray(info)) {
105
+ throw new CommandExecutionError('LinkedIn company extraction returned a malformed payload');
106
+ }
107
+ if (!info.name) {
108
+ throw new CommandExecutionError('LinkedIn company page rendered but no company name was found (layout drift or company not found)');
109
+ }
110
+ let followers = 0;
111
+ if (info.followers) {
112
+ followers = Number(info.followers);
113
+ if (!Number.isFinite(followers)) {
114
+ throw new CommandExecutionError('LinkedIn company extraction returned a malformed followers count');
115
+ }
116
+ }
117
+ return {
118
+ name: String(info.name),
119
+ industry: String(info.industry || ''),
120
+ size: String(info.size || ''),
121
+ headquarters: String(info.headquarters || ''),
122
+ founded: String(info.founded || ''),
123
+ website: String(info.website || ''),
124
+ specialties: String(info.specialties || ''),
125
+ followers,
126
+ about: String(info.about || ''),
127
+ url: normalizeCompanyOutputUrl(info.url, targetUrl),
128
+ };
129
+ }
130
+
131
+ cli({
132
+ site: 'linkedin',
133
+ name: 'company',
134
+ access: 'read',
135
+ description: 'Read a LinkedIn company page: industry, size, HQ, founded, website, followers, and about text',
136
+ domain: LINKEDIN_DOMAIN,
137
+ strategy: Strategy.COOKIE,
138
+ browser: true,
139
+ args: [
140
+ { name: 'company', type: 'string', required: true, positional: true, help: 'Company universal name, /company/<name> path, or full URL' },
141
+ ],
142
+ columns: ['name', 'industry', 'size', 'headquarters', 'founded', 'website', 'specialties', 'followers', 'about', 'url'],
143
+ func: async (page, args) => {
144
+ const targetUrl = normalizeCompanyUrl(args.company);
145
+ await page.goto(targetUrl);
146
+ await page.wait(2);
147
+ await assertLinkedInAuthenticated(page, 'linkedin company');
148
+ const info = unwrapEvaluateResult(await page.evaluate(buildCompanyExtractionScript()));
149
+ return [normalizeCompanyInfo(info, targetUrl)];
150
+ },
151
+ });
152
+
153
+ export const __test__ = { normalizeCompanyUrl, normalizeCompanyInfo, buildCompanyExtractionScript };
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
4
+ import './company.js';
5
+
6
+ const { normalizeCompanyInfo, normalizeCompanyUrl } = await import('./company.js').then((module) => module.__test__);
7
+
8
+ function makePage(evaluateResult) {
9
+ return {
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ wait: vi.fn().mockResolvedValue(undefined),
12
+ evaluate: vi.fn()
13
+ .mockResolvedValueOnce(false)
14
+ .mockResolvedValueOnce(evaluateResult),
15
+ };
16
+ }
17
+
18
+ describe('linkedin company', () => {
19
+ it('registers the company command', () => {
20
+ const command = getRegistry().get('linkedin/company');
21
+ expect(command).toMatchObject({
22
+ access: 'read',
23
+ browser: true,
24
+ strategy: 'cookie',
25
+ columns: ['name', 'industry', 'size', 'headquarters', 'founded', 'website', 'specialties', 'followers', 'about', 'url'],
26
+ });
27
+ });
28
+
29
+ it('normalizes bare names, paths, and URLs to the about page', () => {
30
+ expect(normalizeCompanyUrl('nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/');
31
+ expect(normalizeCompanyUrl('/company/nvidia')).toBe('https://www.linkedin.com/company/nvidia/about/');
32
+ expect(normalizeCompanyUrl('https://www.linkedin.com/company/databricks/'))
33
+ .toBe('https://www.linkedin.com/company/databricks/about/');
34
+ });
35
+
36
+ it('rejects invalid company identifiers as argument errors', () => {
37
+ for (const value of [
38
+ '',
39
+ 'bad name!',
40
+ 'https://www.linkedin.com/in/someone/',
41
+ 'https://evil.example/company/nvidia/',
42
+ 'https://www.linkedin.com/company/%E0%A4%A',
43
+ ]) {
44
+ expect(() => normalizeCompanyUrl(value)).toThrow(ArgumentError);
45
+ }
46
+ });
47
+
48
+ it('maps extracted company facts to a row', async () => {
49
+ const command = getRegistry().get('linkedin/company');
50
+ const page = makePage({
51
+ url: 'https://www.linkedin.com/company/nvidia/about/',
52
+ name: 'NVIDIA',
53
+ industry: 'Computer Hardware Manufacturing',
54
+ size: '10,001+ employees',
55
+ headquarters: 'Santa Clara, CA',
56
+ founded: '1993',
57
+ website: 'http://www.nvidia.com',
58
+ specialties: 'GPU, AI',
59
+ followers: '42040089',
60
+ about: 'Accelerated computing.',
61
+ });
62
+
63
+ await expect(command.func(page, { company: 'nvidia' })).resolves.toEqual([{
64
+ name: 'NVIDIA',
65
+ industry: 'Computer Hardware Manufacturing',
66
+ size: '10,001+ employees',
67
+ headquarters: 'Santa Clara, CA',
68
+ founded: '1993',
69
+ website: 'http://www.nvidia.com',
70
+ specialties: 'GPU, AI',
71
+ followers: 42040089,
72
+ about: 'Accelerated computing.',
73
+ url: 'https://www.linkedin.com/company/nvidia/about/',
74
+ }]);
75
+ expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/company/nvidia/about/');
76
+ });
77
+
78
+ it('fails closed when no company name is found', async () => {
79
+ const command = getRegistry().get('linkedin/company');
80
+ const page = makePage({ url: 'https://www.linkedin.com/company/ghost/about/', name: '' });
81
+
82
+ await expect(command.func(page, { company: 'ghost' })).rejects.toBeInstanceOf(CommandExecutionError);
83
+ });
84
+
85
+ it('normalizes emitted URLs and rejects non-company extraction URLs', () => {
86
+ expect(normalizeCompanyInfo({
87
+ url: 'https://www.linkedin.com/company/nvidia/posts/?trk=public_profile',
88
+ name: 'NVIDIA',
89
+ followers: '123',
90
+ }, 'https://www.linkedin.com/company/nvidia/about/')).toMatchObject({
91
+ url: 'https://www.linkedin.com/company/nvidia/about/',
92
+ followers: 123,
93
+ });
94
+
95
+ for (const url of [
96
+ 'https://www.linkedin.com/in/not-a-company/',
97
+ 'https://evil.example/company/nvidia/',
98
+ ]) {
99
+ expect(() => normalizeCompanyInfo({ url, name: 'NVIDIA' }, 'https://www.linkedin.com/company/nvidia/about/'))
100
+ .toThrow(CommandExecutionError);
101
+ }
102
+ });
103
+
104
+ it('rejects malformed follower counts', () => {
105
+ expect(() => normalizeCompanyInfo({
106
+ url: 'https://www.linkedin.com/company/nvidia/about/',
107
+ name: 'NVIDIA',
108
+ followers: 'many',
109
+ }, 'https://www.linkedin.com/company/nvidia/about/')).toThrow(CommandExecutionError);
110
+ });
111
+ });
@@ -0,0 +1,135 @@
1
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors';
3
+ import {
4
+ LINKEDIN_DOMAIN,
5
+ assertLinkedInAuthenticated,
6
+ normalizeWhitespace,
7
+ parseLimit,
8
+ requireLinkedInCookie,
9
+ unwrapEvaluateResult,
10
+ } from './shared.js';
11
+
12
+ const CONNECTIONS_PATH = '/voyager/api/relationships/connections';
13
+ const PAGE_SIZE = 40;
14
+
15
+ async function fetchConnections(url, csrf) {
16
+ try {
17
+ const response = await fetch(url, {
18
+ credentials: 'include',
19
+ headers: {
20
+ 'csrf-token': csrf,
21
+ accept: 'application/json',
22
+ 'x-restli-protocol-version': '2.0.0',
23
+ },
24
+ });
25
+ if (response.status === 401 || response.status === 403) {
26
+ return { authRequired: true, error: `HTTP ${response.status}` };
27
+ }
28
+ if (!response.ok) return { error: `HTTP ${response.status}` };
29
+
30
+ const contentType = response.headers?.get?.('content-type') || '';
31
+ if (/\btext\/html\b/i.test(contentType)) {
32
+ return { authRequired: true, error: 'HTML auth/checkpoint response' };
33
+ }
34
+ try {
35
+ return { json: await response.json() };
36
+ } catch {
37
+ return { error: 'response was not valid JSON' };
38
+ }
39
+ } catch (error) {
40
+ return { error: `fetch failed: ${error?.message || String(error)}` };
41
+ }
42
+ }
43
+
44
+ function optionalText(value, field) {
45
+ if (value == null) return '';
46
+ if (typeof value !== 'string') {
47
+ throw new CommandExecutionError(`LinkedIn connection miniProfile field ${field} was malformed`);
48
+ }
49
+ return normalizeWhitespace(value);
50
+ }
51
+
52
+ function mapConnection(element, index) {
53
+ const miniProfile = element?.miniProfile;
54
+ if (!miniProfile || typeof miniProfile !== 'object') {
55
+ throw new CommandExecutionError('LinkedIn connections returned an element without a miniProfile');
56
+ }
57
+ const publicId = optionalText(miniProfile.publicIdentifier, 'publicIdentifier');
58
+ if (!publicId || /[\s/?#]/.test(publicId)) {
59
+ throw new CommandExecutionError('LinkedIn connection element missing a stable public identifier');
60
+ }
61
+ const name = normalizeWhitespace([
62
+ optionalText(miniProfile.firstName, 'firstName'),
63
+ optionalText(miniProfile.lastName, 'lastName'),
64
+ ].filter(Boolean).join(' ')) || publicId;
65
+ return {
66
+ rank: index + 1,
67
+ name,
68
+ occupation: optionalText(miniProfile.occupation, 'occupation'),
69
+ public_id: publicId,
70
+ connected_at: Number.isFinite(element.createdAt) ? element.createdAt : 0,
71
+ url: `https://www.linkedin.com/in/${encodeURIComponent(publicId)}`,
72
+ };
73
+ }
74
+
75
+ cli({
76
+ site: 'linkedin',
77
+ name: 'connections',
78
+ access: 'read',
79
+ description: 'List your LinkedIn first-degree connections with names, headlines, and profile URLs',
80
+ domain: LINKEDIN_DOMAIN,
81
+ strategy: Strategy.COOKIE,
82
+ browser: true,
83
+ args: [
84
+ { name: 'limit', type: 'int', default: 20, help: 'Number of connections to return (max 500)' },
85
+ ],
86
+ columns: ['rank', 'name', 'occupation', 'public_id', 'connected_at', 'url'],
87
+ func: async (page, args) => {
88
+ const limit = parseLimit(args.limit, 20, 500);
89
+ await page.goto('https://www.linkedin.com/mynetwork/invite-connect/connections/');
90
+ await page.wait(2);
91
+ await assertLinkedInAuthenticated(page, 'linkedin connections');
92
+ const csrf = await requireLinkedInCookie(page, 'linkedin connections');
93
+ const rows = [];
94
+ let start = 0;
95
+
96
+ while (rows.length < limit) {
97
+ const remaining = limit - rows.length;
98
+ const count = remaining < PAGE_SIZE ? remaining : PAGE_SIZE;
99
+ const url = `${CONNECTIONS_PATH}?start=${start}&count=${count}`;
100
+ const fetched = unwrapEvaluateResult(
101
+ await page.evaluate(`(${fetchConnections.toString()})(${JSON.stringify(url)}, ${JSON.stringify(csrf)})`),
102
+ );
103
+ if (fetched?.authRequired) {
104
+ throw new AuthRequiredError(
105
+ LINKEDIN_DOMAIN,
106
+ `LinkedIn connections API authentication failed: ${fetched.error}`,
107
+ );
108
+ }
109
+ if (!fetched || fetched.error || !fetched.json) {
110
+ throw new CommandExecutionError(
111
+ `LinkedIn connections API returned an unexpected response: ${fetched?.error || 'no data'}`,
112
+ );
113
+ }
114
+ const elements = fetched.json.elements;
115
+ if (!Array.isArray(elements)) {
116
+ throw new CommandExecutionError('LinkedIn connections API returned a malformed payload: missing elements array');
117
+ }
118
+ if (elements.length === 0) break;
119
+
120
+ for (const element of elements) {
121
+ rows.push(mapConnection(element, rows.length));
122
+ if (rows.length >= limit) break;
123
+ }
124
+ start += elements.length;
125
+ if (elements.length < count) break;
126
+ }
127
+
128
+ if (rows.length === 0) {
129
+ throw new EmptyResultError('linkedin connections', 'No LinkedIn connections were found.');
130
+ }
131
+ return rows;
132
+ },
133
+ });
134
+
135
+ export const __test__ = { fetchConnections, mapConnection };
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import {
4
+ ArgumentError,
5
+ AuthRequiredError,
6
+ CommandExecutionError,
7
+ EmptyResultError,
8
+ } from '@agentrhq/webcmd/errors';
9
+ import './connections.js';
10
+
11
+ const { mapConnection } = await import('./connections.js').then((module) => module.__test__);
12
+
13
+ function makePage({ evaluateResults = [false], cookies = [{ name: 'JSESSIONID', value: '"ajax:12345"' }] } = {}) {
14
+ const evaluate = vi.fn();
15
+ for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
16
+ return {
17
+ goto: vi.fn().mockResolvedValue(undefined),
18
+ wait: vi.fn().mockResolvedValue(undefined),
19
+ getCookies: vi.fn().mockResolvedValue(cookies),
20
+ evaluate,
21
+ };
22
+ }
23
+
24
+ const connection = (id) => ({
25
+ createdAt: 1700000000000 + id,
26
+ miniProfile: {
27
+ firstName: `First${id}`,
28
+ lastName: `Last${id}`,
29
+ occupation: `Job ${id}`,
30
+ publicIdentifier: `user${id}`,
31
+ },
32
+ });
33
+
34
+ describe('linkedin connections', () => {
35
+ it('registers the connections command', () => {
36
+ const command = getRegistry().get('linkedin/connections');
37
+ expect(command).toMatchObject({
38
+ access: 'read',
39
+ browser: true,
40
+ strategy: 'cookie',
41
+ columns: ['rank', 'name', 'occupation', 'public_id', 'connected_at', 'url'],
42
+ });
43
+ });
44
+
45
+ it('maps a connection element to a row', () => {
46
+ expect(mapConnection(connection(1), 0)).toEqual({
47
+ rank: 1,
48
+ name: 'First1 Last1',
49
+ occupation: 'Job 1',
50
+ public_id: 'user1',
51
+ connected_at: 1700000000001,
52
+ url: 'https://www.linkedin.com/in/user1',
53
+ });
54
+ });
55
+
56
+ it('requires a miniProfile with a stable public identity', () => {
57
+ expect(() => mapConnection({ createdAt: 1 }, 0)).toThrow(CommandExecutionError);
58
+ expect(() => mapConnection({
59
+ createdAt: 1,
60
+ miniProfile: { firstName: 'Only', lastName: 'Name' },
61
+ }, 0)).toThrow(CommandExecutionError);
62
+ expect(() => mapConnection({
63
+ createdAt: 1,
64
+ miniProfile: { firstName: 'Bad', lastName: 'Id', publicIdentifier: 'bad/id' },
65
+ }, 0)).toThrow(CommandExecutionError);
66
+ });
67
+
68
+ it('fails closed for malformed miniProfile scalar fields', () => {
69
+ expect(() => mapConnection({
70
+ createdAt: 1,
71
+ miniProfile: { firstName: { text: 'Alice' }, lastName: 'Example', publicIdentifier: 'alice' },
72
+ }, 0)).toThrow(CommandExecutionError);
73
+ });
74
+
75
+ it('returns rows from the Voyager connections API', async () => {
76
+ const command = getRegistry().get('linkedin/connections');
77
+ const page = makePage({ evaluateResults: [false, { json: { elements: [connection(1), connection(2)] } }] });
78
+
79
+ const rows = await command.func(page, { limit: 2 });
80
+
81
+ expect(rows.map((row) => row.public_id)).toEqual(['user1', 'user2']);
82
+ expect(page.evaluate.mock.calls[1][0]).toContain('/voyager/api/relationships/connections?start=0&count=2');
83
+ expect(page.evaluate.mock.calls[1][0]).toContain('ajax:12345');
84
+ });
85
+
86
+ it('paginates with bounded page sizes', async () => {
87
+ const command = getRegistry().get('linkedin/connections');
88
+ const firstPage = Array.from({ length: 40 }, (_, index) => connection(index + 1));
89
+ const page = makePage({
90
+ evaluateResults: [
91
+ false,
92
+ { json: { elements: firstPage } },
93
+ { json: { elements: [connection(41)] } },
94
+ ],
95
+ });
96
+
97
+ const rows = await command.func(page, { limit: 41 });
98
+
99
+ expect(rows).toHaveLength(41);
100
+ expect(page.evaluate.mock.calls[1][0]).toContain('?start=0&count=40');
101
+ expect(page.evaluate.mock.calls[2][0]).toContain('?start=40&count=1');
102
+ });
103
+
104
+ it('maps page and API authentication failures to AuthRequiredError', async () => {
105
+ const command = getRegistry().get('linkedin/connections');
106
+ await expect(command.func(makePage({ evaluateResults: [true] }), { limit: 2 }))
107
+ .rejects.toBeInstanceOf(AuthRequiredError);
108
+ await expect(command.func(makePage({ evaluateResults: [false], cookies: [] }), { limit: 2 }))
109
+ .rejects.toBeInstanceOf(AuthRequiredError);
110
+
111
+ for (const error of ['HTTP 403', 'HTML auth/checkpoint response']) {
112
+ const page = makePage({ evaluateResults: [false, { authRequired: true, error }] });
113
+ await expect(command.func(page, { limit: 2 })).rejects.toBeInstanceOf(AuthRequiredError);
114
+ }
115
+ });
116
+
117
+ it('fails closed for malformed API responses', async () => {
118
+ const command = getRegistry().get('linkedin/connections');
119
+ for (const result of [
120
+ { error: 'response was not valid JSON' },
121
+ { json: {} },
122
+ ]) {
123
+ const page = makePage({ evaluateResults: [false, result] });
124
+ await expect(command.func(page, { limit: 2 })).rejects.toBeInstanceOf(CommandExecutionError);
125
+ }
126
+ });
127
+
128
+ it('throws EmptyResultError when no connections are found', async () => {
129
+ const command = getRegistry().get('linkedin/connections');
130
+ const page = makePage({ evaluateResults: [false, { json: { elements: [] } }] });
131
+
132
+ await expect(command.func(page, { limit: 2 })).rejects.toBeInstanceOf(EmptyResultError);
133
+ });
134
+
135
+ it('rejects invalid limits', async () => {
136
+ const command = getRegistry().get('linkedin/connections');
137
+ for (const limit of [0, 501, 1.5]) {
138
+ await expect(command.func(makePage(), { limit })).rejects.toBeInstanceOf(ArgumentError);
139
+ }
140
+ });
141
+ });
@@ -26,7 +26,18 @@ cli({
26
26
  defaultWindowMode: 'foreground',
27
27
  navigateBefore: false,
28
28
  args: [
29
- { name: 'receipt', required: true, help: 'Local receipt/proof file path' },
29
+ {
30
+ name: 'receipt',
31
+ required: true,
32
+ help: 'Local receipt/proof file path',
33
+ file: {
34
+ direction: 'input',
35
+ pathKind: 'file',
36
+ multiple: false,
37
+ contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'],
38
+ maxBytes: 26_214_400,
39
+ },
40
+ },
30
41
  { name: 'amount', required: true, help: 'Original-currency amount, e.g. 140.00' },
31
42
  { name: 'currency', default: 'CNY', help: 'Original currency code' },
32
43
  { name: 'date', required: true, help: 'Expense date as YYYY-MM-DD' },
@@ -19,9 +19,8 @@ cli({
19
19
  columns: ['rank', 'name', 'votes', 'url'],
20
20
  func: async (page, args) => {
21
21
  const count = Math.min(Number(args.limit) || 20, 50);
22
- await page.installInterceptor('producthunt.com');
23
22
  await page.goto('https://www.producthunt.com');
24
- await page.waitForCapture(5);
23
+ await page.wait(2);
25
24
  const domItems = await page.evaluate(`
26
25
  (() => {
27
26
  const seen = new Set();
@@ -108,7 +108,7 @@ cli({
108
108
  name: 'auth',
109
109
  access: 'write',
110
110
  description: 'Authenticate with Spotify (OAuth — run once)',
111
- strategy: Strategy.PUBLIC,
111
+ strategy: Strategy.LOCAL,
112
112
  browser: false,
113
113
  args: [],
114
114
  columns: ['status'],
@@ -175,7 +175,7 @@ cli({
175
175
  name: 'status',
176
176
  access: 'read',
177
177
  description: 'Show current playback status',
178
- strategy: Strategy.PUBLIC,
178
+ strategy: Strategy.LOCAL,
179
179
  browser: false,
180
180
  args: [],
181
181
  columns: ['track', 'artist', 'album', 'status', 'progress'],
@@ -197,7 +197,7 @@ cli({
197
197
  name: 'play',
198
198
  access: 'write',
199
199
  description: 'Resume playback or search and play a track/artist',
200
- strategy: Strategy.PUBLIC,
200
+ strategy: Strategy.LOCAL,
201
201
  browser: false,
202
202
  args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }],
203
203
  columns: ['track', 'artist', 'status'],
@@ -216,7 +216,7 @@ cli({
216
216
  name: 'pause',
217
217
  access: 'write',
218
218
  description: 'Pause playback',
219
- strategy: Strategy.PUBLIC,
219
+ strategy: Strategy.LOCAL,
220
220
  browser: false,
221
221
  args: [],
222
222
  columns: ['status'],
@@ -227,7 +227,7 @@ cli({
227
227
  name: 'next',
228
228
  access: 'write',
229
229
  description: 'Skip to next track',
230
- strategy: Strategy.PUBLIC,
230
+ strategy: Strategy.LOCAL,
231
231
  browser: false,
232
232
  args: [],
233
233
  columns: ['status'],
@@ -238,7 +238,7 @@ cli({
238
238
  name: 'prev',
239
239
  access: 'write',
240
240
  description: 'Skip to previous track',
241
- strategy: Strategy.PUBLIC,
241
+ strategy: Strategy.LOCAL,
242
242
  browser: false,
243
243
  args: [],
244
244
  columns: ['status'],
@@ -249,7 +249,7 @@ cli({
249
249
  name: 'volume',
250
250
  access: 'write',
251
251
  description: 'Set playback volume (0-100)',
252
- strategy: Strategy.PUBLIC,
252
+ strategy: Strategy.LOCAL,
253
253
  browser: false,
254
254
  args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }],
255
255
  columns: ['volume'],
@@ -266,7 +266,7 @@ cli({
266
266
  name: 'search',
267
267
  access: 'read',
268
268
  description: 'Search for tracks',
269
- strategy: Strategy.PUBLIC,
269
+ strategy: Strategy.LOCAL,
270
270
  browser: false,
271
271
  args: [
272
272
  { name: 'query', type: 'str', required: true, positional: true, help: 'Search query' },
@@ -287,7 +287,7 @@ cli({
287
287
  name: 'queue',
288
288
  access: 'write',
289
289
  description: 'Add a track to the playback queue',
290
- strategy: Strategy.PUBLIC,
290
+ strategy: Strategy.LOCAL,
291
291
  browser: false,
292
292
  args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }],
293
293
  columns: ['track', 'artist', 'status'],
@@ -302,7 +302,7 @@ cli({
302
302
  name: 'shuffle',
303
303
  access: 'write',
304
304
  description: 'Toggle shuffle on/off',
305
- strategy: Strategy.PUBLIC,
305
+ strategy: Strategy.LOCAL,
306
306
  browser: false,
307
307
  args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }],
308
308
  columns: ['shuffle'],
@@ -316,7 +316,7 @@ cli({
316
316
  name: 'repeat',
317
317
  access: 'write',
318
318
  description: 'Set repeat mode (off / track / context)',
319
- strategy: Strategy.PUBLIC,
319
+ strategy: Strategy.LOCAL,
320
320
  browser: false,
321
321
  args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }],
322
322
  columns: ['repeat'],