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,454 @@
1
+ package cmd
2
+
3
+ import (
4
+ "encoding/csv"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/url"
8
+ "os"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/spf13/cobra"
13
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
14
+ )
15
+
16
+ type personCandidate struct {
17
+ Name string `json:"name"`
18
+ Title string `json:"title"`
19
+ SourceView string `json:"source_view"`
20
+ DiscoveryURL string `json:"discovery_url"`
21
+ PublicProfile string `json:"public_profile_url"`
22
+ ResolutionNote string `json:"resolution_note,omitempty"`
23
+ }
24
+
25
+ var (
26
+ peopleCompanyURL string
27
+ peopleMode string
28
+ peopleLimit int
29
+ peopleFormat string
30
+ peopleOutput string
31
+ peopleResolveProfiles bool
32
+ )
33
+
34
+ func init() {
35
+ rootCmd.AddCommand(peopleCmd)
36
+ peopleCmd.Flags().StringVar(
37
+ &peopleCompanyURL,
38
+ "company-url",
39
+ "",
40
+ "LinkedIn company or Sales Navigator company URL",
41
+ )
42
+ peopleCmd.Flags().StringVar(
43
+ &peopleMode,
44
+ "mode",
45
+ "both",
46
+ "Discovery mode: employees|key-persons|both",
47
+ )
48
+ peopleCmd.Flags().IntVar(&peopleLimit, "limit", 10, "Maximum people to return")
49
+ peopleCmd.Flags().StringVar(
50
+ &peopleFormat,
51
+ "format",
52
+ "json",
53
+ "Output format: json|table|csv",
54
+ )
55
+ peopleCmd.Flags().StringVar(&peopleOutput, "output", "", "Write output to file")
56
+ peopleCmd.Flags().BoolVar(
57
+ &peopleResolveProfiles,
58
+ "resolve-free-profile",
59
+ true,
60
+ "Open Sales Navigator lead pages to resolve public /in/ URLs",
61
+ )
62
+ }
63
+
64
+ var peopleCmd = &cobra.Command{
65
+ Use: "people",
66
+ Short: "Discover company employees/key persons and public profile URLs",
67
+ Long: `Discover candidate contacts from a company page in LinkedIn/Sales Navigator.
68
+
69
+ This command focuses on atomic operations:
70
+ - navigate to company page
71
+ - click Employees / Key persons views
72
+ - extract candidate contacts
73
+ - optionally resolve public /in/ URLs from Sales Navigator lead pages
74
+ `,
75
+ Run: runPeople,
76
+ }
77
+
78
+ func runPeople(cmd *cobra.Command, args []string) {
79
+ ensureProfile()
80
+
81
+ if strings.TrimSpace(peopleCompanyURL) == "" {
82
+ exitWithError("--company-url is required")
83
+ }
84
+
85
+ normalizedMode := strings.ToLower(strings.TrimSpace(peopleMode))
86
+ if normalizedMode != "employees" && normalizedMode != "key-persons" && normalizedMode != "both" {
87
+ exitWithError("invalid --mode: %s", peopleMode)
88
+ }
89
+
90
+ if peopleLimit <= 0 {
91
+ exitWithError("--limit must be > 0")
92
+ }
93
+
94
+ normalizedFormat := strings.ToLower(strings.TrimSpace(peopleFormat))
95
+ if normalizedFormat != "json" && normalizedFormat != "table" && normalizedFormat != "csv" {
96
+ exitWithError("invalid --format: %s", peopleFormat)
97
+ }
98
+
99
+ if dryRun {
100
+ fmt.Println("[dry-run] Would:")
101
+ fmt.Printf(" - Navigate to company URL: %s\n", peopleCompanyURL)
102
+ fmt.Printf(" - Browse mode: %s\n", normalizedMode)
103
+ fmt.Printf(" - Extract up to %d contacts\n", peopleLimit)
104
+ if peopleResolveProfiles {
105
+ fmt.Println(" - Resolve public /in/ URLs from Sales Navigator lead pages")
106
+ }
107
+ return
108
+ }
109
+
110
+ client := pinchtab.NewClient(getPinchTabHost())
111
+ if err := client.Navigate(peopleCompanyURL); err != nil {
112
+ exitWithError("failed to navigate to company page: %v", err)
113
+ }
114
+ time.Sleep(3 * time.Second)
115
+
116
+ views := []string{}
117
+ if normalizedMode == "employees" || normalizedMode == "both" {
118
+ views = append(views, "employees")
119
+ }
120
+ if normalizedMode == "key-persons" || normalizedMode == "both" {
121
+ views = append(views, "key-persons")
122
+ }
123
+
124
+ collected := make([]personCandidate, 0, peopleLimit)
125
+ seen := map[string]bool{}
126
+
127
+ for _, view := range views {
128
+ clicked, clickErr := clickPeopleView(client, view)
129
+ if clickErr != nil {
130
+ fmt.Fprintf(os.Stderr, "Warning: failed to click %s view: %v\n", view, clickErr)
131
+ continue
132
+ }
133
+ if clicked {
134
+ time.Sleep(2 * time.Second)
135
+ }
136
+
137
+ rows, err := extractPeopleCandidates(client, view, peopleLimit)
138
+ if err != nil {
139
+ fmt.Fprintf(os.Stderr, "Warning: failed to extract %s candidates: %v\n", view, err)
140
+ continue
141
+ }
142
+
143
+ for _, row := range rows {
144
+ key := strings.ToLower(strings.TrimSpace(row.Name)) + "|" + row.DiscoveryURL
145
+ if key == "|" || seen[key] {
146
+ continue
147
+ }
148
+ seen[key] = true
149
+
150
+ if peopleResolveProfiles && row.PublicProfile == "" && isSalesLeadURL(row.DiscoveryURL) {
151
+ profileURL, resolveErr := resolvePublicProfileFromLead(client, row.DiscoveryURL)
152
+ if resolveErr == nil && profileURL != "" {
153
+ row.PublicProfile = profileURL
154
+ row.ResolutionNote = "resolved_from_sales_lead"
155
+ } else if resolveErr != nil {
156
+ row.ResolutionNote = "resolve_failed"
157
+ }
158
+ }
159
+
160
+ collected = append(collected, row)
161
+ if len(collected) >= peopleLimit {
162
+ break
163
+ }
164
+ }
165
+
166
+ if len(collected) >= peopleLimit {
167
+ break
168
+ }
169
+ }
170
+
171
+ renderPeopleOutput(collected, normalizedFormat, peopleOutput)
172
+ }
173
+
174
+ func clickPeopleView(client *pinchtab.Client, view string) (bool, error) {
175
+ terms := "['employee','employees','people']"
176
+ if view == "key-persons" {
177
+ terms = "['key person','key persons','key contact','key contacts','decision maker','decision makers','senior']"
178
+ }
179
+
180
+ script := fmt.Sprintf(`(() => {
181
+ const terms = %s;
182
+ const nodes = Array.from(document.querySelectorAll('button,a,[role="button"],li,div'));
183
+ const match = nodes.find((el) => {
184
+ const text = (el.innerText || el.textContent || '').toLowerCase().trim();
185
+ if (!text) return false;
186
+ return terms.some((t) => text.includes(t));
187
+ });
188
+ if (!match) return { clicked: false };
189
+ match.click();
190
+ return { clicked: true, text: (match.innerText || match.textContent || '').trim() };
191
+ })()`, terms)
192
+
193
+ result, err := client.Execute(script)
194
+ if err != nil {
195
+ return false, err
196
+ }
197
+ obj, ok := result.(map[string]interface{})
198
+ if !ok {
199
+ return false, nil
200
+ }
201
+ clicked, ok := obj["clicked"].(bool)
202
+ if !ok {
203
+ return false, nil
204
+ }
205
+ return clicked, nil
206
+ }
207
+
208
+ func extractPeopleCandidates(client *pinchtab.Client, sourceView string, limit int) ([]personCandidate, error) {
209
+ script := fmt.Sprintf(`(() => {
210
+ const MAX = %d;
211
+ const anchors = Array.from(document.querySelectorAll('a[href]'));
212
+ const rows = [];
213
+ const seen = new Set();
214
+ for (const a of anchors) {
215
+ const href = (a.href || '').trim();
216
+ if (!href) continue;
217
+ const lower = href.toLowerCase();
218
+ if (!lower.includes('/in/') && !lower.includes('/sales/lead/')) continue;
219
+
220
+ let name = (a.innerText || a.textContent || '').trim();
221
+ if (!name) {
222
+ const card = a.closest('li,article,div');
223
+ if (card) {
224
+ const nameLink = card.querySelector('a[href*="/in/"], a[href*="/sales/lead/"]');
225
+ if (nameLink) {
226
+ name = (nameLink.innerText || nameLink.textContent || '').trim();
227
+ }
228
+ }
229
+ }
230
+ if (!name) continue;
231
+
232
+ const key = href.split('?')[0] + '|' + name.toLowerCase();
233
+ if (seen.has(key)) continue;
234
+ seen.add(key);
235
+
236
+ let title = '';
237
+ const card = a.closest('li,article,div');
238
+ if (card) {
239
+ const textBits = Array.from(card.querySelectorAll('span,div,p'))
240
+ .map((el) => (el.innerText || '').trim())
241
+ .filter(Boolean)
242
+ .filter((t) => t !== name)
243
+ .filter((t) => t.length < 160);
244
+ if (textBits.length > 0) {
245
+ title = textBits[0];
246
+ }
247
+ }
248
+
249
+ rows.push({
250
+ name,
251
+ title,
252
+ discovery_url: href,
253
+ public_profile_url: lower.includes('/in/') ? href : ''
254
+ });
255
+
256
+ if (rows.length >= MAX) break;
257
+ }
258
+ return rows;
259
+ })()`, limit)
260
+
261
+ raw, err := client.Execute(script)
262
+ if err != nil {
263
+ return nil, err
264
+ }
265
+
266
+ items, ok := raw.([]interface{})
267
+ if !ok {
268
+ return []personCandidate{}, nil
269
+ }
270
+
271
+ results := make([]personCandidate, 0, len(items))
272
+ for _, item := range items {
273
+ row, ok := item.(map[string]interface{})
274
+ if !ok {
275
+ continue
276
+ }
277
+
278
+ name := strings.TrimSpace(toString(row["name"]))
279
+ if name == "" {
280
+ continue
281
+ }
282
+ discoveryURL := strings.TrimSpace(toString(row["discovery_url"]))
283
+ publicURL := normalizePublicProfileURL(toString(row["public_profile_url"]))
284
+ if publicURL == "" {
285
+ publicURL = normalizePublicProfileURL(discoveryURL)
286
+ }
287
+
288
+ results = append(results, personCandidate{
289
+ Name: name,
290
+ Title: strings.TrimSpace(toString(row["title"])),
291
+ SourceView: sourceView,
292
+ DiscoveryURL: discoveryURL,
293
+ PublicProfile: publicURL,
294
+ })
295
+ }
296
+
297
+ return results, nil
298
+ }
299
+
300
+ func resolvePublicProfileFromLead(client *pinchtab.Client, leadURL string) (string, error) {
301
+ if err := client.Navigate(leadURL); err != nil {
302
+ return "", err
303
+ }
304
+ time.Sleep(2 * time.Second)
305
+
306
+ raw, err := client.Execute(`(() => {
307
+ const anchors = Array.from(document.querySelectorAll('a[href]'));
308
+ for (const a of anchors) {
309
+ const href = (a.href || '').trim();
310
+ if (href.toLowerCase().includes('linkedin.com/in/')) {
311
+ return { profile_url: href };
312
+ }
313
+ }
314
+ const canonical = document.querySelector('link[rel="canonical"]')?.href || '';
315
+ if (canonical.toLowerCase().includes('linkedin.com/in/')) {
316
+ return { profile_url: canonical };
317
+ }
318
+ return { profile_url: '' };
319
+ })()`)
320
+ if err != nil {
321
+ return "", err
322
+ }
323
+ obj, ok := raw.(map[string]interface{})
324
+ if !ok {
325
+ return "", nil
326
+ }
327
+ return normalizePublicProfileURL(toString(obj["profile_url"])), nil
328
+ }
329
+
330
+ func isSalesLeadURL(value string) bool {
331
+ return strings.Contains(strings.ToLower(value), "/sales/lead/")
332
+ }
333
+
334
+ func normalizePublicProfileURL(value string) string {
335
+ trimmed := strings.TrimSpace(value)
336
+ if trimmed == "" {
337
+ return ""
338
+ }
339
+
340
+ if fromSales := convertSalesLeadURLToPublicProfile(trimmed); fromSales != "" {
341
+ return fromSales
342
+ }
343
+
344
+ if !strings.Contains(strings.ToLower(trimmed), "linkedin.com/in/") {
345
+ return ""
346
+ }
347
+ base := strings.Split(trimmed, "?")[0]
348
+ base = strings.Split(base, "#")[0]
349
+ base = strings.TrimRight(base, "/")
350
+ return base
351
+ }
352
+
353
+ func convertSalesLeadURLToPublicProfile(raw string) string {
354
+ parsed, err := url.Parse(strings.TrimSpace(raw))
355
+ if err != nil {
356
+ return ""
357
+ }
358
+ host := strings.ToLower(parsed.Host)
359
+ if host != "" && !strings.Contains(host, "linkedin.com") {
360
+ return ""
361
+ }
362
+
363
+ path := strings.TrimSpace(parsed.Path)
364
+ lowerPath := strings.ToLower(path)
365
+ if !strings.Contains(lowerPath, "/sales/lead/") {
366
+ return ""
367
+ }
368
+
369
+ parts := strings.Split(path, "/sales/lead/")
370
+ if len(parts) < 2 {
371
+ return ""
372
+ }
373
+ tail := parts[len(parts)-1]
374
+ if tail == "" {
375
+ return ""
376
+ }
377
+ memberPart := strings.Split(tail, ",")[0]
378
+ memberPart = strings.Trim(memberPart, "/")
379
+ if memberPart == "" {
380
+ return ""
381
+ }
382
+
383
+ return "https://www.linkedin.com/in/" + memberPart
384
+ }
385
+
386
+ func toString(value interface{}) string {
387
+ if value == nil {
388
+ return ""
389
+ }
390
+ if text, ok := value.(string); ok {
391
+ return text
392
+ }
393
+ return fmt.Sprintf("%v", value)
394
+ }
395
+
396
+ func renderPeopleOutput(rows []personCandidate, format string, outputFile string) {
397
+ if format == "json" {
398
+ payload, _ := json.MarshalIndent(rows, "", " ")
399
+ writeOrPrint(string(payload), outputFile)
400
+ return
401
+ }
402
+
403
+ if format == "csv" {
404
+ builder := &strings.Builder{}
405
+ writer := csv.NewWriter(builder)
406
+ _ = writer.Write([]string{"name", "title", "source_view", "discovery_url", "public_profile_url", "resolution_note"})
407
+ for _, row := range rows {
408
+ _ = writer.Write([]string{row.Name, row.Title, row.SourceView, row.DiscoveryURL, row.PublicProfile, row.ResolutionNote})
409
+ }
410
+ writer.Flush()
411
+ writeOrPrint(builder.String(), outputFile)
412
+ return
413
+ }
414
+
415
+ builder := &strings.Builder{}
416
+ if len(rows) == 0 {
417
+ builder.WriteString("No people found\n")
418
+ } else {
419
+ for _, row := range rows {
420
+ builder.WriteString(
421
+ fmt.Sprintf(
422
+ "%s | %s | %s | %s\n",
423
+ row.Name,
424
+ emptyDash(row.Title),
425
+ emptyDash(row.SourceView),
426
+ emptyDash(row.PublicProfile),
427
+ ),
428
+ )
429
+ }
430
+ }
431
+ writeOrPrint(builder.String(), outputFile)
432
+ }
433
+
434
+ func emptyDash(value string) string {
435
+ if strings.TrimSpace(value) == "" {
436
+ return "-"
437
+ }
438
+ return value
439
+ }
440
+
441
+ func writeOrPrint(content string, outputFile string) {
442
+ if strings.TrimSpace(outputFile) == "" {
443
+ fmt.Print(content)
444
+ if !strings.HasSuffix(content, "\n") {
445
+ fmt.Println()
446
+ }
447
+ return
448
+ }
449
+
450
+ if err := os.WriteFile(outputFile, []byte(content), 0644); err != nil {
451
+ exitWithError("failed to write output: %v", err)
452
+ }
453
+ fmt.Printf("Wrote output to %s\n", outputFile)
454
+ }
@@ -0,0 +1,121 @@
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "text/tabwriter"
7
+
8
+ "github.com/spf13/cobra"
9
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
10
+ )
11
+
12
+ func init() {
13
+ rootCmd.AddCommand(profilesCmd)
14
+ profilesCmd.AddCommand(profilesListCmd)
15
+ profilesCmd.AddCommand(profilesRemoveCmd)
16
+ }
17
+
18
+ var profilesCmd = &cobra.Command{
19
+ Use: "profiles",
20
+ Short: "Manage LinkedIn profiles",
21
+ Long: `List, or remove LinkedIn profiles.`,
22
+ }
23
+
24
+ var profilesListCmd = &cobra.Command{
25
+ Use: "list",
26
+ Short: "List all profiles",
27
+ Run: runProfilesList,
28
+ }
29
+
30
+ func runProfilesList(cmd *cobra.Command, args []string) {
31
+ cfg, err := getConfigManager()
32
+ if err != nil {
33
+ exitWithError("Failed to initialize config: %v", err)
34
+ }
35
+
36
+ profiles, err := cfg.ListProfiles()
37
+ if err != nil {
38
+ exitWithError("Failed to list profiles: %v", err)
39
+ }
40
+
41
+ if len(profiles) == 0 {
42
+ fmt.Println("No profiles found.")
43
+ fmt.Println("Use 'linkedin auth --profile <name>' to create one.")
44
+ return
45
+ }
46
+
47
+ // Load rate limits for status
48
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
49
+ fmt.Fprintln(w, "NAME\tLAST USED")
50
+
51
+ for _, name := range profiles {
52
+ profile, err := cfg.LoadProfile(name)
53
+ if err != nil {
54
+ fmt.Fprintf(w, "%s\t<error>\n", name)
55
+ continue
56
+ }
57
+
58
+ lastUsed := "Never"
59
+ if !profile.LastUsed.IsZero() {
60
+ lastUsed = profile.LastUsed.Format("2006-01-02 15:04")
61
+ }
62
+
63
+ fmt.Fprintf(w, "%s\t%s\n", profile.Name, lastUsed)
64
+ }
65
+
66
+ w.Flush()
67
+
68
+ // Show rate limits
69
+ fmt.Println("\nRate Limits:")
70
+ for _, name := range profiles {
71
+ limiter := ratelimit.NewLimiter(name, ratelimit.DefaultLimits(), cfg)
72
+ status, err := limiter.Status()
73
+ if err != nil {
74
+ continue
75
+ }
76
+
77
+ fmt.Printf(" %s: %d/%d connections today, %d/%d messages today\n",
78
+ name,
79
+ status["connections_today"],
80
+ status["connections_limit"],
81
+ status["messages_today"],
82
+ status["messages_limit"],
83
+ )
84
+ }
85
+ }
86
+
87
+ var profilesRemoveCmd = &cobra.Command{
88
+ Use: "remove [name]",
89
+ Short: "Remove a profile",
90
+ Args: cobra.ExactArgs(1),
91
+ Run: runProfilesRemove,
92
+ }
93
+
94
+ func runProfilesRemove(cmd *cobra.Command, args []string) {
95
+ name := args[0]
96
+
97
+ cfg, err := getConfigManager()
98
+ if err != nil {
99
+ exitWithError("Failed to initialize config: %v", err)
100
+ }
101
+
102
+ if !cfg.ProfileExists(name) {
103
+ exitWithError("Profile '%s' not found", name)
104
+ }
105
+
106
+ fmt.Printf("Are you sure you want to remove profile '%s'? (y/N): ", name)
107
+ var response string
108
+ fmt.Scanln(&response)
109
+
110
+ if response != "y" && response != "Y" {
111
+ fmt.Println("Cancelled.")
112
+ return
113
+ }
114
+
115
+ if err := cfg.DeleteProfile(name); err != nil {
116
+ exitWithError("Failed to remove profile: %v", err)
117
+ }
118
+
119
+ fmt.Printf("✓ Profile '%s' removed.\n", name)
120
+ fmt.Println("Note: The PinchTab browser profile is still saved. Remove it manually if needed.")
121
+ }
@@ -0,0 +1,89 @@
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+
7
+ "github.com/spf13/cobra"
8
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
9
+ )
10
+
11
+ var (
12
+ profileName string
13
+ dryRun bool
14
+ verbose bool
15
+ )
16
+
17
+ // rootCmd represents the base command
18
+ var rootCmd = &cobra.Command{
19
+ Use: "linkedin",
20
+ Short: "LinkedIn automation CLI",
21
+ Long: `A CLI tool for LinkedIn automation using PinchTab.
22
+
23
+ Prerequisites:
24
+ 1. Install PinchTab: curl -fsSL https://pinchtab.com/install.sh | bash
25
+ 2. Start PinchTab: pinchtab
26
+
27
+ Examples:
28
+ # Authenticate a profile
29
+ linkedin auth --profile john
30
+
31
+ # Send a connection request
32
+ linkedin connect --profile john --url linkedin.com/in/alice
33
+
34
+ # Send a message
35
+ linkedin message --profile john --url linkedin.com/in/alice --message "Hello!"
36
+
37
+ # Run sequence step
38
+ linkedin sequence --profile john --url linkedin.com/in/alice --step connect --message "Hi Alice"
39
+
40
+ # Discover contacts from company page
41
+ linkedin people --profile john --company-url "https://www.linkedin.com/sales/company/123456" --mode both --limit 10
42
+ `,
43
+ }
44
+
45
+ // Execute adds all child commands to the root command
46
+ func Execute() {
47
+ if err := rootCmd.Execute(); err != nil {
48
+ fmt.Fprintln(os.Stderr, err)
49
+ os.Exit(1)
50
+ }
51
+ }
52
+
53
+ func init() {
54
+ rootCmd.PersistentFlags().StringVarP(&profileName, "profile", "p", "", "Profile name (env: LINKEDIN_PROFILE)")
55
+ rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing")
56
+ rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
57
+
58
+ // Set profile from environment if not provided
59
+ if profileName == "" {
60
+ profileName = os.Getenv("LINKEDIN_PROFILE")
61
+ }
62
+ }
63
+
64
+ // getConfigManager returns a config manager
65
+ func getConfigManager() (*config.Manager, error) {
66
+ return config.NewManager()
67
+ }
68
+
69
+ // getPinchTabHost returns PinchTab host from environment
70
+ func getPinchTabHost() string {
71
+ host := os.Getenv("PINCHTAB_HOST")
72
+ if host == "" {
73
+ return "http://localhost:9867"
74
+ }
75
+ return host
76
+ }
77
+
78
+ // exitWithError prints error and exits
79
+ func exitWithError(format string, args ...interface{}) {
80
+ fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
81
+ os.Exit(1)
82
+ }
83
+
84
+ // logVerbose prints if verbose mode is enabled
85
+ func logVerbose(format string, args ...interface{}) {
86
+ if verbose {
87
+ fmt.Printf("[verbose] "+format+"\n", args...)
88
+ }
89
+ }