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,2420 @@
1
+ # LinkedIn CLI Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Build a Go CLI that wraps PinchTab HTTP API to automate LinkedIn connection requests and messaging with built-in rate limiting.
6
+
7
+ **Architecture:** Go CLI using Cobra framework with packages for PinchTab HTTP client, LinkedIn-specific selectors, rate limiting, and configuration management. State stored in JSON files.
8
+
9
+ **Tech Stack:** Go 1.21+, Cobra (CLI), standard library HTTP client
10
+
11
+ ---
12
+
13
+ ## Prerequisites
14
+
15
+ - Go 1.21+ installed
16
+ - PinchTab installed and running (`pinchtab` command available)
17
+ - Git initialized in project directory
18
+
19
+ ---
20
+
21
+ ## Task 1: Project Setup
22
+
23
+ **Files:**
24
+ - Create: `go.mod`
25
+ - Create: `go.sum` (generated)
26
+ - Create: `.gitignore`
27
+ - Create: `README.md`
28
+
29
+ **Step 1: Initialize Go module**
30
+
31
+ ```bash
32
+ cd /Users/thaddeus/projects/linkedin-cli
33
+ go mod init github.com/thaddeus-git/linkedin-cli
34
+ ```
35
+
36
+ **Step 2: Create .gitignore**
37
+
38
+ ```bash
39
+ cat > .gitignore << 'EOF'
40
+ # Binaries
41
+ linkedin-cli
42
+ *.exe
43
+
44
+ # Go
45
+ vendor/
46
+ *.test
47
+ *.out
48
+
49
+ # Project
50
+ .linkedin-cli/
51
+ *.log
52
+
53
+ # IDE
54
+ .vscode/
55
+ .idea/
56
+ *.swp
57
+ *.swo
58
+ EOF
59
+ ```
60
+
61
+ **Step 3: Create directory structure**
62
+
63
+ ```bash
64
+ mkdir -p cmd/linkedin
65
+ mkdir -p internal/cmd
66
+ mkdir -p internal/config
67
+ mkdir -p internal/pinchtab
68
+ mkdir -p internal/linkedin
69
+ mkdir -p internal/ratelimit
70
+ ```
71
+
72
+ **Step 4: Initial README**
73
+
74
+ Create: `README.md`
75
+
76
+ ```markdown
77
+ # LinkedIn CLI
78
+
79
+ A CLI tool for LinkedIn automation using PinchTab.
80
+
81
+ ## Prerequisites
82
+
83
+ - Go 1.21+
84
+ - PinchTab installed: `curl -fsSL https://pinchtab.com/install.sh | bash`
85
+
86
+ ## Installation
87
+
88
+ ```bash
89
+ go install github.com/thaddeus-git/linkedin-cli/cmd/linkedin@latest
90
+ ```
91
+
92
+ ## Quick Start
93
+
94
+ 1. Start PinchTab:
95
+ ```bash
96
+ pinchtab
97
+ ```
98
+
99
+ 2. Authenticate your LinkedIn account:
100
+ ```bash
101
+ linkedin auth --profile john
102
+ ```
103
+
104
+ 3. Send a connection request:
105
+ ```bash
106
+ linkedin connect --profile john --url linkedin.com/in/alice
107
+ ```
108
+
109
+ ## Commands
110
+
111
+ - `linkedin auth` - Authenticate a LinkedIn profile
112
+ - `linkedin connect` - Send connection requests
113
+ - `linkedin message` - Send direct messages
114
+ - `linkedin queue` - Process batch requests from file
115
+
116
+ ## Configuration
117
+
118
+ Profiles and rate limits are stored in `~/.linkedin-cli/`.
119
+
120
+ ## License
121
+
122
+ MIT
123
+ ```
124
+
125
+ **Step 5: Commit**
126
+
127
+ ```bash
128
+ git add go.mod .gitignore README.md
129
+ git commit -m "chore: initialize project structure"
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Task 2: PinchTab HTTP Client (Core)
135
+
136
+ **Files:**
137
+ - Create: `internal/pinchtab/types.go`
138
+ - Create: `internal/pinchtab/client.go`
139
+ - Test: `internal/pinchtab/client_test.go`
140
+
141
+ **Step 1: Create types**
142
+
143
+ Create: `internal/pinchtab/types.go`
144
+
145
+ ```go
146
+ package pinchtab
147
+
148
+ // Instance represents a PinchTab browser instance
149
+ type Instance struct {
150
+ ID string `json:"id"`
151
+ ProfileID string `json:"profileId"`
152
+ Port int `json:"port"`
153
+ Mode string `json:"mode"`
154
+ Status string `json:"status"`
155
+ }
156
+
157
+ // Tab represents a browser tab
158
+ type Tab struct {
159
+ ID string `json:"id"`
160
+ InstanceID string `json:"instanceId"`
161
+ URL string `json:"url"`
162
+ Title string `json:"title"`
163
+ }
164
+
165
+ // NavigateRequest represents a navigation action
166
+ type NavigateRequest struct {
167
+ URL string `json:"url"`
168
+ TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
169
+ BlockImages bool `json:"blockImages,omitempty"`
170
+ }
171
+
172
+ // ActionRequest represents a browser action
173
+ type ActionRequest struct {
174
+ Kind string `json:"kind"`
175
+ Ref string `json:"ref,omitempty"`
176
+ Selector string `json:"selector,omitempty"`
177
+ Text string `json:"text,omitempty"`
178
+ Value interface{} `json:"value,omitempty"`
179
+ Key string `json:"key,omitempty"`
180
+ Direction string `json:"direction,omitempty"`
181
+ Amount int `json:"amount,omitempty"`
182
+ }
183
+
184
+ // Snapshot represents the page accessibility tree
185
+ type Snapshot struct {
186
+ URL string `json:"url"`
187
+ Title string `json:"title"`
188
+ Elements []Element `json:"elements"`
189
+ }
190
+
191
+ // Element represents an interactive element
192
+ type Element struct {
193
+ Ref string `json:"ref"`
194
+ Type string `json:"type"`
195
+ Role string `json:"role"`
196
+ Name string `json:"name"`
197
+ Visible bool `json:"visible"`
198
+ }
199
+
200
+ // TextResponse represents extracted text
201
+ type TextResponse struct {
202
+ Text string `json:"text"`
203
+ }
204
+ ```
205
+
206
+ **Step 2: Create client**
207
+
208
+ Create: `internal/pinchtab/client.go`
209
+
210
+ ```go
211
+ package pinchtab
212
+
213
+ import (
214
+ "bytes"
215
+ "encoding/json"
216
+ "fmt"
217
+ "io"
218
+ "net/http"
219
+ "time"
220
+ )
221
+
222
+ // Client wraps the PinchTab HTTP API
223
+ type Client struct {
224
+ baseURL string
225
+ client *http.Client
226
+ }
227
+
228
+ // NewClient creates a new PinchTab client
229
+ func NewClient(baseURL string) *Client {
230
+ if baseURL == "" {
231
+ baseURL = "http://localhost:9867"
232
+ }
233
+ return &Client{
234
+ baseURL: baseURL,
235
+ client: &http.Client{
236
+ Timeout: 30 * time.Second,
237
+ },
238
+ }
239
+ }
240
+
241
+ // StartInstance starts a new browser instance
242
+ func (c *Client) StartInstance(profileID string) (*Instance, error) {
243
+ reqBody := map[string]string{
244
+ "profileId": profileID,
245
+ "mode": "headed",
246
+ }
247
+
248
+ var instance Instance
249
+ err := c.post("/instances/start", reqBody, &instance)
250
+ if err != nil {
251
+ return nil, fmt.Errorf("failed to start instance: %w", err)
252
+ }
253
+
254
+ return &instance, nil
255
+ }
256
+
257
+ // StopInstance stops a browser instance
258
+ func (c *Client) StopInstance(instanceID string) error {
259
+ return c.post(fmt.Sprintf("/instances/%s/stop", instanceID), nil, nil)
260
+ }
261
+
262
+ // NewTab creates a new tab in an instance
263
+ func (c *Client) NewTab(instanceID string, url string) (*Tab, error) {
264
+ reqBody := map[string]string{
265
+ "url": url,
266
+ }
267
+
268
+ var tab Tab
269
+ err := c.post(fmt.Sprintf("/instances/%s/tabs/open", instanceID), reqBody, &tab)
270
+ if err != nil {
271
+ return nil, fmt.Errorf("failed to create tab: %w", err)
272
+ }
273
+
274
+ return &tab, nil
275
+ }
276
+
277
+ // Navigate navigates a tab to a URL
278
+ func (c *Client) Navigate(tabID string, req NavigateRequest) error {
279
+ return c.post(fmt.Sprintf("/tabs/%s/navigate", tabID), req, nil)
280
+ }
281
+
282
+ // GetSnapshot gets the accessibility tree snapshot
283
+ func (c *Client) GetSnapshot(tabID string, filter string) (*Snapshot, error) {
284
+ url := fmt.Sprintf("/tabs/%s/snapshot", tabID)
285
+ if filter != "" {
286
+ url += "?filter=" + filter
287
+ }
288
+
289
+ var snapshot Snapshot
290
+ err := c.get(url, &snapshot)
291
+ if err != nil {
292
+ return nil, fmt.Errorf("failed to get snapshot: %w", err)
293
+ }
294
+
295
+ return &snapshot, nil
296
+ }
297
+
298
+ // GetText extracts text from a tab
299
+ func (c *Client) GetText(tabID string) (*TextResponse, error) {
300
+ var resp TextResponse
301
+ err := c.get(fmt.Sprintf("/tabs/%s/text", tabID), &resp)
302
+ if err != nil {
303
+ return nil, fmt.Errorf("failed to get text: %w", err)
304
+ }
305
+
306
+ return &resp, nil
307
+ }
308
+
309
+ // Click clicks an element
310
+ func (c *Client) Click(tabID string, ref string) error {
311
+ req := ActionRequest{
312
+ Kind: "click",
313
+ Ref: ref,
314
+ }
315
+ return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
316
+ }
317
+
318
+ // HumanClick clicks with human-like randomization
319
+ func (c *Client) HumanClick(tabID string, ref string) error {
320
+ req := ActionRequest{
321
+ Kind: "humanClick",
322
+ Ref: ref,
323
+ }
324
+ return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
325
+ }
326
+
327
+ // Fill fills an input field
328
+ func (c *Client) Fill(tabID string, ref string, text string) error {
329
+ req := ActionRequest{
330
+ Kind: "fill",
331
+ Ref: ref,
332
+ Text: text,
333
+ }
334
+ return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
335
+ }
336
+
337
+ // HumanType types with human-like delays
338
+ func (c *Client) HumanType(tabID string, ref string, text string) error {
339
+ req := ActionRequest{
340
+ Kind: "type",
341
+ Ref: ref,
342
+ Text: text,
343
+ }
344
+ return c.post(fmt.Sprintf("/tabs/%s/actions", tabID), []ActionRequest{req}, nil)
345
+ }
346
+
347
+ // Execute runs JavaScript in the tab
348
+ func (c *Client) Execute(tabID string, script string) (interface{}, error) {
349
+ req := map[string]string{
350
+ "script": script,
351
+ }
352
+
353
+ var result interface{}
354
+ err := c.post(fmt.Sprintf("/tabs/%s/evaluate", tabID), req, &result)
355
+ if err != nil {
356
+ return nil, fmt.Errorf("failed to execute script: %w", err)
357
+ }
358
+
359
+ return result, nil
360
+ }
361
+
362
+ // post makes a POST request
363
+ func (c *Client) post(path string, body interface{}, result interface{}) error {
364
+ var bodyReader io.Reader
365
+ if body != nil {
366
+ jsonBody, err := json.Marshal(body)
367
+ if err != nil {
368
+ return fmt.Errorf("failed to marshal request: %w", err)
369
+ }
370
+ bodyReader = bytes.NewReader(jsonBody)
371
+ }
372
+
373
+ req, err := http.NewRequest("POST", c.baseURL+path, bodyReader)
374
+ if err != nil {
375
+ return fmt.Errorf("failed to create request: %w", err)
376
+ }
377
+
378
+ if body != nil {
379
+ req.Header.Set("Content-Type", "application/json")
380
+ }
381
+
382
+ resp, err := c.client.Do(req)
383
+ if err != nil {
384
+ return fmt.Errorf("request failed: %w", err)
385
+ }
386
+ defer resp.Body.Close()
387
+
388
+ if resp.StatusCode >= 400 {
389
+ bodyBytes, _ := io.ReadAll(resp.Body)
390
+ return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
391
+ }
392
+
393
+ if result != nil {
394
+ if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
395
+ return fmt.Errorf("failed to decode response: %w", err)
396
+ }
397
+ }
398
+
399
+ return nil
400
+ }
401
+
402
+ // get makes a GET request
403
+ func (c *Client) get(path string, result interface{}) error {
404
+ resp, err := c.client.Get(c.baseURL + path)
405
+ if err != nil {
406
+ return fmt.Errorf("request failed: %w", err)
407
+ }
408
+ defer resp.Body.Close()
409
+
410
+ if resp.StatusCode >= 400 {
411
+ bodyBytes, _ := io.ReadAll(resp.Body)
412
+ return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
413
+ }
414
+
415
+ if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
416
+ return fmt.Errorf("failed to decode response: %w", err)
417
+ }
418
+
419
+ return nil
420
+ }
421
+ ```
422
+
423
+ **Step 3: Write test**
424
+
425
+ Create: `internal/pinchtab/client_test.go`
426
+
427
+ ```go
428
+ package pinchtab
429
+
430
+ import (
431
+ "encoding/json"
432
+ "net/http"
433
+ "net/http/httptest"
434
+ "testing"
435
+ )
436
+
437
+ func TestNewClient(t *testing.T) {
438
+ client := NewClient("")
439
+ if client.baseURL != "http://localhost:9867" {
440
+ t.Errorf("expected default URL, got %s", client.baseURL)
441
+ }
442
+
443
+ client = NewClient("http://custom:8080")
444
+ if client.baseURL != "http://custom:8080" {
445
+ t.Errorf("expected custom URL, got %s", client.baseURL)
446
+ }
447
+ }
448
+
449
+ func TestGetSnapshot(t *testing.T) {
450
+ // Mock server
451
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
452
+ if r.URL.Path != "/tabs/test-tab/snapshot" {
453
+ t.Errorf("unexpected path: %s", r.URL.Path)
454
+ }
455
+
456
+ snapshot := Snapshot{
457
+ URL: "https://linkedin.com",
458
+ Title: "LinkedIn",
459
+ Elements: []Element{
460
+ {Ref: "e0", Type: "button", Name: "Connect"},
461
+ },
462
+ }
463
+ json.NewEncoder(w).Encode(snapshot)
464
+ }))
465
+ defer server.Close()
466
+
467
+ client := NewClient(server.URL)
468
+ snapshot, err := client.GetSnapshot("test-tab", "")
469
+ if err != nil {
470
+ t.Fatalf("unexpected error: %v", err)
471
+ }
472
+
473
+ if snapshot.Title != "LinkedIn" {
474
+ t.Errorf("expected title 'LinkedIn', got %s", snapshot.Title)
475
+ }
476
+
477
+ if len(snapshot.Elements) != 1 {
478
+ t.Errorf("expected 1 element, got %d", len(snapshot.Elements))
479
+ }
480
+ }
481
+
482
+ func TestGetSnapshot_Error(t *testing.T) {
483
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
484
+ w.WriteHeader(http.StatusInternalServerError)
485
+ w.Write([]byte(`{"error": "tab not found"}`))
486
+ }))
487
+ defer server.Close()
488
+
489
+ client := NewClient(server.URL)
490
+ _, err := client.GetSnapshot("invalid-tab", "")
491
+ if err == nil {
492
+ t.Error("expected error, got nil")
493
+ }
494
+ }
495
+ ```
496
+
497
+ **Step 4: Run tests**
498
+
499
+ ```bash
500
+ cd /Users/thaddeus/projects/linkedin-cli
501
+ go test ./internal/pinchtab/... -v
502
+ ```
503
+
504
+ Expected: 3 tests PASS
505
+
506
+ **Step 5: Commit**
507
+
508
+ ```bash
509
+ git add internal/pinchtab/
510
+ git commit -m "feat: add PinchTab HTTP client"
511
+ ```
512
+
513
+ ---
514
+
515
+ ## Task 3: Configuration Management
516
+
517
+ **Files:**
518
+ - Create: `internal/config/config.go`
519
+ - Create: `internal/config/profile.go`
520
+ - Test: `internal/config/config_test.go`
521
+
522
+ **Step 1: Create config types**
523
+
524
+ Create: `internal/config/profile.go`
525
+
526
+ ```go
527
+ package config
528
+
529
+ import "time"
530
+
531
+ // Profile represents a LinkedIn profile configuration
532
+ type Profile struct {
533
+ Name string `json:"name"`
534
+ PinchTabProfile string `json:"pinchtab_profile"`
535
+ CreatedAt time.Time `json:"created_at"`
536
+ LastUsed time.Time `json:"last_used"`
537
+ }
538
+
539
+ // RateLimits tracks rate limit state per profile
540
+ type RateLimits struct {
541
+ Connections RateLimit `json:"connections"`
542
+ Messages RateLimit `json:"messages"`
543
+ }
544
+
545
+ // RateLimit tracks a single rate limit
546
+ type RateLimit struct {
547
+ Today int `json:"today"`
548
+ ThisWeek int `json:"this_week"`
549
+ LastAction time.Time `json:"last_action"`
550
+ }
551
+
552
+ // CanConnect checks if connection request is allowed
553
+ func (r *RateLimits) CanConnect(dailyLimit, weeklyLimit int) bool {
554
+ return r.Connections.Today < dailyLimit && r.Connections.ThisWeek < weeklyLimit
555
+ }
556
+
557
+ // CanMessage checks if message is allowed
558
+ func (r *RateLimits) CanMessage(dailyLimit int) bool {
559
+ return r.Messages.Today < dailyLimit
560
+ }
561
+
562
+ // RecordConnection records a connection request
563
+ func (r *RateLimits) RecordConnection() {
564
+ r.Connections.Today++
565
+ r.Connections.ThisWeek++
566
+ r.Connections.LastAction = time.Now()
567
+ }
568
+
569
+ // RecordMessage records a message sent
570
+ func (r *RateLimits) RecordMessage() {
571
+ r.Messages.Today++
572
+ r.Messages.LastAction = time.Now()
573
+ }
574
+
575
+ // ResetIfNeeded resets daily/weekly counters if day/week has changed
576
+ func (r *RateLimits) ResetIfNeeded() {
577
+ now := time.Now()
578
+ last := r.Connections.LastAction
579
+
580
+ // Reset daily if different day
581
+ if last.Year() != now.Year() || last.YearDay() != now.YearDay() {
582
+ r.Connections.Today = 0
583
+ r.Messages.Today = 0
584
+ }
585
+
586
+ // Reset weekly if different week
587
+ _, lastWeek := last.ISOWeek()
588
+ _, nowWeek := now.ISOWeek()
589
+ if lastWeek != nowWeek {
590
+ r.Connections.ThisWeek = 0
591
+ }
592
+ }
593
+ ```
594
+
595
+ **Step 2: Create config manager**
596
+
597
+ Create: `internal/config/config.go`
598
+
599
+ ```go
600
+ package config
601
+
602
+ import (
603
+ "encoding/json"
604
+ "fmt"
605
+ "os"
606
+ "path/filepath"
607
+ "time"
608
+ )
609
+
610
+ // Manager handles configuration and state
611
+ type Manager struct {
612
+ configDir string
613
+ }
614
+
615
+ // NewManager creates a new config manager
616
+ func NewManager() (*Manager, error) {
617
+ home, err := os.UserHomeDir()
618
+ if err != nil {
619
+ return nil, fmt.Errorf("failed to get home directory: %w", err)
620
+ }
621
+
622
+ configDir := filepath.Join(home, ".linkedin-cli")
623
+ if err := os.MkdirAll(configDir, 0755); err != nil {
624
+ return nil, fmt.Errorf("failed to create config directory: %w", err)
625
+ }
626
+
627
+ return &Manager{configDir: configDir}, nil
628
+ }
629
+
630
+ // GetConfigDir returns the config directory path
631
+ func (m *Manager) GetConfigDir() string {
632
+ return m.configDir
633
+ }
634
+
635
+ // ProfileExists checks if a profile exists
636
+ func (m *Manager) ProfileExists(name string) bool {
637
+ path := filepath.Join(m.configDir, "profiles", name+".json")
638
+ _, err := os.Stat(path)
639
+ return err == nil
640
+ }
641
+
642
+ // LoadProfile loads a profile
643
+ func (m *Manager) LoadProfile(name string) (*Profile, error) {
644
+ path := filepath.Join(m.configDir, "profiles", name+".json")
645
+
646
+ data, err := os.ReadFile(path)
647
+ if err != nil {
648
+ if os.IsNotExist(err) {
649
+ return nil, fmt.Errorf("profile '%s' not found", name)
650
+ }
651
+ return nil, fmt.Errorf("failed to read profile: %w", err)
652
+ }
653
+
654
+ var profile Profile
655
+ if err := json.Unmarshal(data, &profile); err != nil {
656
+ return nil, fmt.Errorf("failed to parse profile: %w", err)
657
+ }
658
+
659
+ return &profile, nil
660
+ }
661
+
662
+ // SaveProfile saves a profile
663
+ func (m *Manager) SaveProfile(profile *Profile) error {
664
+ profilesDir := filepath.Join(m.configDir, "profiles")
665
+ if err := os.MkdirAll(profilesDir, 0755); err != nil {
666
+ return fmt.Errorf("failed to create profiles directory: %w", err)
667
+ }
668
+
669
+ path := filepath.Join(profilesDir, profile.Name+".json")
670
+
671
+ data, err := json.MarshalIndent(profile, "", " ")
672
+ if err != nil {
673
+ return fmt.Errorf("failed to marshal profile: %w", err)
674
+ }
675
+
676
+ if err := os.WriteFile(path, data, 0644); err != nil {
677
+ return fmt.Errorf("failed to write profile: %w", err)
678
+ }
679
+
680
+ return nil
681
+ }
682
+
683
+ // ListProfiles lists all profiles
684
+ func (m *Manager) ListProfiles() ([]string, error) {
685
+ profilesDir := filepath.Join(m.configDir, "profiles")
686
+
687
+ entries, err := os.ReadDir(profilesDir)
688
+ if err != nil {
689
+ if os.IsNotExist(err) {
690
+ return []string{}, nil
691
+ }
692
+ return nil, fmt.Errorf("failed to read profiles directory: %w", err)
693
+ }
694
+
695
+ var profiles []string
696
+ for _, entry := range entries {
697
+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
698
+ name := entry.Name()[:len(entry.Name())-5] // Remove .json
699
+ profiles = append(profiles, name)
700
+ }
701
+ }
702
+
703
+ return profiles, nil
704
+ }
705
+
706
+ // DeleteProfile deletes a profile
707
+ func (m *Manager) DeleteProfile(name string) error {
708
+ path := filepath.Join(m.configDir, "profiles", name+".json")
709
+ if err := os.Remove(path); err != nil {
710
+ if os.IsNotExist(err) {
711
+ return fmt.Errorf("profile '%s' not found", name)
712
+ }
713
+ return fmt.Errorf("failed to delete profile: %w", err)
714
+ }
715
+ return nil
716
+ }
717
+
718
+ // LoadRateLimits loads rate limits for a profile
719
+ func (m *Manager) LoadRateLimits(profileName string) (*RateLimits, error) {
720
+ path := filepath.Join(m.configDir, "ratelimit.json")
721
+
722
+ data, err := os.ReadFile(path)
723
+ if err != nil {
724
+ if os.IsNotExist(err) {
725
+ return &RateLimits{}, nil
726
+ }
727
+ return nil, fmt.Errorf("failed to read rate limits: %w", err)
728
+ }
729
+
730
+ var allLimits map[string]*RateLimits
731
+ if err := json.Unmarshal(data, &allLimits); err != nil {
732
+ return nil, fmt.Errorf("failed to parse rate limits: %w", err)
733
+ }
734
+
735
+ limits, exists := allLimits[profileName]
736
+ if !exists {
737
+ return &RateLimits{}, nil
738
+ }
739
+
740
+ // Reset counters if needed
741
+ limits.ResetIfNeeded()
742
+
743
+ return limits, nil
744
+ }
745
+
746
+ // SaveRateLimits saves rate limits for a profile
747
+ func (m *Manager) SaveRateLimits(profileName string, limits *RateLimits) error {
748
+ path := filepath.Join(m.configDir, "ratelimit.json")
749
+
750
+ var allLimits map[string]*RateLimits
751
+
752
+ // Load existing
753
+ data, err := os.ReadFile(path)
754
+ if err == nil {
755
+ json.Unmarshal(data, &allLimits)
756
+ }
757
+
758
+ if allLimits == nil {
759
+ allLimits = make(map[string]*RateLimits)
760
+ }
761
+
762
+ allLimits[profileName] = limits
763
+
764
+ // Save all
765
+ data, err = json.MarshalIndent(allLimits, "", " ")
766
+ if err != nil {
767
+ return fmt.Errorf("failed to marshal rate limits: %w", err)
768
+ }
769
+
770
+ if err := os.WriteFile(path, data, 0644); err != nil {
771
+ return fmt.Errorf("failed to write rate limits: %w", err)
772
+ }
773
+
774
+ return nil
775
+ }
776
+
777
+ // UpdateLastUsed updates the last used timestamp for a profile
778
+ func (m *Manager) UpdateLastUsed(profileName string) error {
779
+ profile, err := m.LoadProfile(profileName)
780
+ if err != nil {
781
+ return err
782
+ }
783
+
784
+ profile.LastUsed = time.Now()
785
+ return m.SaveProfile(profile)
786
+ }
787
+ ```
788
+
789
+ **Step 3: Write tests**
790
+
791
+ Create: `internal/config/config_test.go`
792
+
793
+ ```go
794
+ package config
795
+
796
+ import (
797
+ "os"
798
+ "path/filepath"
799
+ "testing"
800
+ "time"
801
+ )
802
+
803
+ func TestNewManager(t *testing.T) {
804
+ mgr, err := NewManager()
805
+ if err != nil {
806
+ t.Fatalf("unexpected error: %v", err)
807
+ }
808
+
809
+ if mgr.configDir == "" {
810
+ t.Error("config dir should not be empty")
811
+ }
812
+
813
+ // Cleanup
814
+ os.RemoveAll(mgr.configDir)
815
+ }
816
+
817
+ func TestProfileCRUD(t *testing.T) {
818
+ // Use temp dir for testing
819
+ tempDir := t.TempDir()
820
+ mgr := &Manager{configDir: tempDir}
821
+
822
+ // Create profile
823
+ profile := &Profile{
824
+ Name: "test",
825
+ PinchTabProfile: "linkedin-test",
826
+ CreatedAt: time.Now(),
827
+ LastUsed: time.Now(),
828
+ }
829
+
830
+ if err := mgr.SaveProfile(profile); err != nil {
831
+ t.Fatalf("failed to save profile: %v", err)
832
+ }
833
+
834
+ // Check exists
835
+ if !mgr.ProfileExists("test") {
836
+ t.Error("profile should exist")
837
+ }
838
+
839
+ // Load
840
+ loaded, err := mgr.LoadProfile("test")
841
+ if err != nil {
842
+ t.Fatalf("failed to load profile: %v", err)
843
+ }
844
+
845
+ if loaded.Name != "test" {
846
+ t.Errorf("expected name 'test', got %s", loaded.Name)
847
+ }
848
+
849
+ // List
850
+ profiles, err := mgr.ListProfiles()
851
+ if err != nil {
852
+ t.Fatalf("failed to list profiles: %v", err)
853
+ }
854
+
855
+ if len(profiles) != 1 || profiles[0] != "test" {
856
+ t.Errorf("expected ['test'], got %v", profiles)
857
+ }
858
+
859
+ // Delete
860
+ if err := mgr.DeleteProfile("test"); err != nil {
861
+ t.Fatalf("failed to delete profile: %v", err)
862
+ }
863
+
864
+ if mgr.ProfileExists("test") {
865
+ t.Error("profile should not exist after delete")
866
+ }
867
+ }
868
+
869
+ func TestRateLimits(t *testing.T) {
870
+ tempDir := t.TempDir()
871
+ mgr := &Manager{configDir: tempDir}
872
+
873
+ limits := &RateLimits{
874
+ Connections: RateLimit{
875
+ Today: 15,
876
+ ThisWeek: 87,
877
+ },
878
+ Messages: RateLimit{
879
+ Today: 23,
880
+ },
881
+ }
882
+
883
+ // Save
884
+ if err := mgr.SaveRateLimits("john", limits); err != nil {
885
+ t.Fatalf("failed to save rate limits: %v", err)
886
+ }
887
+
888
+ // Load
889
+ loaded, err := mgr.LoadRateLimits("john")
890
+ if err != nil {
891
+ t.Fatalf("failed to load rate limits: %v", err)
892
+ }
893
+
894
+ if loaded.Connections.Today != 15 {
895
+ t.Errorf("expected 15 connections today, got %d", loaded.Connections.Today)
896
+ }
897
+ }
898
+
899
+ func TestRateLimitCanConnect(t *testing.T) {
900
+ limits := &RateLimits{
901
+ Connections: RateLimit{Today: 19, ThisWeek: 99},
902
+ }
903
+
904
+ if !limits.CanConnect(20, 100) {
905
+ t.Error("should be able to connect at 19/20 daily")
906
+ }
907
+
908
+ limits.Connections.Today = 20
909
+ if limits.CanConnect(20, 100) {
910
+ t.Error("should not be able to connect at 20/20 daily")
911
+ }
912
+ }
913
+ ```
914
+
915
+ **Step 4: Run tests**
916
+
917
+ ```bash
918
+ go test ./internal/config/... -v
919
+ ```
920
+
921
+ Expected: 4 tests PASS
922
+
923
+ **Step 5: Commit**
924
+
925
+ ```bash
926
+ git add internal/config/
927
+ git commit -m "feat: add configuration and profile management"
928
+ ```
929
+
930
+ ---
931
+
932
+ ## Task 4: Rate Limiting
933
+
934
+ **Files:**
935
+ - Create: `internal/ratelimit/limits.go`
936
+ - Create: `internal/ratelimit/limiter.go`
937
+
938
+ **Step 1: Create limits configuration**
939
+
940
+ Create: `internal/ratelimit/limits.go`
941
+
942
+ ```go
943
+ package ratelimit
944
+
945
+ // Limits defines rate limit thresholds
946
+ type Limits struct {
947
+ ConnectionsDaily int
948
+ ConnectionsWeekly int
949
+ MessagesDaily int
950
+ MinDelaySeconds int
951
+ MaxDelaySeconds int
952
+ }
953
+
954
+ // DefaultLimits returns default LinkedIn-safe limits
955
+ func DefaultLimits() Limits {
956
+ return Limits{
957
+ ConnectionsDaily: 20,
958
+ ConnectionsWeekly: 100,
959
+ MessagesDaily: 50,
960
+ MinDelaySeconds: 3,
961
+ MaxDelaySeconds: 8,
962
+ }
963
+ }
964
+
965
+ // ConservativeLimits returns more conservative limits for new accounts
966
+ func ConservativeLimits() Limits {
967
+ return Limits{
968
+ ConnectionsDaily: 10,
969
+ ConnectionsWeekly: 50,
970
+ MessagesDaily: 25,
971
+ MinDelaySeconds: 5,
972
+ MaxDelaySeconds: 12,
973
+ }
974
+ }
975
+ ```
976
+
977
+ **Step 2: Create limiter**
978
+
979
+ Create: `internal/ratelimit/limiter.go`
980
+
981
+ ```go
982
+ package ratelimit
983
+
984
+ import (
985
+ "fmt"
986
+ "math/rand"
987
+ "time"
988
+
989
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
990
+ )
991
+
992
+ // Limiter handles rate limiting logic
993
+ type Limiter struct {
994
+ limits Limits
995
+ config *config.Manager
996
+ profile string
997
+ }
998
+
999
+ // NewLimiter creates a new rate limiter
1000
+ func NewLimiter(profile string, limits Limits, cfg *config.Manager) *Limiter {
1001
+ return &Limiter{
1002
+ limits: limits,
1003
+ config: cfg,
1004
+ profile: profile,
1005
+ }
1006
+ }
1007
+
1008
+ // CheckConnection checks if a connection request is allowed
1009
+ func (l *Limiter) CheckConnection() error {
1010
+ state, err := l.config.LoadRateLimits(l.profile)
1011
+ if err != nil {
1012
+ return fmt.Errorf("failed to load rate limits: %w", err)
1013
+ }
1014
+
1015
+ if !state.CanConnect(l.limits.ConnectionsDaily, l.limits.ConnectionsWeekly) {
1016
+ return fmt.Errorf(
1017
+ "rate limit exceeded: %d/%d daily, %d/%d weekly connections",
1018
+ state.Connections.Today,
1019
+ l.limits.ConnectionsDaily,
1020
+ state.Connections.ThisWeek,
1021
+ l.limits.ConnectionsWeekly,
1022
+ )
1023
+ }
1024
+
1025
+ return nil
1026
+ }
1027
+
1028
+ // CheckMessage checks if a message is allowed
1029
+ func (l *Limiter) CheckMessage() error {
1030
+ state, err := l.config.LoadRateLimits(l.profile)
1031
+ if err != nil {
1032
+ return fmt.Errorf("failed to load rate limits: %w", err)
1033
+ }
1034
+
1035
+ if !state.CanMessage(l.limits.MessagesDaily) {
1036
+ return fmt.Errorf(
1037
+ "rate limit exceeded: %d/%d daily messages",
1038
+ state.Messages.Today,
1039
+ l.limits.MessagesDaily,
1040
+ )
1041
+ }
1042
+
1043
+ return nil
1044
+ }
1045
+
1046
+ // RecordConnection records a connection request
1047
+ func (l *Limiter) RecordConnection() error {
1048
+ state, err := l.config.LoadRateLimits(l.profile)
1049
+ if err != nil {
1050
+ return err
1051
+ }
1052
+
1053
+ state.RecordConnection()
1054
+ return l.config.SaveRateLimits(l.profile, state)
1055
+ }
1056
+
1057
+ // RecordMessage records a message
1058
+ func (l *Limiter) RecordMessage() error {
1059
+ state, err := l.config.LoadRateLimits(l.profile)
1060
+ if err != nil {
1061
+ return err
1062
+ }
1063
+
1064
+ state.RecordMessage()
1065
+ return l.config.SaveRateLimits(l.profile, state)
1066
+ }
1067
+
1068
+ // GetDelay returns a random delay between min and max seconds
1069
+ func (l *Limiter) GetDelay() time.Duration {
1070
+ seconds := rand.Intn(l.limits.MaxDelaySeconds-l.limits.MinDelaySeconds+1) + l.limits.MinDelaySeconds
1071
+ return time.Duration(seconds) * time.Second
1072
+ }
1073
+
1074
+ // Sleep delays execution for a random duration
1075
+ func (l *Limiter) Sleep() {
1076
+ time.Sleep(l.GetDelay())
1077
+ }
1078
+
1079
+ // Status returns current rate limit status
1080
+ func (l *Limiter) Status() (map[string]interface{}, error) {
1081
+ state, err := l.config.LoadRateLimits(l.profile)
1082
+ if err != nil {
1083
+ return nil, err
1084
+ }
1085
+
1086
+ return map[string]interface{}{
1087
+ "connections_today": state.Connections.Today,
1088
+ "connections_weekly": state.Connections.ThisWeek,
1089
+ "connections_limit": l.limits.ConnectionsDaily,
1090
+ "connections_weekly_limit": l.limits.ConnectionsWeekly,
1091
+ "messages_today": state.Messages.Today,
1092
+ "messages_limit": l.limits.MessagesDaily,
1093
+ "last_connection": state.Connections.LastAction,
1094
+ "last_message": state.Messages.LastAction,
1095
+ }, nil
1096
+ }
1097
+ ```
1098
+
1099
+ **Step 3: Commit**
1100
+
1101
+ ```bash
1102
+ git add internal/ratelimit/
1103
+ git commit -m "feat: add rate limiting"
1104
+ ```
1105
+
1106
+ ---
1107
+
1108
+ ## Task 5: LinkedIn Selectors & Navigator
1109
+
1110
+ **Files:**
1111
+ - Create: `internal/linkedin/selectors.go`
1112
+ - Create: `internal/linkedin/navigator.go`
1113
+ - Create: `internal/linkedin/validator.go`
1114
+
1115
+ **Step 1: Create selectors**
1116
+
1117
+ Create: `internal/linkedin/selectors.go`
1118
+
1119
+ ```go
1120
+ package linkedin
1121
+
1122
+ // Selectors contains LinkedIn DOM selectors
1123
+ // Note: These are fragile and may need updating as LinkedIn changes
1124
+ var Selectors = struct {
1125
+ // Connection buttons
1126
+ ConnectButton string
1127
+ ConnectButtonAlt string
1128
+
1129
+ // Connection modal
1130
+ ConnectModalTextarea string
1131
+ ConnectModalSend string
1132
+ ConnectModalCancel string
1133
+
1134
+ // Messaging
1135
+ MessageButton string
1136
+ MessageTextarea string
1137
+ MessageSendButton string
1138
+
1139
+ // Navigation
1140
+ ProfileName string
1141
+ ProfileHeadline string
1142
+ ProfileCompany string
1143
+ }{
1144
+ ConnectButton: "button[aria-label*='Connect']",
1145
+ ConnectButtonAlt: "button:has-text('Connect')",
1146
+
1147
+ ConnectModalTextarea: "textarea[name='message']",
1148
+ ConnectModalSend: "button[aria-label='Send now']",
1149
+ ConnectModalCancel: "button[aria-label='Dismiss']",
1150
+
1151
+ MessageButton: "button[aria-label*='Message']",
1152
+ MessageTextarea: "div[role='textbox']",
1153
+ MessageSendButton: "button[type='submit']",
1154
+
1155
+ ProfileName: "h1",
1156
+ ProfileHeadline: "div.text-body-medium",
1157
+ ProfileCompany: "a[href*='/company/']",
1158
+ }
1159
+ ```
1160
+
1161
+ **Step 2: Create navigator**
1162
+
1163
+ Create: `internal/linkedin/navigator.go`
1164
+
1165
+ ```go
1166
+ package linkedin
1167
+
1168
+ import (
1169
+ "fmt"
1170
+ "strings"
1171
+ "time"
1172
+
1173
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1174
+ )
1175
+
1176
+ // Navigator handles LinkedIn page interactions
1177
+ type Navigator struct {
1178
+ client *pinchtab.Client
1179
+ }
1180
+
1181
+ // NewNavigator creates a new navigator
1182
+ func NewNavigator(client *pinchtab.Client) *Navigator {
1183
+ return &Navigator{client: client}
1184
+ }
1185
+
1186
+ // NavigateToProfile navigates to a LinkedIn profile
1187
+ func (n *Navigator) NavigateToProfile(tabID string, profileURL string) error {
1188
+ // Ensure URL is absolute
1189
+ if !strings.HasPrefix(profileURL, "http") {
1190
+ profileURL = "https://linkedin.com/in/" + strings.TrimPrefix(profileURL, "/in/")
1191
+ }
1192
+
1193
+ req := pinchtab.NavigateRequest{
1194
+ URL: profileURL,
1195
+ TimeoutSeconds: 30,
1196
+ BlockImages: true,
1197
+ }
1198
+
1199
+ if err := n.client.Navigate(tabID, req); err != nil {
1200
+ return fmt.Errorf("failed to navigate: %w", err)
1201
+ }
1202
+
1203
+ // Wait for page to load
1204
+ time.Sleep(3 * time.Second)
1205
+
1206
+ return nil
1207
+ }
1208
+
1209
+ // FindConnectButton finds the connect button in the snapshot
1210
+ func (n *Navigator) FindConnectButton(snapshot *pinchtab.Snapshot) (*pinchtab.Element, error) {
1211
+ for _, elem := range snapshot.Elements {
1212
+ if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "connect") {
1213
+ return &elem, nil
1214
+ }
1215
+ }
1216
+
1217
+ return nil, fmt.Errorf("connect button not found")
1218
+ }
1219
+
1220
+ // FindMessageButton finds the message button in the snapshot
1221
+ func (n *Navigator) FindMessageButton(snapshot *pinchtab.Snapshot) (*pinchtab.Element, error) {
1222
+ for _, elem := range snapshot.Elements {
1223
+ if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "message") {
1224
+ return &elem, nil
1225
+ }
1226
+ }
1227
+
1228
+ return nil, fmt.Errorf("message button not found")
1229
+ }
1230
+
1231
+ // ClickConnect clicks the connect button
1232
+ func (n *Navigator) ClickConnect(tabID string) error {
1233
+ snapshot, err := n.client.GetSnapshot(tabID, "interactive")
1234
+ if err != nil {
1235
+ return fmt.Errorf("failed to get snapshot: %w", err)
1236
+ }
1237
+
1238
+ button, err := n.FindConnectButton(snapshot)
1239
+ if err != nil {
1240
+ return err
1241
+ }
1242
+
1243
+ if err := n.client.HumanClick(tabID, button.Ref); err != nil {
1244
+ return fmt.Errorf("failed to click connect: %w", err)
1245
+ }
1246
+
1247
+ // Wait for modal
1248
+ time.Sleep(2 * time.Second)
1249
+
1250
+ return nil
1251
+ }
1252
+
1253
+ // SendConnectionRequest sends a connection request with optional note
1254
+ func (n *Navigator) SendConnectionRequest(tabID string, note string) error {
1255
+ if note != "" {
1256
+ // Check for textarea in modal
1257
+ snapshot, err := n.client.GetSnapshot(tabID, "interactive")
1258
+ if err != nil {
1259
+ return fmt.Errorf("failed to get modal snapshot: %w", err)
1260
+ }
1261
+
1262
+ // Find textarea
1263
+ var textarea *pinchtab.Element
1264
+ for _, elem := range snapshot.Elements {
1265
+ if elem.Role == "textbox" || strings.Contains(strings.ToLower(elem.Name), "message") {
1266
+ textarea = &elem
1267
+ break
1268
+ }
1269
+ }
1270
+
1271
+ if textarea != nil {
1272
+ // Type the note
1273
+ if err := n.client.HumanType(tabID, textarea.Ref, note); err != nil {
1274
+ return fmt.Errorf("failed to type note: %w", err)
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ // Find and click send button
1280
+ snapshot, err := n.client.GetSnapshot(tabID, "interactive")
1281
+ if err != nil {
1282
+ return fmt.Errorf("failed to get snapshot for send: %w", err)
1283
+ }
1284
+
1285
+ var sendButton *pinchtab.Element
1286
+ for _, elem := range snapshot.Elements {
1287
+ if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "send") {
1288
+ sendButton = &elem
1289
+ break
1290
+ }
1291
+ }
1292
+
1293
+ if sendButton == nil {
1294
+ return fmt.Errorf("send button not found")
1295
+ }
1296
+
1297
+ if err := n.client.HumanClick(tabID, sendButton.Ref); err != nil {
1298
+ return fmt.Errorf("failed to click send: %w", err)
1299
+ }
1300
+
1301
+ return nil
1302
+ }
1303
+
1304
+ // OpenMessageModal opens the message modal
1305
+ func (n *Navigator) OpenMessageModal(tabID string) error {
1306
+ snapshot, err := n.client.GetSnapshot(tabID, "interactive")
1307
+ if err != nil {
1308
+ return fmt.Errorf("failed to get snapshot: %w", err)
1309
+ }
1310
+
1311
+ button, err := n.FindMessageButton(snapshot)
1312
+ if err != nil {
1313
+ return err
1314
+ }
1315
+
1316
+ if err := n.client.HumanClick(tabID, button.Ref); err != nil {
1317
+ return fmt.Errorf("failed to click message button: %w", err)
1318
+ }
1319
+
1320
+ time.Sleep(2 * time.Second)
1321
+ return nil
1322
+ }
1323
+
1324
+ // SendMessage sends a direct message
1325
+ func (n *Navigator) SendMessage(tabID string, message string) error {
1326
+ snapshot, err := n.client.GetSnapshot(tabID, "interactive")
1327
+ if err != nil {
1328
+ return fmt.Errorf("failed to get snapshot: %w", err)
1329
+ }
1330
+
1331
+ // Find message textarea
1332
+ var textarea *pinchtab.Element
1333
+ for _, elem := range snapshot.Elements {
1334
+ if elem.Role == "textbox" || elem.Type == "textbox" {
1335
+ textarea = &elem
1336
+ break
1337
+ }
1338
+ }
1339
+
1340
+ if textarea == nil {
1341
+ return fmt.Errorf("message textarea not found")
1342
+ }
1343
+
1344
+ // Type message
1345
+ if err := n.client.HumanType(tabID, textarea.Ref, message); err != nil {
1346
+ return fmt.Errorf("failed to type message: %w", err)
1347
+ }
1348
+
1349
+ // Find send button
1350
+ snapshot, err = n.client.GetSnapshot(tabID, "interactive")
1351
+ if err != nil {
1352
+ return fmt.Errorf("failed to get snapshot for send: %w", err)
1353
+ }
1354
+
1355
+ var sendButton *pinchtab.Element
1356
+ for _, elem := range snapshot.Elements {
1357
+ if elem.Role == "button" && strings.Contains(strings.ToLower(elem.Name), "send") {
1358
+ sendButton = &elem
1359
+ break
1360
+ }
1361
+ }
1362
+
1363
+ if sendButton == nil {
1364
+ return fmt.Errorf("send button not found")
1365
+ }
1366
+
1367
+ if err := n.client.HumanClick(tabID, sendButton.Ref); err != nil {
1368
+ return fmt.Errorf("failed to click send: %w", err)
1369
+ }
1370
+
1371
+ return nil
1372
+ }
1373
+ ```
1374
+
1375
+ **Step 3: Create validator**
1376
+
1377
+ Create: `internal/linkedin/validator.go`
1378
+
1379
+ ```go
1380
+ package linkedin
1381
+
1382
+ import (
1383
+ "fmt"
1384
+ "net/url"
1385
+ "strings"
1386
+ )
1387
+
1388
+ // ValidateProfileURL validates a LinkedIn profile URL
1389
+ func ValidateProfileURL(input string) (string, error) {
1390
+ // Normalize input
1391
+ input = strings.TrimSpace(input)
1392
+
1393
+ // Check if it's already a full URL
1394
+ if strings.HasPrefix(input, "http") {
1395
+ u, err := url.Parse(input)
1396
+ if err != nil {
1397
+ return "", fmt.Errorf("invalid URL: %w", err)
1398
+ }
1399
+
1400
+ if !strings.Contains(u.Host, "linkedin.com") {
1401
+ return "", fmt.Errorf("URL must be from linkedin.com")
1402
+ }
1403
+
1404
+ if !strings.Contains(u.Path, "/in/") {
1405
+ return "", fmt.Errorf("URL must be a LinkedIn profile (contains /in/)")
1406
+ }
1407
+
1408
+ return input, nil
1409
+ }
1410
+
1411
+ // Handle vanity URL format (linkedin.com/in/username or /in/username)
1412
+ if strings.HasPrefix(input, "linkedin.com/in/") || strings.HasPrefix(input, "/in/") {
1413
+ username := strings.TrimPrefix(input, "linkedin.com/in/")
1414
+ username = strings.TrimPrefix(username, "/in/")
1415
+ username = strings.TrimSuffix(username, "/")
1416
+
1417
+ if username == "" {
1418
+ return "", fmt.Errorf("invalid LinkedIn profile URL")
1419
+ }
1420
+
1421
+ return fmt.Sprintf("https://linkedin.com/in/%s", username), nil
1422
+ }
1423
+
1424
+ // Assume it's just a username
1425
+ return fmt.Sprintf("https://linkedin.com/in/%s", input), nil
1426
+ }
1427
+
1428
+ // ExtractProfileUsername extracts the username from a LinkedIn profile URL
1429
+ func ExtractProfileUsername(profileURL string) string {
1430
+ u, err := url.Parse(profileURL)
1431
+ if err != nil {
1432
+ return ""
1433
+ }
1434
+
1435
+ parts := strings.Split(u.Path, "/")
1436
+ for i, part := range parts {
1437
+ if part == "in" && i+1 < len(parts) {
1438
+ return parts[i+1]
1439
+ }
1440
+ }
1441
+
1442
+ return ""
1443
+ }
1444
+ ```
1445
+
1446
+ **Step 4: Commit**
1447
+
1448
+ ```bash
1449
+ git add internal/linkedin/
1450
+ git commit -m "feat: add LinkedIn selectors and navigator"
1451
+ ```
1452
+
1453
+ ---
1454
+
1455
+ ## Task 6: CLI Commands
1456
+
1457
+ **Files:**
1458
+ - Create: `internal/cmd/root.go`
1459
+ - Create: `internal/cmd/auth.go`
1460
+ - Create: `internal/cmd/connect.go`
1461
+ - Create: `internal/cmd/message.go`
1462
+ - Create: `cmd/linkedin/main.go`
1463
+
1464
+ **Step 1: Create root command**
1465
+
1466
+ Create: `internal/cmd/root.go`
1467
+
1468
+ ```go
1469
+ package cmd
1470
+
1471
+ import (
1472
+ "fmt"
1473
+ "os"
1474
+
1475
+ "github.com/spf13/cobra"
1476
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
1477
+ )
1478
+
1479
+ var (
1480
+ profileName string
1481
+ dryRun bool
1482
+ verbose bool
1483
+ )
1484
+
1485
+ // rootCmd represents the base command
1486
+ var rootCmd = &cobra.Command{
1487
+ Use: "linkedin",
1488
+ Short: "LinkedIn automation CLI",
1489
+ Long: `A CLI tool for LinkedIn automation using PinchTab.
1490
+
1491
+ Prerequisites:
1492
+ 1. Install PinchTab: curl -fsSL https://pinchtab.com/install.sh | bash
1493
+ 2. Start PinchTab: pinchtab
1494
+
1495
+ Examples:
1496
+ # Authenticate a profile
1497
+ linkedin auth --profile john
1498
+
1499
+ # Send a connection request
1500
+ linkedin connect --profile john --url linkedin.com/in/alice
1501
+
1502
+ # Send a message
1503
+ linkedin message --profile john --url linkedin.com/in/alice --message "Hello!"
1504
+ `,
1505
+ }
1506
+
1507
+ // Execute adds all child commands to the root command
1508
+ func Execute() {
1509
+ if err := rootCmd.Execute(); err != nil {
1510
+ fmt.Fprintln(os.Stderr, err)
1511
+ os.Exit(1)
1512
+ }
1513
+ }
1514
+
1515
+ func init() {
1516
+ rootCmd.PersistentFlags().StringVarP(&profileName, "profile", "p", "", "Profile name (env: LINKEDIN_PROFILE)")
1517
+ rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Show what would be done without executing")
1518
+ rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
1519
+
1520
+ // Set profile from environment if not provided
1521
+ if profileName == "" {
1522
+ profileName = os.Getenv("LINKEDIN_PROFILE")
1523
+ }
1524
+ }
1525
+
1526
+ // getConfigManager returns a config manager
1527
+ func getConfigManager() (*config.Manager, error) {
1528
+ return config.NewManager()
1529
+ }
1530
+
1531
+ // getPinchTabHost returns PinchTab host from environment
1532
+ func getPinchTabHost() string {
1533
+ host := os.Getenv("PINCHTAB_HOST")
1534
+ if host == "" {
1535
+ return "http://localhost:9867"
1536
+ }
1537
+ return host
1538
+ }
1539
+
1540
+ // exitWithError prints error and exits
1541
+ func exitWithError(format string, args ...interface{}) {
1542
+ fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
1543
+ os.Exit(1)
1544
+ }
1545
+
1546
+ // logVerbose prints if verbose mode is enabled
1547
+ func logVerbose(format string, args ...interface{}) {
1548
+ if verbose {
1549
+ fmt.Printf("[verbose] "+format+"\n", args...)
1550
+ }
1551
+ }
1552
+ ```
1553
+
1554
+ **Step 2: Create auth command**
1555
+
1556
+ Create: `internal/cmd/auth.go`
1557
+
1558
+ ```go
1559
+ package cmd
1560
+
1561
+ import (
1562
+ "fmt"
1563
+ "os"
1564
+ "time"
1565
+
1566
+ "github.com/spf13/cobra"
1567
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
1568
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1569
+ )
1570
+
1571
+ func init() {
1572
+ rootCmd.AddCommand(authCmd)
1573
+ }
1574
+
1575
+ var authCmd = &cobra.Command{
1576
+ Use: "auth",
1577
+ Short: "Authenticate a LinkedIn profile",
1578
+ Long: `Authenticate a LinkedIn profile by logging in through PinchTab.
1579
+
1580
+ This command will:
1581
+ 1. Start a PinchTab browser instance
1582
+ 2. Navigate to LinkedIn login page
1583
+ 3. Wait for you to log in manually
1584
+ 4. Save the session for future use
1585
+
1586
+ Example:
1587
+ linkedin auth --profile john
1588
+ `,
1589
+ Run: runAuth,
1590
+ }
1591
+
1592
+ func runAuth(cmd *cobra.Command, args []string) {
1593
+ if profileName == "" {
1594
+ exitWithError("Profile name is required (--profile or LINKEDIN_PROFILE)")
1595
+ }
1596
+
1597
+ cfg, err := getConfigManager()
1598
+ if err != nil {
1599
+ exitWithError("Failed to initialize config: %v", err)
1600
+ }
1601
+
1602
+ // Check if PinchTab is running
1603
+ client := pinchtab.NewClient(getPinchTabHost())
1604
+
1605
+ fmt.Printf("Starting authentication for profile '%s'...\n", profileName)
1606
+ logVerbose("Using PinchTab at %s", getPinchTabHost())
1607
+
1608
+ if dryRun {
1609
+ fmt.Println("[dry-run] Would:")
1610
+ fmt.Printf(" - Start PinchTab instance with profile 'linkedin-%s'\n", profileName)
1611
+ fmt.Println(" - Navigate to https://linkedin.com/login")
1612
+ fmt.Println(" - Wait for manual login")
1613
+ fmt.Println(" - Save profile configuration")
1614
+ return
1615
+ }
1616
+
1617
+ // Start instance
1618
+ pinchTabProfile := fmt.Sprintf("linkedin-%s", profileName)
1619
+ fmt.Println("Starting browser instance...")
1620
+ instance, err := client.StartInstance(pinchTabProfile)
1621
+ if err != nil {
1622
+ exitWithError("Failed to start PinchTab instance. Is PinchTab running? (%v)", err)
1623
+ }
1624
+
1625
+ defer func() {
1626
+ fmt.Println("Stopping browser instance...")
1627
+ client.StopInstance(instance.ID)
1628
+ }()
1629
+
1630
+ // Navigate to login
1631
+ tab, err := client.NewTab(instance.ID, "https://linkedin.com/login")
1632
+ if err != nil {
1633
+ exitWithError("Failed to create tab: %v", err)
1634
+ }
1635
+
1636
+ fmt.Println("\n========================================")
1637
+ fmt.Println("A browser window has opened.")
1638
+ fmt.Println("Please log in to LinkedIn manually.")
1639
+ fmt.Println("Press Enter when you're logged in...")
1640
+ fmt.Println("========================================\n")
1641
+
1642
+ os.Stdin.Read(make([]byte, 1))
1643
+
1644
+ // Verify we're logged in by checking for profile menu
1645
+ text, err := client.GetText(tab.ID)
1646
+ if err != nil {
1647
+ exitWithError("Failed to get page text: %v", err)
1648
+ }
1649
+
1650
+ if !containsAny(text.Text, []string{"Me", "Messaging", "Notifications"}) {
1651
+ fmt.Println("Warning: Could not verify login. Session may not be saved.")
1652
+ fmt.Println("Please try logging in again.")
1653
+ return
1654
+ }
1655
+
1656
+ // Save profile
1657
+ profile := &config.Profile{
1658
+ Name: profileName,
1659
+ PinchTabProfile: pinchTabProfile,
1660
+ CreatedAt: time.Now(),
1661
+ LastUsed: time.Now(),
1662
+ }
1663
+
1664
+ if err := cfg.SaveProfile(profile); err != nil {
1665
+ exitWithError("Failed to save profile: %v", err)
1666
+ }
1667
+
1668
+ fmt.Printf("\n✓ Profile '%s' authenticated successfully!\n", profileName)
1669
+ fmt.Println("You can now use this profile for automation.")
1670
+ }
1671
+
1672
+ func containsAny(text string, substrs []string) bool {
1673
+ for _, substr := range substrs {
1674
+ if contains(text, substr) {
1675
+ return true
1676
+ }
1677
+ }
1678
+ return false
1679
+ }
1680
+
1681
+ func contains(text, substr string) bool {
1682
+ return len(text) > 0 && len(substr) > 0 &&
1683
+ (len(text) > len(substr) && (text[:len(substr)] == substr ||
1684
+ contains(text[1:], substr)))
1685
+ }
1686
+ ```
1687
+
1688
+ **Step 3: Create connect command**
1689
+
1690
+ Create: `internal/cmd/connect.go`
1691
+
1692
+ ```go
1693
+ package cmd
1694
+
1695
+ import (
1696
+ "fmt"
1697
+
1698
+ "github.com/spf13/cobra"
1699
+ "github.com/thaddeus-git/linkedin-cli/internal/linkedin"
1700
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1701
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
1702
+ )
1703
+
1704
+ var (
1705
+ connectURL string
1706
+ connectNote string
1707
+ )
1708
+
1709
+ func init() {
1710
+ rootCmd.AddCommand(connectCmd)
1711
+ connectCmd.Flags().StringVarP(&connectURL, "url", "u", "", "LinkedIn profile URL (required)")
1712
+ connectCmd.Flags().StringVarP(&connectNote, "message", "m", "", "Connection request note (optional)")
1713
+ connectCmd.MarkFlagRequired("url")
1714
+ }
1715
+
1716
+ var connectCmd = &cobra.Command{
1717
+ Use: "connect",
1718
+ Short: "Send a connection request",
1719
+ Long: `Send a connection request to a LinkedIn profile.
1720
+
1721
+ Examples:
1722
+ linkedin connect --profile john --url linkedin.com/in/alice
1723
+ linkedin connect --profile john --url linkedin.com/in/alice --message "Hi Alice, loved your post!"
1724
+ `,
1725
+ Run: runConnect,
1726
+ }
1727
+
1728
+ func runConnect(cmd *cobra.Command, args []string) {
1729
+ if profileName == "" {
1730
+ exitWithError("Profile name is required (--profile or LINKEDIN_PROFILE)")
1731
+ }
1732
+
1733
+ // Validate URL
1734
+ profileURL, err := linkedin.ValidateProfileURL(connectURL)
1735
+ if err != nil {
1736
+ exitWithError("Invalid URL: %v", err)
1737
+ }
1738
+
1739
+ cfg, err := getConfigManager()
1740
+ if err != nil {
1741
+ exitWithError("Failed to initialize config: %v", err)
1742
+ }
1743
+
1744
+ // Load profile
1745
+ profile, err := cfg.LoadProfile(profileName)
1746
+ if err != nil {
1747
+ exitWithError("Failed to load profile: %v", err)
1748
+ }
1749
+
1750
+ // Check rate limits
1751
+ limiter := ratelimit.NewLimiter(profileName, ratelimit.DefaultLimits(), cfg)
1752
+ if err := limiter.CheckConnection(); err != nil {
1753
+ exitWithError("Rate limit check failed: %v", err)
1754
+ }
1755
+
1756
+ if dryRun {
1757
+ fmt.Println("[dry-run] Would:")
1758
+ fmt.Printf(" - Navigate to: %s\n", profileURL)
1759
+ fmt.Println(" - Click Connect button")
1760
+ if connectNote != "" {
1761
+ fmt.Printf(" - Type note: %s\n", connectNote)
1762
+ }
1763
+ fmt.Println(" - Click Send")
1764
+ return
1765
+ }
1766
+
1767
+ // Initialize PinchTab client
1768
+ client := pinchtab.NewClient(getPinchTabHost())
1769
+ navigator := linkedin.NewNavigator(client)
1770
+
1771
+ fmt.Printf("Sending connection request to %s...\n", profileURL)
1772
+ logVerbose("Using profile: %s", profileName)
1773
+
1774
+ // Start instance
1775
+ logVerbose("Starting PinchTab instance...")
1776
+ instance, err := client.StartInstance(profile.PinchTabProfile)
1777
+ if err != nil {
1778
+ exitWithError("Failed to start PinchTab instance: %v", err)
1779
+ }
1780
+
1781
+ defer func() {
1782
+ logVerbose("Stopping instance...")
1783
+ client.StopInstance(instance.ID)
1784
+ }()
1785
+
1786
+ // Create tab and navigate
1787
+ tab, err := client.NewTab(instance.ID, profileURL)
1788
+ if err != nil {
1789
+ exitWithError("Failed to create tab: %v", err)
1790
+ }
1791
+
1792
+ logVerbose("Navigating to profile...")
1793
+ if err := navigator.NavigateToProfile(tab.ID, profileURL); err != nil {
1794
+ exitWithError("Failed to navigate: %v", err)
1795
+ }
1796
+
1797
+ // Click connect
1798
+ logVerbose("Clicking Connect button...")
1799
+ if err := navigator.ClickConnect(tab.ID); err != nil {
1800
+ exitWithError("Failed to click connect: %v", err)
1801
+ }
1802
+
1803
+ // Send with note if provided
1804
+ logVerbose("Sending connection request...")
1805
+ if err := navigator.SendConnectionRequest(tab.ID, connectNote); err != nil {
1806
+ exitWithError("Failed to send request: %v", err)
1807
+ }
1808
+
1809
+ // Record success
1810
+ if err := limiter.RecordConnection(); err != nil {
1811
+ fmt.Printf("Warning: Failed to record connection: %v\n", err)
1812
+ }
1813
+
1814
+ // Update last used
1815
+ cfg.UpdateLastUsed(profileName)
1816
+
1817
+ fmt.Println("✓ Connection request sent successfully!")
1818
+
1819
+ // Apply rate limiting delay
1820
+ delay := limiter.GetDelay()
1821
+ logVerbose("Waiting %v before exiting...", delay)
1822
+ limiter.Sleep()
1823
+ }
1824
+ ```
1825
+
1826
+ **Step 4: Create message command**
1827
+
1828
+ Create: `internal/cmd/message.go`
1829
+
1830
+ ```go
1831
+ package cmd
1832
+
1833
+ import (
1834
+ "fmt"
1835
+
1836
+ "github.com/spf13/cobra"
1837
+ "github.com/thaddeus-git/linkedin-cli/internal/linkedin"
1838
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1839
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
1840
+ )
1841
+
1842
+ var (
1843
+ messageURL string
1844
+ messageContent string
1845
+ )
1846
+
1847
+ func init() {
1848
+ rootCmd.AddCommand(messageCmd)
1849
+ messageCmd.Flags().StringVarP(&messageURL, "url", "u", "", "LinkedIn profile URL (required)")
1850
+ messageCmd.Flags().StringVarP(&messageContent, "message", "m", "", "Message content (required)")
1851
+ messageCmd.MarkFlagRequired("url")
1852
+ messageCmd.MarkFlagRequired("message")
1853
+ }
1854
+
1855
+ var messageCmd = &cobra.Command{
1856
+ Use: "message",
1857
+ Short: "Send a direct message",
1858
+ Long: `Send a direct message to a LinkedIn connection.
1859
+
1860
+ You must already be connected with the recipient.
1861
+
1862
+ Examples:
1863
+ linkedin message --profile john --url linkedin.com/in/alice --message "Thanks for connecting!"
1864
+ `,
1865
+ Run: runMessage,
1866
+ }
1867
+
1868
+ func runMessage(cmd *cobra.Command, args []string) {
1869
+ if profileName == "" {
1870
+ exitWithError("Profile name is required (--profile or LINKEDIN_PROFILE)")
1871
+ }
1872
+
1873
+ // Validate URL
1874
+ profileURL, err := linkedin.ValidateProfileURL(messageURL)
1875
+ if err != nil {
1876
+ exitWithError("Invalid URL: %v", err)
1877
+ }
1878
+
1879
+ cfg, err := getConfigManager()
1880
+ if err != nil {
1881
+ exitWithError("Failed to initialize config: %v", err)
1882
+ }
1883
+
1884
+ // Load profile
1885
+ profile, err := cfg.LoadProfile(profileName)
1886
+ if err != nil {
1887
+ exitWithError("Failed to load profile: %v", err)
1888
+ }
1889
+
1890
+ // Check rate limits
1891
+ limiter := ratelimit.NewLimiter(profileName, ratelimit.DefaultLimits(), cfg)
1892
+ if err := limiter.CheckMessage(); err != nil {
1893
+ exitWithError("Rate limit check failed: %v", err)
1894
+ }
1895
+
1896
+ if dryRun {
1897
+ fmt.Println("[dry-run] Would:")
1898
+ fmt.Printf(" - Navigate to: %s\n", profileURL)
1899
+ fmt.Println(" - Click Message button")
1900
+ fmt.Printf(" - Type message: %s\n", messageContent)
1901
+ fmt.Println(" - Click Send")
1902
+ return
1903
+ }
1904
+
1905
+ // Initialize PinchTab client
1906
+ client := pinchtab.NewClient(getPinchTabHost())
1907
+ navigator := linkedin.NewNavigator(client)
1908
+
1909
+ fmt.Printf("Sending message to %s...\n", profileURL)
1910
+ logVerbose("Using profile: %s", profileName)
1911
+
1912
+ // Start instance
1913
+ logVerbose("Starting PinchTab instance...")
1914
+ instance, err := client.StartInstance(profile.PinchTabProfile)
1915
+ if err != nil {
1916
+ exitWithError("Failed to start PinchTab instance: %v", err)
1917
+ }
1918
+
1919
+ defer func() {
1920
+ logVerbose("Stopping instance...")
1921
+ client.StopInstance(instance.ID)
1922
+ }()
1923
+
1924
+ // Create tab and navigate
1925
+ tab, err := client.NewTab(instance.ID, profileURL)
1926
+ if err != nil {
1927
+ exitWithError("Failed to create tab: %v", err)
1928
+ }
1929
+
1930
+ logVerbose("Navigating to profile...")
1931
+ if err := navigator.NavigateToProfile(tab.ID, profileURL); err != nil {
1932
+ exitWithError("Failed to navigate: %v", err)
1933
+ }
1934
+
1935
+ // Open message modal
1936
+ logVerbose("Opening message modal...")
1937
+ if err := navigator.OpenMessageModal(tab.ID); err != nil {
1938
+ exitWithError("Failed to open message modal: %v", err)
1939
+ }
1940
+
1941
+ // Send message
1942
+ logVerbose("Sending message...")
1943
+ if err := navigator.SendMessage(tab.ID, messageContent); err != nil {
1944
+ exitWithError("Failed to send message: %v", err)
1945
+ }
1946
+
1947
+ // Record success
1948
+ if err := limiter.RecordMessage(); err != nil {
1949
+ fmt.Printf("Warning: Failed to record message: %v\n", err)
1950
+ }
1951
+
1952
+ // Update last used
1953
+ cfg.UpdateLastUsed(profileName)
1954
+
1955
+ fmt.Println("✓ Message sent successfully!")
1956
+
1957
+ // Apply rate limiting delay
1958
+ delay := limiter.GetDelay()
1959
+ logVerbose("Waiting %v before exiting...", delay)
1960
+ limiter.Sleep()
1961
+ }
1962
+ ```
1963
+
1964
+ **Step 5: Create main.go**
1965
+
1966
+ Create: `cmd/linkedin/main.go`
1967
+
1968
+ ```go
1969
+ package main
1970
+
1971
+ import (
1972
+ "github.com/thaddeus-git/linkedin-cli/internal/cmd"
1973
+ )
1974
+
1975
+ func main() {
1976
+ cmd.Execute()
1977
+ }
1978
+ ```
1979
+
1980
+ **Step 6: Add Cobra dependency**
1981
+
1982
+ ```bash
1983
+ cd /Users/thaddeus/projects/linkedin-cli
1984
+ go get github.com/spf13/cobra
1985
+ ```
1986
+
1987
+ **Step 7: Build and test**
1988
+
1989
+ ```bash
1990
+ go build -o linkedin-cli ./cmd/linkedin
1991
+ ./linkedin-cli --help
1992
+ ```
1993
+
1994
+ Expected: Shows help output with auth, connect, message commands
1995
+
1996
+ **Step 8: Commit**
1997
+
1998
+ ```bash
1999
+ git add go.mod go.sum internal/cmd/ cmd/
2000
+ git commit -m "feat: add CLI commands (auth, connect, message)"
2001
+ ```
2002
+
2003
+ ---
2004
+
2005
+ ## Task 7: Profiles Subcommand
2006
+
2007
+ **Files:**
2008
+ - Create: `internal/cmd/profiles.go`
2009
+
2010
+ **Step 1: Create profiles command**
2011
+
2012
+ Create: `internal/cmd/profiles.go`
2013
+
2014
+ ```go
2015
+ package cmd
2016
+
2017
+ import (
2018
+ "fmt"
2019
+ "os"
2020
+ "text/tabwriter"
2021
+
2022
+ "github.com/spf13/cobra"
2023
+ )
2024
+
2025
+ func init() {
2026
+ rootCmd.AddCommand(profilesCmd)
2027
+ profilesCmd.AddCommand(profilesListCmd)
2028
+ profilesCmd.AddCommand(profilesRemoveCmd)
2029
+ }
2030
+
2031
+ var profilesCmd = &cobra.Command{
2032
+ Use: "profiles",
2033
+ Short: "Manage LinkedIn profiles",
2034
+ Long: `List, add, or remove LinkedIn profiles.`,
2035
+ }
2036
+
2037
+ var profilesListCmd = &cobra.Command{
2038
+ Use: "list",
2039
+ Short: "List all profiles",
2040
+ Run: runProfilesList,
2041
+ }
2042
+
2043
+ func runProfilesList(cmd *cobra.Command, args []string) {
2044
+ cfg, err := getConfigManager()
2045
+ if err != nil {
2046
+ exitWithError("Failed to initialize config: %v", err)
2047
+ }
2048
+
2049
+ profiles, err := cfg.ListProfiles()
2050
+ if err != nil {
2051
+ exitWithError("Failed to list profiles: %v", err)
2052
+ }
2053
+
2054
+ if len(profiles) == 0 {
2055
+ fmt.Println("No profiles found.")
2056
+ fmt.Println("Use 'linkedin auth --profile <name>' to create one.")
2057
+ return
2058
+ }
2059
+
2060
+ // Load rate limits for status
2061
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
2062
+ fmt.Fprintln(w, "NAME\tPINCHTAB PROFILE\tLAST USED")
2063
+
2064
+ for _, name := range profiles {
2065
+ profile, err := cfg.LoadProfile(name)
2066
+ if err != nil {
2067
+ fmt.Fprintf(w, "%s\t<error>\t<error>\n", name)
2068
+ continue
2069
+ }
2070
+
2071
+ lastUsed := "Never"
2072
+ if !profile.LastUsed.IsZero() {
2073
+ lastUsed = profile.LastUsed.Format("2006-01-02 15:04")
2074
+ }
2075
+
2076
+ fmt.Fprintf(w, "%s\t%s\t%s\n", profile.Name, profile.PinchTabProfile, lastUsed)
2077
+ }
2078
+
2079
+ w.Flush()
2080
+
2081
+ // Show rate limits
2082
+ fmt.Println("\nRate Limits:")
2083
+ for _, name := range profiles {
2084
+ limiter := ratelimit.NewLimiter(name, ratelimit.DefaultLimits(), cfg)
2085
+ status, err := limiter.Status()
2086
+ if err != nil {
2087
+ continue
2088
+ }
2089
+
2090
+ fmt.Printf(" %s: %d/%d connections today, %d/%d messages today\n",
2091
+ name,
2092
+ status["connections_today"],
2093
+ status["connections_limit"],
2094
+ status["messages_today"],
2095
+ status["messages_limit"],
2096
+ )
2097
+ }
2098
+ }
2099
+
2100
+ var profilesRemoveCmd = &cobra.Command{
2101
+ Use: "remove [name]",
2102
+ Short: "Remove a profile",
2103
+ Args: cobra.ExactArgs(1),
2104
+ Run: runProfilesRemove,
2105
+ }
2106
+
2107
+ func runProfilesRemove(cmd *cobra.Command, args []string) {
2108
+ name := args[0]
2109
+
2110
+ cfg, err := getConfigManager()
2111
+ if err != nil {
2112
+ exitWithError("Failed to initialize config: %v", err)
2113
+ }
2114
+
2115
+ if !cfg.ProfileExists(name) {
2116
+ exitWithError("Profile '%s' not found", name)
2117
+ }
2118
+
2119
+ fmt.Printf("Are you sure you want to remove profile '%s'? (y/N): ", name)
2120
+ var response string
2121
+ fmt.Scanln(&response)
2122
+
2123
+ if response != "y" && response != "Y" {
2124
+ fmt.Println("Cancelled.")
2125
+ return
2126
+ }
2127
+
2128
+ if err := cfg.DeleteProfile(name); err != nil {
2129
+ exitWithError("Failed to remove profile: %v", err)
2130
+ }
2131
+
2132
+ fmt.Printf("✓ Profile '%s' removed.\n", name)
2133
+ fmt.Println("Note: The PinchTab browser profile is still saved. Remove it manually if needed.")
2134
+ }
2135
+ ```
2136
+
2137
+ **Step 2: Add import**
2138
+
2139
+ Edit: `internal/cmd/profiles.go`
2140
+
2141
+ Add at imports:
2142
+ ```go
2143
+ import (
2144
+ "fmt"
2145
+ "os"
2146
+ "text/tabwriter"
2147
+
2148
+ "github.com/spf13/cobra"
2149
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
2150
+ )
2151
+ ```
2152
+
2153
+ **Step 3: Test**
2154
+
2155
+ ```bash
2156
+ go build -o linkedin-cli ./cmd/linkedin
2157
+ ./linkedin-cli profiles list
2158
+ ./linkedin-cli profiles --help
2159
+ ```
2160
+
2161
+ **Step 4: Commit**
2162
+
2163
+ ```bash
2164
+ git add internal/cmd/profiles.go
2165
+ git commit -m "feat: add profiles list and remove commands"
2166
+ ```
2167
+
2168
+ ---
2169
+
2170
+ ## Task 8: Final Polish
2171
+
2172
+ **Files:**
2173
+ - Modify: `README.md`
2174
+ - Create: `Makefile`
2175
+
2176
+ **Step 1: Update README**
2177
+
2178
+ Replace: `README.md` content
2179
+
2180
+ ```markdown
2181
+ # LinkedIn CLI
2182
+
2183
+ A CLI tool for LinkedIn automation using PinchTab. Send connection requests and messages safely with built-in rate limiting.
2184
+
2185
+ ## Prerequisites
2186
+
2187
+ 1. **Go 1.21+** - [Install Go](https://go.dev/doc/install)
2188
+ 2. **PinchTab** - Install and run:
2189
+ ```bash
2190
+ curl -fsSL https://pinchtab.com/install.sh | bash
2191
+ pinchtab # Keep this running in a separate terminal
2192
+ ```
2193
+
2194
+ ## Installation
2195
+
2196
+ ```bash
2197
+ go install github.com/thaddeus-git/linkedin-cli/cmd/linkedin@latest
2198
+ ```
2199
+
2200
+ Or build from source:
2201
+
2202
+ ```bash
2203
+ git clone https://github.com/thaddeus-git/linkedin-cli.git
2204
+ cd linkedin-cli
2205
+ go build -o linkedin-cli ./cmd/linkedin
2206
+ mv linkedin-cli $GOPATH/bin/linkedin
2207
+ ```
2208
+
2209
+ ## Quick Start
2210
+
2211
+ ### 1. Authenticate your LinkedIn profile
2212
+
2213
+ ```bash
2214
+ linkedin auth --profile john
2215
+ ```
2216
+
2217
+ This opens a browser window. Log in to LinkedIn manually, then press Enter in the terminal.
2218
+
2219
+ ### 2. Send a connection request
2220
+
2221
+ ```bash
2222
+ linkedin connect --profile john --url linkedin.com/in/alice
2223
+ ```
2224
+
2225
+ With a personal note:
2226
+
2227
+ ```bash
2228
+ linkedin connect --profile john --url linkedin.com/in/alice \
2229
+ --message "Hi Alice, saw your post about Go concurrency. Would love to connect!"
2230
+ ```
2231
+
2232
+ ### 3. Send a direct message
2233
+
2234
+ ```bash
2235
+ linkedin message --profile john --url linkedin.com/in/alice \
2236
+ --message "Thanks for connecting! Looking forward to staying in touch."
2237
+ ```
2238
+
2239
+ ## Commands
2240
+
2241
+ | Command | Description |
2242
+ |---------|-------------|
2243
+ | `linkedin auth` | Authenticate a new LinkedIn profile |
2244
+ | `linkedin connect` | Send a connection request |
2245
+ | `linkedin message` | Send a direct message |
2246
+ | `linkedin profiles list` | List authenticated profiles |
2247
+ | `linkedin profiles remove` | Remove a profile |
2248
+
2249
+ ## Configuration
2250
+
2251
+ ### Environment Variables
2252
+
2253
+ | Variable | Description | Default |
2254
+ |----------|-------------|---------|
2255
+ | `LINKEDIN_PROFILE` | Default profile to use | - |
2256
+ | `PINCHTAB_HOST` | PinchTab server URL | `http://localhost:9867` |
2257
+
2258
+ ### Profile Storage
2259
+
2260
+ Profiles and rate limits are stored in `~/.linkedin-cli/`:
2261
+
2262
+ - `profiles/*.json` - Profile configurations
2263
+ - `ratelimit.json` - Rate limit tracking
2264
+
2265
+ ### Rate Limits
2266
+
2267
+ Built-in limits to keep your account safe:
2268
+
2269
+ - **Connection requests**: 20/day, 100/week
2270
+ - **Messages**: 50/day
2271
+ - **Delays**: 3-8 seconds between actions (randomized)
2272
+
2273
+ Use `--verbose` to see rate limit status.
2274
+
2275
+ ## Safety Features
2276
+
2277
+ - ✓ Built-in rate limiting
2278
+ - ✓ Human-like delays between actions
2279
+ - ✓ Dry-run mode (`--dry-run`)
2280
+ - ✓ Session persistence (log in once)
2281
+ - ✓ LinkedIn profile URL validation
2282
+
2283
+ ## Examples
2284
+
2285
+ ### Dry run (test without executing)
2286
+
2287
+ ```bash
2288
+ linkedin connect --profile john --url linkedin.com/in/alice --dry-run
2289
+ ```
2290
+
2291
+ ### Verbose output
2292
+
2293
+ ```bash
2294
+ linkedin connect --profile john --url linkedin.com/in/alice --verbose
2295
+ ```
2296
+
2297
+ ### Use environment variable for profile
2298
+
2299
+ ```bash
2300
+ export LINKEDIN_PROFILE=john
2301
+ linkedin connect --url linkedin.com/in/alice
2302
+ ```
2303
+
2304
+ ## Troubleshooting
2305
+
2306
+ ### "Failed to start PinchTab instance"
2307
+
2308
+ Make sure PinchTab is running:
2309
+
2310
+ ```bash
2311
+ pinchtab # Run in separate terminal
2312
+ ```
2313
+
2314
+ ### "Rate limit exceeded"
2315
+
2316
+ Wait until the rate limit resets. Check status with:
2317
+
2318
+ ```bash
2319
+ linkedin profiles list
2320
+ ```
2321
+
2322
+ ### "Profile not found"
2323
+
2324
+ Authenticate first:
2325
+
2326
+ ```bash
2327
+ linkedin auth --profile <name>
2328
+ ```
2329
+
2330
+ ## Disclaimer
2331
+
2332
+ This tool is for educational and personal use. Using automation on LinkedIn may violate their Terms of Service. Use at your own risk. Start with conservative limits and monitor your account.
2333
+
2334
+ ## License
2335
+
2336
+ MIT
2337
+ ```
2338
+
2339
+ **Step 2: Create Makefile**
2340
+
2341
+ Create: `Makefile`
2342
+
2343
+ ```makefile
2344
+ .PHONY: build test clean install lint
2345
+
2346
+ BINARY_NAME=linkedin-cli
2347
+ MAIN_PACKAGE=./cmd/linkedin
2348
+
2349
+ build:
2350
+ go build -o $(BINARY_NAME) $(MAIN_PACKAGE)
2351
+
2352
+ test:
2353
+ go test -v ./...
2354
+
2355
+ clean:
2356
+ rm -f $(BINARY_NAME)
2357
+ go clean
2358
+
2359
+ install: build
2360
+ mv $(BINARY_NAME) $(GOPATH)/bin/linkedin
2361
+
2362
+ dev:
2363
+ go run $(MAIN_PACKAGE)
2364
+
2365
+ lint:
2366
+ golangci-lint run
2367
+
2368
+ fmt:
2369
+ go fmt ./...
2370
+
2371
+ deps:
2372
+ go mod download
2373
+ go mod tidy
2374
+ ```
2375
+
2376
+ **Step 3: Final build test**
2377
+
2378
+ ```bash
2379
+ make clean
2380
+ make build
2381
+ make test
2382
+ ./linkedin-cli --help
2383
+ ./linkedin-cli auth --help
2384
+ ./linkedin-cli connect --help
2385
+ ./linkedin-cli message --help
2386
+ ./linkedin-cli profiles --help
2387
+ ```
2388
+
2389
+ **Step 4: Final commit**
2390
+
2391
+ ```bash
2392
+ git add README.md Makefile
2393
+ git commit -m "docs: add comprehensive README and Makefile"
2394
+ ```
2395
+
2396
+ ---
2397
+
2398
+ ## Summary
2399
+
2400
+ **Completed implementation:**
2401
+
2402
+ 1. ✅ PinchTab HTTP client (`internal/pinchtab/`)
2403
+ 2. ✅ Configuration management (`internal/config/`)
2404
+ 3. ✅ Rate limiting (`internal/ratelimit/`)
2405
+ 4. ✅ LinkedIn selectors & navigator (`internal/linkedin/`)
2406
+ 5. ✅ CLI commands (`internal/cmd/`)
2407
+ - `auth` - Authenticate profiles
2408
+ - `connect` - Send connection requests
2409
+ - `message` - Send direct messages
2410
+ - `profiles list/remove` - Manage profiles
2411
+ 6. ✅ Main entry point (`cmd/linkedin/main.go`)
2412
+ 7. ✅ Documentation (README, Makefile)
2413
+
2414
+ **Next steps:**
2415
+ 1. Test with real LinkedIn account
2416
+ 2. Add `queue` command for batch processing
2417
+ 3. Add more robust error handling for LinkedIn UI changes
2418
+ 4. Add tests for navigator (requires mocking)
2419
+
2420
+ **Plan saved to:** `docs/plans/2025-03-03-linkedin-cli-implementation.md`