linkedin-toolkit-mcp 2.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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/dist/bridge.d.ts +95 -0
  4. package/dist/bridge.js +259 -0
  5. package/dist/bridge.js.map +1 -0
  6. package/dist/cli.d.ts +83 -0
  7. package/dist/cli.js +898 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/config.d.ts +47 -0
  10. package/dist/config.js +149 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/contract.d.ts +6770 -0
  13. package/dist/contract.js +926 -0
  14. package/dist/contract.js.map +1 -0
  15. package/dist/db.d.ts +64 -0
  16. package/dist/db.js +542 -0
  17. package/dist/db.js.map +1 -0
  18. package/dist/fake-data.d.ts +277 -0
  19. package/dist/fake-data.js +789 -0
  20. package/dist/fake-data.js.map +1 -0
  21. package/dist/fake-extension.d.ts +51 -0
  22. package/dist/fake-extension.js +151 -0
  23. package/dist/fake-extension.js.map +1 -0
  24. package/dist/gen.d.ts +5 -0
  25. package/dist/gen.js +22 -0
  26. package/dist/gen.js.map +1 -0
  27. package/dist/http.d.ts +27 -0
  28. package/dist/http.js +329 -0
  29. package/dist/http.js.map +1 -0
  30. package/dist/openapi.d.ts +30 -0
  31. package/dist/openapi.js +306 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/prompts.d.ts +9 -0
  34. package/dist/prompts.js +65 -0
  35. package/dist/prompts.js.map +1 -0
  36. package/dist/resources.d.ts +7 -0
  37. package/dist/resources.js +73 -0
  38. package/dist/resources.js.map +1 -0
  39. package/dist/server.d.ts +3 -0
  40. package/dist/server.js +38 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/toolkit.d.ts +82 -0
  43. package/dist/toolkit.js +171 -0
  44. package/dist/toolkit.js.map +1 -0
  45. package/dist/tools.d.ts +31 -0
  46. package/dist/tools.js +103 -0
  47. package/dist/tools.js.map +1 -0
  48. package/dist/webhooks.d.ts +52 -0
  49. package/dist/webhooks.js +112 -0
  50. package/dist/webhooks.js.map +1 -0
  51. package/openapi.json +8334 -0
  52. package/package.json +45 -0
  53. package/tools.json +1205 -0
