@agentrhq/webcmd 0.2.5 → 0.3.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 (200) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/_shared/site-auth.js +3 -3
  4. package/clis/_shared/site-auth.test.js +4 -4
  5. package/clis/chatgpt/ask.js +1 -1
  6. package/clis/chatgpt/ask.test.js +11 -0
  7. package/clis/chatgpt/commands.test.js +102 -1
  8. package/clis/chatgpt/deep-research-result.js +45 -3
  9. package/clis/chatgpt/image.js +22 -2
  10. package/clis/chatgpt/project-file-add.js +24 -1
  11. package/clis/chatgpt/utils.js +258 -52
  12. package/clis/chatgpt/utils.test.js +259 -1
  13. package/clis/claude/ask.js +21 -1
  14. package/clis/facebook/feed.js +110 -24
  15. package/clis/facebook/feed.test.js +62 -0
  16. package/clis/facebook/search.js +185 -38
  17. package/clis/facebook/search.test.js +95 -50
  18. package/clis/instagram/explore.js +30 -12
  19. package/clis/instagram/explore.test.js +77 -0
  20. package/clis/instagram/post.js +14 -1
  21. package/clis/instagram/reel.js +13 -1
  22. package/clis/linkedin/company.js +153 -0
  23. package/clis/linkedin/company.test.js +111 -0
  24. package/clis/linkedin/connections.js +135 -0
  25. package/clis/linkedin/connections.test.js +141 -0
  26. package/clis/mercury/reimbursement-draft.js +12 -1
  27. package/clis/slock/whoami.test.js +2 -2
  28. package/clis/twitter/article-evaluate.test.js +27 -0
  29. package/clis/twitter/article.js +73 -16
  30. package/clis/twitter/article.test.js +209 -0
  31. package/clis/twitter/download.js +6 -1
  32. package/clis/twitter/post.js +14 -1
  33. package/clis/twitter/profile.js +1 -1
  34. package/clis/twitter/profile.test.js +8 -0
  35. package/clis/twitter/quote.js +11 -1
  36. package/clis/twitter/reply.js +11 -1
  37. package/dist/src/browser/base-page.d.ts +26 -0
  38. package/dist/src/browser/base-page.js +23 -8
  39. package/dist/src/browser/base-page.test.js +45 -12
  40. package/dist/src/browser/command-catalog.d.ts +6 -0
  41. package/dist/src/browser/command-catalog.js +259 -0
  42. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  43. package/dist/src/browser/command-catalog.test.js +79 -0
  44. package/dist/src/browser/daemon-client.d.ts +2 -1
  45. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  46. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  47. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  48. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  49. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  50. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  51. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +10 -2
  52. package/dist/src/browser/runtime/local-cloak/session-manager.js +25 -9
  53. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  54. package/dist/src/browser/target-resolver.d.ts +1 -0
  55. package/dist/src/browser/target-resolver.js +99 -4
  56. package/dist/src/browser/target-resolver.test.js +141 -1
  57. package/dist/src/build-manifest.d.ts +6 -0
  58. package/dist/src/build-manifest.js +18 -1
  59. package/dist/src/build-manifest.test.js +57 -1
  60. package/dist/src/builtin-command-surface.d.ts +9 -0
  61. package/dist/src/builtin-command-surface.js +16 -0
  62. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  63. package/dist/src/check-hosted-contract.test.js +56 -0
  64. package/dist/src/cli.js +63 -110
  65. package/dist/src/cli.test.js +82 -6
  66. package/dist/src/command-presentation.d.ts +95 -0
  67. package/dist/src/command-presentation.js +486 -0
  68. package/dist/src/command-presentation.test.d.ts +1 -0
  69. package/dist/src/command-presentation.test.js +97 -0
  70. package/dist/src/command-surface.d.ts +43 -0
  71. package/dist/src/command-surface.js +205 -0
  72. package/dist/src/command-surface.test.d.ts +1 -0
  73. package/dist/src/command-surface.test.js +406 -0
  74. package/dist/src/commanderAdapter.d.ts +5 -1
  75. package/dist/src/commanderAdapter.js +12 -55
  76. package/dist/src/commanderAdapter.test.js +15 -3
  77. package/dist/src/commands/auth.js +3 -2
  78. package/dist/src/commands/auth.test.js +6 -6
  79. package/dist/src/community-plugin-sync.d.ts +11 -0
  80. package/dist/src/community-plugin-sync.js +138 -0
  81. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  82. package/dist/src/community-plugin-sync.test.js +123 -0
  83. package/dist/src/completion-fast.d.ts +4 -4
  84. package/dist/src/completion-fast.js +15 -34
  85. package/dist/src/completion-shared.d.ts +4 -0
  86. package/dist/src/completion-shared.js +38 -0
  87. package/dist/src/completion.js +3 -27
  88. package/dist/src/completion.test.js +31 -2
  89. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  90. package/dist/src/docs-sync-review-cli.test.js +322 -0
  91. package/dist/src/docs-sync-review.d.ts +117 -0
  92. package/dist/src/docs-sync-review.js +475 -0
  93. package/dist/src/docs-sync-review.test.d.ts +1 -0
  94. package/dist/src/docs-sync-review.test.js +324 -0
  95. package/dist/src/errors.d.ts +12 -3
  96. package/dist/src/errors.js +11 -7
  97. package/dist/src/errors.test.js +14 -1
  98. package/dist/src/execution.d.ts +1 -2
  99. package/dist/src/execution.js +4 -45
  100. package/dist/src/generate-release-notes-cli.test.js +11 -1
  101. package/dist/src/help.d.ts +4 -0
  102. package/dist/src/help.js +50 -255
  103. package/dist/src/help.test.js +27 -1
  104. package/dist/src/hosted/args.d.ts +4 -0
  105. package/dist/src/hosted/args.js +4 -0
  106. package/dist/src/hosted/args.test.d.ts +1 -0
  107. package/dist/src/hosted/args.test.js +49 -0
  108. package/dist/src/hosted/availability.d.ts +13 -0
  109. package/dist/src/hosted/availability.js +16 -0
  110. package/dist/src/hosted/availability.test.d.ts +1 -0
  111. package/dist/src/hosted/availability.test.js +180 -0
  112. package/dist/src/hosted/browser-args.d.ts +18 -0
  113. package/dist/src/hosted/browser-args.js +152 -0
  114. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  115. package/dist/src/hosted/browser-args.test.js +182 -0
  116. package/dist/src/hosted/client.d.ts +59 -0
  117. package/dist/src/hosted/client.js +510 -0
  118. package/dist/src/hosted/client.test.d.ts +1 -0
  119. package/dist/src/hosted/client.test.js +738 -0
  120. package/dist/src/hosted/config.d.ts +55 -0
  121. package/dist/src/hosted/config.js +112 -0
  122. package/dist/src/hosted/config.test.d.ts +1 -0
  123. package/dist/src/hosted/config.test.js +81 -0
  124. package/dist/src/hosted/contract.d.ts +94 -0
  125. package/dist/src/hosted/contract.js +208 -0
  126. package/dist/src/hosted/contract.test.d.ts +1 -0
  127. package/dist/src/hosted/contract.test.js +361 -0
  128. package/dist/src/hosted/credentials.d.ts +38 -0
  129. package/dist/src/hosted/credentials.js +248 -0
  130. package/dist/src/hosted/credentials.test.d.ts +1 -0
  131. package/dist/src/hosted/credentials.test.js +93 -0
  132. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  133. package/dist/src/hosted/file-contract.test.js +174 -0
  134. package/dist/src/hosted/files.d.ts +29 -0
  135. package/dist/src/hosted/files.js +296 -0
  136. package/dist/src/hosted/files.test.d.ts +1 -0
  137. package/dist/src/hosted/files.test.js +231 -0
  138. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  139. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  140. package/dist/src/hosted/manifest.d.ts +14 -0
  141. package/dist/src/hosted/manifest.js +47 -0
  142. package/dist/src/hosted/manifest.test.d.ts +1 -0
  143. package/dist/src/hosted/manifest.test.js +164 -0
  144. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  145. package/dist/src/hosted/output-parity.test.js +108 -0
  146. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  147. package/dist/src/hosted/root-command-surface.test.js +646 -0
  148. package/dist/src/hosted/runner.d.ts +19 -0
  149. package/dist/src/hosted/runner.js +705 -0
  150. package/dist/src/hosted/runner.test.d.ts +1 -0
  151. package/dist/src/hosted/runner.test.js +1607 -0
  152. package/dist/src/hosted/setup.d.ts +10 -0
  153. package/dist/src/hosted/setup.js +79 -0
  154. package/dist/src/hosted/setup.test.d.ts +1 -0
  155. package/dist/src/hosted/setup.test.js +195 -0
  156. package/dist/src/hosted/types.d.ts +175 -0
  157. package/dist/src/hosted/types.js +1 -0
  158. package/dist/src/main.js +123 -97
  159. package/dist/src/manifest-types.d.ts +2 -0
  160. package/dist/src/output.d.ts +17 -2
  161. package/dist/src/output.js +88 -81
  162. package/dist/src/output.test.js +141 -51
  163. package/dist/src/pipeline/executor.test.js +1 -0
  164. package/dist/src/pipeline/steps/download.test.js +1 -0
  165. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  166. package/dist/src/plugin-create-cli.test.js +37 -0
  167. package/dist/src/plugin-manifest.d.ts +12 -0
  168. package/dist/src/plugin-manifest.js +16 -0
  169. package/dist/src/plugin-manifest.test.js +19 -1
  170. package/dist/src/plugin-scaffold.d.ts +4 -1
  171. package/dist/src/plugin-scaffold.js +4 -1
  172. package/dist/src/plugin-scaffold.test.js +23 -8
  173. package/dist/src/plugin.js +4 -1
  174. package/dist/src/plugin.test.js +13 -0
  175. package/dist/src/registry.d.ts +2 -0
  176. package/dist/src/release-notes.d.ts +6 -1
  177. package/dist/src/release-notes.js +192 -4
  178. package/dist/src/release-notes.test.js +143 -4
  179. package/dist/src/root-command-surface.d.ts +31 -0
  180. package/dist/src/root-command-surface.js +106 -0
  181. package/dist/src/serialization.d.ts +1 -16
  182. package/dist/src/serialization.js +5 -55
  183. package/dist/src/stream-write.d.ts +12 -0
  184. package/dist/src/stream-write.js +91 -0
  185. package/dist/src/stream-write.test.d.ts +1 -0
  186. package/dist/src/stream-write.test.js +186 -0
  187. package/dist/src/types.d.ts +5 -0
  188. package/dist/src/utils.d.ts +1 -1
  189. package/dist/src/utils.js +2 -8
  190. package/dist/src/utils.test.js +50 -0
  191. package/hosted-contract.json +36748 -0
  192. package/package.json +6 -1
  193. package/scripts/check-hosted-contract.mjs +108 -0
  194. package/scripts/docs-sync-review.ts +332 -0
  195. package/scripts/generate-release-notes.ts +1 -1
  196. package/scripts/sync-community-plugins.ts +14 -0
  197. package/skills/smart-search/SKILL.md +14 -3
  198. package/skills/webcmd-autofix/SKILL.md +26 -37
  199. package/skills/webcmd-usage/SKILL.md +21 -7
  200. package/clis/antigravity/SKILL.md +0 -38
