linkedin-automation-cli 1.0.0

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 (314) hide show
  1. package/.env.example +12 -0
  2. package/.github/workflows/ci.yml +66 -0
  3. package/.github/workflows/publish.yml +48 -0
  4. package/.husky/pre-commit +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +10 -0
  7. package/AGENTS.md +294 -0
  8. package/CHANGELOG.md +40 -0
  9. package/GIT_RELEASE.md +167 -0
  10. package/LICENSE +21 -0
  11. package/Makefile +30 -0
  12. package/NPM_PUBLISHING.md +230 -0
  13. package/PYEOF +0 -0
  14. package/README.md +295 -0
  15. package/TESTING-GUIDE.md +151 -0
  16. package/cmd/linkedin/main.go +9 -0
  17. package/dist/agent/action-executor.d.ts +81 -0
  18. package/dist/agent/action-executor.d.ts.map +1 -0
  19. package/dist/agent/action-executor.js +170 -0
  20. package/dist/agent/action-executor.js.map +1 -0
  21. package/dist/agent/action-executor.test.d.ts +2 -0
  22. package/dist/agent/action-executor.test.d.ts.map +1 -0
  23. package/dist/agent/action-executor.test.js +366 -0
  24. package/dist/agent/action-executor.test.js.map +1 -0
  25. package/dist/agent/claude-client.d.ts +74 -0
  26. package/dist/agent/claude-client.d.ts.map +1 -0
  27. package/dist/agent/claude-client.js +314 -0
  28. package/dist/agent/claude-client.js.map +1 -0
  29. package/dist/agent/claude-client.test.d.ts +2 -0
  30. package/dist/agent/claude-client.test.d.ts.map +1 -0
  31. package/dist/agent/claude-client.test.js +590 -0
  32. package/dist/agent/claude-client.test.js.map +1 -0
  33. package/dist/agent/dom-extractor.d.ts +50 -0
  34. package/dist/agent/dom-extractor.d.ts.map +1 -0
  35. package/dist/agent/dom-extractor.js +374 -0
  36. package/dist/agent/dom-extractor.js.map +1 -0
  37. package/dist/agent/dom-extractor.test.d.ts +7 -0
  38. package/dist/agent/dom-extractor.test.d.ts.map +1 -0
  39. package/dist/agent/dom-extractor.test.js +504 -0
  40. package/dist/agent/dom-extractor.test.js.map +1 -0
  41. package/dist/agent/extension-client.d.ts +75 -0
  42. package/dist/agent/extension-client.d.ts.map +1 -0
  43. package/dist/agent/extension-client.js +245 -0
  44. package/dist/agent/extension-client.js.map +1 -0
  45. package/dist/agent/index.d.ts +8 -0
  46. package/dist/agent/index.d.ts.map +1 -0
  47. package/dist/agent/index.js +16 -0
  48. package/dist/agent/index.js.map +1 -0
  49. package/dist/agent/page-agent.d.ts +76 -0
  50. package/dist/agent/page-agent.d.ts.map +1 -0
  51. package/dist/agent/page-agent.js +236 -0
  52. package/dist/agent/page-agent.js.map +1 -0
  53. package/dist/agent/types.d.ts +236 -0
  54. package/dist/agent/types.d.ts.map +1 -0
  55. package/dist/agent/types.js +37 -0
  56. package/dist/agent/types.js.map +1 -0
  57. package/dist/cli/agent-commands.d.ts +3 -0
  58. package/dist/cli/agent-commands.d.ts.map +1 -0
  59. package/dist/cli/agent-commands.js +250 -0
  60. package/dist/cli/agent-commands.js.map +1 -0
  61. package/dist/cli/auth.d.ts +3 -0
  62. package/dist/cli/auth.d.ts.map +1 -0
  63. package/dist/cli/auth.js +288 -0
  64. package/dist/cli/auth.js.map +1 -0
  65. package/dist/cli/company.d.ts +3 -0
  66. package/dist/cli/company.d.ts.map +1 -0
  67. package/dist/cli/company.js +55 -0
  68. package/dist/cli/company.js.map +1 -0
  69. package/dist/cli/connection.d.ts +3 -0
  70. package/dist/cli/connection.d.ts.map +1 -0
  71. package/dist/cli/connection.js +79 -0
  72. package/dist/cli/connection.js.map +1 -0
  73. package/dist/cli/index.d.ts +7 -0
  74. package/dist/cli/index.d.ts.map +1 -0
  75. package/dist/cli/index.js +17 -0
  76. package/dist/cli/index.js.map +1 -0
  77. package/dist/cli/messages.d.ts +3 -0
  78. package/dist/cli/messages.d.ts.map +1 -0
  79. package/dist/cli/messages.js +268 -0
  80. package/dist/cli/messages.js.map +1 -0
  81. package/dist/cli/profile.d.ts +3 -0
  82. package/dist/cli/profile.d.ts.map +1 -0
  83. package/dist/cli/profile.js +81 -0
  84. package/dist/cli/profile.js.map +1 -0
  85. package/dist/cli/profile.test.d.ts +2 -0
  86. package/dist/cli/profile.test.d.ts.map +1 -0
  87. package/dist/cli/profile.test.js +15 -0
  88. package/dist/cli/profile.test.js.map +1 -0
  89. package/dist/cli/reply.d.ts +3 -0
  90. package/dist/cli/reply.d.ts.map +1 -0
  91. package/dist/cli/reply.js +129 -0
  92. package/dist/cli/reply.js.map +1 -0
  93. package/dist/core/audit.d.ts +17 -0
  94. package/dist/core/audit.d.ts.map +1 -0
  95. package/dist/core/audit.js +121 -0
  96. package/dist/core/audit.js.map +1 -0
  97. package/dist/core/audit.test.d.ts +2 -0
  98. package/dist/core/audit.test.d.ts.map +1 -0
  99. package/dist/core/audit.test.js +142 -0
  100. package/dist/core/audit.test.js.map +1 -0
  101. package/dist/core/browser-cookies.d.ts +19 -0
  102. package/dist/core/browser-cookies.d.ts.map +1 -0
  103. package/dist/core/browser-cookies.js +181 -0
  104. package/dist/core/browser-cookies.js.map +1 -0
  105. package/dist/core/browser.d.ts +50 -0
  106. package/dist/core/browser.d.ts.map +1 -0
  107. package/dist/core/browser.js +318 -0
  108. package/dist/core/browser.js.map +1 -0
  109. package/dist/core/config.d.ts +20 -0
  110. package/dist/core/config.d.ts.map +1 -0
  111. package/dist/core/config.js +103 -0
  112. package/dist/core/config.js.map +1 -0
  113. package/dist/core/config.test.d.ts +2 -0
  114. package/dist/core/config.test.d.ts.map +1 -0
  115. package/dist/core/config.test.js +111 -0
  116. package/dist/core/config.test.js.map +1 -0
  117. package/dist/core/storage.d.ts +19 -0
  118. package/dist/core/storage.d.ts.map +1 -0
  119. package/dist/core/storage.js +124 -0
  120. package/dist/core/storage.js.map +1 -0
  121. package/dist/core/storage.test.d.ts +2 -0
  122. package/dist/core/storage.test.d.ts.map +1 -0
  123. package/dist/core/storage.test.js +142 -0
  124. package/dist/core/storage.test.js.map +1 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +63 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/linkedin/auth.d.ts +22 -0
  130. package/dist/linkedin/auth.d.ts.map +1 -0
  131. package/dist/linkedin/auth.js +167 -0
  132. package/dist/linkedin/auth.js.map +1 -0
  133. package/dist/linkedin/company-extractor.d.ts +36 -0
  134. package/dist/linkedin/company-extractor.d.ts.map +1 -0
  135. package/dist/linkedin/company-extractor.js +211 -0
  136. package/dist/linkedin/company-extractor.js.map +1 -0
  137. package/dist/linkedin/company-extractor.test.d.ts +2 -0
  138. package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
  139. package/dist/linkedin/company-extractor.test.js +52 -0
  140. package/dist/linkedin/company-extractor.test.js.map +1 -0
  141. package/dist/linkedin/connector.d.ts +45 -0
  142. package/dist/linkedin/connector.d.ts.map +1 -0
  143. package/dist/linkedin/connector.js +245 -0
  144. package/dist/linkedin/connector.js.map +1 -0
  145. package/dist/linkedin/message-sender.d.ts +32 -0
  146. package/dist/linkedin/message-sender.d.ts.map +1 -0
  147. package/dist/linkedin/message-sender.js +112 -0
  148. package/dist/linkedin/message-sender.js.map +1 -0
  149. package/dist/linkedin/messages.d.ts +78 -0
  150. package/dist/linkedin/messages.d.ts.map +1 -0
  151. package/dist/linkedin/messages.js +745 -0
  152. package/dist/linkedin/messages.js.map +1 -0
  153. package/dist/linkedin/profile.d.ts +37 -0
  154. package/dist/linkedin/profile.d.ts.map +1 -0
  155. package/dist/linkedin/profile.js +268 -0
  156. package/dist/linkedin/profile.js.map +1 -0
  157. package/dist/linkedin/profile.test.d.ts +2 -0
  158. package/dist/linkedin/profile.test.d.ts.map +1 -0
  159. package/dist/linkedin/profile.test.js +68 -0
  160. package/dist/linkedin/profile.test.js.map +1 -0
  161. package/dist/linkedin/reply.d.ts +21 -0
  162. package/dist/linkedin/reply.d.ts.map +1 -0
  163. package/dist/linkedin/reply.js +76 -0
  164. package/dist/linkedin/reply.js.map +1 -0
  165. package/dist/linkedin/selector-engine.d.ts +69 -0
  166. package/dist/linkedin/selector-engine.d.ts.map +1 -0
  167. package/dist/linkedin/selector-engine.js +339 -0
  168. package/dist/linkedin/selector-engine.js.map +1 -0
  169. package/dist/linkedin/selector-engine.test.d.ts +2 -0
  170. package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
  171. package/dist/linkedin/selector-engine.test.js +135 -0
  172. package/dist/linkedin/selector-engine.test.js.map +1 -0
  173. package/dist/linkedin/selectors.d.ts +65 -0
  174. package/dist/linkedin/selectors.d.ts.map +1 -0
  175. package/dist/linkedin/selectors.js +261 -0
  176. package/dist/linkedin/selectors.js.map +1 -0
  177. package/dist/templates/engine.d.ts +37 -0
  178. package/dist/templates/engine.d.ts.map +1 -0
  179. package/dist/templates/engine.js +215 -0
  180. package/dist/templates/engine.js.map +1 -0
  181. package/dist/templates/engine.test.d.ts +2 -0
  182. package/dist/templates/engine.test.d.ts.map +1 -0
  183. package/dist/templates/engine.test.js +212 -0
  184. package/dist/templates/engine.test.js.map +1 -0
  185. package/dist/templates/index.d.ts +2 -0
  186. package/dist/templates/index.d.ts.map +1 -0
  187. package/dist/templates/index.js +7 -0
  188. package/dist/templates/index.js.map +1 -0
  189. package/dist/types/index.d.ts +113 -0
  190. package/dist/types/index.d.ts.map +1 -0
  191. package/dist/types/index.js +3 -0
  192. package/dist/types/index.js.map +1 -0
  193. package/dist/types/index.test.d.ts +2 -0
  194. package/dist/types/index.test.d.ts.map +1 -0
  195. package/dist/types/index.test.js +90 -0
  196. package/dist/types/index.test.js.map +1 -0
  197. package/dist/utils/paths.d.ts +8 -0
  198. package/dist/utils/paths.d.ts.map +1 -0
  199. package/dist/utils/paths.js +68 -0
  200. package/dist/utils/paths.js.map +1 -0
  201. package/dist/utils/rate-limiter.d.ts +22 -0
  202. package/dist/utils/rate-limiter.d.ts.map +1 -0
  203. package/dist/utils/rate-limiter.js +57 -0
  204. package/dist/utils/rate-limiter.js.map +1 -0
  205. package/dist/utils/retry.d.ts +18 -0
  206. package/dist/utils/retry.d.ts.map +1 -0
  207. package/dist/utils/retry.js +49 -0
  208. package/dist/utils/retry.js.map +1 -0
  209. package/docs/connection-command.md +52 -0
  210. package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
  211. package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
  212. package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
  213. package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
  214. package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
  215. package/docs/plans/2026-02-28-messages-show-design.md +243 -0
  216. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
  217. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
  218. package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
  219. package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
  220. package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
  221. package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
  222. package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
  223. package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
  224. package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
  225. package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
  226. package/eslint.config.mjs +58 -0
  227. package/go.mod +9 -0
  228. package/go.sum +10 -0
  229. package/import-cookies.js +376 -0
  230. package/internal/cmd/actions.go +123 -0
  231. package/internal/cmd/auth.go +108 -0
  232. package/internal/cmd/connect.go +42 -0
  233. package/internal/cmd/message.go +44 -0
  234. package/internal/cmd/people.go +454 -0
  235. package/internal/cmd/profiles.go +121 -0
  236. package/internal/cmd/root.go +89 -0
  237. package/internal/cmd/sequence.go +192 -0
  238. package/internal/config/config.go +187 -0
  239. package/internal/config/config_test.go +121 -0
  240. package/internal/config/profile.go +65 -0
  241. package/internal/linkedin/navigator.go +195 -0
  242. package/internal/linkedin/selectors.go +39 -0
  243. package/internal/linkedin/validator.go +69 -0
  244. package/internal/pinchtab/client.go +183 -0
  245. package/internal/pinchtab/client_test.go +67 -0
  246. package/internal/pinchtab/types.go +50 -0
  247. package/internal/ratelimit/limiter.go +115 -0
  248. package/internal/ratelimit/limits.go +32 -0
  249. package/package.json +67 -0
  250. package/release.sh +66 -0
  251. package/scripts/debug-linkedin.js +156 -0
  252. package/scripts/debug-login.js +193 -0
  253. package/scripts/extract-from-edge.js +96 -0
  254. package/scripts/import-cookies.js +101 -0
  255. package/scripts/poc-show-data.js +205 -0
  256. package/scripts/proof-of-access.js +87 -0
  257. package/scripts/prove-connection.js +110 -0
  258. package/scripts/show-linkedin-data.js +173 -0
  259. package/src/agent/action-executor.test.ts +464 -0
  260. package/src/agent/action-executor.ts +203 -0
  261. package/src/agent/claude-client.test.ts +707 -0
  262. package/src/agent/claude-client.ts +422 -0
  263. package/src/agent/dom-extractor.test.ts +574 -0
  264. package/src/agent/dom-extractor.ts +437 -0
  265. package/src/agent/extension-client.ts +306 -0
  266. package/src/agent/index.ts +28 -0
  267. package/src/agent/page-agent.ts +292 -0
  268. package/src/agent/types.ts +288 -0
  269. package/src/cli/agent-commands.ts +274 -0
  270. package/src/cli/auth.ts +343 -0
  271. package/src/cli/company.ts +66 -0
  272. package/src/cli/connection.ts +89 -0
  273. package/src/cli/index.ts +7 -0
  274. package/src/cli/messages.ts +338 -0
  275. package/src/cli/profile.test.ts +14 -0
  276. package/src/cli/profile.ts +95 -0
  277. package/src/cli/reply.ts +110 -0
  278. package/src/core/audit.test.ts +134 -0
  279. package/src/core/audit.ts +98 -0
  280. package/src/core/browser-cookies.ts +203 -0
  281. package/src/core/browser.ts +304 -0
  282. package/src/core/config.test.ts +90 -0
  283. package/src/core/config.ts +81 -0
  284. package/src/core/storage.test.ts +129 -0
  285. package/src/core/storage.ts +100 -0
  286. package/src/index.ts +70 -0
  287. package/src/linkedin/auth.ts +218 -0
  288. package/src/linkedin/company-extractor.test.ts +58 -0
  289. package/src/linkedin/company-extractor.ts +222 -0
  290. package/src/linkedin/connector.ts +336 -0
  291. package/src/linkedin/message-sender.ts +141 -0
  292. package/src/linkedin/messages.ts +894 -0
  293. package/src/linkedin/profile.test.ts +79 -0
  294. package/src/linkedin/profile.ts +314 -0
  295. package/src/linkedin/reply.ts +96 -0
  296. package/src/linkedin/selector-engine.test.ts +167 -0
  297. package/src/linkedin/selector-engine.ts +393 -0
  298. package/src/linkedin/selectors.ts +268 -0
  299. package/src/templates/defaults/followup.txt +14 -0
  300. package/src/templates/defaults/meeting.txt +16 -0
  301. package/src/templates/defaults/welcome.txt +14 -0
  302. package/src/templates/engine.test.ts +228 -0
  303. package/src/templates/engine.ts +208 -0
  304. package/src/templates/index.ts +1 -0
  305. package/src/types/index.test.ts +94 -0
  306. package/src/types/index.ts +143 -0
  307. package/src/types/sql.js.d.ts +23 -0
  308. package/src/utils/paths.ts +33 -0
  309. package/src/utils/rate-limiter.ts +75 -0
  310. package/src/utils/retry.ts +78 -0
  311. package/test-cli.sh +85 -0
  312. package/test-real-data.sh +97 -0
  313. package/tsconfig.json +23 -0
  314. package/vitest.config.ts +35 -0