package/dist/cli.js ADDED
@@ -0,0 +1,898 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `lit` — the command line for the LinkedIn Toolkit.
4
+ *
5
+ * `lit serve` runs the server (bridge plus MCP over stdio); `lit serve --http`
6
+ * adds the HTTP surface. Every other command talks to that running server over
7
+ * HTTP, so the CLI and an agent see exactly the same data through exactly the
8
+ * same code path.
9
+ */
10
+ import { Command, CommanderError } from 'commander';
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { clearRuntime, generateToken, loadConfig, pairingInstructions, readRuntime, saveConfig, withOverrides, writeRuntime, } from './config.js';
15
+ import { ORIGIN_HEADER } from './contract.js';
16
+ import { TABLES } from './db.js';
17
+ import { createDemoHandlers, FAKE_BANNER } from './fake-data.js';
18
+ import { FakeExtensionClient } from './fake-extension.js';
19
+ import { HttpServer } from './http.js';
20
+ import { Toolkit } from './toolkit.js';
21
+ import { createMcpServer, SERVER_VERSION } from './tools.js';
22
+ export const NOT_RUNNING = 'linkedin-toolkit server is not running. Start it with: lit serve --http';
23
+ const defaultIo = {
24
+ out: (text) => process.stdout.write(`${text}\n`),
25
+ err: (text) => process.stderr.write(`${text}\n`),
26
+ };
27
+ /** Thrown to end a command with a message and a non-zero exit code. */
28
+ export class CliError extends Error {
29
+ exitCode;
30
+ constructor(message, exitCode = 1) {
31
+ super(message);
32
+ this.exitCode = exitCode;
33
+ this.name = 'CliError';
34
+ }
35
+ }
36
+ /* ------------------------------------------------------------------ *
37
+ * Small helpers
38
+ * ------------------------------------------------------------------ */
39
+ export function publicIdFrom(input) {
40
+ const match = /linkedin\.com\/in\/([^/?#]+)/i.exec(input);
41
+ if (match)
42
+ return decodeURIComponent(match[1]);
43
+ return input.replace(/^\/+|\/+$/g, '');
44
+ }
45
+ export function universalNameFrom(input) {
46
+ const match = /linkedin\.com\/company\/([^/?#]+)/i.exec(input);
47
+ if (match)
48
+ return decodeURIComponent(match[1]);
49
+ return input.replace(/^\/+|\/+$/g, '');
50
+ }
51
+ /** `24h`, `7d`, `30m`, or an epoch-milliseconds number. */
52
+ export function parseSince(input, now = Date.now()) {
53
+ if (!input)
54
+ return undefined;
55
+ const relative = /^(\d+)\s*(m|h|d)$/i.exec(input.trim());
56
+ if (relative) {
57
+ const value = Number(relative[1]);
58
+ const unit = relative[2].toLowerCase();
59
+ const ms = unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
60
+ return now - value * ms;
61
+ }
62
+ const numeric = Number(input);
63
+ if (Number.isFinite(numeric))
64
+ return numeric;
65
+ throw new CliError(`Cannot read "${input}" as a time window. Use 30m, 24h, 7d or a timestamp.`);
66
+ }
67
+ export function toCsv(rows, columns) {
68
+ const keys = columns ?? [...new Set(rows.flatMap((row) => Object.keys(row)))];
69
+ const cell = (value) => {
70
+ if (value === null || value === undefined)
71
+ return '';
72
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
73
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
74
+ };
75
+ return [keys.join(','), ...rows.map((row) => keys.map((key) => cell(row[key])).join(','))].join('\n') + '\n';
76
+ }
77
+ /** A small RFC-4180-ish reader: quoted fields, doubled quotes, CRLF. */
78
+ export function fromCsv(text) {
79
+ const rows = [];
80
+ let row = [];
81
+ let field = '';
82
+ let quoted = false;
83
+ for (let i = 0; i < text.length; i++) {
84
+ const char = text[i];
85
+ if (quoted) {
86
+ if (char === '"') {
87
+ if (text[i + 1] === '"') {
88
+ field += '"';
89
+ i++;
90
+ }
91
+ else
92
+ quoted = false;
93
+ }
94
+ else
95
+ field += char;
96
+ continue;
97
+ }
98
+ if (char === '"')
99
+ quoted = true;
100
+ else if (char === ',') {
101
+ row.push(field);
102
+ field = '';
103
+ }
104
+ else if (char === '\n') {
105
+ row.push(field);
106
+ rows.push(row);
107
+ row = [];
108
+ field = '';
109
+ }
110
+ else if (char !== '\r')
111
+ field += char;
112
+ }
113
+ if (field !== '' || row.length > 0) {
114
+ row.push(field);
115
+ rows.push(row);
116
+ }
117
+ const [header, ...body] = rows.filter((r) => r.some((cell) => cell.trim() !== ''));
118
+ if (!header)
119
+ return [];
120
+ const keys = header.map((key) => key.trim());
121
+ return body.map((values) => Object.fromEntries(keys.map((key, index) => [key, (values[index] ?? '').trim()])));
122
+ }
123
+ export function slugify(input) {
124
+ return (input
125
+ .toLowerCase()
126
+ .normalize('NFKD')
127
+ .replace(/[^a-z0-9]+/g, '-')
128
+ .replace(/^-+|-+$/g, '')
129
+ .slice(0, 60) || 'row');
130
+ }
131
+ /** The note column of `lit endpoints check`. */
132
+ export function endpointNote(result, clientVersion, error) {
133
+ if (result === 'ok')
134
+ return `verified ${clientVersion}`;
135
+ if (result === 'failed') {
136
+ return error
137
+ ? `${error} — LinkedIn likely moved; recapture per docs/voyager-endpoints.md`
138
+ : 'LinkedIn likely moved; recapture per docs/voyager-endpoints.md';
139
+ }
140
+ if (result === 'skipped')
141
+ return 'nothing to check it against on this account';
142
+ return 'not yet verified against the current LinkedIn client';
143
+ }
144
+ /** Fixed-width table for human output. */
145
+ export function table(rows, columns) {
146
+ if (rows.length === 0)
147
+ return '(nothing)';
148
+ const text = rows.map((row) => columns.map((column) => {
149
+ const value = row[column];
150
+ return value === null || value === undefined ? '' : String(value);
151
+ }));
152
+ const widths = columns.map((column, index) => Math.max(column.length, ...text.map((row) => row[index].length)));
153
+ const line = (cells) => cells.map((cell, index) => cell.padEnd(widths[index])).join(' ').trimEnd();
154
+ return [line(columns), line(widths.map((width) => '-'.repeat(width))), ...text.map(line)].join('\n');
155
+ }
156
+ /* ------------------------------------------------------------------ *
157
+ * HTTP client for the running server
158
+ * ------------------------------------------------------------------ */
159
+ /** Settable keys of the local server config. `token` is deliberately absent. */
160
+ export const SETTABLE_KEYS = [
161
+ 'bridgePort',
162
+ 'httpPort',
163
+ 'webhookUrl',
164
+ 'dbPath',
165
+ 'researchTimeoutMs',
166
+ ];
167
+ /** Show only the last four characters, so a shoulder or a screen share leaks nothing. */
168
+ export function maskToken(token) {
169
+ if (!token)
170
+ return '';
171
+ return token.length <= 4 ? '*'.repeat(token.length) : `${'*'.repeat(token.length - 4)}${token.slice(-4)}`;
172
+ }
173
+ /** Parse and validate one `lit config set` value. Throws CliError on bad input. */
174
+ export function coerceSetting(key, raw) {
175
+ if (!SETTABLE_KEYS.includes(key)) {
176
+ throw new CliError(`"${key}" is not a settable key. Choose one of: ${SETTABLE_KEYS.join(', ')}.\n` +
177
+ 'The pairing token is rotated with `lit token rotate`; LinkedIn behaviour settings ' +
178
+ '(delays, caps, autopilot) live in the extension, not here.');
179
+ }
180
+ const settable = key;
181
+ if (settable === 'bridgePort' || settable === 'httpPort') {
182
+ const port = Number(raw);
183
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
184
+ throw new CliError(`${settable} must be a whole number between 1 and 65535, not "${raw}".`);
185
+ }
186
+ return { key: settable, value: port };
187
+ }
188
+ if (settable === 'researchTimeoutMs') {
189
+ const ms = Number(raw);
190
+ if (!Number.isInteger(ms) || ms < 1000) {
191
+ throw new CliError(`researchTimeoutMs must be a whole number of at least 1000, not "${raw}".`);
192
+ }
193
+ return { key: settable, value: ms };
194
+ }
195
+ if (settable === 'webhookUrl') {
196
+ if (raw === '' || raw === 'none')
197
+ return { key: settable, value: undefined };
198
+ let url;
199
+ try {
200
+ url = new URL(raw);
201
+ }
202
+ catch {
203
+ throw new CliError(`webhookUrl must be a URL, not "${raw}". Pass "" to unset it.`);
204
+ }
205
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
206
+ throw new CliError(`webhookUrl must be http or https, not "${url.protocol}".`);
207
+ }
208
+ return { key: settable, value: url.toString() };
209
+ }
210
+ if (raw.trim() === '')
211
+ throw new CliError('dbPath cannot be empty.');
212
+ return { key: settable, value: resolve(raw) };
213
+ }
214
+ export class ServerClient {
215
+ baseUrl;
216
+ token;
217
+ constructor(baseUrl, token) {
218
+ this.baseUrl = baseUrl;
219
+ this.token = token;
220
+ }
221
+ /**
222
+ * Where to find the server, most specific first:
223
+ * 1. `LINKEDIN_TOOLKIT_URL` / `LINKEDIN_TOOLKIT_TOKEN` — the same names the
224
+ * docs and the Node and Python clients use, so one export points them
225
+ * all at the same place;
226
+ * 2. the port a running `lit serve` actually bound, so
227
+ * `lit serve --http --port 9000` does not strand every other command;
228
+ * 3. the configured default.
229
+ */
230
+ static fromConfig(config) {
231
+ const port = readRuntime()?.httpPort ?? config.httpPort;
232
+ const baseUrl = (process.env.LINKEDIN_TOOLKIT_URL || `http://127.0.0.1:${port}`).replace(/\/+$/, '');
233
+ return new ServerClient(baseUrl, process.env.LINKEDIN_TOOLKIT_TOKEN || config.token);
234
+ }
235
+ async post(path, body) {
236
+ let response;
237
+ try {
238
+ response = await fetch(`${this.baseUrl}${path}`, {
239
+ method: 'POST',
240
+ headers: {
241
+ 'content-type': 'application/json',
242
+ authorization: `Bearer ${this.token}`,
243
+ // Marks these calls as human-originated rather than agent-originated.
244
+ [ORIGIN_HEADER]: 'cli',
245
+ },
246
+ body: JSON.stringify(body ?? {}),
247
+ });
248
+ }
249
+ catch {
250
+ throw new CliError(NOT_RUNNING);
251
+ }
252
+ if (response.status === 401) {
253
+ throw new CliError('The server rejected the pairing token. Check ~/.linkedin-toolkit/config.json.');
254
+ }
255
+ const envelope = (await response.json().catch(() => null));
256
+ if (!envelope)
257
+ throw new CliError(`The server returned ${response.status} with no body.`);
258
+ if (envelope.ok === true)
259
+ return envelope.data;
260
+ const error = envelope.error ?? { code: 'INTERNAL', message: 'Unknown error.' };
261
+ const parts = [`${error.code}: ${error.message}`];
262
+ if (error.howToFix)
263
+ parts.push(` ${error.howToFix}`);
264
+ if (error.retryAfter)
265
+ parts.push(` retry after ${error.retryAfter}s`);
266
+ throw new CliError(parts.join('\n'));
267
+ }
268
+ action(action, params = {}) {
269
+ return this.post(`/actions/${action}`, params);
270
+ }
271
+ tool(tool, args = {}) {
272
+ return this.post(`/tools/${tool}`, args);
273
+ }
274
+ async health() {
275
+ try {
276
+ const response = await fetch(`${this.baseUrl}/health`);
277
+ return (await response.json());
278
+ }
279
+ catch {
280
+ throw new CliError(NOT_RUNNING);
281
+ }
282
+ }
283
+ }
284
+ export async function serve(options, io) {
285
+ const { config } = loadConfig();
286
+ const merged = withOverrides(config, {
287
+ httpPort: options.port,
288
+ bridgePort: options.bridgePort,
289
+ });
290
+ const toolkit = new Toolkit({ config: merged });
291
+ await toolkit.start();
292
+ // In stdio mode stdout carries MCP frames, so the banner goes to stderr.
293
+ const say = options.http ? io.out : io.err;
294
+ if (options.fake) {
295
+ const rule = '='.repeat(FAKE_BANNER.length);
296
+ say(`${rule}\n${FAKE_BANNER}\n${rule}\n`);
297
+ }
298
+ say(pairingInstructions(merged, { http: options.http }));
299
+ // The demo extension attaches over the real bridge, so fake mode exercises
300
+ // exactly the same path a real extension would.
301
+ let fake;
302
+ if (options.fake) {
303
+ fake = new FakeExtensionClient({ port: toolkit.bridge.port, token: merged.token });
304
+ fake.setHandlers(createDemoHandlers((event, payload) => fake.emit(event, payload)));
305
+ await fake.connect();
306
+ say('\nDemo extension connected. No pairing needed in fake mode.');
307
+ }
308
+ let http;
309
+ if (options.http) {
310
+ http = new HttpServer({ toolkit, port: merged.httpPort });
311
+ await http.start();
312
+ writeRuntime({
313
+ httpPort: http.port,
314
+ bridgePort: toolkit.bridge.port,
315
+ pid: process.pid,
316
+ startedAt: Date.now(),
317
+ });
318
+ io.out(`\nListening on ${http.url}. Ctrl-C to stop.`);
319
+ }
320
+ else {
321
+ const server = createMcpServer(toolkit);
322
+ await server.connect(new StdioServerTransport());
323
+ }
324
+ return {
325
+ toolkit,
326
+ http,
327
+ fake,
328
+ stop: async () => {
329
+ if (http)
330
+ clearRuntime();
331
+ await fake?.close();
332
+ await http?.stop();
333
+ await toolkit.stop();
334
+ },
335
+ };
336
+ }
337
+ /* ------------------------------------------------------------------ *
338
+ * Commands
339
+ * ------------------------------------------------------------------ */
340
+ function client() {
341
+ return ServerClient.fromConfig(loadConfig().config);
342
+ }
343
+ function print(io, json, data, human) {
344
+ if (json)
345
+ io.out(JSON.stringify(data, null, 2));
346
+ else
347
+ io.out(human());
348
+ }
349
+ const PROFILE_COLUMNS = ['publicId', 'fullName', 'headline', 'company', 'location'];
350
+ async function saveToList(api, listName, profiles, io) {
351
+ const { lists } = await api.action('list.getAll', {});
352
+ const existing = (lists ?? []).find((list) => list.name === listName);
353
+ const list = existing ?? (await api.action('list.create', { name: listName }));
354
+ const result = await api.action('list.add', { listId: list.listId, profiles });
355
+ io.out(`Saved ${result.added} to list "${listName}" (${result.duplicates} already there).`);
356
+ }
357
+ export function buildProgram(io = defaultIo) {
358
+ const program = new Command();
359
+ program
360
+ .name('lit')
361
+ .description('LinkedIn Toolkit: drive your own logged-in Chrome through the toolkit extension.\n' +
362
+ 'Start the server with `lit serve --http`, then every other command talks to it.')
363
+ .version(SERVER_VERSION)
364
+ .configureOutput({
365
+ writeOut: (text) => io.out(text.replace(/\n$/, '')),
366
+ writeErr: (text) => io.err(text.replace(/\n$/, '')),
367
+ });
368
+ program
369
+ .command('serve')
370
+ .description('Run the bridge and the MCP server. Add --http for the HTTP API.')
371
+ .option('--http', 'also serve the HTTP action API and MCP over Streamable HTTP')
372
+ .option('--port <port>', 'HTTP port (default 47830)', (v) => Number(v))
373
+ .option('--bridge-port <port>', 'WebSocket bridge port (default 47829)', (v) => Number(v))
374
+ .option('--fake', 'run against built-in demo data instead of Chrome: no extension, no LinkedIn account, no network calls')
375
+ .action(async (options) => {
376
+ const handles = await serve({
377
+ http: options.http,
378
+ port: options.port,
379
+ bridgePort: options.bridgePort,
380
+ fake: options.fake,
381
+ }, io);
382
+ const shutdown = () => void handles.stop().finally(() => process.exit(0));
383
+ process.on('SIGINT', shutdown);
384
+ process.on('SIGTERM', shutdown);
385
+ await new Promise(() => undefined); // run until interrupted
386
+ });
387
+ program
388
+ .command('status')
389
+ .description('Show the extension connection, quotas, queue and campaigns.')
390
+ .option('--json', 'print raw JSON')
391
+ .action(async (options) => {
392
+ const api = client();
393
+ const health = await api.health();
394
+ if (!health.extensionConnected) {
395
+ io.out('Server: running');
396
+ io.out('Extension: NOT CONNECTED');
397
+ io.out('');
398
+ io.out('Open the toolkit popup in Chrome, go to Settings → Local bridge, paste the pairing');
399
+ io.out('token from ~/.linkedin-toolkit/config.json and enable the bridge.');
400
+ return;
401
+ }
402
+ const status = await api.action('status.get', {});
403
+ print(io, options.json, status, () => [
404
+ `Server: running (v${health.version})`,
405
+ `Extension: connected (v${status.extensionVersion})`,
406
+ `LinkedIn: ${status.loggedIn ? 'logged in' : 'NOT logged in'}`,
407
+ `Mode: ${status.autopilot ? 'Autopilot' : 'Copilot (writes need approval)'}`,
408
+ `Hours: ${status.businessHours ? 'inside business hours' : 'outside business hours'}`,
409
+ '',
410
+ table(Object.entries(status.quotas ?? {}).map(([kind, quota]) => ({
411
+ quota: kind,
412
+ hourly: `${quota.hourlyUsed}/${quota.hourlyCap}`,
413
+ daily: `${quota.dailyUsed}/${quota.dailyCap}`,
414
+ })), ['quota', 'hourly', 'daily']),
415
+ '',
416
+ `Queue: ${status.queue?.pending ?? 0} pending`,
417
+ `Campaigns: ${status.campaigns?.active ?? 0} active, ${status.campaigns?.paused ?? 0} paused`,
418
+ ].join('\n'));
419
+ });
420
+ program
421
+ .command('search')
422
+ .argument('<keywords>', 'what to search for')
423
+ .description('Search LinkedIn people.')
424
+ .option('--source <source>', 'search | salesnav | recruiter', 'search')
425
+ .option('--count <n>', 'how many results (max 100)', (v) => Number(v))
426
+ .option('--csv <file>', 'write the results to a CSV file')
427
+ .option('--list <name>', 'save the results to a list')
428
+ .option('--json', 'print raw JSON')
429
+ .action(async (keywords, options) => {
430
+ const api = client();
431
+ const data = await api.action('search.people', {
432
+ keywords,
433
+ source: options.source,
434
+ ...(options.count ? { count: options.count } : {}),
435
+ });
436
+ const profiles = data.profiles ?? [];
437
+ if (options.csv) {
438
+ writeFileSync(resolve(options.csv), toCsv(profiles, PROFILE_COLUMNS), 'utf8');
439
+ io.out(`Wrote ${profiles.length} profiles to ${options.csv}`);
440
+ }
441
+ if (options.list)
442
+ await saveToList(api, options.list, profiles, io);
443
+ if (!options.csv || options.json) {
444
+ print(io, options.json, data, () => table(profiles, PROFILE_COLUMNS));
445
+ }
446
+ });
447
+ program
448
+ .command('profile')
449
+ .argument('<url>', 'profile URL or publicId')
450
+ .description('Fetch one profile.')
451
+ .option('--full', 'capture the full page, experience and photo (costs one profile visit)')
452
+ .option('--json', 'print raw JSON')
453
+ .action(async (url, options) => {
454
+ const data = await client().action('profile.get', {
455
+ publicId: publicIdFrom(url),
456
+ ...(options.full ? { full: true } : {}),
457
+ });
458
+ print(io, options.json, data, () => [
459
+ data.fullName,
460
+ data.headline ?? '',
461
+ [data.company, data.location].filter(Boolean).join(' — '),
462
+ data.url,
463
+ ]
464
+ .filter(Boolean)
465
+ .join('\n'));
466
+ });
467
+ program
468
+ .command('engagers')
469
+ .argument('<postUrl>', 'the post URL')
470
+ .description('List people who liked or commented on a post.')
471
+ .option('--kind <kind>', 'likes | comments | both', 'both')
472
+ .option('--list <name>', 'save the engagers to a list')
473
+ .option('--json', 'print raw JSON')
474
+ .action(async (postUrl, options) => {
475
+ const api = client();
476
+ const data = await api.action('post.engagers', { postUrl, kind: options.kind });
477
+ const engagers = data.engagers ?? [];
478
+ if (options.list)
479
+ await saveToList(api, options.list, engagers, io);
480
+ print(io, options.json, data, () => table(engagers, [...PROFILE_COLUMNS, 'reaction']));
481
+ });
482
+ program
483
+ .command('company')
484
+ .argument('<url>', 'company URL or universalName')
485
+ .description('Fetch a company, and optionally its employees.')
486
+ .option('--employees', 'also list employees')
487
+ .option('--json', 'print raw JSON')
488
+ .action(async (url, options) => {
489
+ const api = client();
490
+ const universalName = universalNameFrom(url);
491
+ const company = await api.action('company.get', { universalName });
492
+ const employees = options.employees
493
+ ? (await api.action('company.employees', { universalName })).profiles ?? []
494
+ : [];
495
+ print(io, options.json, options.employees ? { company, employees } : company, () => [
496
+ `${company.name} (${company.universalName})`,
497
+ [company.industry, company.size, company.hq].filter(Boolean).join(' — '),
498
+ company.url,
499
+ ...(options.employees ? ['', table(employees, PROFILE_COLUMNS)] : []),
500
+ ]
501
+ .filter(Boolean)
502
+ .join('\n'));
503
+ });
504
+ program
505
+ .command('invite')
506
+ .argument('<url>', 'profile URL or publicId')
507
+ .description('Send a connection invite (queued for approval in Copilot mode).')
508
+ .option('--note <note>', 'a note, under 300 characters')
509
+ .option('--dry-run', 'show what would be sent without sending it')
510
+ .action(async (url, options) => {
511
+ const data = await client().action('outreach.invite', {
512
+ publicId: publicIdFrom(url),
513
+ ...(options.note ? { note: options.note } : {}),
514
+ ...(options.dryRun ? { dry_run: true } : {}),
515
+ });
516
+ io.out(data.status === 'queued'
517
+ ? `Queued for approval (${data.queueId}). Approve it with: lit queue approve ${data.queueId}`
518
+ : `Invite ${data.status}.`);
519
+ });
520
+ program
521
+ .command('message')
522
+ .argument('<url>', 'profile URL or publicId')
523
+ .requiredOption('--body <body>', 'the message body')
524
+ .description('Send a message to a first-degree connection.')
525
+ .option('--dry-run', 'show what would be sent without sending it')
526
+ .action(async (url, options) => {
527
+ const data = await client().action('outreach.message', {
528
+ publicId: publicIdFrom(url),
529
+ body: options.body,
530
+ ...(options.dryRun ? { dry_run: true } : {}),
531
+ });
532
+ io.out(data.status === 'queued'
533
+ ? `Queued for approval (${data.queueId}). Approve it with: lit queue approve ${data.queueId}`
534
+ : `Message ${data.status}.`);
535
+ });
536
+ program
537
+ .command('inbox')
538
+ .description('List inbox threads.')
539
+ .option('--since <window>', 'e.g. 24h, 7d, or a timestamp')
540
+ .option('--sentiment', 'show the sentiment column')
541
+ .option('--json', 'print raw JSON')
542
+ .action(async (options) => {
543
+ const since = parseSince(options.since);
544
+ const data = await client().action('inbox.threads', since ? { since } : {});
545
+ const threads = (data.threads ?? []).map((thread) => ({
546
+ threadId: thread.threadId,
547
+ who: (thread.participants ?? []).map((p) => p.fullName).join(', '),
548
+ unread: thread.unread ? 'yes' : '',
549
+ sentiment: thread.sentiment ?? '',
550
+ snippet: thread.snippet,
551
+ }));
552
+ const columns = ['threadId', 'who', 'unread', ...(options.sentiment ? ['sentiment'] : []), 'snippet'];
553
+ print(io, options.json, data, () => table(threads, columns));
554
+ });
555
+ program
556
+ .command('queue')
557
+ .argument('[action]', 'approve | reject | list', 'list')
558
+ .argument('[ids...]', 'queue item ids')
559
+ .description('Show the approval queue, or approve or reject items.')
560
+ .option('--json', 'print raw JSON')
561
+ .action(async (action, ids, options) => {
562
+ const api = client();
563
+ if (action === 'approve' || action === 'reject') {
564
+ if (ids.length === 0)
565
+ throw new CliError(`Give at least one id: lit queue ${action} <id>`);
566
+ const data = await api.action(`queue.${action}`, { ids });
567
+ io.out(`${action === 'approve' ? 'Approved' : 'Rejected'} ${data.approved ?? data.rejected}.`);
568
+ return;
569
+ }
570
+ if (action !== 'list')
571
+ throw new CliError(`Unknown queue action "${action}".`);
572
+ const data = await api.action('queue.list', { status: 'pending' });
573
+ const items = (data.items ?? []).map((item) => ({
574
+ id: item.id,
575
+ action: item.action,
576
+ who: item.profile?.fullName ?? item.params?.publicId ?? '',
577
+ origin: item.origin,
578
+ preview: String(item.params?.note ?? item.params?.body ?? '').slice(0, 60),
579
+ }));
580
+ print(io, options.json, data, () => table(items, ['id', 'action', 'who', 'origin', 'preview']));
581
+ });
582
+ const campaign = program.command('campaign').description('Create and control campaigns.');
583
+ campaign
584
+ .command('create')
585
+ .requiredOption('--from <file>', 'a JSON file with { name, steps } or a bare steps array')
586
+ .option('--list <name>', 'enroll everyone in this list')
587
+ .option('--json', 'print raw JSON')
588
+ .description('Create a campaign from a JSON sequence file.')
589
+ .action(async (options) => {
590
+ const api = client();
591
+ const parsed = JSON.parse(readFileSync(resolve(options.from), 'utf8'));
592
+ const steps = Array.isArray(parsed) ? parsed : parsed.steps;
593
+ if (!Array.isArray(steps))
594
+ throw new CliError(`${options.from} has no steps array.`);
595
+ const name = Array.isArray(parsed) ? `Campaign ${new Date().toISOString().slice(0, 10)}` : parsed.name;
596
+ let listId;
597
+ if (options.list) {
598
+ const { lists } = await api.action('list.getAll', {});
599
+ const found = (lists ?? []).find((list) => list.name === options.list);
600
+ if (!found)
601
+ throw new CliError(`No list named "${options.list}".`);
602
+ listId = found.listId;
603
+ }
604
+ const data = await api.action('campaign.create', {
605
+ name,
606
+ steps,
607
+ ...(listId ? { listId } : {}),
608
+ ...(parsed.settings ? { settings: parsed.settings } : {}),
609
+ });
610
+ print(io, options.json, data, () => `Created campaign "${data.name}" (${data.campaignId}) with ${steps.length} steps, status ${data.status}.`);
611
+ });
612
+ campaign
613
+ .command('list')
614
+ .description('List every campaign.')
615
+ .option('--json', 'print raw JSON')
616
+ .action(async (options) => {
617
+ const data = await client().action('campaign.getAll', {});
618
+ const campaigns = (data.campaigns ?? []).map((c) => ({
619
+ campaignId: c.campaignId,
620
+ name: c.name,
621
+ status: c.status,
622
+ steps: (c.steps ?? []).length,
623
+ enrolled: c.stats?.enrolled ?? '',
624
+ sent: c.stats?.sent ?? '',
625
+ replied: c.stats?.replied ?? '',
626
+ }));
627
+ print(io, options.json, data, () => table(campaigns, ['campaignId', 'name', 'status', 'steps', 'enrolled', 'sent', 'replied']));
628
+ });
629
+ for (const verb of ['pause', 'resume']) {
630
+ campaign
631
+ .command(verb)
632
+ .argument('<campaignId>')
633
+ .description(`${verb[0].toUpperCase()}${verb.slice(1)} a campaign.`)
634
+ .action(async (campaignId) => {
635
+ const data = await client().action(`campaign.${verb}`, { campaignId });
636
+ io.out(`Campaign "${data.name}" is now ${data.status}.`);
637
+ });
638
+ }
639
+ program
640
+ .command('sql')
641
+ .argument('<query>', 'a single SELECT or WITH statement')
642
+ .description('Query the local SQLite mirror (read-only, 1,000 rows).')
643
+ .option('--csv <file>', 'write the rows to a CSV file')
644
+ .option('--json', 'print raw JSON')
645
+ .action(async (query, options) => {
646
+ const data = await client().tool('linkedin_query_sql', { sql: query });
647
+ if (options.csv) {
648
+ writeFileSync(resolve(options.csv), toCsv(data.rows, data.columns), 'utf8');
649
+ io.out(`Wrote ${data.rowCount} rows to ${options.csv}`);
650
+ return;
651
+ }
652
+ print(io, options.json, data, () => {
653
+ const rendered = table(data.rows, data.columns);
654
+ return data.truncated ? `${rendered}\n\n(truncated at 1,000 rows)` : rendered;
655
+ });
656
+ });
657
+ program
658
+ .command('export')
659
+ .requiredOption('--table <table>', `one of: ${TABLES.join(', ')}`)
660
+ .requiredOption('--csv <file>', 'the file to write')
661
+ .description('Export a table from the local mirror to CSV.')
662
+ .action(async (options) => {
663
+ if (!TABLES.includes(options.table)) {
664
+ throw new CliError(`Unknown table "${options.table}". Choose one of: ${TABLES.join(', ')}`);
665
+ }
666
+ const data = await client().tool('linkedin_query_sql', {
667
+ sql: `SELECT * FROM ${options.table}`,
668
+ });
669
+ writeFileSync(resolve(options.csv), toCsv(data.rows, data.columns), 'utf8');
670
+ io.out(`Wrote ${data.rowCount} rows from ${options.table} to ${options.csv}`);
671
+ });
672
+ program
673
+ .command('sync')
674
+ .description('Pull everything changed in the extension into the local mirror.')
675
+ .option('--since <window>', 'e.g. 24h, 7d, or a timestamp')
676
+ .option('--json', 'print raw JSON')
677
+ .action(async (options) => {
678
+ const since = parseSince(options.since);
679
+ const data = await client().tool('linkedin_sync', since === undefined ? {} : { since });
680
+ print(io, options.json, data, () => {
681
+ const changed = Object.entries(data.counts ?? {});
682
+ if (changed.length === 0)
683
+ return 'Nothing changed since the last sync.';
684
+ return [
685
+ 'Synced:',
686
+ ...changed.map(([table, count]) => ` ${table}: ${count}`),
687
+ '',
688
+ `Mirror now holds ${data.totals.profiles} profiles. Query it with: lit sql "SELECT ..."`,
689
+ ].join('\n');
690
+ });
691
+ });
692
+ const endpointsCommand = program
693
+ .command('endpoints')
694
+ .description('Check the LinkedIn endpoints the extension depends on.');
695
+ endpointsCommand
696
+ .command('check')
697
+ .description('Self-test every endpoint and report which ones LinkedIn still serves. ' +
698
+ 'Exits 2 if any endpoint failed.')
699
+ .option('--post <url>', 'a post URL, so the reaction endpoint can be checked too')
700
+ .option('--json', 'print raw JSON')
701
+ .action(async (options) => {
702
+ const status = await client().action('status.get', {
703
+ verify: true,
704
+ ...(options.post ? { postUrl: options.post } : {}),
705
+ });
706
+ const endpoints = status.endpoints ?? {};
707
+ const errors = status.endpointErrors ?? {};
708
+ const captured = status.clientVersionCaptured ?? 'unknown';
709
+ if (options.json) {
710
+ io.out(JSON.stringify(status, null, 2));
711
+ }
712
+ else {
713
+ const rows = Object.entries(endpoints).map(([name, result]) => ({
714
+ name,
715
+ result,
716
+ note: endpointNote(String(result), captured, errors[name]),
717
+ }));
718
+ io.out(table(rows, ['name', 'result', 'note']));
719
+ }
720
+ const failed = Object.entries(endpoints).filter(([, result]) => result === 'failed');
721
+ if (failed.length > 0) {
722
+ if (!options.json) {
723
+ io.out('');
724
+ io.out(`${failed.length} endpoint${failed.length === 1 ? '' : 's'} failed. ` +
725
+ 'LinkedIn most likely moved: see docs/voyager-endpoints.md for how to recapture.');
726
+ }
727
+ // A distinct code so CI and scripts can tell "some endpoint broke" from
728
+ // "the command itself could not run".
729
+ throw new CliError('', 2);
730
+ }
731
+ });
732
+ const configCommand = program
733
+ .command('config')
734
+ .description('Read and change this server\'s local settings (~/.linkedin-toolkit/config.json).');
735
+ configCommand
736
+ .command('get')
737
+ .argument('[key]', `one of: token, ${SETTABLE_KEYS.join(', ')}`)
738
+ .description('Print the local server settings, or one of them. The token is masked.')
739
+ .option('--reveal', 'print the pairing token in full instead of masking it')
740
+ .option('--json', 'print raw JSON')
741
+ .action((key, options) => {
742
+ const { config } = loadConfig();
743
+ const shown = {
744
+ ...config,
745
+ token: options.reveal ? config.token : maskToken(config.token),
746
+ };
747
+ if (config.webhookUrl === undefined)
748
+ shown.webhookUrl = '';
749
+ if (key) {
750
+ if (!(key in shown)) {
751
+ throw new CliError(`"${key}" is not a setting. Choose one of: token, ${SETTABLE_KEYS.join(', ')}.`);
752
+ }
753
+ io.out(options.json ? JSON.stringify(shown[key]) : String(shown[key] ?? ''));
754
+ return;
755
+ }
756
+ print(io, options.json, shown, () => [
757
+ table(Object.entries(shown).map(([name, value]) => ({
758
+ setting: name,
759
+ value: String(value ?? ''),
760
+ })), ['setting', 'value']),
761
+ '',
762
+ options.reveal
763
+ ? 'Pair the extension with the token above: popup → Settings → Local bridge.'
764
+ : 'The token is masked. Show it with: lit config get token --reveal',
765
+ ].join('\n'));
766
+ });
767
+ configCommand
768
+ .command('set')
769
+ .argument('<key>', SETTABLE_KEYS.join(' | '))
770
+ .argument('<value>', 'the new value; pass "" to unset webhookUrl')
771
+ .description('Change one local server setting.')
772
+ .action((key, value) => {
773
+ const { config } = loadConfig();
774
+ const { key: settable, value: parsed } = coerceSetting(key, value);
775
+ const next = { ...config };
776
+ if (parsed === undefined)
777
+ delete next[settable];
778
+ else
779
+ next[settable] = parsed;
780
+ saveConfig(next);
781
+ io.out(`${settable} = ${parsed === undefined ? '(unset)' : String(parsed)}`);
782
+ if (settable === 'bridgePort' || settable === 'httpPort' || settable === 'dbPath') {
783
+ io.out('Restart the server for this to take effect: lit serve --http');
784
+ }
785
+ });
786
+ program
787
+ .command('token')
788
+ .argument('<action>', 'rotate')
789
+ .description('Manage the pairing token.')
790
+ .action((action) => {
791
+ if (action !== 'rotate') {
792
+ throw new CliError(`Unknown token action "${action}". The only action is: rotate`);
793
+ }
794
+ const { config } = loadConfig();
795
+ const rotated = { ...config, token: generateToken() };
796
+ saveConfig(rotated);
797
+ io.out('Pairing token rotated. The old token no longer works.');
798
+ io.out('');
799
+ io.out(pairingInstructions(rotated, { http: true }));
800
+ io.out('');
801
+ io.out('Re-pair the extension with the new token, and restart a running server so it');
802
+ io.out('starts accepting the new one: lit serve --http');
803
+ });
804
+ program
805
+ .command('research')
806
+ .argument('<input>', 'a CSV of rows with any of: name, linkedinUrl, email, domain, company')
807
+ .description('Turn a CSV of rows into research packs.')
808
+ .option('--out <dir>', 'where to write the packs', './packs')
809
+ .option('--enrich', 'also run the configured enrichment provider')
810
+ .option('--list <name>', 'save everyone resolved to this list')
811
+ .option('--poll <ms>', 'how often to poll for progress', (v) => Number(v), 2000)
812
+ .action(async (input, options) => {
813
+ const api = client();
814
+ const rows = fromCsv(readFileSync(resolve(input), 'utf8'));
815
+ if (rows.length === 0)
816
+ throw new CliError(`${input} has no rows.`);
817
+ io.out(`Researching ${rows.length} rows...`);
818
+ const started = await api.action('research.pack', {
819
+ rows,
820
+ ...(options.list ? { listName: options.list } : {}),
821
+ ...(options.enrich ? { enrich: true } : {}),
822
+ });
823
+ const jobId = started.jobId;
824
+ io.out(`Job ${jobId} started (${started.total} rows).`);
825
+ let done = -1;
826
+ let result;
827
+ for (;;) {
828
+ result = await api.action('research.get', { jobId });
829
+ if (result.done !== done) {
830
+ done = result.done;
831
+ io.out(` ${done}/${result.total} packs`);
832
+ }
833
+ if (result.status === 'completed' || result.status === 'failed')
834
+ break;
835
+ await new Promise((r) => setTimeout(r, options.poll));
836
+ }
837
+ if (result.status === 'failed')
838
+ throw new CliError(`Job ${jobId} failed.`);
839
+ const outDir = resolve(options.out);
840
+ const packs = result.packs ?? [];
841
+ const csvRows = [];
842
+ for (const pack of packs) {
843
+ const name = pack.resolved?.publicId ??
844
+ pack.profile?.publicId ??
845
+ pack.resolved?.universalName ??
846
+ pack.row?.name ??
847
+ 'row';
848
+ const dir = join(outDir, slugify(String(name)));
849
+ mkdirSync(dir, { recursive: true });
850
+ writeFileSync(join(dir, 'pack.md'), pack.markdown ?? '', 'utf8');
851
+ writeFileSync(join(dir, 'pack.json'), `${JSON.stringify(pack, null, 2)}\n`, 'utf8');
852
+ if (pack.csvRow)
853
+ csvRows.push(pack.csvRow);
854
+ }
855
+ if (csvRows.length > 0) {
856
+ mkdirSync(outDir, { recursive: true });
857
+ writeFileSync(join(outDir, 'output.csv'), toCsv(csvRows), 'utf8');
858
+ }
859
+ io.out(`Wrote ${packs.length} packs to ${outDir}`);
860
+ if (csvRows.length > 0)
861
+ io.out(`Wrote ${join(outDir, 'output.csv')}`);
862
+ });
863
+ return program;
864
+ }
865
+ /* ------------------------------------------------------------------ *
866
+ * Entry point
867
+ * ------------------------------------------------------------------ */
868
+ export async function run(argv, io = defaultIo) {
869
+ const program = buildProgram(io);
870
+ program.exitOverride();
871
+ try {
872
+ await program.parseAsync(argv, { from: 'user' });
873
+ return 0;
874
+ }
875
+ catch (err) {
876
+ if (err instanceof CommanderError) {
877
+ // --help and --version are not failures.
878
+ return err.exitCode === 0 || err.code === 'commander.helpDisplayed' ? 0 : err.exitCode;
879
+ }
880
+ if (err instanceof CliError) {
881
+ // An exit code on its own is a valid outcome: `lit endpoints check`
882
+ // reports the detail itself and then fails with code 2.
883
+ if (err.message)
884
+ io.err(err.message);
885
+ return err.exitCode;
886
+ }
887
+ io.err(err instanceof Error ? err.message : String(err));
888
+ return 1;
889
+ }
890
+ }
891
+ const invokedDirectly = process.argv[1]?.endsWith('cli.js') ?? false;
892
+ if (invokedDirectly) {
893
+ run(process.argv.slice(2)).then((code) => {
894
+ if (code !== 0)
895
+ process.exit(code);
896
+ });
897
+ }
898
+ //# sourceMappingURL=cli.js.map