@@ -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' },
@@ -14,10 +14,10 @@ function makePage(authMe, status = 200) {
14
14
  describe('slock whoami', () => {
15
15
  const command = getRegistry().get('slock/whoami');
16
16
 
17
- it('returns a normalized identity object from /auth/me on 200', async () => {
17
+ it('returns a normalized identity row from /auth/me on 200', async () => {
18
18
  const page = makePage({ id: 'u1', name: 'Alice', email: 'a@b.c' });
19
19
  const identity = await command.func(page, {});
20
- expect(identity).toEqual({ logged_in: true, site: 'slock', id: 'u1', name: 'Alice', email: 'a@b.c' });
20
+ expect(identity).toEqual([{ logged_in: true, site: 'slock', id: 'u1', name: 'Alice', email: 'a@b.c' }]);
21
21
  expect(page.goto).toHaveBeenCalled();
22
22
  });
23
23
  });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@agentrhq/webcmd/registry';
3
+ import { createPageMock } from '../test-utils.js';
4
+ import './article.js';
5
+
6
+ describe('twitter article evaluated arguments', () => {
7
+ it('serializes tweet-id before embedding it in page.evaluate', async () => {
8
+ const command = getRegistry().get('twitter/article');
9
+ const tweetId = '123"; window.__webcmdInjected = true; //';
10
+ const rows = [{
11
+ title: '(Note Tweet)',
12
+ author: 'alice',
13
+ content: 'hello',
14
+ url: 'https://x.com/alice/status/123',
15
+ }];
16
+ const page = createPageMock([null, rows], {
17
+ getCookies: async () => [{ name: 'ct0', value: 'csrf-token' }],
18
+ });
19
+
20
+ await expect(command.func(page, { 'tweet-id': tweetId })).resolves.toEqual(rows);
21
+
22
+ expect(page.goto).toHaveBeenCalledWith(`https://x.com/i/status/${tweetId}`);
23
+ const graphqlScript = page.evaluate.mock.calls[1][0];
24
+ expect(graphqlScript).toContain(`const tweetId = ${JSON.stringify(tweetId)};`);
25
+ expect(graphqlScript).not.toContain('const tweetId = "123"; window.__webcmdInjected = true; //";');
26
+ });
27
+ });