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,192 @@
1
+ package cmd
2
+
3
+ import (
4
+ "encoding/csv"
5
+ "fmt"
6
+ "os"
7
+ "strings"
8
+
9
+ "github.com/spf13/cobra"
10
+ "github.com/thaddeus-git/linkedin-cli/internal/linkedin"
11
+ )
12
+
13
+ var (
14
+ sequenceURL string
15
+ sequenceStep string
16
+ sequenceMessage string
17
+ sequencePDFURL string
18
+ sequenceFile string
19
+ sequenceContinue bool
20
+ sequenceDefaultStep string
21
+ )
22
+
23
+ func init() {
24
+ rootCmd.AddCommand(sequenceCmd)
25
+ sequenceCmd.Flags().StringVarP(&sequenceURL, "url", "u", "", "LinkedIn profile URL")
26
+ sequenceCmd.Flags().StringVarP(&sequenceStep, "step", "s", "", "Step: connect|followup|pdf-followup")
27
+ sequenceCmd.Flags().StringVarP(&sequenceMessage, "message", "m", "", "Message content")
28
+ sequenceCmd.Flags().StringVar(&sequencePDFURL, "pdf-url", "", "Public PDF URL to include in message")
29
+ sequenceCmd.Flags().StringVarP(&sequenceFile, "file", "f", "", "CSV file with sequence actions (url,step,message,pdf_url)")
30
+ sequenceCmd.Flags().BoolVar(&sequenceContinue, "continue-on-error", false, "Continue processing rows after an error")
31
+ sequenceCmd.Flags().StringVar(&sequenceDefaultStep, "default-step", "", "Default step for CSV rows missing step")
32
+ }
33
+
34
+ var sequenceCmd = &cobra.Command{
35
+ Use: "sequence",
36
+ Short: "Run repeatable LinkedIn outreach steps",
37
+ Long: `Run outreach sequence actions for one profile URL or from CSV.
38
+
39
+ Single target examples:
40
+ linkedin sequence --profile john --url linkedin.com/in/alice --step connect --message "Hi Alice"
41
+ linkedin sequence --profile john --url linkedin.com/in/alice --step followup --message "Thanks for accepting"
42
+ linkedin sequence --profile john --url linkedin.com/in/alice --step pdf-followup --message "Sharing brochure" --pdf-url "https://example.com/brochure.pdf"
43
+
44
+ Batch example:
45
+ linkedin sequence --profile john --file sequence.csv --continue-on-error
46
+
47
+ CSV columns: url,step,message,pdf_url
48
+ `,
49
+ Run: runSequence,
50
+ }
51
+
52
+ func runSequence(cmd *cobra.Command, args []string) {
53
+ ensureProfile()
54
+
55
+ if sequenceFile != "" {
56
+ runSequenceFile()
57
+ return
58
+ }
59
+
60
+ if sequenceURL == "" || sequenceStep == "" {
61
+ exitWithError("--url and --step are required for single-target sequence")
62
+ }
63
+
64
+ runSingleSequence(sequenceURL, sequenceStep, sequenceMessage, sequencePDFURL)
65
+ }
66
+
67
+ func runSequenceFile() {
68
+ file, err := os.Open(sequenceFile)
69
+ if err != nil {
70
+ exitWithError("Failed to open file: %v", err)
71
+ }
72
+ defer file.Close()
73
+
74
+ reader := csv.NewReader(file)
75
+ rows, err := reader.ReadAll()
76
+ if err != nil {
77
+ exitWithError("Failed to read CSV: %v", err)
78
+ }
79
+ if len(rows) == 0 {
80
+ exitWithError("CSV is empty")
81
+ }
82
+
83
+ headers := make(map[string]int)
84
+ for i, h := range rows[0] {
85
+ headers[strings.TrimSpace(strings.ToLower(h))] = i
86
+ }
87
+
88
+ urlIdx, ok := headers["url"]
89
+ if !ok {
90
+ exitWithError("CSV must include url column")
91
+ }
92
+
93
+ stepIdx, hasStep := headers["step"]
94
+ msgIdx, hasMsg := headers["message"]
95
+ pdfIdx, hasPDF := headers["pdf_url"]
96
+
97
+ failed := 0
98
+ processed := 0
99
+ for i := 1; i < len(rows); i++ {
100
+ row := rows[i]
101
+ if len(row) <= urlIdx {
102
+ continue
103
+ }
104
+
105
+ url := strings.TrimSpace(row[urlIdx])
106
+ if url == "" {
107
+ continue
108
+ }
109
+
110
+ step := sequenceDefaultStep
111
+ if hasStep && len(row) > stepIdx {
112
+ cell := strings.TrimSpace(row[stepIdx])
113
+ if cell != "" {
114
+ step = cell
115
+ }
116
+ }
117
+
118
+ message := ""
119
+ if hasMsg && len(row) > msgIdx {
120
+ message = strings.TrimSpace(row[msgIdx])
121
+ }
122
+
123
+ pdfURL := ""
124
+ if hasPDF && len(row) > pdfIdx {
125
+ pdfURL = strings.TrimSpace(row[pdfIdx])
126
+ }
127
+
128
+ if step == "" {
129
+ failed++
130
+ fmt.Printf("[row %d] missing step\n", i+1)
131
+ if !sequenceContinue {
132
+ exitWithError("aborting on first error")
133
+ }
134
+ continue
135
+ }
136
+
137
+ fmt.Printf("[row %d] %s -> %s\n", i+1, url, step)
138
+ err = runSingleSequenceErr(url, step, message, pdfURL)
139
+ if err != nil {
140
+ failed++
141
+ fmt.Printf("[row %d] error: %v\n", i+1, err)
142
+ if !sequenceContinue {
143
+ exitWithError("aborting on first error")
144
+ }
145
+ continue
146
+ }
147
+ processed++
148
+ }
149
+
150
+ fmt.Printf("Sequence complete: processed=%d failed=%d\n", processed, failed)
151
+ if failed > 0 {
152
+ os.Exit(1)
153
+ }
154
+ }
155
+
156
+ func runSingleSequence(url string, step string, message string, pdfURL string) {
157
+ if err := runSingleSequenceErr(url, step, message, pdfURL); err != nil {
158
+ exitWithError("sequence failed: %v", err)
159
+ }
160
+ }
161
+
162
+ func runSingleSequenceErr(url string, step string, message string, pdfURL string) error {
163
+ profileURL, err := linkedin.ValidateProfileURL(url)
164
+ if err != nil {
165
+ return err
166
+ }
167
+
168
+ normalizedStep := strings.TrimSpace(strings.ToLower(step))
169
+ switch normalizedStep {
170
+ case "connect":
171
+ sendConnectionAction(profileURL, message)
172
+ return nil
173
+ case "followup":
174
+ if strings.TrimSpace(message) == "" {
175
+ return fmt.Errorf("followup step requires --message")
176
+ }
177
+ sendMessageAction(profileURL, message)
178
+ return nil
179
+ case "pdf-followup":
180
+ if strings.TrimSpace(message) == "" {
181
+ return fmt.Errorf("pdf-followup step requires --message")
182
+ }
183
+ full := message
184
+ if strings.TrimSpace(pdfURL) != "" {
185
+ full = fmt.Sprintf("%s\n\nPDF: %s", message, pdfURL)
186
+ }
187
+ sendMessageAction(profileURL, full)
188
+ return nil
189
+ default:
190
+ return fmt.Errorf("invalid step: %s", step)
191
+ }
192
+ }
@@ -0,0 +1,187 @@
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "time"
9
+ )
10
+
11
+ // Manager handles configuration and state
12
+ type Manager struct {
13
+ configDir string
14
+ }
15
+
16
+ // NewManager creates a new config manager
17
+ func NewManager() (*Manager, error) {
18
+ home, err := os.UserHomeDir()
19
+ if err != nil {
20
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
21
+ }
22
+
23
+ configDir := filepath.Join(home, ".linkedin-cli")
24
+ if err := os.MkdirAll(configDir, 0755); err != nil {
25
+ return nil, fmt.Errorf("failed to create config directory: %w", err)
26
+ }
27
+
28
+ return &Manager{configDir: configDir}, nil
29
+ }
30
+
31
+ // GetConfigDir returns the config directory path
32
+ func (m *Manager) GetConfigDir() string {
33
+ return m.configDir
34
+ }
35
+
36
+ // ProfileExists checks if a profile exists
37
+ func (m *Manager) ProfileExists(name string) bool {
38
+ path := filepath.Join(m.configDir, "profiles", name+".json")
39
+ _, err := os.Stat(path)
40
+ return err == nil
41
+ }
42
+
43
+ // LoadProfile loads a profile
44
+ func (m *Manager) LoadProfile(name string) (*Profile, error) {
45
+ path := filepath.Join(m.configDir, "profiles", name+".json")
46
+
47
+ data, err := os.ReadFile(path)
48
+ if err != nil {
49
+ if os.IsNotExist(err) {
50
+ return nil, fmt.Errorf("profile '%s' not found", name)
51
+ }
52
+ return nil, fmt.Errorf("failed to read profile: %w", err)
53
+ }
54
+
55
+ var profile Profile
56
+ if err := json.Unmarshal(data, &profile); err != nil {
57
+ return nil, fmt.Errorf("failed to parse profile: %w", err)
58
+ }
59
+
60
+ return &profile, nil
61
+ }
62
+
63
+ // SaveProfile saves a profile
64
+ func (m *Manager) SaveProfile(profile *Profile) error {
65
+ profilesDir := filepath.Join(m.configDir, "profiles")
66
+ if err := os.MkdirAll(profilesDir, 0755); err != nil {
67
+ return fmt.Errorf("failed to create profiles directory: %w", err)
68
+ }
69
+
70
+ path := filepath.Join(profilesDir, profile.Name+".json")
71
+
72
+ data, err := json.MarshalIndent(profile, "", " ")
73
+ if err != nil {
74
+ return fmt.Errorf("failed to marshal profile: %w", err)
75
+ }
76
+
77
+ if err := os.WriteFile(path, data, 0644); err != nil {
78
+ return fmt.Errorf("failed to write profile: %w", err)
79
+ }
80
+
81
+ return nil
82
+ }
83
+
84
+ // ListProfiles lists all profiles
85
+ func (m *Manager) ListProfiles() ([]string, error) {
86
+ profilesDir := filepath.Join(m.configDir, "profiles")
87
+
88
+ entries, err := os.ReadDir(profilesDir)
89
+ if err != nil {
90
+ if os.IsNotExist(err) {
91
+ return []string{}, nil
92
+ }
93
+ return nil, fmt.Errorf("failed to read profiles directory: %w", err)
94
+ }
95
+
96
+ var profiles []string
97
+ for _, entry := range entries {
98
+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
99
+ name := entry.Name()[:len(entry.Name())-5] // Remove .json
100
+ profiles = append(profiles, name)
101
+ }
102
+ }
103
+
104
+ return profiles, nil
105
+ }
106
+
107
+ // DeleteProfile deletes a profile
108
+ func (m *Manager) DeleteProfile(name string) error {
109
+ path := filepath.Join(m.configDir, "profiles", name+".json")
110
+ if err := os.Remove(path); err != nil {
111
+ if os.IsNotExist(err) {
112
+ return fmt.Errorf("profile '%s' not found", name)
113
+ }
114
+ return fmt.Errorf("failed to delete profile: %w", err)
115
+ }
116
+ return nil
117
+ }
118
+
119
+ // LoadRateLimits loads rate limits for a profile
120
+ func (m *Manager) LoadRateLimits(profileName string) (*RateLimits, error) {
121
+ path := filepath.Join(m.configDir, "ratelimit.json")
122
+
123
+ data, err := os.ReadFile(path)
124
+ if err != nil {
125
+ if os.IsNotExist(err) {
126
+ return &RateLimits{}, nil
127
+ }
128
+ return nil, fmt.Errorf("failed to read rate limits: %w", err)
129
+ }
130
+
131
+ var allLimits map[string]*RateLimits
132
+ if err := json.Unmarshal(data, &allLimits); err != nil {
133
+ return nil, fmt.Errorf("failed to parse rate limits: %w", err)
134
+ }
135
+
136
+ limits, exists := allLimits[profileName]
137
+ if !exists {
138
+ return &RateLimits{}, nil
139
+ }
140
+
141
+ // Reset counters if needed
142
+ limits.ResetIfNeeded()
143
+
144
+ return limits, nil
145
+ }
146
+
147
+ // SaveRateLimits saves rate limits for a profile
148
+ func (m *Manager) SaveRateLimits(profileName string, limits *RateLimits) error {
149
+ path := filepath.Join(m.configDir, "ratelimit.json")
150
+
151
+ var allLimits map[string]*RateLimits
152
+
153
+ // Load existing
154
+ data, err := os.ReadFile(path)
155
+ if err == nil {
156
+ json.Unmarshal(data, &allLimits)
157
+ }
158
+
159
+ if allLimits == nil {
160
+ allLimits = make(map[string]*RateLimits)
161
+ }
162
+
163
+ allLimits[profileName] = limits
164
+
165
+ // Save all
166
+ data, err = json.MarshalIndent(allLimits, "", " ")
167
+ if err != nil {
168
+ return fmt.Errorf("failed to marshal rate limits: %w", err)
169
+ }
170
+
171
+ if err := os.WriteFile(path, data, 0644); err != nil {
172
+ return fmt.Errorf("failed to write rate limits: %w", err)
173
+ }
174
+
175
+ return nil
176
+ }
177
+
178
+ // UpdateLastUsed updates the last used timestamp for a profile
179
+ func (m *Manager) UpdateLastUsed(profileName string) error {
180
+ profile, err := m.LoadProfile(profileName)
181
+ if err != nil {
182
+ return err
183
+ }
184
+
185
+ profile.LastUsed = time.Now()
186
+ return m.SaveProfile(profile)
187
+ }
@@ -0,0 +1,121 @@
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "testing"
6
+ "time"
7
+ )
8
+
9
+ func TestNewManager(t *testing.T) {
10
+ mgr, err := NewManager()
11
+ if err != nil {
12
+ t.Fatalf("unexpected error: %v", err)
13
+ }
14
+
15
+ if mgr.configDir == "" {
16
+ t.Error("config dir should not be empty")
17
+ }
18
+
19
+ // Cleanup
20
+ os.RemoveAll(mgr.configDir)
21
+ }
22
+
23
+ func TestProfileCRUD(t *testing.T) {
24
+ // Use temp dir for testing
25
+ tempDir := t.TempDir()
26
+ mgr := &Manager{configDir: tempDir}
27
+
28
+ // Create profile
29
+ profile := &Profile{
30
+ Name: "test",
31
+ CreatedAt: time.Now(),
32
+ LastUsed: time.Now(),
33
+ }
34
+
35
+ if err := mgr.SaveProfile(profile); err != nil {
36
+ t.Fatalf("failed to save profile: %v", err)
37
+ }
38
+
39
+ // Check exists
40
+ if !mgr.ProfileExists("test") {
41
+ t.Error("profile should exist")
42
+ }
43
+
44
+ // Load
45
+ loaded, err := mgr.LoadProfile("test")
46
+ if err != nil {
47
+ t.Fatalf("failed to load profile: %v", err)
48
+ }
49
+
50
+ if loaded.Name != "test" {
51
+ t.Errorf("expected name 'test', got %s", loaded.Name)
52
+ }
53
+
54
+ // List
55
+ profiles, err := mgr.ListProfiles()
56
+ if err != nil {
57
+ t.Fatalf("failed to list profiles: %v", err)
58
+ }
59
+
60
+ if len(profiles) != 1 || profiles[0] != "test" {
61
+ t.Errorf("expected ['test'], got %v", profiles)
62
+ }
63
+
64
+ // Delete
65
+ if err := mgr.DeleteProfile("test"); err != nil {
66
+ t.Fatalf("failed to delete profile: %v", err)
67
+ }
68
+
69
+ if mgr.ProfileExists("test") {
70
+ t.Error("profile should not exist after delete")
71
+ }
72
+ }
73
+
74
+ func TestRateLimits(t *testing.T) {
75
+ tempDir := t.TempDir()
76
+ mgr := &Manager{configDir: tempDir}
77
+
78
+ // Use today's date to ensure the counters don't get reset
79
+ now := time.Now()
80
+ limits := &RateLimits{
81
+ Connections: RateLimit{
82
+ Today: 15,
83
+ ThisWeek: 87,
84
+ LastAction: now,
85
+ },
86
+ Messages: RateLimit{
87
+ Today: 23,
88
+ LastAction: now,
89
+ },
90
+ }
91
+
92
+ // Save
93
+ if err := mgr.SaveRateLimits("john", limits); err != nil {
94
+ t.Fatalf("failed to save rate limits: %v", err)
95
+ }
96
+
97
+ // Load
98
+ loaded, err := mgr.LoadRateLimits("john")
99
+ if err != nil {
100
+ t.Fatalf("failed to load rate limits: %v", err)
101
+ }
102
+
103
+ if loaded.Connections.Today != 15 {
104
+ t.Errorf("expected 15 connections today, got %d", loaded.Connections.Today)
105
+ }
106
+ }
107
+
108
+ func TestRateLimitCanConnect(t *testing.T) {
109
+ limits := &RateLimits{
110
+ Connections: RateLimit{Today: 19, ThisWeek: 99},
111
+ }
112
+
113
+ if !limits.CanConnect(20, 100) {
114
+ t.Error("should be able to connect at 19/20 daily")
115
+ }
116
+
117
+ limits.Connections.Today = 20
118
+ if limits.CanConnect(20, 100) {
119
+ t.Error("should not be able to connect at 20/20 daily")
120
+ }
121
+ }
@@ -0,0 +1,65 @@
1
+ package config
2
+
3
+ import "time"
4
+
5
+ // Profile represents a LinkedIn profile configuration
6
+ type Profile struct {
7
+ Name string `json:"name"`
8
+ CreatedAt time.Time `json:"created_at"`
9
+ LastUsed time.Time `json:"last_used"`
10
+ }
11
+
12
+ // RateLimits tracks rate limit state per profile
13
+ type RateLimits struct {
14
+ Connections RateLimit `json:"connections"`
15
+ Messages RateLimit `json:"messages"`
16
+ }
17
+
18
+ // RateLimit tracks a single rate limit
19
+ type RateLimit struct {
20
+ Today int `json:"today"`
21
+ ThisWeek int `json:"this_week"`
22
+ LastAction time.Time `json:"last_action"`
23
+ }
24
+
25
+ // CanConnect checks if connection request is allowed
26
+ func (r *RateLimits) CanConnect(dailyLimit, weeklyLimit int) bool {
27
+ return r.Connections.Today < dailyLimit && r.Connections.ThisWeek < weeklyLimit
28
+ }
29
+
30
+ // CanMessage checks if message is allowed
31
+ func (r *RateLimits) CanMessage(dailyLimit int) bool {
32
+ return r.Messages.Today < dailyLimit
33
+ }
34
+
35
+ // RecordConnection records a connection request
36
+ func (r *RateLimits) RecordConnection() {
37
+ r.Connections.Today++
38
+ r.Connections.ThisWeek++
39
+ r.Connections.LastAction = time.Now()
40
+ }
41
+
42
+ // RecordMessage records a message sent
43
+ func (r *RateLimits) RecordMessage() {
44
+ r.Messages.Today++
45
+ r.Messages.LastAction = time.Now()
46
+ }
47
+
48
+ // ResetIfNeeded resets daily/weekly counters if day/week has changed
49
+ func (r *RateLimits) ResetIfNeeded() {
50
+ now := time.Now()
51
+ last := r.Connections.LastAction
52
+
53
+ // Reset daily if different day
54
+ if last.Year() != now.Year() || last.YearDay() != now.YearDay() {
55
+ r.Connections.Today = 0
56
+ r.Messages.Today = 0
57
+ }
58
+
59
+ // Reset weekly if different week
60
+ _, lastWeek := last.ISOWeek()
61
+ _, thisWeek := now.ISOWeek()
62
+ if lastWeek != thisWeek {
63
+ r.Connections.ThisWeek = 0
64
+ }
65
+ }