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,14 @@
1
+ ---
2
+ name: Welcome Message
3
+ description: Initial outreach message to new connections
4
+ category: welcome
5
+ ---
6
+
7
+ Hi {{firstName}},
8
+
9
+ Thanks for connecting! I noticed you're working at {{company}} - that's really interesting work.
10
+
11
+ I'd love to learn more about what you're currently focused on. Are you open to a brief chat sometime this week?
12
+
13
+ Best regards,
14
+ {{senderName}}
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+
6
+ // Mock the paths module
7
+ vi.mock('../utils/paths', () => {
8
+ const testDir = path.join(os.tmpdir(), 'linkedin-cli-templates-test');
9
+ return {
10
+ getConfigDir: () => testDir,
11
+ getSessionsDir: () => path.join(testDir, 'sessions'),
12
+ getTemplatesDir: () => path.join(testDir, 'templates'),
13
+ getDefaultsDir: () => path.join(testDir, 'templates', 'defaults'),
14
+ getCustomDir: () => path.join(testDir, 'templates', 'custom'),
15
+ getConfigFile: () => path.join(testDir, 'config.json'),
16
+ getLogFile: () => path.join(testDir, 'audit.log'),
17
+ };
18
+ });
19
+
20
+ import { TemplateEngine, getTemplateEngine } from './engine';
21
+
22
+ describe('TemplateEngine', () => {
23
+ let engine: TemplateEngine;
24
+ const testDir = path.join(os.tmpdir(), 'linkedin-cli-templates-test');
25
+ const defaultsDir = path.join(testDir, 'templates', 'defaults');
26
+ const customDir = path.join(testDir, 'templates', 'custom');
27
+
28
+ beforeEach(() => {
29
+ // Clean up test directory
30
+ if (fs.existsSync(testDir)) {
31
+ fs.rmSync(testDir, { recursive: true, force: true });
32
+ }
33
+ engine = new TemplateEngine();
34
+ });
35
+
36
+ afterEach(() => {
37
+ // Clean up after tests
38
+ if (fs.existsSync(testDir)) {
39
+ fs.rmSync(testDir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ describe('load', () => {
44
+ it('should return null for non-existent template', () => {
45
+ const template = engine.load('non-existent');
46
+ expect(template).toBeNull();
47
+ });
48
+
49
+ it('should load template from defaults', () => {
50
+ // Create a default template
51
+ fs.mkdirSync(defaultsDir, { recursive: true });
52
+ fs.writeFileSync(path.join(defaultsDir, 'test.txt'), 'Hello {{name}}!');
53
+
54
+ const template = engine.load('test');
55
+
56
+ expect(template).toBeTruthy();
57
+ expect(template?.id).toBe('test');
58
+ expect(template?.content).toBe('Hello {{name}}!');
59
+ });
60
+
61
+ it('should prefer custom templates over defaults', () => {
62
+ // Create both default and custom templates
63
+ fs.mkdirSync(defaultsDir, { recursive: true });
64
+ fs.mkdirSync(customDir, { recursive: true });
65
+
66
+ fs.writeFileSync(path.join(defaultsDir, 'test.txt'), 'Default content');
67
+ fs.writeFileSync(path.join(customDir, 'test.txt'), 'Custom content');
68
+
69
+ const template = engine.load('test');
70
+
71
+ expect(template?.content).toBe('Custom content');
72
+ });
73
+ });
74
+
75
+ describe('parseTemplate', () => {
76
+ it('should parse template without frontmatter', () => {
77
+ fs.mkdirSync(defaultsDir, { recursive: true });
78
+ fs.writeFileSync(path.join(defaultsDir, 'simple.txt'), 'Hello {{firstName}}!');
79
+
80
+ const template = engine.load('simple');
81
+
82
+ expect(template?.name).toBe('simple');
83
+ expect(template?.content).toBe('Hello {{firstName}}!');
84
+ expect(template?.variables).toContain('firstName');
85
+ });
86
+
87
+ it('should parse template with frontmatter', () => {
88
+ fs.mkdirSync(defaultsDir, { recursive: true });
89
+ fs.writeFileSync(
90
+ path.join(defaultsDir, 'frontmatter.txt'),
91
+ `---
92
+ name: Welcome Template
93
+ description: A welcome message
94
+ category: welcome
95
+ ---
96
+
97
+ Hi {{firstName}}, welcome to {{company}}!`
98
+ );
99
+
100
+ const template = engine.load('frontmatter');
101
+
102
+ expect(template?.name).toBe('Welcome Template');
103
+ expect(template?.description).toBe('A welcome message');
104
+ expect(template?.category).toBe('welcome');
105
+ expect(template?.variables).toEqual(['firstName', 'company']);
106
+ });
107
+
108
+ it('should extract unique variables', () => {
109
+ fs.mkdirSync(defaultsDir, { recursive: true });
110
+ fs.writeFileSync(path.join(defaultsDir, 'multi-var.txt'), '{{name}} {{name}} {{other}}');
111
+
112
+ const template = engine.load('multi-var');
113
+
114
+ expect(template?.variables).toEqual(['name', 'other']);
115
+ });
116
+ });
117
+
118
+ describe('save', () => {
119
+ it('should save custom template', () => {
120
+ const template = {
121
+ id: 'custom-test',
122
+ name: 'Custom Test',
123
+ description: 'Test template',
124
+ content: 'Hello {{name}}!',
125
+ variables: ['name'],
126
+ category: 'custom' as const,
127
+ };
128
+
129
+ engine.save(template);
130
+
131
+ const loaded = engine.load('custom-test');
132
+ expect(loaded?.name).toBe('Custom Test');
133
+ expect(loaded?.content).toBe('Hello {{name}}!');
134
+ });
135
+ });
136
+
137
+ describe('delete', () => {
138
+ it('should delete custom template', () => {
139
+ fs.mkdirSync(customDir, { recursive: true });
140
+ fs.writeFileSync(path.join(customDir, 'to-delete.txt'), 'Content to delete');
141
+
142
+ const deleted = engine.delete('to-delete');
143
+
144
+ expect(deleted).toBe(true);
145
+ expect(engine.load('to-delete')).toBeNull();
146
+ });
147
+
148
+ it('should return false for non-existent template', () => {
149
+ const deleted = engine.delete('non-existent');
150
+ expect(deleted).toBe(false);
151
+ });
152
+
153
+ it('should not delete default templates', () => {
154
+ fs.mkdirSync(defaultsDir, { recursive: true });
155
+ fs.writeFileSync(path.join(defaultsDir, 'default.txt'), 'Default content');
156
+
157
+ const deleted = engine.delete('default');
158
+
159
+ expect(deleted).toBe(false);
160
+ expect(engine.load('default')).toBeTruthy();
161
+ });
162
+ });
163
+
164
+ describe('list', () => {
165
+ it('should return empty array when no templates exist', () => {
166
+ const templates = engine.list();
167
+ expect(templates).toEqual([]);
168
+ });
169
+
170
+ it('should list all templates', () => {
171
+ fs.mkdirSync(defaultsDir, { recursive: true });
172
+ fs.writeFileSync(path.join(defaultsDir, 'template1.txt'), 'Content 1');
173
+ fs.writeFileSync(path.join(defaultsDir, 'template2.txt'), 'Content 2');
174
+
175
+ const templates = engine.list();
176
+
177
+ expect(templates.length).toBe(2);
178
+ expect(templates.map((t) => t.id)).toEqual(
179
+ expect.arrayContaining(['template1', 'template2'])
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('render', () => {
185
+ it('should render template with variables', () => {
186
+ fs.mkdirSync(defaultsDir, { recursive: true });
187
+ fs.writeFileSync(
188
+ path.join(defaultsDir, 'render-test.txt'),
189
+ 'Hello {{firstName}} {{lastName}}!'
190
+ );
191
+
192
+ const result = engine.render('render-test', {
193
+ variables: {
194
+ firstName: 'John',
195
+ lastName: 'Doe',
196
+ },
197
+ });
198
+
199
+ expect(result).toBe('Hello John Doe!');
200
+ });
201
+
202
+ it('should use fallback for missing variables', () => {
203
+ fs.mkdirSync(defaultsDir, { recursive: true });
204
+ fs.writeFileSync(path.join(defaultsDir, 'fallback-test.txt'), 'Hello {{firstName}}!');
205
+
206
+ const result = engine.render('fallback-test', {
207
+ variables: {},
208
+ fallback: '[NAME]',
209
+ });
210
+
211
+ expect(result).toBe('Hello [NAME]!');
212
+ });
213
+
214
+ it('should throw for non-existent template', () => {
215
+ expect(() => {
216
+ engine.render('non-existent', { variables: {} });
217
+ }).toThrow('Template "non-existent" not found');
218
+ });
219
+ });
220
+ });
221
+
222
+ describe('getTemplateEngine singleton', () => {
223
+ it('should return same instance on multiple calls', () => {
224
+ const engine1 = getTemplateEngine();
225
+ const engine2 = getTemplateEngine();
226
+ expect(engine1).toBe(engine2);
227
+ });
228
+ });
@@ -0,0 +1,208 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { getDefaultsDir, getCustomDir } from '../utils/paths';
4
+ import type { Template } from '../types';
5
+
6
+ export interface RenderOptions {
7
+ variables: Record<string, string>;
8
+ fallback?: string;
9
+ }
10
+
11
+ export class TemplateEngine {
12
+ private defaultsDir: string;
13
+ private customDir: string;
14
+
15
+ constructor() {
16
+ this.defaultsDir = getDefaultsDir();
17
+ this.customDir = getCustomDir();
18
+ this.ensureDirectories();
19
+ }
20
+
21
+ private ensureDirectories(): void {
22
+ if (!fs.existsSync(this.defaultsDir)) {
23
+ fs.mkdirSync(this.defaultsDir, { recursive: true });
24
+ }
25
+ if (!fs.existsSync(this.customDir)) {
26
+ fs.mkdirSync(this.customDir, { recursive: true });
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load a template by ID
32
+ */
33
+ load(templateId: string): Template | null {
34
+ // Try custom templates first (user overrides)
35
+ const customPath = path.join(this.customDir, `${templateId}.txt`);
36
+ if (fs.existsSync(customPath)) {
37
+ return this.parseTemplate(templateId, customPath);
38
+ }
39
+
40
+ // Fall back to defaults
41
+ const defaultPath = path.join(this.defaultsDir, `${templateId}.txt`);
42
+ if (fs.existsSync(defaultPath)) {
43
+ return this.parseTemplate(templateId, defaultPath);
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Parse a template file into a Template object
51
+ */
52
+ private parseTemplate(id: string, filePath: string): Template {
53
+ const content = fs.readFileSync(filePath, 'utf8');
54
+ const lines = content.split('\n');
55
+
56
+ // Parse frontmatter if present
57
+ let name = id;
58
+ let description = '';
59
+ let category: Template['category'] = 'custom';
60
+ let variables: string[] = [];
61
+ let templateContent = content;
62
+
63
+ if (lines[0] === '---') {
64
+ const endIndex = lines.indexOf('---', 1);
65
+ if (endIndex !== -1) {
66
+ const frontmatter = lines.slice(1, endIndex).join('\n');
67
+ templateContent = lines.slice(endIndex + 1).join('\n');
68
+
69
+ // Parse YAML-like frontmatter
70
+ for (const line of frontmatter.split('\n')) {
71
+ const match = line.match(/^(\w+):\s*(.+)$/);
72
+ if (match) {
73
+ const [, key, value] = match;
74
+ switch (key) {
75
+ case 'name':
76
+ name = value;
77
+ break;
78
+ case 'description':
79
+ description = value;
80
+ break;
81
+ case 'category':
82
+ if (['welcome', 'followup', 'meeting', 'custom'].includes(value)) {
83
+ category = value as Template['category'];
84
+ }
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Extract variables from template content
93
+ const variableMatches = templateContent.match(/\{\{(\w+)\}\}/g);
94
+ if (variableMatches) {
95
+ variables = [...new Set(variableMatches.map((m) => m.replace(/[\{\}]/g, '')))];
96
+ }
97
+
98
+ return {
99
+ id,
100
+ name,
101
+ description,
102
+ content: templateContent.trim(),
103
+ variables,
104
+ category,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Save a custom template
110
+ */
111
+ save(template: Template): void {
112
+ const filePath = path.join(this.customDir, `${template.id}.txt`);
113
+
114
+ const frontmatter = [
115
+ '---',
116
+ `name: ${template.name}`,
117
+ `description: ${template.description}`,
118
+ `category: ${template.category}`,
119
+ '---',
120
+ ].join('\n');
121
+
122
+ const content = `${frontmatter}\n\n${template.content}`;
123
+ fs.writeFileSync(filePath, content);
124
+ }
125
+
126
+ /**
127
+ * Delete a custom template
128
+ */
129
+ delete(templateId: string): boolean {
130
+ const customPath = path.join(this.customDir, `${templateId}.txt`);
131
+ if (fs.existsSync(customPath)) {
132
+ fs.unlinkSync(customPath);
133
+ return true;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ /**
139
+ * List all available templates
140
+ */
141
+ list(): Template[] {
142
+ const templates: Template[] = [];
143
+
144
+ // Load defaults
145
+ if (fs.existsSync(this.defaultsDir)) {
146
+ const defaultFiles = fs.readdirSync(this.defaultsDir).filter((f) => f.endsWith('.txt'));
147
+ for (const file of defaultFiles) {
148
+ const id = path.basename(file, '.txt');
149
+ const template = this.load(id);
150
+ if (template) templates.push(template);
151
+ }
152
+ }
153
+
154
+ // Load customs (may override defaults)
155
+ if (fs.existsSync(this.customDir)) {
156
+ const customFiles = fs.readdirSync(this.customDir).filter((f) => f.endsWith('.txt'));
157
+ for (const file of customFiles) {
158
+ const id = path.basename(file, '.txt');
159
+ const template = this.load(id);
160
+ if (template) {
161
+ // Remove default if exists
162
+ const existingIndex = templates.findIndex((t) => t.id === id);
163
+ if (existingIndex !== -1) {
164
+ templates.splice(existingIndex, 1);
165
+ }
166
+ templates.push(template);
167
+ }
168
+ }
169
+ }
170
+
171
+ return templates;
172
+ }
173
+
174
+ /**
175
+ * Render a template with variables
176
+ */
177
+ render(templateId: string, options: RenderOptions): string {
178
+ const template = this.load(templateId);
179
+ if (!template) {
180
+ throw new Error(`Template "${templateId}" not found`);
181
+ }
182
+
183
+ let content = template.content;
184
+
185
+ // Replace variables
186
+ for (const [key, value] of Object.entries(options.variables)) {
187
+ const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
188
+ content = content.replace(regex, value);
189
+ }
190
+
191
+ // Handle missing variables
192
+ if (options.fallback !== undefined) {
193
+ content = content.replace(/\{\{\s*\w+\s*\}\}/g, options.fallback);
194
+ }
195
+
196
+ return content;
197
+ }
198
+ }
199
+
200
+ // Singleton instance
201
+ let templateEngine: TemplateEngine | null = null;
202
+
203
+ export function getTemplateEngine(): TemplateEngine {
204
+ if (!templateEngine) {
205
+ templateEngine = new TemplateEngine();
206
+ }
207
+ return templateEngine;
208
+ }
@@ -0,0 +1 @@
1
+ export { TemplateEngine, getTemplateEngine, type RenderOptions } from './engine';
@@ -0,0 +1,94 @@
1
+ // src/types/index.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import type { ProfileData, ProfileExtractionOptions, ProfileExtractionResult } from '../types';
4
+
5
+ describe('Profile Types', () => {
6
+ describe('ProfileData', () => {
7
+ it('should allow valid ProfileData', () => {
8
+ const data: ProfileData = {
9
+ full_name: 'John Doe',
10
+ headline: 'Engineer at Company',
11
+ location: 'San Francisco',
12
+ current_company: 'Company',
13
+ current_title: 'Engineer',
14
+ company_linkedin_url: 'https://linkedin.com/company/x',
15
+ years_experience: 5,
16
+ email: 'john@example.com',
17
+ phone: '+1234567890',
18
+ profile_url: 'https://www.linkedin.com/in/johndoe',
19
+ };
20
+ expect(data.full_name).toBe('John Doe');
21
+ });
22
+
23
+ it('should allow null optional fields', () => {
24
+ const data: ProfileData = {
25
+ full_name: 'Jane Doe',
26
+ headline: '',
27
+ location: '',
28
+ current_company: null,
29
+ current_title: null,
30
+ company_linkedin_url: null,
31
+ years_experience: null,
32
+ email: null,
33
+ phone: null,
34
+ profile_url: 'https://www.linkedin.com/in/janedoe',
35
+ };
36
+ expect(data.current_company).toBeNull();
37
+ });
38
+ });
39
+
40
+ describe('ProfileExtractionOptions', () => {
41
+ it('should allow valid options with all fields', () => {
42
+ const options: ProfileExtractionOptions = {
43
+ profileUrl: 'https://www.linkedin.com/in/johndoe/',
44
+ includeContact: true,
45
+ timeout: 30000,
46
+ };
47
+ expect(options.profileUrl).toBe('https://www.linkedin.com/in/johndoe/');
48
+ expect(options.includeContact).toBe(true);
49
+ expect(options.timeout).toBe(30000);
50
+ });
51
+
52
+ it('should allow options with only required fields', () => {
53
+ const options: ProfileExtractionOptions = {
54
+ profileUrl: 'https://www.linkedin.com/in/janedoe/',
55
+ };
56
+ expect(options.profileUrl).toBe('https://www.linkedin.com/in/janedoe/');
57
+ expect(options.includeContact).toBeUndefined();
58
+ expect(options.timeout).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe('ProfileExtractionResult', () => {
63
+ it('should allow successful result with data', () => {
64
+ const result: ProfileExtractionResult = {
65
+ success: true,
66
+ message: 'Profile extracted successfully',
67
+ data: {
68
+ full_name: 'Test User',
69
+ headline: 'Test',
70
+ location: 'Test',
71
+ current_company: null,
72
+ current_title: null,
73
+ company_linkedin_url: null,
74
+ years_experience: null,
75
+ email: null,
76
+ phone: null,
77
+ profile_url: 'https://www.linkedin.com/in/testuser/',
78
+ },
79
+ };
80
+ expect(result.success).toBe(true);
81
+ expect(result.data?.full_name).toBe('Test User');
82
+ });
83
+
84
+ it('should allow failed result with error', () => {
85
+ const result: ProfileExtractionResult = {
86
+ success: false,
87
+ message: 'Failed to extract profile',
88
+ error: 'Profile not found',
89
+ };
90
+ expect(result.success).toBe(false);
91
+ expect(result.error).toBe('Profile not found');
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,143 @@
1
+ // Session types
2
+ export interface Session {
3
+ cookies: Array<{
4
+ name: string;
5
+ value: string;
6
+ domain: string;
7
+ path: string;
8
+ expires?: number;
9
+ httpOnly?: boolean;
10
+ secure?: boolean;
11
+ sameSite?: 'Strict' | 'Lax' | 'None';
12
+ }>;
13
+ localStorage: Record<string, string>;
14
+ timestamp: Date;
15
+ }
16
+
17
+ // Message types
18
+ export interface Message {
19
+ id: string;
20
+ threadId: string;
21
+ sender: {
22
+ name: string;
23
+ profileUrl?: string;
24
+ isMe: boolean;
25
+ };
26
+ content: string;
27
+ timestamp: Date;
28
+ isRead: boolean;
29
+ }
30
+
31
+ // New message sending types
32
+ export interface SendMessageOptions {
33
+ profileUrl: string;
34
+ text: string;
35
+ dryRun?: boolean;
36
+ }
37
+
38
+ export interface SendMessageResult {
39
+ success: boolean;
40
+ messageId?: string;
41
+ threadId?: string;
42
+ error?: string;
43
+ }
44
+
45
+ export interface Thread {
46
+ id: string;
47
+ participants: Array<{
48
+ name: string;
49
+ profileUrl?: string;
50
+ }>;
51
+ messages: Message[];
52
+ lastActivity: Date;
53
+ unreadCount: number;
54
+ }
55
+
56
+ // Template types
57
+ export interface Template {
58
+ id: string;
59
+ name: string;
60
+ description: string;
61
+ content: string;
62
+ variables: string[];
63
+ category: 'welcome' | 'followup' | 'meeting' | 'custom';
64
+ }
65
+
66
+ // Configuration types
67
+ export interface Config {
68
+ headless: boolean;
69
+ rateLimit: number;
70
+ sessionTimeout: number;
71
+ dryRun: boolean;
72
+ }
73
+
74
+ // Audit log types
75
+ export interface AuditLog {
76
+ timestamp: Date;
77
+ action: string;
78
+ details: Record<string, unknown>;
79
+ success: boolean;
80
+ error?: string;
81
+ }
82
+
83
+ // Company profile types
84
+ export interface CompanyProfile {
85
+ name: string;
86
+ linkedin_url: string;
87
+ website: string | null;
88
+ industry: string | null;
89
+ company_size: string | null;
90
+ headquarters: string | null;
91
+ founded: string | null;
92
+ specialties: string[] | null;
93
+ type: string | null;
94
+ follower_count: number | null;
95
+ }
96
+
97
+ // ============================================================================
98
+ // Profile Extraction Types
99
+ // ============================================================================
100
+
101
+ /**
102
+ * Profile data extracted from a LinkedIn profile
103
+ */
104
+ export interface ProfileData {
105
+ // Top card fields
106
+ full_name: string;
107
+ headline: string;
108
+ location: string;
109
+
110
+ // Experience section
111
+ current_company: string | null;
112
+ current_title: string | null;
113
+ company_linkedin_url: string | null;
114
+
115
+ // Calculated
116
+ years_experience: number | null;
117
+
118
+ // Contact info (optional - requires clicking to reveal)
119
+ email: string | null;
120
+ phone: string | null;
121
+
122
+ // Reference
123
+ profile_url: string; // Canonical URL after LinkedIn redirects
124
+ }
125
+
126
+ /**
127
+ * Options for profile extraction
128
+ */
129
+ export interface ProfileExtractionOptions {
130
+ profileUrl: string;
131
+ includeContact?: boolean;
132
+ timeout?: number;
133
+ }
134
+
135
+ /**
136
+ * Result of profile extraction following existing pattern
137
+ */
138
+ export interface ProfileExtractionResult {
139
+ success: boolean;
140
+ message: string;
141
+ data?: ProfileData;
142
+ error?: string;
143
+ }