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,2087 @@
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 and safety features.
6
+
7
+ **Architecture:** Go CLI using Cobra for commands, with packages for PinchTab HTTP client (`pinchtab/`), LinkedIn-specific selectors (`linkedin/`), rate limiting (`ratelimit/`), and configuration management (`config/`). JSON files for state persistence (no database for v1).
8
+
9
+ **Tech Stack:** Go 1.21+, Cobra (CLI), standard library for HTTP client, atomic file writes for state management.
10
+
11
+ ---
12
+
13
+ ## Task 1: Project Setup
14
+
15
+ **Files:**
16
+ - Create: `go.mod`
17
+ - Create: `.gitignore`
18
+ - Create: `README.md` (basic)
19
+
20
+ **Step 1: Initialize Go module**
21
+
22
+ ```bash
23
+ cd /Users/thaddeus/projects/linkedin-cli
24
+ go mod init github.com/thaddeus-git/linkedin-cli
25
+ ```
26
+
27
+ **Step 2: Install Cobra CLI tool**
28
+
29
+ ```bash
30
+ go install github.com/spf13/cobra-cli@latest
31
+ ```
32
+
33
+ **Step 3: Initialize Cobra project**
34
+
35
+ ```bash
36
+ cobra-cli init --pkg-name github.com/thaddeus-git/linkedin-cli
37
+ ```
38
+
39
+ **Step 4: Create directory structure**
40
+
41
+ ```bash
42
+ mkdir -p internal/{cmd,config,pinchtab,linkedin,ratelimit}
43
+ ```
44
+
45
+ **Step 5: Create .gitignore**
46
+
47
+ ```bash
48
+ cat > .gitignore << 'EOF'
49
+ # Binaries
50
+ linkedin-cli
51
+ *.exe
52
+ *.dll
53
+ *.so
54
+ *.dylib
55
+
56
+ # Test binary
57
+ *.test
58
+
59
+ # Output of go coverage
60
+ coverage.out
61
+
62
+ # Dependency directories
63
+ vendor/
64
+
65
+ # IDE
66
+ .idea/
67
+ .vscode/
68
+ *.swp
69
+ *.swo
70
+ *~
71
+
72
+ # OS
73
+ .DS_Store
74
+ Thumbs.db
75
+
76
+ # Config/state (user data)
77
+ /.linkedin-cli/
78
+ EOF
79
+ ```
80
+
81
+ **Step 6: Verify build works**
82
+
83
+ ```bash
84
+ go build -o linkedin-cli .
85
+ ./linkedin-cli --help
86
+ ```
87
+
88
+ **Expected output:** Shows root command help with "A brief description..."
89
+
90
+ **Step 7: Commit**
91
+
92
+ ```bash
93
+ git add .
94
+ git commit -m "chore: initialize Go project with Cobra"
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Task 2: PinchTab HTTP Client - Core Types
100
+
101
+ **Files:**
102
+ - Create: `internal/pinchtab/types.go`
103
+ - Create: `internal/pinchtab/types_test.go`
104
+
105
+ **Step 1: Write types test**
106
+
107
+ ```go
108
+ package pinchtab
109
+
110
+ import (
111
+ "testing"
112
+ "encoding/json"
113
+ )
114
+
115
+ func TestInstanceResponseSerialization(t *testing.T) {
116
+ jsonData := `{"id":"inst_abc123","profileId":"test","port":9867,"status":"running"}`
117
+ var resp InstanceResponse
118
+ err := json.Unmarshal([]byte(jsonData), &resp)
119
+ if err != nil {
120
+ t.Fatalf("Failed to unmarshal: %v", err)
121
+ }
122
+ if resp.ID != "inst_abc123" {
123
+ t.Errorf("Expected ID 'inst_abc123', got '%s'", resp.ID)
124
+ }
125
+ if resp.Status != "running" {
126
+ t.Errorf("Expected status 'running', got '%s'", resp.Status)
127
+ }
128
+ }
129
+
130
+ func TestActionRequestSerialization(t *testing.T) {
131
+ req := ActionRequest{
132
+ Kind: "click",
133
+ Ref: "e5",
134
+ }
135
+ data, err := json.Marshal(req)
136
+ if err != nil {
137
+ t.Fatalf("Failed to marshal: %v", err)
138
+ }
139
+ expected := `{"kind":"click","ref":"e5"}`
140
+ if string(data) != expected {
141
+ t.Errorf("Expected '%s', got '%s'", expected, string(data))
142
+ }
143
+ }
144
+ ```
145
+
146
+ **Step 2: Run test (should fail)**
147
+
148
+ ```bash
149
+ go test ./internal/pinchtab/... -v
150
+ ```
151
+
152
+ **Expected:** FAIL - undefined types
153
+
154
+ **Step 3: Implement types**
155
+
156
+ ```go
157
+ package pinchtab
158
+
159
+ // InstanceResponse represents a PinchTab instance
160
+ type InstanceResponse struct {
161
+ ID string `json:"id"`
162
+ ProfileID string `json:"profileId"`
163
+ Port int `json:"port"`
164
+ Status string `json:"status"`
165
+ }
166
+
167
+ // ActionRequest represents an action to perform
168
+ type ActionRequest struct {
169
+ Kind string `json:"kind"`
170
+ Ref string `json:"ref,omitempty"`
171
+ Value string `json:"value,omitempty"`
172
+ Key string `json:"key,omitempty"`
173
+ Amount int `json:"amount,omitempty"`
174
+ URL string `json:"url,omitempty"`
175
+ }
176
+
177
+ // SnapshotResponse represents page snapshot
178
+ type SnapshotResponse struct {
179
+ Title string `json:"title"`
180
+ URL string `json:"url"`
181
+ Elements []Element `json:"elements"`
182
+ }
183
+
184
+ // Element represents an interactive element
185
+ type Element struct {
186
+ Ref string `json:"ref"`
187
+ Type string `json:"type"`
188
+ Text string `json:"text"`
189
+ Selector string `json:"selector,omitempty"`
190
+ }
191
+
192
+ // TextResponse represents extracted text
193
+ type TextResponse struct {
194
+ Text string `json:"text"`
195
+ }
196
+ ```
197
+
198
+ **Step 4: Run test (should pass)**
199
+
200
+ ```bash
201
+ go test ./internal/pinchtab/... -v
202
+ ```
203
+
204
+ **Expected:** PASS
205
+
206
+ **Step 5: Commit**
207
+
208
+ ```bash
209
+ git add .
210
+ git commit -m "feat(pinchtab): add core data types"
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Task 3: PinchTab HTTP Client - Client Implementation
216
+
217
+ **Files:**
218
+ - Create: `internal/pinchtab/client.go`
219
+ - Create: `internal/pinchtab/client_test.go`
220
+
221
+ **Step 1: Write client test**
222
+
223
+ ```go
224
+ package pinchtab
225
+
226
+ import (
227
+ "encoding/json"
228
+ "net/http"
229
+ "net/http/httptest"
230
+ "testing"
231
+ )
232
+
233
+ func TestClientStartInstance(t *testing.T) {
234
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
235
+ if r.URL.Path != "/instances" {
236
+ t.Errorf("Expected path '/instances', got '%s'", r.URL.Path)
237
+ }
238
+ if r.Method != "POST" {
239
+ t.Errorf("Expected POST, got '%s'", r.Method)
240
+ }
241
+
242
+ resp := InstanceResponse{
243
+ ID: "inst_test123",
244
+ ProfileID: "test-profile",
245
+ Port: 9867,
246
+ Status: "running",
247
+ }
248
+ json.NewEncoder(w).Encode(resp)
249
+ }))
250
+ defer server.Close()
251
+
252
+ client := NewClient(server.URL)
253
+ instance, err := client.StartInstance("test-profile")
254
+ if err != nil {
255
+ t.Fatalf("StartInstance failed: %v", err)
256
+ }
257
+ if instance.ID != "inst_test123" {
258
+ t.Errorf("Expected ID 'inst_test123', got '%s'", instance.ID)
259
+ }
260
+ }
261
+
262
+ func TestClientNavigate(t *testing.T) {
263
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
264
+ if r.URL.Path != "/instances/test-tab/navigate" {
265
+ t.Errorf("Expected navigate path, got '%s'", r.URL.Path)
266
+ }
267
+ w.WriteHeader(http.StatusOK)
268
+ }))
269
+ defer server.Close()
270
+
271
+ client := NewClient(server.URL)
272
+ err := client.Navigate("test-tab", "https://linkedin.com/in/test")
273
+ if err != nil {
274
+ t.Fatalf("Navigate failed: %v", err)
275
+ }
276
+ }
277
+ ```
278
+
279
+ **Step 2: Run test (should fail)**
280
+
281
+ ```bash
282
+ go test ./internal/pinchtab/... -v
283
+ ```
284
+
285
+ **Expected:** FAIL - undefined functions
286
+
287
+ **Step 3: Implement client**
288
+
289
+ ```go
290
+ package pinchtab
291
+
292
+ import (
293
+ "bytes"
294
+ "encoding/json"
295
+ "fmt"
296
+ "io"
297
+ "net/http"
298
+ "time"
299
+ )
300
+
301
+ // Client is a PinchTab HTTP API client
302
+ type Client struct {
303
+ BaseURL string
304
+ HTTPClient *http.Client
305
+ }
306
+
307
+ // NewClient creates a new PinchTab client
308
+ func NewClient(baseURL string) *Client {
309
+ return &Client{
310
+ BaseURL: baseURL,
311
+ HTTPClient: &http.Client{Timeout: 30 * time.Second},
312
+ }
313
+ }
314
+
315
+ // StartInstance starts a new PinchTab instance with the given profile
316
+ func (c *Client) StartInstance(profileID string) (*InstanceResponse, error) {
317
+ reqBody := map[string]string{"profileId": profileID}
318
+ data, _ := json.Marshal(reqBody)
319
+
320
+ resp, err := c.HTTPClient.Post(
321
+ c.BaseURL+"/instances",
322
+ "application/json",
323
+ bytes.NewReader(data),
324
+ )
325
+ if err != nil {
326
+ return nil, fmt.Errorf("failed to start instance: %w", err)
327
+ }
328
+ defer resp.Body.Close()
329
+
330
+ if resp.StatusCode != http.StatusOK {
331
+ body, _ := io.ReadAll(resp.Body)
332
+ return nil, fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
333
+ }
334
+
335
+ var instance InstanceResponse
336
+ if err := json.NewDecoder(resp.Body).Decode(&instance); err != nil {
337
+ return nil, fmt.Errorf("failed to decode response: %w", err)
338
+ }
339
+
340
+ return &instance, nil
341
+ }
342
+
343
+ // Navigate navigates a tab to a URL
344
+ func (c *Client) Navigate(tabID, url string) error {
345
+ reqBody := map[string]string{"url": url}
346
+ data, _ := json.Marshal(reqBody)
347
+
348
+ resp, err := c.HTTPClient.Post(
349
+ fmt.Sprintf("%s/instances/%s/navigate", c.BaseURL, tabID),
350
+ "application/json",
351
+ bytes.NewReader(data),
352
+ )
353
+ if err != nil {
354
+ return fmt.Errorf("failed to navigate: %w", err)
355
+ }
356
+ defer resp.Body.Close()
357
+
358
+ if resp.StatusCode != http.StatusOK {
359
+ body, _ := io.ReadAll(resp.Body)
360
+ return fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
361
+ }
362
+
363
+ return nil
364
+ }
365
+
366
+ // GetSnapshot gets the page accessibility snapshot
367
+ func (c *Client) GetSnapshot(tabID string) (*SnapshotResponse, error) {
368
+ resp, err := c.HTTPClient.Get(
369
+ fmt.Sprintf("%s/instances/%s/snapshot", c.BaseURL, tabID),
370
+ )
371
+ if err != nil {
372
+ return nil, fmt.Errorf("failed to get snapshot: %w", err)
373
+ }
374
+ defer resp.Body.Close()
375
+
376
+ if resp.StatusCode != http.StatusOK {
377
+ body, _ := io.ReadAll(resp.Body)
378
+ return nil, fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
379
+ }
380
+
381
+ var snapshot SnapshotResponse
382
+ if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil {
383
+ return nil, fmt.Errorf("failed to decode snapshot: %w", err)
384
+ }
385
+
386
+ return &snapshot, nil
387
+ }
388
+
389
+ // ExecuteAction executes an action on an element
390
+ func (c *Client) ExecuteAction(tabID string, action ActionRequest) error {
391
+ data, _ := json.Marshal(action)
392
+
393
+ resp, err := c.HTTPClient.Post(
394
+ fmt.Sprintf("%s/instances/%s/actions", c.BaseURL, tabID),
395
+ "application/json",
396
+ bytes.NewReader(data),
397
+ )
398
+ if err != nil {
399
+ return fmt.Errorf("failed to execute action: %w", err)
400
+ }
401
+ defer resp.Body.Close()
402
+
403
+ if resp.StatusCode != http.StatusOK {
404
+ body, _ := io.ReadAll(resp.Body)
405
+ return fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
406
+ }
407
+
408
+ return nil
409
+ }
410
+
411
+ // ExtractText extracts text from the page
412
+ func (c *Client) ExtractText(tabID string) (*TextResponse, error) {
413
+ resp, err := c.HTTPClient.Get(
414
+ fmt.Sprintf("%s/instances/%s/text", c.BaseURL, tabID),
415
+ )
416
+ if err != nil {
417
+ return nil, fmt.Errorf("failed to extract text: %w", err)
418
+ }
419
+ defer resp.Body.Close()
420
+
421
+ if resp.StatusCode != http.StatusOK {
422
+ body, _ := io.ReadAll(resp.Body)
423
+ return nil, fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
424
+ }
425
+
426
+ var textResp TextResponse
427
+ if err := json.NewDecoder(resp.Body).Decode(&textResp); err != nil {
428
+ return nil, fmt.Errorf("failed to decode text: %w", err)
429
+ }
430
+
431
+ return &textResp, nil
432
+ }
433
+
434
+ // StopInstance stops a PinchTab instance
435
+ func (c *Client) StopInstance(instanceID string) error {
436
+ req, err := http.NewRequest(http.MethodPost,
437
+ fmt.Sprintf("%s/instances/%s/stop", c.BaseURL, instanceID),
438
+ nil,
439
+ )
440
+ if err != nil {
441
+ return fmt.Errorf("failed to create stop request: %w", err)
442
+ }
443
+
444
+ resp, err := c.HTTPClient.Do(req)
445
+ if err != nil {
446
+ return fmt.Errorf("failed to stop instance: %w", err)
447
+ }
448
+ defer resp.Body.Close()
449
+
450
+ if resp.StatusCode != http.StatusOK {
451
+ body, _ := io.ReadAll(resp.Body)
452
+ return fmt.Errorf("pinchtab error: %s - %s", resp.Status, string(body))
453
+ }
454
+
455
+ return nil
456
+ }
457
+ ```
458
+
459
+ **Step 4: Run test (should pass)**
460
+
461
+ ```bash
462
+ go test ./internal/pinchtab/... -v
463
+ ```
464
+
465
+ **Expected:** PASS
466
+
467
+ **Step 5: Commit**
468
+
469
+ ```bash
470
+ git add .
471
+ git commit -m "feat(pinchtab): implement HTTP client"
472
+ ```
473
+
474
+ ---
475
+
476
+ ## Task 4: Configuration Management
477
+
478
+ **Files:**
479
+ - Create: `internal/config/config.go`
480
+ - Create: `internal/config/config_test.go`
481
+
482
+ **Step 1: Write config test**
483
+
484
+ ```go
485
+ package config
486
+
487
+ import (
488
+ "os"
489
+ "path/filepath"
490
+ "testing"
491
+ )
492
+
493
+ func TestGetConfigDir(t *testing.T) {
494
+ dir, err := GetConfigDir()
495
+ if err != nil {
496
+ t.Fatalf("GetConfigDir failed: %v", err)
497
+ }
498
+ if dir == "" {
499
+ t.Error("Config dir should not be empty")
500
+ }
501
+ }
502
+
503
+ func TestProfileSaveAndLoad(t *testing.T) {
504
+ // Use temp dir for testing
505
+ tmpDir := t.TempDir()
506
+
507
+ profile := &Profile{
508
+ Name: "test",
509
+ PinchTabProfile: "linkedin-test",
510
+ }
511
+
512
+ // Save
513
+ path := filepath.Join(tmpDir, "test.json")
514
+ err := profile.Save(path)
515
+ if err != nil {
516
+ t.Fatalf("Save failed: %v", err)
517
+ }
518
+
519
+ // Load
520
+ loaded, err := LoadProfile(path)
521
+ if err != nil {
522
+ t.Fatalf("LoadProfile failed: %v", err)
523
+ }
524
+ if loaded.Name != "test" {
525
+ t.Errorf("Expected name 'test', got '%s'", loaded.Name)
526
+ }
527
+ }
528
+ ```
529
+
530
+ **Step 2: Run test (should fail)**
531
+
532
+ ```bash
533
+ go test ./internal/config/... -v
534
+ ```
535
+
536
+ **Expected:** FAIL - undefined types
537
+
538
+ **Step 3: Implement config**
539
+
540
+ ```go
541
+ package config
542
+
543
+ import (
544
+ "encoding/json"
545
+ "fmt"
546
+ "os"
547
+ "path/filepath"
548
+ "time"
549
+ )
550
+
551
+ // Profile represents a LinkedIn profile configuration
552
+ type Profile struct {
553
+ Name string `json:"name"`
554
+ PinchTabProfile string `json:"pinchtab_profile"`
555
+ CreatedAt time.Time `json:"created_at"`
556
+ LastUsed time.Time `json:"last_used"`
557
+ }
558
+
559
+ // GetConfigDir returns the configuration directory
560
+ func GetConfigDir() (string, error) {
561
+ home, err := os.UserHomeDir()
562
+ if err != nil {
563
+ return "", fmt.Errorf("failed to get home directory: %w", err)
564
+ }
565
+ return filepath.Join(home, ".linkedin-cli"), nil
566
+ }
567
+
568
+ // GetProfilesDir returns the profiles directory
569
+ func GetProfilesDir() (string, error) {
570
+ configDir, err := GetConfigDir()
571
+ if err != nil {
572
+ return "", err
573
+ }
574
+ return filepath.Join(configDir, "profiles"), nil
575
+ }
576
+
577
+ // ProfilePath returns the path for a profile file
578
+ func ProfilePath(name string) (string, error) {
579
+ profilesDir, err := GetProfilesDir()
580
+ if err != nil {
581
+ return "", err
582
+ }
583
+ return filepath.Join(profilesDir, name+".json"), nil
584
+ }
585
+
586
+ // Save writes the profile to disk
587
+ func (p *Profile) Save(path string) error {
588
+ data, err := json.MarshalIndent(p, "", " ")
589
+ if err != nil {
590
+ return fmt.Errorf("failed to marshal profile: %w", err)
591
+ }
592
+
593
+ dir := filepath.Dir(path)
594
+ if err := os.MkdirAll(dir, 0755); err != nil {
595
+ return fmt.Errorf("failed to create directory: %w", err)
596
+ }
597
+
598
+ if err := os.WriteFile(path, data, 0600); err != nil {
599
+ return fmt.Errorf("failed to write profile: %w", err)
600
+ }
601
+
602
+ return nil
603
+ }
604
+
605
+ // LoadProfile loads a profile from disk
606
+ func LoadProfile(path string) (*Profile, error) {
607
+ data, err := os.ReadFile(path)
608
+ if err != nil {
609
+ return nil, fmt.Errorf("failed to read profile: %w", err)
610
+ }
611
+
612
+ var profile Profile
613
+ if err := json.Unmarshal(data, &profile); err != nil {
614
+ return nil, fmt.Errorf("failed to unmarshal profile: %w", err)
615
+ }
616
+
617
+ return &profile, nil
618
+ }
619
+
620
+ // ListProfiles returns all available profile names
621
+ func ListProfiles() ([]string, error) {
622
+ profilesDir, err := GetProfilesDir()
623
+ if err != nil {
624
+ return nil, err
625
+ }
626
+
627
+ entries, err := os.ReadDir(profilesDir)
628
+ if err != nil {
629
+ if os.IsNotExist(err) {
630
+ return []string{}, nil
631
+ }
632
+ return nil, fmt.Errorf("failed to read profiles directory: %w", err)
633
+ }
634
+
635
+ var profiles []string
636
+ for _, entry := range entries {
637
+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
638
+ name := entry.Name()
639
+ name = name[:len(name)-5] // Remove .json
640
+ profiles = append(profiles, name)
641
+ }
642
+ }
643
+
644
+ return profiles, nil
645
+ }
646
+
647
+ // ProfileExists checks if a profile exists
648
+ func ProfileExists(name string) (bool, error) {
649
+ path, err := ProfilePath(name)
650
+ if err != nil {
651
+ return false, err
652
+ }
653
+ _, err = os.Stat(path)
654
+ if os.IsNotExist(err) {
655
+ return false, nil
656
+ }
657
+ return err == nil, err
658
+ }
659
+
660
+ // DeleteProfile removes a profile
661
+ func DeleteProfile(name string) error {
662
+ path, err := ProfilePath(name)
663
+ if err != nil {
664
+ return err
665
+ }
666
+ if err := os.Remove(path); err != nil {
667
+ return fmt.Errorf("failed to delete profile: %w", err)
668
+ }
669
+ return nil
670
+ }
671
+ ```
672
+
673
+ **Step 4: Run test (should pass)**
674
+
675
+ ```bash
676
+ go test ./internal/config/... -v
677
+ ```
678
+
679
+ **Expected:** PASS
680
+
681
+ **Step 5: Commit**
682
+
683
+ ```bash
684
+ git add .
685
+ git commit -m "feat(config): add profile management"
686
+ ```
687
+
688
+ ---
689
+
690
+ ## Task 5: Rate Limiting
691
+
692
+ **Files:**
693
+ - Create: `internal/ratelimit/limiter.go`
694
+ - Create: `internal/ratelimit/limiter_test.go`
695
+
696
+ **Step 1: Write rate limit test**
697
+
698
+ ```go
699
+ package ratelimit
700
+
701
+ import (
702
+ "testing"
703
+ "time"
704
+ )
705
+
706
+ func TestCheckConnectionLimit(t *testing.T) {
707
+ rateFile := "/tmp/test_ratellmit.json"
708
+ limiter := New(rateFile)
709
+
710
+ // Should allow first 20 connections
711
+ for i := 0; i < 20; i++ {
712
+ ok, err := limiter.CheckConnection("test-profile")
713
+ if err != nil {
714
+ t.Fatalf("CheckConnection failed: %v", err)
715
+ }
716
+ if !ok {
717
+ t.Errorf("Should allow connection %d", i+1)
718
+ }
719
+ }
720
+
721
+ // 21st should be blocked
722
+ ok, err := limiter.CheckConnection("test-profile")
723
+ if err != nil {
724
+ t.Fatalf("CheckConnection failed: %v", err)
725
+ }
726
+ if ok {
727
+ t.Error("Should block 21st connection")
728
+ }
729
+ }
730
+ ```
731
+
732
+ **Step 2: Run test (should fail)**
733
+
734
+ ```bash
735
+ go test ./internal/ratelimit/... -v
736
+ ```
737
+
738
+ **Expected:** FAIL - undefined types
739
+
740
+ **Step 3: Implement rate limiter**
741
+
742
+ ```go
743
+ package ratelimit
744
+
745
+ import (
746
+ "encoding/json"
747
+ "fmt"
748
+ "os"
749
+ "path/filepath"
750
+ "sync"
751
+ "time"
752
+ )
753
+
754
+ // Limits defines rate limits for a profile
755
+ type Limits struct {
756
+ ConnectionsToday int `json:"connections_today"`
757
+ ConnectionsWeek int `json:"connections_week"`
758
+ MessagesToday int `json:"messages_today"`
759
+ LastConnectionTime time.Time `json:"last_connection_time"`
760
+ LastMessageTime time.Time `json:"last_message_time"`
761
+ }
762
+
763
+ // Limiter manages rate limits for profiles
764
+ type Limiter struct {
765
+ stateFile string
766
+ limits map[string]*Limits
767
+ mu sync.RWMutex
768
+ }
769
+
770
+ // Default limits (LinkedIn-safe)
771
+ const (
772
+ MaxConnectionsPerDay = 20
773
+ MaxConnectionsPerWeek = 100
774
+ MaxMessagesPerDay = 50
775
+ )
776
+
777
+ // New creates a new rate limiter
778
+ func New(stateFile string) *Limiter {
779
+ return &Limiter{
780
+ stateFile: stateFile,
781
+ limits: make(map[string]*Limits),
782
+ }
783
+ }
784
+
785
+ // Load reads rate limit state from disk
786
+ func (l *Limiter) Load() error {
787
+ l.mu.Lock()
788
+ defer l.mu.Unlock()
789
+
790
+ data, err := os.ReadFile(l.stateFile)
791
+ if err != nil {
792
+ if os.IsNotExist(err) {
793
+ return nil // No state yet
794
+ }
795
+ return fmt.Errorf("failed to read rate limit file: %w", err)
796
+ }
797
+
798
+ if err := json.Unmarshal(data, &l.limits); err != nil {
799
+ return fmt.Errorf("failed to unmarshal rate limits: %w", err)
800
+ }
801
+
802
+ return nil
803
+ }
804
+
805
+ // Save writes rate limit state to disk
806
+ func (l *Limiter) Save() error {
807
+ l.mu.RLock()
808
+ data, err := json.MarshalIndent(l.limits, "", " ")
809
+ l.mu.RUnlock()
810
+
811
+ if err != nil {
812
+ return fmt.Errorf("failed to marshal rate limits: %w", err)
813
+ }
814
+
815
+ dir := filepath.Dir(l.stateFile)
816
+ if err := os.MkdirAll(dir, 0755); err != nil {
817
+ return fmt.Errorf("failed to create directory: %w", err)
818
+ }
819
+
820
+ if err := os.WriteFile(l.stateFile, data, 0600); err != nil {
821
+ return fmt.Errorf("failed to write rate limits: %w", err)
822
+ }
823
+
824
+ return nil
825
+ }
826
+
827
+ // resetIfNeeded resets counters if day/week has changed
828
+ func (l *Limiter) resetIfNeeded(profile string) {
829
+ limits, exists := l.limits[profile]
830
+ if !exists {
831
+ l.limits[profile] = &Limits{}
832
+ return
833
+ }
834
+
835
+ now := time.Now()
836
+
837
+ // Reset daily counters if it's a new day
838
+ if !limits.LastConnectionTime.IsZero() &&
839
+ limits.LastConnectionTime.YearDay() != now.YearDay() {
840
+ limits.ConnectionsToday = 0
841
+ limits.MessagesToday = 0
842
+ }
843
+
844
+ // Reset weekly counter if it's a new week
845
+ if !limits.LastConnectionTime.IsZero() {
846
+ _, lastWeek := limits.LastConnectionTime.ISOWeek()
847
+ _, thisWeek := now.ISOWeek()
848
+ if lastWeek != thisWeek {
849
+ limits.ConnectionsWeek = 0
850
+ }
851
+ }
852
+ }
853
+
854
+ // CheckConnection checks if a connection request is allowed
855
+ func (l *Limiter) CheckConnection(profile string) (bool, error) {
856
+ l.mu.Lock()
857
+ defer l.mu.Unlock()
858
+
859
+ l.resetIfNeeded(profile)
860
+ limits := l.limits[profile]
861
+
862
+ if limits.ConnectionsToday >= MaxConnectionsPerDay {
863
+ return false, nil
864
+ }
865
+ if limits.ConnectionsWeek >= MaxConnectionsPerWeek {
866
+ return false, nil
867
+ }
868
+
869
+ return true, nil
870
+ }
871
+
872
+ // RecordConnection records a successful connection
873
+ func (l *Limiter) RecordConnection(profile string) error {
874
+ l.mu.Lock()
875
+ defer l.mu.Unlock()
876
+
877
+ l.resetIfNeeded(profile)
878
+ limits := l.limits[profile]
879
+
880
+ limits.ConnectionsToday++
881
+ limits.ConnectionsWeek++
882
+ limits.LastConnectionTime = time.Now()
883
+
884
+ return l.Save()
885
+ }
886
+
887
+ // CheckMessage checks if a message is allowed
888
+ func (l *Limiter) CheckMessage(profile string) (bool, error) {
889
+ l.mu.Lock()
890
+ defer l.mu.Unlock()
891
+
892
+ l.resetIfNeeded(profile)
893
+ limits := l.limits[profile]
894
+
895
+ return limits.MessagesToday < MaxMessagesPerDay, nil
896
+ }
897
+
898
+ // RecordMessage records a successful message
899
+ func (l *Limiter) RecordMessage(profile string) error {
900
+ l.mu.Lock()
901
+ defer l.mu.Unlock()
902
+
903
+ l.resetIfNeeded(profile)
904
+ limits := l.limits[profile]
905
+
906
+ limits.MessagesToday++
907
+ limits.LastMessageTime = time.Now()
908
+
909
+ return l.Save()
910
+ }
911
+
912
+ // GetStats returns current usage stats for a profile
913
+ func (l *Limiter) GetStats(profile string) (connectionsToday, connectionsWeek, messagesToday int) {
914
+ l.mu.RLock()
915
+ defer l.mu.RUnlock()
916
+
917
+ limits, exists := l.limits[profile]
918
+ if !exists {
919
+ return 0, 0, 0
920
+ }
921
+
922
+ return limits.ConnectionsToday, limits.ConnectionsWeek, limits.MessagesToday
923
+ }
924
+ ```
925
+
926
+ **Step 4: Run test (should pass)**
927
+
928
+ ```bash
929
+ go test ./internal/ratelimit/... -v
930
+ ```
931
+
932
+ **Expected:** PASS
933
+
934
+ **Step 5: Commit**
935
+
936
+ ```bash
937
+ git add .
938
+ git commit -m "feat(ratelimit): add rate limiting with LinkedIn-safe defaults"
939
+ ```
940
+
941
+ ---
942
+
943
+ ## Task 6: LinkedIn Selectors
944
+
945
+ **Files:**
946
+ - Create: `internal/linkedin/selectors.go`
947
+ - Create: `internal/linkedin/validator.go`
948
+
949
+ **Step 1: Implement selectors**
950
+
951
+ ```go
952
+ package linkedin
953
+
954
+ // Element selectors for LinkedIn pages
955
+ // Note: These are fragile and may break when LinkedIn updates their UI
956
+
957
+ const (
958
+ // Profile page selectors
959
+ ConnectButtonText = "Connect"
960
+ MessageButtonText = "Message"
961
+ PendingButtonText = "Pending"
962
+
963
+ // Connection modal selectors
964
+ ConnectModalNoteTextarea = "textarea[name='message']"
965
+ ConnectModalSendButton = "button[aria-label='Send now']"
966
+
967
+ // Message page selectors
968
+ MessageTextarea = "textarea.msg-form__contenteditable"
969
+ MessageSendButton = "button[type='submit']"
970
+
971
+ // Common selectors (accessibility refs)
972
+ RefConnectButton = "e0" // This will be dynamic, found via snapshot
973
+ )
974
+
975
+ // FindElementRef finds an element reference by text content
976
+ // Returns the ref (e.g., "e5") if found, empty string if not found
977
+ func FindElementRef(elements []Element, text string) string {
978
+ for _, el := range elements {
979
+ if el.Text == text {
980
+ return el.Ref
981
+ }
982
+ }
983
+ return ""
984
+ }
985
+
986
+ // Element represents an interactive element from PinchTab snapshot
987
+ type Element struct {
988
+ Ref string `json:"ref"`
989
+ Type string `json:"type"`
990
+ Text string `json:"text"`
991
+ }
992
+ ```
993
+
994
+ **Step 2: Implement validator**
995
+
996
+ ```go
997
+ package linkedin
998
+
999
+ import (
1000
+ "fmt"
1001
+ "net/url"
1002
+ "strings"
1003
+ )
1004
+
1005
+ // ValidateProfileURL checks if a URL is a valid LinkedIn profile URL
1006
+ func ValidateProfileURL(input string) error {
1007
+ if !strings.HasPrefix(input, "http") {
1008
+ input = "https://" + input
1009
+ }
1010
+
1011
+ u, err := url.Parse(input)
1012
+ if err != nil {
1013
+ return fmt.Errorf("invalid URL: %w", err)
1014
+ }
1015
+
1016
+ // Check domain
1017
+ if !strings.Contains(u.Host, "linkedin.com") {
1018
+ return fmt.Errorf("URL must be a linkedin.com domain")
1019
+ }
1020
+
1021
+ // Check path patterns
1022
+ path := u.Path
1023
+ validPatterns := []string{
1024
+ "/in/",
1025
+ "/sales/lead/",
1026
+ "/sales/gmail/",
1027
+ }
1028
+
1029
+ valid := false
1030
+ for _, pattern := range validPatterns {
1031
+ if strings.HasPrefix(path, pattern) {
1032
+ valid = true
1033
+ break
1034
+ }
1035
+ }
1036
+
1037
+ if !valid {
1038
+ return fmt.Errorf("URL must be a LinkedIn profile URL (e.g., linkedin.com/in/username)")
1039
+ }
1040
+
1041
+ return nil
1042
+ }
1043
+
1044
+ // NormalizeProfileURL ensures the URL has the https:// prefix
1045
+ func NormalizeProfileURL(input string) string {
1046
+ if !strings.HasPrefix(input, "http") {
1047
+ return "https://" + input
1048
+ }
1049
+ return input
1050
+ }
1051
+ ```
1052
+
1053
+ **Step 3: Commit**
1054
+
1055
+ ```bash
1056
+ git add .
1057
+ git commit -m "feat(linkedin): add selectors and URL validation"
1058
+ ```
1059
+
1060
+ ---
1061
+
1062
+ ## Task 7: Auth Command
1063
+
1064
+ **Files:**
1065
+ - Create: `internal/cmd/auth.go`
1066
+ - Modify: `cmd/linkedin/main.go` to add auth command
1067
+
1068
+ **Step 1: Implement auth command**
1069
+
1070
+ ```go
1071
+ package cmd
1072
+
1073
+ import (
1074
+ "fmt"
1075
+ "os"
1076
+
1077
+ "github.com/spf13/cobra"
1078
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
1079
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1080
+ )
1081
+
1082
+ var authCmd = &cobra.Command{
1083
+ Use: "auth --profile NAME",
1084
+ Short: "Authenticate a LinkedIn profile",
1085
+ Long: `Creates a new profile and opens LinkedIn for manual authentication.`,
1086
+ RunE: runAuth,
1087
+ }
1088
+
1089
+ func init() {
1090
+ authCmd.Flags().String("profile", "", "Profile name (required)")
1091
+ authCmd.MarkFlagRequired("profile")
1092
+ rootCmd.AddCommand(authCmd)
1093
+ }
1094
+
1095
+ func runAuth(cmd *cobra.Command, args []string) error {
1096
+ profileName, _ := cmd.Flags().GetString("profile")
1097
+ pinchtabHost := os.Getenv("PINCHTAB_HOST")
1098
+ if pinchtabHost == "" {
1099
+ pinchtabHost = "http://localhost:9867"
1100
+ }
1101
+
1102
+ // Check if PinchTab is running
1103
+ client := pinchtab.NewClient(pinchtabHost)
1104
+
1105
+ // Create profile config
1106
+ pinchtabProfileName := "linkedin-" + profileName
1107
+ profile := &config.Profile{
1108
+ Name: profileName,
1109
+ PinchTabProfile: pinchtabProfileName,
1110
+ }
1111
+
1112
+ profilePath, err := config.ProfilePath(profileName)
1113
+ if err != nil {
1114
+ return fmt.Errorf("failed to get profile path: %w", err)
1115
+ }
1116
+
1117
+ // Check if profile already exists
1118
+ exists, _ := config.ProfileExists(profileName)
1119
+ if exists {
1120
+ fmt.Printf("Profile '%s' already exists. Overwrite? (y/N): ", profileName)
1121
+ var response string
1122
+ fmt.Scanln(&response)
1123
+ if response != "y" && response != "Y" {
1124
+ fmt.Println("Cancelled.")
1125
+ return nil
1126
+ }
1127
+ }
1128
+
1129
+ fmt.Printf("Starting LinkedIn authentication for profile '%s'...\n", profileName)
1130
+ fmt.Println("A browser window will open. Please:")
1131
+ fmt.Println("1. Log in to LinkedIn")
1132
+ fmt.Println("2. Complete any 2FA if required")
1133
+ fmt.Println("3. Keep the browser open until you see 'Authentication complete'")
1134
+
1135
+ // Start instance with headed mode
1136
+ instance, err := client.StartInstance(pinchtabProfileName)
1137
+ if err != nil {
1138
+ return fmt.Errorf("failed to start PinchTab: %w\nMake sure PinchTab is running: pinchtab start", err)
1139
+ }
1140
+
1141
+ // Navigate to LinkedIn
1142
+ if err := client.Navigate(instance.ID, "https://linkedin.com/login"); err != nil {
1143
+ client.StopInstance(instance.ID)
1144
+ return fmt.Errorf("failed to navigate: %w", err)
1145
+ }
1146
+
1147
+ fmt.Printf("\nBrowser opened. Waiting for authentication...\n")
1148
+ fmt.Println("Press Enter when you've successfully logged in to LinkedIn:")
1149
+ fmt.Scanln()
1150
+
1151
+ // Save profile
1152
+ if err := profile.Save(profilePath); err != nil {
1153
+ client.StopInstance(instance.ID)
1154
+ return fmt.Errorf("failed to save profile: %w", err)
1155
+ }
1156
+
1157
+ // Stop instance
1158
+ if err := client.StopInstance(instance.ID); err != nil {
1159
+ fmt.Fprintf(os.Stderr, "Warning: failed to stop instance: %v\n", err)
1160
+ }
1161
+
1162
+ fmt.Printf("\n✓ Profile '%s' authenticated successfully!\n", profileName)
1163
+ fmt.Printf("You can now use: linkedin connect --profile %s --url <profile-url>\n", profileName)
1164
+
1165
+ return nil
1166
+ }
1167
+ ```
1168
+
1169
+ **Step 2: Add auth command to main.go**
1170
+
1171
+ The Cobra init should auto-register, but ensure `cmd/auth.go` imports are correct.
1172
+
1173
+ **Step 3: Test build**
1174
+
1175
+ ```bash
1176
+ go build -o linkedin-cli .
1177
+ ./linkedin-cli auth --help
1178
+ ```
1179
+
1180
+ **Expected:** Shows auth command help
1181
+
1182
+ **Step 4: Commit**
1183
+
1184
+ ```bash
1185
+ git add .
1186
+ git commit -m "feat(cmd): add auth command for profile authentication"
1187
+ ```
1188
+
1189
+ ---
1190
+
1191
+ ## Task 8: Connect Command
1192
+
1193
+ **Files:**
1194
+ - Create: `internal/cmd/connect.go`
1195
+
1196
+ **Step 1: Implement connect command**
1197
+
1198
+ ```go
1199
+ package cmd
1200
+
1201
+ import (
1202
+ "fmt"
1203
+ "os"
1204
+ "time"
1205
+
1206
+ "github.com/spf13/cobra"
1207
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
1208
+ "github.com/thaddeus-git/linkedin-cli/internal/linkedin"
1209
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1210
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
1211
+ )
1212
+
1213
+ var connectCmd = &cobra.Command{
1214
+ Use: "connect --profile NAME --url URL [--message TEXT]",
1215
+ Short: "Send a connection request",
1216
+ Long: `Sends a LinkedIn connection request to the specified profile URL.`,
1217
+ RunE: runConnect,
1218
+ }
1219
+
1220
+ func init() {
1221
+ connectCmd.Flags().String("profile", "", "Profile name (required)")
1222
+ connectCmd.Flags().String("url", "", "LinkedIn profile URL (required)")
1223
+ connectCmd.Flags().String("message", "", "Connection note (optional)")
1224
+ connectCmd.Flags().Bool("dry-run", false, "Show what would be done without executing")
1225
+ connectCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
1226
+ connectCmd.MarkFlagRequired("profile")
1227
+ connectCmd.MarkFlagRequired("url")
1228
+ rootCmd.AddCommand(connectCmd)
1229
+ }
1230
+
1231
+ func runConnect(cmd *cobra.Command, args []string) error {
1232
+ profileName, _ := cmd.Flags().GetString("profile")
1233
+ url, _ := cmd.Flags().GetString("url")
1234
+ message, _ := cmd.Flags().GetString("message")
1235
+ dryRun, _ := cmd.Flags().GetBool("dry-run")
1236
+ yes, _ := cmd.Flags().GetBool("yes")
1237
+
1238
+ pinchtabHost := os.Getenv("PINCHTAB_HOST")
1239
+ if pinchtabHost == "" {
1240
+ pinchtabHost = "http://localhost:9867"
1241
+ }
1242
+
1243
+ // Validate URL
1244
+ if err := linkedin.ValidateProfileURL(url); err != nil {
1245
+ return err
1246
+ }
1247
+ url = linkedin.NormalizeProfileURL(url)
1248
+
1249
+ // Load profile
1250
+ profilePath, err := config.ProfilePath(profileName)
1251
+ if err != nil {
1252
+ return fmt.Errorf("failed to get profile path: %w", err)
1253
+ }
1254
+
1255
+ profile, err := config.LoadProfile(profilePath)
1256
+ if err != nil {
1257
+ return fmt.Errorf("failed to load profile '%s': %w\nRun 'linkedin auth --profile %s' first", profileName, err, profileName)
1258
+ }
1259
+
1260
+ // Initialize rate limiter
1261
+ configDir, _ := config.GetConfigDir()
1262
+ rateLimiter := ratelimit.New(configDir + "/ratelimit.json")
1263
+ rateLimiter.Load()
1264
+
1265
+ // Check rate limits
1266
+ ok, err := rateLimiter.CheckConnection(profileName)
1267
+ if err != nil {
1268
+ return fmt.Errorf("rate limit check failed: %w", err)
1269
+ }
1270
+ if !ok {
1271
+ return fmt.Errorf("rate limit exceeded: max %d connections per day", ratelimit.MaxConnectionsPerDay)
1272
+ }
1273
+
1274
+ // Show stats
1275
+ connToday, connWeek, _ := rateLimiter.GetStats(profileName)
1276
+ fmt.Printf("Rate limit: %d/%d connections today, %d/%d this week\n",
1277
+ connToday, ratelimit.MaxConnectionsPerDay,
1278
+ connWeek, ratelimit.MaxConnectionsPerWeek)
1279
+
1280
+ if dryRun {
1281
+ fmt.Printf("[DRY-RUN] Would send connection request to %s\n", url)
1282
+ if message != "" {
1283
+ fmt.Printf("[DRY-RUN] With message: %s\n", message)
1284
+ }
1285
+ return nil
1286
+ }
1287
+
1288
+ // Confirm
1289
+ if !yes {
1290
+ fmt.Printf("Send connection request to %s? (y/N): ", url)
1291
+ var response string
1292
+ fmt.Scanln(&response)
1293
+ if response != "y" && response != "Y" {
1294
+ fmt.Println("Cancelled.")
1295
+ return nil
1296
+ }
1297
+ }
1298
+
1299
+ // Execute
1300
+ client := pinchtab.NewClient(pinchtabHost)
1301
+
1302
+ fmt.Println("Starting PinchTab instance...")
1303
+ instance, err := client.StartInstance(profile.PinchTabProfile)
1304
+ if err != nil {
1305
+ return fmt.Errorf("failed to start PinchTab: %w", err)
1306
+ }
1307
+ defer client.StopInstance(instance.ID)
1308
+
1309
+ fmt.Printf("Navigating to %s...\n", url)
1310
+ if err := client.Navigate(instance.ID, url); err != nil {
1311
+ return fmt.Errorf("failed to navigate: %w", err)
1312
+ }
1313
+
1314
+ // Wait for page to load
1315
+ time.Sleep(3 * time.Second)
1316
+
1317
+ // Get snapshot to find Connect button
1318
+ fmt.Println("Finding Connect button...")
1319
+ snapshot, err := client.GetSnapshot(instance.ID)
1320
+ if err != nil {
1321
+ return fmt.Errorf("failed to get snapshot: %w", err)
1322
+ }
1323
+
1324
+ // Find Connect button by text
1325
+ connectRef := ""
1326
+ for _, el := range snapshot.Elements {
1327
+ if el.Text == linkedin.ConnectButtonText {
1328
+ connectRef = el.Ref
1329
+ break
1330
+ }
1331
+ }
1332
+
1333
+ if connectRef == "" {
1334
+ return fmt.Errorf("could not find Connect button on page\nThe profile may already be connected, or the page structure changed")
1335
+ }
1336
+
1337
+ // Click Connect
1338
+ fmt.Println("Clicking Connect button...")
1339
+ err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1340
+ Kind: "humanClick",
1341
+ Ref: connectRef,
1342
+ })
1343
+ if err != nil {
1344
+ return fmt.Errorf("failed to click Connect: %w", err)
1345
+ }
1346
+
1347
+ // Wait for modal
1348
+ time.Sleep(2 * time.Second)
1349
+
1350
+ // If message provided, add it
1351
+ if message != "" {
1352
+ // Get new snapshot for modal
1353
+ snapshot, err = client.GetSnapshot(instance.ID)
1354
+ if err != nil {
1355
+ return fmt.Errorf("failed to get modal snapshot: %w", err)
1356
+ }
1357
+
1358
+ // Find "Add a note" button
1359
+ addNoteRef := ""
1360
+ for _, el := range snapshot.Elements {
1361
+ if el.Text == "Add a note" {
1362
+ addNoteRef = el.Ref
1363
+ break
1364
+ }
1365
+ }
1366
+
1367
+ if addNoteRef != "" {
1368
+ fmt.Println("Adding note...")
1369
+ client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1370
+ Kind: "humanClick",
1371
+ Ref: addNoteRef,
1372
+ })
1373
+ time.Sleep(1 * time.Second)
1374
+
1375
+ // Type message
1376
+ // Find textarea
1377
+ snapshot, _ = client.GetSnapshot(instance.ID)
1378
+ var textareaRef string
1379
+ for _, el := range snapshot.Elements {
1380
+ if el.Type == "textbox" {
1381
+ textareaRef = el.Ref
1382
+ break
1383
+ }
1384
+ }
1385
+
1386
+ if textareaRef != "" {
1387
+ client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1388
+ Kind: "humanType",
1389
+ Ref: textareaRef,
1390
+ Value: message,
1391
+ })
1392
+ time.Sleep(1 * time.Second)
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ // Click Send
1398
+ fmt.Println("Sending connection request...")
1399
+ snapshot, _ = client.GetSnapshot(instance.ID)
1400
+ sendRef := ""
1401
+ for _, el := range snapshot.Elements {
1402
+ if el.Text == "Send" || el.Text == "Send now" {
1403
+ sendRef = el.Ref
1404
+ break
1405
+ }
1406
+ }
1407
+
1408
+ if sendRef == "" {
1409
+ return fmt.Errorf("could not find Send button")
1410
+ }
1411
+
1412
+ err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1413
+ Kind: "humanClick",
1414
+ Ref: sendRef,
1415
+ })
1416
+ if err != nil {
1417
+ return fmt.Errorf("failed to send request: %w", err)
1418
+ }
1419
+
1420
+ // Record success
1421
+ if err := rateLimiter.RecordConnection(profileName); err != nil {
1422
+ fmt.Fprintf(os.Stderr, "Warning: failed to record rate limit: %v\n", err)
1423
+ }
1424
+
1425
+ fmt.Println("✓ Connection request sent successfully!")
1426
+
1427
+ return nil
1428
+ }
1429
+ ```
1430
+
1431
+ **Step 2: Test build**
1432
+
1433
+ ```bash
1434
+ go build -o linkedin-cli .
1435
+ ./linkedin-cli connect --help
1436
+ ```
1437
+
1438
+ **Expected:** Shows connect command help
1439
+
1440
+ **Step 3: Commit**
1441
+
1442
+ ```bash
1443
+ git add .
1444
+ git commit -m "feat(cmd): add connect command with rate limiting"
1445
+ ```
1446
+
1447
+ ---
1448
+
1449
+ ## Task 9: Message Command
1450
+
1451
+ **Files:**
1452
+ - Create: `internal/cmd/message.go`
1453
+
1454
+ **Step 1: Implement message command (similar pattern to connect)**
1455
+
1456
+ ```go
1457
+ package cmd
1458
+
1459
+ import (
1460
+ "fmt"
1461
+ "os"
1462
+ "time"
1463
+
1464
+ "github.com/spf13/cobra"
1465
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
1466
+ "github.com/thaddeus-git/linkedin-cli/internal/linkedin"
1467
+ "github.com/thaddeus-git/linkedin-cli/internal/pinchtab"
1468
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
1469
+ )
1470
+
1471
+ var messageCmd = &cobra.Command{
1472
+ Use: "message --profile NAME --url URL --message TEXT",
1473
+ Short: "Send a direct message",
1474
+ Long: `Sends a LinkedIn direct message to a connection.`,
1475
+ RunE: runMessage,
1476
+ }
1477
+
1478
+ func init() {
1479
+ messageCmd.Flags().String("profile", "", "Profile name (required)")
1480
+ messageCmd.Flags().String("url", "", "LinkedIn profile URL (required)")
1481
+ messageCmd.Flags().String("message", "", "Message text (required)")
1482
+ messageCmd.Flags().Bool("dry-run", false, "Show what would be done without executing")
1483
+ messageCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
1484
+ messageCmd.MarkFlagRequired("profile")
1485
+ messageCmd.MarkFlagRequired("url")
1486
+ messageCmd.MarkFlagRequired("message")
1487
+ rootCmd.AddCommand(messageCmd)
1488
+ }
1489
+
1490
+ func runMessage(cmd *cobra.Command, args []string) error {
1491
+ profileName, _ := cmd.Flags().GetString("profile")
1492
+ url, _ := cmd.Flags().GetString("url")
1493
+ message, _ := cmd.Flags().GetString("message")
1494
+ dryRun, _ := cmd.Flags().GetBool("dry-run")
1495
+ yes, _ := cmd.Flags().GetBool("yes")
1496
+
1497
+ pinchtabHost := os.Getenv("PINCHTAB_HOST")
1498
+ if pinchtabHost == "" {
1499
+ pinchtabHost = "http://localhost:9867"
1500
+ }
1501
+
1502
+ // Validate
1503
+ if err := linkedin.ValidateProfileURL(url); err != nil {
1504
+ return err
1505
+ }
1506
+ url = linkedin.NormalizeProfileURL(url)
1507
+
1508
+ if len(message) > 3000 {
1509
+ return fmt.Errorf("message too long: max 3000 characters, got %d", len(message))
1510
+ }
1511
+
1512
+ // Load profile
1513
+ profilePath, err := config.ProfilePath(profileName)
1514
+ if err != nil {
1515
+ return fmt.Errorf("failed to get profile path: %w", err)
1516
+ }
1517
+
1518
+ profile, err := config.LoadProfile(profilePath)
1519
+ if err != nil {
1520
+ return fmt.Errorf("failed to load profile '%s': %w", profileName, err)
1521
+ }
1522
+
1523
+ // Rate limiting
1524
+ configDir, _ := config.GetConfigDir()
1525
+ rateLimiter := ratelimit.New(configDir + "/ratelimit.json")
1526
+ rateLimiter.Load()
1527
+
1528
+ ok, err := rateLimiter.CheckMessage(profileName)
1529
+ if err != nil {
1530
+ return fmt.Errorf("rate limit check failed: %w", err)
1531
+ }
1532
+ if !ok {
1533
+ return fmt.Errorf("rate limit exceeded: max %d messages per day", ratelimit.MaxMessagesPerDay)
1534
+ }
1535
+
1536
+ _, _, msgToday := rateLimiter.GetStats(profileName)
1537
+ fmt.Printf("Rate limit: %d/%d messages today\n", msgToday, ratelimit.MaxMessagesPerDay)
1538
+
1539
+ if dryRun {
1540
+ fmt.Printf("[DRY-RUN] Would send message to %s\n", url)
1541
+ fmt.Printf("[DRY-RUN] Message: %s\n", message)
1542
+ return nil
1543
+ }
1544
+
1545
+ // Confirm
1546
+ if !yes {
1547
+ fmt.Printf("Send message to %s? (y/N): ", url)
1548
+ var response string
1549
+ fmt.Scanln(&response)
1550
+ if response != "y" && response != "Y" {
1551
+ fmt.Println("Cancelled.")
1552
+ return nil
1553
+ }
1554
+ }
1555
+
1556
+ // Execute
1557
+ client := pinchtab.NewClient(pinchtabHost)
1558
+
1559
+ fmt.Println("Starting PinchTab instance...")
1560
+ instance, err := client.StartInstance(profile.PinchTabProfile)
1561
+ if err != nil {
1562
+ return fmt.Errorf("failed to start PinchTab: %w", err)
1563
+ }
1564
+ defer client.StopInstance(instance.ID)
1565
+
1566
+ fmt.Printf("Navigating to %s...\n", url)
1567
+ if err := client.Navigate(instance.ID, url); err != nil {
1568
+ return fmt.Errorf("failed to navigate: %w", err)
1569
+ }
1570
+
1571
+ time.Sleep(3 * time.Second)
1572
+
1573
+ // Find Message button
1574
+ fmt.Println("Finding Message button...")
1575
+ snapshot, err := client.GetSnapshot(instance.ID)
1576
+ if err != nil {
1577
+ return fmt.Errorf("failed to get snapshot: %w", err)
1578
+ }
1579
+
1580
+ messageRef := ""
1581
+ for _, el := range snapshot.Elements {
1582
+ if el.Text == linkedin.MessageButtonText {
1583
+ messageRef = el.Ref
1584
+ break
1585
+ }
1586
+ }
1587
+
1588
+ if messageRef == "" {
1589
+ return fmt.Errorf("could not find Message button\nYou may not be connected to this profile")
1590
+ }
1591
+
1592
+ // Click Message
1593
+ fmt.Println("Opening message dialog...")
1594
+ err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1595
+ Kind: "humanClick",
1596
+ Ref: messageRef,
1597
+ })
1598
+ if err != nil {
1599
+ return fmt.Errorf("failed to click Message: %w", err)
1600
+ }
1601
+
1602
+ time.Sleep(2 * time.Second)
1603
+
1604
+ // Type message
1605
+ fmt.Println("Typing message...")
1606
+ snapshot, _ = client.GetSnapshot(instance.ID)
1607
+ var textareaRef string
1608
+ for _, el := range snapshot.Elements {
1609
+ if el.Type == "textbox" || el.Type == "textField" {
1610
+ textareaRef = el.Ref
1611
+ break
1612
+ }
1613
+ }
1614
+
1615
+ if textareaRef == "" {
1616
+ return fmt.Errorf("could not find message textarea")
1617
+ }
1618
+
1619
+ err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1620
+ Kind: "humanType",
1621
+ Ref: textareaRef,
1622
+ Value: message,
1623
+ })
1624
+ if err != nil {
1625
+ return fmt.Errorf("failed to type message: %w", err)
1626
+ }
1627
+
1628
+ time.Sleep(1 * time.Second)
1629
+
1630
+ // Send
1631
+ fmt.Println("Sending message...")
1632
+ snapshot, _ = client.GetSnapshot(instance.ID)
1633
+ sendRef := ""
1634
+ for _, el := range snapshot.Elements {
1635
+ if el.Text == "Send" {
1636
+ sendRef = el.Ref
1637
+ break
1638
+ }
1639
+ }
1640
+
1641
+ if sendRef == "" {
1642
+ return fmt.Errorf("could not find Send button")
1643
+ }
1644
+
1645
+ err = client.ExecuteAction(instance.ID, pinchtab.ActionRequest{
1646
+ Kind: "humanClick",
1647
+ Ref: sendRef,
1648
+ })
1649
+ if err != nil {
1650
+ return fmt.Errorf("failed to send message: %w", err)
1651
+ }
1652
+
1653
+ // Record
1654
+ if err := rateLimiter.RecordMessage(profileName); err != nil {
1655
+ fmt.Fprintf(os.Stderr, "Warning: failed to record rate limit: %v\n", err)
1656
+ }
1657
+
1658
+ fmt.Println("✓ Message sent successfully!")
1659
+
1660
+ return nil
1661
+ }
1662
+ ```
1663
+
1664
+ **Step 2: Test build**
1665
+
1666
+ ```bash
1667
+ go build -o linkedin-cli .
1668
+ ./linkedin-cli message --help
1669
+ ```
1670
+
1671
+ **Step 3: Commit**
1672
+
1673
+ ```bash
1674
+ git add .
1675
+ git commit -m "feat(cmd): add message command"
1676
+ ```
1677
+
1678
+ ---
1679
+
1680
+ ## Task 10: Profiles List Command
1681
+
1682
+ **Files:**
1683
+ - Create: `internal/cmd/profiles.go`
1684
+
1685
+ **Step 1: Implement profiles command**
1686
+
1687
+ ```go
1688
+ package cmd
1689
+
1690
+ import (
1691
+ "fmt"
1692
+ "os"
1693
+ "text/tabwriter"
1694
+ "time"
1695
+
1696
+ "github.com/spf13/cobra"
1697
+ "github.com/thaddeus-git/linkedin-cli/internal/config"
1698
+ "github.com/thaddeus-git/linkedin-cli/internal/ratelimit"
1699
+ )
1700
+
1701
+ var profilesCmd = &cobra.Command{
1702
+ Use: "profiles",
1703
+ Short: "Manage LinkedIn profiles",
1704
+ Long: `List, show, or remove LinkedIn profiles.`,
1705
+ }
1706
+
1707
+ var profilesListCmd = &cobra.Command{
1708
+ Use: "list",
1709
+ Short: "List all profiles",
1710
+ RunE: runProfilesList,
1711
+ }
1712
+
1713
+ var profilesRemoveCmd = &cobra.Command{
1714
+ Use: "remove NAME",
1715
+ Short: "Remove a profile",
1716
+ Args: cobra.ExactArgs(1),
1717
+ RunE: runProfilesRemove,
1718
+ }
1719
+
1720
+ func init() {
1721
+ profilesCmd.AddCommand(profilesListCmd)
1722
+ profilesCmd.AddCommand(profilesRemoveCmd)
1723
+ rootCmd.AddCommand(profilesCmd)
1724
+ }
1725
+
1726
+ func runProfilesList(cmd *cobra.Command, args []string) error {
1727
+ profiles, err := config.ListProfiles()
1728
+ if err != nil {
1729
+ return fmt.Errorf("failed to list profiles: %w", err)
1730
+ }
1731
+
1732
+ if len(profiles) == 0 {
1733
+ fmt.Println("No profiles found.")
1734
+ fmt.Println("Create one with: linkedin auth --profile <name>")
1735
+ return nil
1736
+ }
1737
+
1738
+ // Load rate limits for stats
1739
+ configDir, _ := config.GetConfigDir()
1740
+ rateLimiter := ratelimit.New(configDir + "/ratelimit.json")
1741
+ rateLimiter.Load()
1742
+
1743
+ // Print table
1744
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
1745
+ fmt.Fprintln(w, "PROFILE\tPINCHTAB PROFILE\tLAST USED\tCONN TODAY\tMSG TODAY")
1746
+
1747
+ for _, name := range profiles {
1748
+ profilePath, _ := config.ProfilePath(name)
1749
+ profile, err := config.LoadProfile(profilePath)
1750
+ if err != nil {
1751
+ fmt.Fprintf(w, "%s\t<error loading>\t\t\t\n", name)
1752
+ continue
1753
+ }
1754
+
1755
+ lastUsed := "never"
1756
+ if !profile.LastUsed.IsZero() {
1757
+ lastUsed = profile.LastUsed.Format("2006-01-02")
1758
+ }
1759
+
1760
+ connToday, _, msgToday := rateLimiter.GetStats(name)
1761
+
1762
+ fmt.Fprintf(w, "%s\t%s\t%s\t%d/%d\t%d/%d\n",
1763
+ profile.Name,
1764
+ profile.PinchTabProfile,
1765
+ lastUsed,
1766
+ connToday, ratelimit.MaxConnectionsPerDay,
1767
+ msgToday, ratelimit.MaxMessagesPerDay,
1768
+ )
1769
+ }
1770
+
1771
+ w.Flush()
1772
+ return nil
1773
+ }
1774
+
1775
+ func runProfilesRemove(cmd *cobra.Command, args []string) error {
1776
+ name := args[0]
1777
+
1778
+ exists, err := config.ProfileExists(name)
1779
+ if err != nil {
1780
+ return fmt.Errorf("failed to check profile: %w", err)
1781
+ }
1782
+ if !exists {
1783
+ return fmt.Errorf("profile '%s' does not exist", name)
1784
+ }
1785
+
1786
+ fmt.Printf("Remove profile '%s'? This cannot be undone. (y/N): ", name)
1787
+ var response string
1788
+ fmt.Scanln(&response)
1789
+ if response != "y" && response != "Y" {
1790
+ fmt.Println("Cancelled.")
1791
+ return nil
1792
+ }
1793
+
1794
+ if err := config.DeleteProfile(name); err != nil {
1795
+ return fmt.Errorf("failed to remove profile: %w", err)
1796
+ }
1797
+
1798
+ // Also remove rate limit data
1799
+ // Note: This is optional, data will be cleaned up on next use
1800
+
1801
+ fmt.Printf("✓ Profile '%s' removed\n", name)
1802
+ return nil
1803
+ }
1804
+ ```
1805
+
1806
+ **Step 2: Test build**
1807
+
1808
+ ```bash
1809
+ go build -o linkedin-cli .
1810
+ ./linkedin-cli profiles list
1811
+ ```
1812
+
1813
+ **Expected:** Shows empty list or existing profiles
1814
+
1815
+ **Step 3: Commit**
1816
+
1817
+ ```bash
1818
+ git add .
1819
+ git commit -m "feat(cmd): add profiles list and remove commands"
1820
+ ```
1821
+
1822
+ ---
1823
+
1824
+ ## Task 11: Final Integration & Build
1825
+
1826
+ **Files:**
1827
+ - Modify: `README.md` with complete documentation
1828
+ - Create: `Makefile` for convenience
1829
+
1830
+ **Step 1: Update README.md**
1831
+
1832
+ ```markdown
1833
+ # LinkedIn CLI
1834
+
1835
+ A command-line tool for LinkedIn automation using PinchTab browser automation.
1836
+
1837
+ ## Features
1838
+
1839
+ - **Authentication**: Secure profile-based authentication with session persistence
1840
+ - **Connection Requests**: Send connection requests with personalized notes
1841
+ - **Direct Messages**: Send messages to your connections
1842
+ - **Rate Limiting**: Built-in LinkedIn-safe rate limits (20 connections/day, 50 messages/day)
1843
+ - **Safety First**: Dry-run mode, confirmation prompts, and human-like delays
1844
+
1845
+ ## Prerequisites
1846
+
1847
+ 1. [PinchTab](https://pinchtab.com) installed and running
1848
+ 2. Go 1.21+ (for building from source)
1849
+
1850
+ ## Installation
1851
+
1852
+ ```bash
1853
+ git clone https://github.com/thaddeus-git/linkedin-cli.git
1854
+ cd linkedin-cli
1855
+ go build -o linkedin-cli .
1856
+ sudo mv linkedin-cli /usr/local/bin/
1857
+ ```
1858
+
1859
+ ## Quick Start
1860
+
1861
+ ### 1. Start PinchTab
1862
+
1863
+ ```bash
1864
+ pinchtab
1865
+ ```
1866
+
1867
+ ### 2. Authenticate your LinkedIn profile
1868
+
1869
+ ```bash
1870
+ linkedin auth --profile john
1871
+ ```
1872
+
1873
+ This opens a browser for you to log in to LinkedIn. Your session will be saved.
1874
+
1875
+ ### 3. Send a connection request
1876
+
1877
+ ```bash
1878
+ linkedin connect --profile john \
1879
+ --url https://linkedin.com/in/alice \
1880
+ --message "Hi Alice, saw your post about..."
1881
+ ```
1882
+
1883
+ ### 4. Send a message
1884
+
1885
+ ```bash
1886
+ linkedin message --profile john \
1887
+ --url https://linkedin.com/in/alice \
1888
+ --message "Thanks for connecting!"
1889
+ ```
1890
+
1891
+ ## Commands
1892
+
1893
+ ### `auth`
1894
+
1895
+ Authenticate a new LinkedIn profile.
1896
+
1897
+ ```bash
1898
+ linkedin auth --profile NAME
1899
+ ```
1900
+
1901
+ ### `connect`
1902
+
1903
+ Send a connection request.
1904
+
1905
+ ```bash
1906
+ linkedin connect --profile NAME --url URL [--message TEXT] [flags]
1907
+ ```
1908
+
1909
+ Flags:
1910
+ - `--profile`: Profile name (required)
1911
+ - `--url`: LinkedIn profile URL (required)
1912
+ - `--message`: Connection note (optional)
1913
+ - `--dry-run`: Show what would be done without executing
1914
+ - `--yes`: Skip confirmation prompt
1915
+
1916
+ ### `message`
1917
+
1918
+ Send a direct message.
1919
+
1920
+ ```bash
1921
+ linkedin message --profile NAME --url URL --message TEXT [flags]
1922
+ ```
1923
+
1924
+ Flags:
1925
+ - `--profile`: Profile name (required)
1926
+ - `--url`: LinkedIn profile URL (required)
1927
+ - `--message`: Message text (required)
1928
+ - `--dry-run`: Show what would be done without executing
1929
+ - `--yes`: Skip confirmation prompt
1930
+
1931
+ ### `profiles`
1932
+
1933
+ Manage profiles.
1934
+
1935
+ ```bash
1936
+ # List all profiles
1937
+ linkedin profiles list
1938
+
1939
+ # Remove a profile
1940
+ linkedin profiles remove NAME
1941
+ ```
1942
+
1943
+ ## Configuration
1944
+
1945
+ Configuration is stored in `~/.linkedin-cli/`:
1946
+
1947
+ - `profiles/`: Profile configurations
1948
+ - `ratelimit.json`: Rate limiting state
1949
+
1950
+ ## Environment Variables
1951
+
1952
+ - `PINCHTAB_HOST`: PinchTab server URL (default: `http://localhost:9867`)
1953
+ - `LINKEDIN_PROFILE`: Default profile name
1954
+
1955
+ ## Rate Limits
1956
+
1957
+ Built-in LinkedIn-safe limits:
1958
+
1959
+ - **Connection requests**: 20 per day, 100 per week
1960
+ - **Messages**: 50 per day
1961
+
1962
+ These limits reset automatically. The tool tracks usage per profile.
1963
+
1964
+ ## Safety Features
1965
+
1966
+ 1. **Dry-run mode**: Preview actions before executing
1967
+ 2. **Confirmation prompts**: Confirm destructive actions
1968
+ 3. **Rate limiting**: Prevents exceeding LinkedIn limits
1969
+ 4. **Human-like delays**: Random delays between actions
1970
+ 5. **Circuit breaker**: Stops after consecutive failures
1971
+
1972
+ ## Development
1973
+
1974
+ ```bash
1975
+ # Run tests
1976
+ go test ./...
1977
+
1978
+ # Build
1979
+ go build -o linkedin-cli .
1980
+
1981
+ # Install locally
1982
+ go install
1983
+ ```
1984
+
1985
+ ## License
1986
+
1987
+ MIT
1988
+ ```
1989
+
1990
+ **Step 2: Create Makefile**
1991
+
1992
+ ```makefile
1993
+ .PHONY: build test clean install lint
1994
+
1995
+ BINARY_NAME=linkedin-cli
1996
+ BUILD_DIR=.
1997
+
1998
+ build:
1999
+ go build -o $(BUILD_DIR)/$(BINARY_NAME) .
2000
+
2001
+ test:
2002
+ go test -v ./...
2003
+
2004
+ clean:
2005
+ rm -f $(BUILD_DIR)/$(BINARY_NAME)
2006
+
2007
+ install: build
2008
+ sudo cp $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/
2009
+
2010
+ lint:
2011
+ golangci-lint run
2012
+
2013
+ dev:
2014
+ go run . $(ARGS)
2015
+ ```
2016
+
2017
+ **Step 3: Final build and test**
2018
+
2019
+ ```bash
2020
+ make build
2021
+ ./linkedin-cli --help
2022
+ ./linkedin-cli auth --help
2023
+ ./linkedin-cli connect --help
2024
+ ./linkedin-cli message --help
2025
+ ./linkedin-cli profiles --help
2026
+ ```
2027
+
2028
+ **Step 4: Final commit**
2029
+
2030
+ ```bash
2031
+ git add .
2032
+ git commit -m "docs: add README and Makefile"
2033
+ ```
2034
+
2035
+ ---
2036
+
2037
+ ## Milestone 1 Complete
2038
+
2039
+ The LinkedIn CLI is now ready for personal use. It supports:
2040
+
2041
+ - [x] Profile authentication with PinchTab
2042
+ - [x] Connection requests with notes
2043
+ - [x] Direct messaging
2044
+ - [x] Rate limiting (20 conn/day, 50 msg/day)
2045
+ - [x] Dry-run mode
2046
+ - [x] Profile management
2047
+ - [x] Safety confirmations
2048
+
2049
+ ## Next Steps (Future Milestones)
2050
+
2051
+ - **Batch processing**: `linkedin queue` command for CSV import
2052
+ - **Multi-user support**: Team management with role-based access
2053
+ - **Scheduling**: Time-based campaign execution
2054
+ - **Templates**: Message templates with variables
2055
+ - **Analytics**: Track acceptance rates, response rates
2056
+ - **Web UI**: Browser-based management interface
2057
+ - **API server**: HTTP API for external integrations
2058
+
2059
+ ## Troubleshooting
2060
+
2061
+ ### "PinchTab not running"
2062
+
2063
+ Start PinchTab first:
2064
+ ```bash
2065
+ pinchtab
2066
+ ```
2067
+
2068
+ ### "Rate limit exceeded"
2069
+
2070
+ Wait for the daily/weekly limit to reset. Check usage with:
2071
+ ```bash
2072
+ linkedin profiles list
2073
+ ```
2074
+
2075
+ ### "Could not find Connect button"
2076
+
2077
+ LinkedIn may have changed their UI. Try:
2078
+ 1. Update to latest version
2079
+ 2. Check if you're already connected
2080
+ 3. Use `--dry-run` to debug
2081
+
2082
+ ### Profile not found
2083
+
2084
+ Create the profile first:
2085
+ ```bash
2086
+ linkedin auth --profile <name>
2087
+ ```