@@ -0,0 +1,338 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { BrowserController } from '../core/browser';
5
+ import { LinkedInMessages } from '../linkedin/messages';
6
+ import { LinkedInMessageSender } from '../linkedin/message-sender';
7
+ import { getAuditLogger } from '../core/audit';
8
+ import { getConfig } from '../core/config';
9
+
10
+ export function registerMessageCommands(program: Command): void {
11
+ const messages = program.command('messages').description('Message management commands');
12
+
13
+ // List conversations
14
+ messages
15
+ .command('list')
16
+ .description('List recent conversations')
17
+ .option('-l, --limit <number>', 'Number of conversations to show', '20')
18
+ .option('-u, --unread', 'Show only unread conversations', false)
19
+ .option('--headless', 'Run browser in headless mode', false)
20
+ .action(async (options) => {
21
+ const spinner = ora('Loading conversations...').start();
22
+
23
+ try {
24
+ // Launch browser - will connect to existing Edge/Chrome via CDP if available
25
+ const config = getConfig();
26
+ const browser = new BrowserController({
27
+ headless: options.headless ?? config.getValue('headless'),
28
+ });
29
+
30
+ await browser.launch();
31
+
32
+ // Get the page (could be existing CDP tab or new page)
33
+ const page = browser.getPage();
34
+ if (!page) throw new Error('Browser page not available');
35
+
36
+ // Check if user is on linkedin.cn (limited functionality)
37
+ if (browser.isOnLinkedInCN()) {
38
+ spinner.fail('Detected linkedin.cn - messaging requires linkedin.com');
39
+ console.error(chalk.red('\n⚠️ Network Issue Detected: linkedin.cn'));
40
+ console.error(
41
+ chalk.yellow(
42
+ "\nlinkedin.cn (China's local LinkedIn) has limited functionality and does not support messaging."
43
+ )
44
+ );
45
+ console.error(
46
+ chalk.yellow(
47
+ '\nPlease enable your VPN or switch to a network that can access linkedin.com'
48
+ )
49
+ );
50
+ console.error(
51
+ chalk.gray('\nOnce your VPN is active, close this tab and run the command again.')
52
+ );
53
+ await browser.close();
54
+ process.exit(1);
55
+ }
56
+
57
+ // If connected via CDP to existing browser, use the existing session
58
+ if (browser.isUsingCDP()) {
59
+ console.log('Using existing browser session...');
60
+ spinner.text = 'Navigating to LinkedIn messaging...';
61
+
62
+ // Check current URL
63
+ const currentUrl = page.url();
64
+ console.log('Current URL:', currentUrl);
65
+
66
+ // If already on messaging page, just refresh to get the list view
67
+ if (currentUrl.includes('/messaging/')) {
68
+ if (currentUrl.includes('/thread/')) {
69
+ // We're in a thread, navigate back to main messaging
70
+ console.log('In thread, navigating to messaging list...');
71
+ await page.goto('https://www.linkedin.com/messaging/', {
72
+ waitUntil: 'domcontentloaded',
73
+ timeout: 10000,
74
+ });
75
+ } else {
76
+ // Already on messaging list, just reload
77
+ console.log('Already on messaging list, reloading...');
78
+ await page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
79
+ }
80
+ } else {
81
+ // Navigate from elsewhere to messaging
82
+ await page.goto('https://www.linkedin.com/messaging/', {
83
+ waitUntil: 'domcontentloaded',
84
+ timeout: 10000,
85
+ });
86
+ }
87
+ } else {
88
+ // Fresh browser, navigate to messaging
89
+ await page.goto('https://www.linkedin.com/messaging/', {
90
+ waitUntil: 'domcontentloaded',
91
+ timeout: 30000,
92
+ });
93
+ }
94
+
95
+ // Wait a bit for the page to stabilize
96
+ await page.waitForTimeout(2000);
97
+
98
+ const linkedInMessages = new LinkedInMessages(page, browser);
99
+
100
+ spinner.text = 'Fetching conversations from LinkedIn...';
101
+
102
+ const threads = await linkedInMessages.getThreads({
103
+ limit: parseInt(options.limit),
104
+ unreadOnly: options.unread,
105
+ });
106
+
107
+ spinner.succeed(`Found ${threads.length} conversations`);
108
+
109
+ // Display results
110
+ console.log('\n' + chalk.bold('Conversations:'));
111
+ console.log(chalk.gray('─'.repeat(80)));
112
+
113
+ threads.forEach((thread) => {
114
+ const participantNames = thread.participants.map((p) => p.name).join(', ');
115
+ const unreadIndicator = thread.unreadCount > 0 ? chalk.red('● ') : ' ';
116
+ const preview = thread.messages[0]?.content?.slice(0, 60) || 'No preview';
117
+
118
+ console.log(`${unreadIndicator}${chalk.bold(participantNames)}`);
119
+ console.log(` ${chalk.gray(preview)}${preview.length >= 60 ? '...' : ''}`);
120
+ console.log(` ${chalk.cyan(`ID: ${thread.id}`)}`);
121
+ console.log();
122
+ });
123
+
124
+ // Log action
125
+ const logger = getAuditLogger();
126
+ logger.log('messages.list', { count: threads.length, unreadOnly: options.unread }, true);
127
+
128
+ await browser.close();
129
+ } catch (error) {
130
+ spinner.fail('Failed to load conversations');
131
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
132
+ process.exit(1);
133
+ }
134
+ });
135
+
136
+ // Show thread details
137
+ messages
138
+ .command('show')
139
+ .description('Show conversation thread details')
140
+ .requiredOption('-t, --thread <id>', 'Thread ID to show')
141
+ .option('-l, --limit <number>', 'Number of messages to show', '50')
142
+ .option('--json', 'Output as JSON for LLM processing', false)
143
+ .option('--headless', 'Run browser in headless mode', false)
144
+ .action(async (options) => {
145
+ const spinner = ora('Loading conversation...').start();
146
+
147
+ try {
148
+ // Launch browser - will connect to existing Edge/Chrome via CDP if available
149
+ const config = getConfig();
150
+ const browser = new BrowserController({
151
+ headless: options.headless ?? config.getValue('headless'),
152
+ });
153
+
154
+ await browser.launch();
155
+
156
+ // Get the page (could be existing CDP tab or new page)
157
+ const page = browser.getPage();
158
+ if (!page) throw new Error('Browser page not available');
159
+
160
+ // Check if user is on linkedin.cn (limited functionality)
161
+ if (browser.isOnLinkedInCN()) {
162
+ spinner.fail('Detected linkedin.cn - messaging requires linkedin.com');
163
+ console.error(chalk.red('\n⚠️ Network Issue Detected: linkedin.cn'));
164
+ console.error(
165
+ chalk.yellow(
166
+ "\nlinkedin.cn (China's local LinkedIn) has limited functionality and does not support messaging."
167
+ )
168
+ );
169
+ console.error(
170
+ chalk.yellow(
171
+ '\nPlease enable your VPN or switch to a network that can access linkedin.com'
172
+ )
173
+ );
174
+ console.error(
175
+ chalk.gray('\nOnce your VPN is active, close this tab and run the command again.')
176
+ );
177
+ await browser.close();
178
+ process.exit(1);
179
+ }
180
+
181
+ // If connected via CDP to existing browser, use the existing session
182
+ if (browser.isUsingCDP()) {
183
+ console.log('Using existing browser session...');
184
+ spinner.text = 'Navigating to conversation...';
185
+
186
+ // Only navigate to messaging list for ember ID lookup
187
+ // Real thread IDs can be accessed directly
188
+ const isEmberId = options.thread.startsWith('ember');
189
+ if (isEmberId) {
190
+ console.log('Navigating to messaging list for ember ID lookup...');
191
+ await page.goto('https://www.linkedin.com/messaging/', {
192
+ waitUntil: 'domcontentloaded',
193
+ timeout: 15000,
194
+ });
195
+ await page.waitForTimeout(2000);
196
+ }
197
+ }
198
+
199
+ const linkedInMessages = new LinkedInMessages(page, browser);
200
+
201
+ spinner.text = 'Fetching messages from LinkedIn...';
202
+
203
+ const thread = await linkedInMessages.showThread(options.thread, parseInt(options.limit));
204
+
205
+ if (!thread) {
206
+ spinner.fail(`Thread ${options.thread} not found`);
207
+ process.exit(1);
208
+ }
209
+
210
+ spinner.succeed(`Loaded ${thread.messages.length} messages`);
211
+
212
+ // Display thread based on output format
213
+ if (options.json) {
214
+ // JSON output for LLM processing
215
+ console.log(linkedInMessages.formatThreadJson(thread));
216
+ } else {
217
+ // Human-readable text output (Style A)
218
+ console.log('\n' + chalk.bold('='.repeat(60)));
219
+ console.log(
220
+ chalk.bold(`Conversations with ${thread.participants.map((p) => p.name).join(', ')}`)
221
+ );
222
+ console.log(chalk.gray(`Thread ID: ${thread.id}`));
223
+ console.log(chalk.bold('='.repeat(60)));
224
+ console.log(linkedInMessages.formatThreadText(thread));
225
+ console.log('\n' + chalk.bold('='.repeat(60)));
226
+ }
227
+
228
+ // Log action
229
+ const logger = getAuditLogger();
230
+ logger.log(
231
+ 'messages.show',
232
+ { threadId: options.thread, messageCount: thread.messages.length, json: options.json },
233
+ true
234
+ );
235
+
236
+ await browser.close();
237
+ } catch (error) {
238
+ spinner.fail('Failed to load conversation');
239
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
240
+ process.exit(1);
241
+ }
242
+ });
243
+
244
+ // Send new message to a profile
245
+ messages
246
+ .command('send')
247
+ .description('Send a new message to a LinkedIn profile')
248
+ .requiredOption('-u, --url <profile>', 'LinkedIn profile URL')
249
+ .requiredOption('-m, --message <text>', 'Message text to send')
250
+ .option('--dry-run', 'Simulate sending without actually sending', false)
251
+ .option('--headless', 'Run browser in headless mode', false)
252
+ .action(async (options) => {
253
+ const spinner = ora('Preparing to send message...').start();
254
+
255
+ try {
256
+ spinner.text = 'Launching browser...';
257
+
258
+ // Launch browser - will connect to existing Edge/Chrome via CDP if available
259
+ const config = getConfig();
260
+ const browser = new BrowserController({
261
+ headless: options.headless ?? config.getValue('headless'),
262
+ });
263
+
264
+ await browser.launch();
265
+
266
+ // Get the page
267
+ const page = browser.getPage();
268
+ if (!page) throw new Error('Browser page not available');
269
+
270
+ // Check if user is on linkedin.cn (limited functionality, messaging won't work)
271
+ if (browser.isOnLinkedInCN()) {
272
+ spinner.fail('Detected linkedin.cn - messaging requires linkedin.com');
273
+ console.error(chalk.red('\n⚠️ Network Issue Detected: linkedin.cn'));
274
+ console.error(
275
+ chalk.yellow(
276
+ "\nlinkedin.cn (China's local LinkedIn) has limited functionality and does not support messaging via API."
277
+ )
278
+ );
279
+ console.error(
280
+ chalk.yellow(
281
+ '\nPlease enable your VPN or switch to a network that can access linkedin.com'
282
+ )
283
+ );
284
+ console.error(
285
+ chalk.gray('\nOnce your VPN is active, close this tab and run the command again.')
286
+ );
287
+ await browser.close();
288
+ process.exit(1);
289
+ }
290
+
291
+ spinner.text = options.dryRun ? 'Simulating message send...' : 'Sending message...';
292
+
293
+ const sender = new LinkedInMessageSender(page);
294
+
295
+ const result = await sender.sendMessage({
296
+ profileUrl: options.url,
297
+ text: options.message,
298
+ dryRun: options.dryRun,
299
+ });
300
+
301
+ if (!result.success) {
302
+ spinner.fail(`Failed to send message: ${result.error}`);
303
+ await browser.close();
304
+ process.exit(1);
305
+ }
306
+
307
+ if (options.dryRun) {
308
+ spinner.succeed('Dry run completed - message would have been sent');
309
+ console.log(chalk.gray('Profile URL: ' + options.url));
310
+ console.log(chalk.gray('Message content:'));
311
+ console.log(chalk.cyan(options.message));
312
+ if (result.threadId) {
313
+ console.log(chalk.gray(`Thread ID: ${result.threadId}`));
314
+ }
315
+ } else {
316
+ spinner.succeed('Message sent successfully!');
317
+ console.log(chalk.gray(`Thread ID: ${result.threadId}`));
318
+ if (result.messageId) {
319
+ console.log(chalk.gray(`Message ID: ${result.messageId}`));
320
+ }
321
+ }
322
+
323
+ // Log action
324
+ const logger = getAuditLogger();
325
+ logger.log(
326
+ options.dryRun ? 'messages.send.dry-run' : 'messages.send',
327
+ { profileUrl: options.url, messageLength: options.message.length },
328
+ true
329
+ );
330
+
331
+ await browser.close();
332
+ } catch (error) {
333
+ spinner.fail('Failed to send message');
334
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
335
+ process.exit(1);
336
+ }
337
+ });
338
+ }
@@ -0,0 +1,14 @@
1
+ // src/cli/profile.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import { Command } from 'commander';
4
+ import { registerProfileCommands } from './profile';
5
+
6
+ describe('registerProfileCommands', () => {
7
+ it('should register profile command', () => {
8
+ const program = new Command();
9
+ registerProfileCommands(program);
10
+
11
+ const commands = program.commands;
12
+ expect(commands.some((c) => c.name() === 'profile')).toBe(true);
13
+ });
14
+ });
@@ -0,0 +1,95 @@
1
+ // src/cli/profile.ts
2
+ import { Command } from 'commander';
3
+ import { BrowserController } from '../core/browser';
4
+ import { LinkedInProfile } from '../linkedin/profile';
5
+ import { getAuditLogger } from '../core/audit';
6
+ import type { ProfileData } from '../types';
7
+
8
+ const CDP_PORT = parseInt(process.env.PAGE_AGENT_CDP_PORT || '9222', 10);
9
+
10
+ export function registerProfileCommands(program: Command): void {
11
+ const profile = program.command('profile').description('LinkedIn profile commands');
12
+
13
+ profile
14
+ .command('get <url>')
15
+ .description('Extract profile data from a LinkedIn URL')
16
+ .option('--include-contact', 'Extract contact info (email, phone)')
17
+ .option('--json', 'Output as JSON')
18
+ .option('--no-cdp', 'Do not connect to existing browser via CDP')
19
+ .option('--cdp-port <port>', 'CDP port number', String(CDP_PORT))
20
+ .action(async (url: string, options) => {
21
+ const browser = new BrowserController();
22
+
23
+ try {
24
+ // Initialize browser
25
+ await browser.launch();
26
+
27
+ const page = browser.getPage();
28
+ if (!page) {
29
+ console.error('Failed to get page from browser');
30
+ process.exit(1);
31
+ }
32
+
33
+ const extractor = new LinkedInProfile(page);
34
+
35
+ // Extract profile
36
+ const result = await extractor.extract({
37
+ profileUrl: url,
38
+ includeContact: options.includeContact,
39
+ });
40
+
41
+ // Log audit
42
+ getAuditLogger().log(
43
+ 'profile_extract',
44
+ {
45
+ profileUrl: url,
46
+ includeContact: options.includeContact ?? false,
47
+ success: result.success,
48
+ extractedFields: result.data
49
+ ? (Object.keys(result.data) as (keyof ProfileData)[]).filter(
50
+ (k) => result.data![k] !== null
51
+ )
52
+ : [],
53
+ },
54
+ result.success
55
+ );
56
+
57
+ if (result.success && result.data) {
58
+ if (options.json) {
59
+ console.log(JSON.stringify(result.data, null, 2));
60
+ } else {
61
+ printProfileOutput(result.data, options.includeContact);
62
+ }
63
+ } else {
64
+ console.error(`Error: ${result.error}`);
65
+ process.exit(1);
66
+ }
67
+ } finally {
68
+ await browser.close();
69
+ }
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Print profile data in human-readable format
75
+ */
76
+ function printProfileOutput(data: ProfileData, includeContact?: boolean): void {
77
+ console.log(`\nProfile: ${data.full_name}`);
78
+ console.log('━'.repeat(50));
79
+ console.log(`Headline: ${data.headline || 'N/A'}`);
80
+ console.log(`Location: ${data.location || 'N/A'}`);
81
+ console.log(`Company: ${data.current_company ?? 'N/A'}`);
82
+ console.log(`Title: ${data.current_title ?? 'N/A'}`);
83
+ console.log(`Company URL: ${data.company_linkedin_url ?? 'N/A'}`);
84
+ console.log(
85
+ `Experience: ${data.years_experience !== null ? `${data.years_experience} years` : 'N/A'}`
86
+ );
87
+
88
+ if (includeContact) {
89
+ console.log(`Email: ${data.email ?? 'Not available'}`);
90
+ console.log(`Phone: ${data.phone ?? 'Not available'}`);
91
+ } else {
92
+ console.log(`Email: (use --include-contact to reveal)`);
93
+ console.log(`Phone: (use --include-contact to reveal)`);
94
+ }
95
+ }
@@ -0,0 +1,110 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import * as fs from 'fs';
5
+ import { BrowserController } from '../core/browser';
6
+ import { LinkedInReply } from '../linkedin/reply';
7
+ import { getSecureStorage } from '../core/storage';
8
+ import { getAuditLogger } from '../core/audit';
9
+ import { getConfig } from '../core/config';
10
+
11
+ export function registerReplyCommands(program: Command): void {
12
+ const reply = program.command('reply').description('Reply to a conversation thread');
13
+
14
+ reply
15
+ .command('send')
16
+ .description('Send a reply to a thread')
17
+ .requiredOption('-t, --thread <id>', 'Thread ID to reply to')
18
+ .option('-m, --message <text>', 'Message text to send')
19
+ .option('-f, --file <path>', 'Read message from file')
20
+ .option('--dry-run', 'Simulate sending without actually sending', false)
21
+ .option('--headless', 'Run browser in headless mode', true)
22
+ .action(async (options) => {
23
+ const spinner = ora('Preparing to send message...').start();
24
+
25
+ try {
26
+ // Get message text
27
+ let messageText = options.message;
28
+
29
+ if (options.file) {
30
+ if (!fs.existsSync(options.file)) {
31
+ spinner.fail(`File not found: ${options.file}`);
32
+ process.exit(1);
33
+ }
34
+ messageText = fs.readFileSync(options.file, 'utf8');
35
+ }
36
+
37
+ if (!messageText || messageText.trim().length === 0) {
38
+ spinner.fail('Message text is required (use --message or --file)');
39
+ process.exit(1);
40
+ }
41
+
42
+ spinner.text = 'Checking authentication...';
43
+
44
+ // Check for existing session
45
+ const storage = getSecureStorage();
46
+ const sessionData = storage.load('linkedin-session');
47
+
48
+ if (!sessionData) {
49
+ spinner.fail('Not logged in. Run: linkedin-cli auth login');
50
+ process.exit(1);
51
+ }
52
+
53
+ spinner.text = 'Launching browser...';
54
+
55
+ // Launch browser and restore session
56
+ const config = getConfig();
57
+ const browser = new BrowserController({
58
+ headless: options.headless ?? config.getValue('headless'),
59
+ });
60
+
61
+ await browser.launch();
62
+ const session = JSON.parse(sessionData);
63
+ await browser.restoreSession(session);
64
+
65
+ const page = browser.getPage();
66
+ if (!page) throw new Error('Browser page not available');
67
+
68
+ spinner.text = options.dryRun ? 'Simulating message send...' : 'Sending message...';
69
+
70
+ const linkedInReply = new LinkedInReply(page);
71
+
72
+ const result = await linkedInReply.sendMessage({
73
+ threadId: options.thread,
74
+ text: messageText,
75
+ dryRun: options.dryRun,
76
+ });
77
+
78
+ if (!result.success) {
79
+ spinner.fail(`Failed to send message: ${result.error}`);
80
+ await browser.close();
81
+ process.exit(1);
82
+ }
83
+
84
+ if (options.dryRun) {
85
+ spinner.succeed('Dry run completed - message would have been sent');
86
+ console.log(chalk.gray('Message content:'));
87
+ console.log(chalk.cyan(messageText));
88
+ } else {
89
+ spinner.succeed('Message sent successfully!');
90
+ if (result.messageId) {
91
+ console.log(chalk.gray(`Message ID: ${result.messageId}`));
92
+ }
93
+ }
94
+
95
+ // Log action
96
+ const logger = getAuditLogger();
97
+ logger.log(
98
+ options.dryRun ? 'reply.send.dry-run' : 'reply.send',
99
+ { threadId: options.thread, messageLength: messageText.length },
100
+ true
101
+ );
102
+
103
+ await browser.close();
104
+ } catch (error) {
105
+ spinner.fail('Failed to send message');
106
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
107
+ process.exit(1);
108
+ }
109
+ });
110
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+
6
+ import { AuditLogger, getAuditLogger, type AuditEntry } from './audit';
7
+
8
+ describe('AuditLogger', () => {
9
+ let logger: AuditLogger;
10
+ const logPath = path.join(os.homedir(), '.linkedin-cli', 'audit-test.log');
11
+
12
+ beforeEach(() => {
13
+ // Clean up before test
14
+ if (fs.existsSync(logPath)) {
15
+ fs.rmSync(logPath, { force: true });
16
+ }
17
+ // Override log path for testing
18
+ logger = new AuditLogger();
19
+ (logger as any).logPath = logPath;
20
+ });
21
+
22
+ afterEach(() => {
23
+ // Clean up after test
24
+ if (fs.existsSync(logPath)) {
25
+ fs.rmSync(logPath, { force: true });
26
+ }
27
+ });
28
+
29
+ describe('log', () => {
30
+ it('should create log file on first write', () => {
31
+ expect(fs.existsSync(logPath)).toBe(false);
32
+
33
+ logger.log('test.action', { key: 'value' }, true);
34
+
35
+ expect(fs.existsSync(logPath)).toBe(true);
36
+ });
37
+
38
+ it('should append log entries', () => {
39
+ logger.log('action.one', {}, true);
40
+ logger.log('action.two', {}, false);
41
+
42
+ const content = fs.readFileSync(logPath, 'utf-8');
43
+ const lines = content
44
+ .trim()
45
+ .split('\n')
46
+ .filter((l) => l.trim());
47
+ expect(lines.length).toBe(2);
48
+ });
49
+
50
+ it('should log success entries correctly', () => {
51
+ logger.log('auth.login', { userId: '123' }, true);
52
+
53
+ const content = fs.readFileSync(logPath, 'utf-8');
54
+ const entry = JSON.parse(content.trim().split('\n')[0]) as AuditEntry;
55
+
56
+ expect(entry.action).toBe('auth.login');
57
+ expect(entry.success).toBe(true);
58
+ });
59
+
60
+ it('should log failure entries correctly', () => {
61
+ logger.log('auth.login', { userId: '123' }, false);
62
+
63
+ const content = fs.readFileSync(logPath, 'utf-8');
64
+ const entry = JSON.parse(content.trim().split('\n')[0]) as AuditEntry;
65
+
66
+ expect(entry.action).toBe('auth.login');
67
+ expect(entry.success).toBe(false);
68
+ });
69
+
70
+ it('should include timestamp', () => {
71
+ const beforeLog = Date.now();
72
+ logger.log('test.action', {}, true);
73
+ const afterLog = Date.now();
74
+
75
+ const content = fs.readFileSync(logPath, 'utf-8');
76
+ const entry = JSON.parse(content.trim().split('\n')[0]) as AuditEntry;
77
+
78
+ expect(entry.timestamp).toBeDefined();
79
+ const timestamp = new Date(entry.timestamp).getTime();
80
+ expect(timestamp).toBeGreaterThanOrEqual(beforeLog);
81
+ expect(timestamp).toBeLessThanOrEqual(afterLog);
82
+ });
83
+ });
84
+
85
+ describe('readRecent', () => {
86
+ it('should return empty array when no logs exist', () => {
87
+ const logs = logger.readRecent();
88
+ expect(logs).toEqual([]);
89
+ });
90
+
91
+ it('should return recent logs in order', () => {
92
+ logger.log('action.first', {}, true);
93
+ logger.log('action.second', {}, true);
94
+ logger.log('action.third', {}, false);
95
+
96
+ const logs = logger.readRecent();
97
+
98
+ expect(logs.length).toBe(3);
99
+ expect(logs[0].action).toBe('action.first');
100
+ expect(logs[1].action).toBe('action.second');
101
+ expect(logs[2].action).toBe('action.third');
102
+ });
103
+
104
+ it('should respect count limit', () => {
105
+ for (let i = 0; i < 10; i++) {
106
+ logger.log(`action.${i}`, {}, true);
107
+ }
108
+
109
+ const logs = logger.readRecent(5);
110
+ expect(logs.length).toBe(5);
111
+ expect(logs[0].action).toBe('action.5'); // Last 5 entries
112
+ });
113
+ });
114
+
115
+ describe('clear', () => {
116
+ it('should clear log file', () => {
117
+ logger.log('action.one', {}, true);
118
+ expect(fs.existsSync(logPath)).toBe(true);
119
+
120
+ logger.clear();
121
+
122
+ const content = fs.readFileSync(logPath, 'utf-8');
123
+ expect(content).toBe('');
124
+ });
125
+ });
126
+ });
127
+
128
+ describe('getAuditLogger singleton', () => {
129
+ it('should return same instance on multiple calls', () => {
130
+ const logger1 = getAuditLogger();
131
+ const logger2 = getAuditLogger();
132
+ expect(logger1).toBe(logger2);
133
+ });
134
+ });