@floomhq/signaldash 0.12.0 → 0.22.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.
package/README.md CHANGED
@@ -11,6 +11,14 @@ the server's Unipile access key.
11
11
  You need Node.js 20 or newer and a single-use SignalDash invite code. Ask the
12
12
  SignalDash administrator for an invite code, then run:
13
13
 
14
+ ```bash
15
+ npx -y @floomhq/signaldash <invite-code>
16
+ ```
17
+
18
+ This agent-first setup logs in, installs the bundled operating skill, registers
19
+ the MCP server with Claude Code when available, and prints hosted-auth links for
20
+ the human to complete. The equivalent manual sequence is:
21
+
14
22
  ```bash
15
23
  npx -y @floomhq/signaldash login <invite-code>
16
24
  npx -y @floomhq/signaldash connect linkedin
@@ -74,18 +82,22 @@ The MCP server uses the user token created by `login`. It cannot access a
74
82
  LinkedIn, WhatsApp, or email account until that channel has been connected for
75
83
  the same logged-in user.
76
84
 
77
- ## Give the agent the safety skill
85
+ ## Give the agent the operating skill
86
+
87
+ The canonical [`signaldash` skill](skills/signaldash/SKILL.md) teaches an agent
88
+ when to use SignalDash, how to persist the skill, complete setup, interpret
89
+ connection state, operate every tool, handle server guards, and run safe worked
90
+ flows. It is also served as raw Markdown for agents at:
78
91
 
79
- Before allowing the agent to send messages, give it the bundled
80
- [`signaldash-safe-usage` skill](skills/signaldash-safe-usage/SKILL.md). The
81
- skill requires the agent to resolve the exact chat, read recent thread
82
- history, check for duplicates, and obtain approval when the recipient or text
83
- has not already been approved.
92
+ ```text
93
+ https://signaldash.dev/SKILL.md
94
+ ```
84
95
 
85
- The MCP registration does not automatically load the skill yet. Copy
86
- `skills/signaldash-safe-usage/SKILL.md` into the agent's skills directory or
87
- attach its contents to the agent instructions. Keep this requirement in both
88
- Claude Code and Cursor.
96
+ The one-command setup installs the bundled copy into Claude Code. Other clients
97
+ must save the skill in their documented skill directory and reload skill
98
+ discovery. The narrower
99
+ [`signaldash-safe-usage` skill](skills/signaldash-safe-usage/SKILL.md) remains
100
+ available for clients that separate operating and send-safety instructions.
89
101
 
90
102
  ## MCP tools
91
103
 
@@ -94,19 +106,95 @@ SignalDash exposes:
94
106
  - `li_list_chats`
95
107
  - `li_read_messages(chat_id)`
96
108
  - `li_send_message(chat_id, text)`
109
+ - `li_send_invitation(provider_id, note?, confirm?)`
110
+ - `li_invitations_received(limit?, cursor?)`
111
+ - `li_accept_invitation(invitation_id, confirm)`
112
+ - `li_invitations_sent(limit?, cursor?)`
113
+ - `li_withdraw_invitation(invitation_id, confirm)`
114
+ - `sd_contact_state(channel, identifiers, action?, reason?, confirm?)`
115
+ - `sd_budget_status()`
116
+ - `sd_settings_get()`
117
+ - `sd_settings_set(auto_accept_linkedin, auto_accept_linkedin_filters?, confirm)`
118
+ - `sd_auto_accept_status()`
119
+ - `li_search_connections(query, filters?, limit?)`
120
+ - `li_discover_people(query, filters?, limit?, confirm?)`
121
+ - `li_create_invitation_batch(source_label, time_zone, targets)`
122
+ - `li_get_invitation_batch(batch_id)`
123
+ - `li_cancel_invitation_batch(batch_id, approval_view_hash, confirm)`
97
124
  - `wa_list_chats`
98
125
  - `wa_read_messages(chat_id)`
99
126
  - `wa_send_message(chat_id, text)`
100
127
  - `email_list(limit)`
101
128
  - `email_read(thread_id, limit)`
102
129
  - `email_send(to, subject, body)`
130
+ - `li_my_posts(limit, member_id)`
131
+ - `li_post_reactions(post_id, limit)`
132
+ - `li_post_comments(post_id, limit)`
133
+ - `li_draft_post(text, publish)`
103
134
 
104
135
  Every operation runs through the hosted SignalDash backend. Agents never
105
136
  receive the Unipile access key.
106
137
 
138
+ LinkedIn invitation auto-accept is off for every user until that user
139
+ explicitly enables it with `confirm:true`. The optional filters are an exact
140
+ public-identifier allowlist and description-keyword matches; when both groups
141
+ are configured, both must match. The single backend worker reads one bounded
142
+ received-invitation page, accepts at most five invitations per run and ten per
143
+ UTC day by default, jitters every action, and also consumes the shared daily
144
+ LinkedIn action budget. Three consecutive parse or provider errors disable the
145
+ setting. `sd_auto_accept_status` exposes accepted counts today and this week,
146
+ failed attempts today, remaining capacity, the stop reason, and sanitized
147
+ records for invitations whose provider shape could not be parsed. Both the current nested
148
+ `specifics.shared_secret` and legacy top-level shape are supported; secrets are
149
+ never returned or logged.
150
+
151
+ `li_search_connections` is the zero-cost, zero-provider-risk list-building
152
+ path. It searches only the authenticated user's stored connection snapshot by
153
+ name, headline, or company, supports company/headline-keyword/date filters, and
154
+ joins exact local contact state. It never calls LinkedIn, Unipile, or a paid
155
+ discovery API. When no snapshot exists, it points to the paced sync below
156
+ without starting provider work.
157
+
158
+ `li_discover_people` is the capped paid fallback for people beyond a completed
159
+ stored network. SignalDash runs the same role/company intent against the local
160
+ snapshot first and returns local matches without spending. With no local
161
+ matches, the first call returns an exact paid preview. An identical
162
+ `confirm:true` call can make one serialized HarvestAPI profile-search request.
163
+ That reservation consumes the preview atomically, so every later paid request
164
+ requires a fresh preview and approval.
165
+ Durable per-user pacing, per-user and tenant request caps, and a tenant-wide
166
+ micro-dollar cap are reserved atomically before the request. Hidden cards,
167
+ malformed identities, exact stored connections, and exact contact-state rows
168
+ already marked connected are excluded. Results are read-only planning evidence
169
+ and cannot become invitation-batch targets automatically.
170
+
171
+ Invitation batches are the narrow executable campaign boundary. An agent turns
172
+ the human's request into 1–10 exact canonical LinkedIn Classic profile URLs,
173
+ inclusion reasons, and optional notes, then creates an immutable durable
174
+ preview. The agent can inspect or cancel the batch but cannot approve it. A
175
+ human opens the returned absolute review URL, reauthenticates with the SignalDash
176
+ invite credential, selects unchecked eligible targets, and approves the exact
177
+ stored payload for up to 24 hours. Five failed credential attempts block that
178
+ batch's authentication surface for 15 minutes. The single backend worker
179
+ rechecks sender binding, exact identity, contact state, prior actions, chats,
180
+ invitations, daily/weekly budgets, weekday work window, pacing, and the tenant
181
+ breaker before each at-most-once write. A tenant provider-authentication
182
+ failure opens the persisted breaker immediately; an open breaker rejects new
183
+ batches and stops queued preview work before provider reads.
184
+
185
+ This is not a general campaign engine. It has no pause/resume, priority,
186
+ future start, recurring schedule, acceptance polling, automatic message,
187
+ follow-up, discovery-to-send bridge, or runtime target/text generation.
188
+
189
+ The CLI also provides a paced, resumable LinkedIn connections export:
190
+
191
+ ```bash
192
+ npx -y @floomhq/signaldash connections linkedin-connections.csv
193
+ ```
194
+
107
195
  ## Safety limits
108
196
 
109
- SignalDash enforces a daily send cap. The safe-usage skill adds the human
197
+ SignalDash enforces a daily send cap. The operating skill adds the human
110
198
  workflow around that runtime control:
111
199
 
112
200
  - Every successful send includes `rate_limit.limit`, `used`, `remaining`, and
@@ -114,16 +202,37 @@ workflow around that runtime control:
114
202
  exhausted, SignalDash returns HTTP 429 with `code: "rate_limit_exceeded"` and
115
203
  `Retry-After`. Usage is persisted server-side and resets at midnight UTC.
116
204
  - Read the exact thread before every send.
205
+ - Preview an exact invitation target and note before sending it.
206
+ - List the exact received or sent invitation before accepting or withdrawing it.
117
207
  - Email sends accept one recipient at a time and require a recent
118
208
  `email_read` containing that address.
119
209
  - Never infer a recipient from a partial name.
120
210
  - Never send a duplicate or retry an ambiguous timeout without re-reading.
211
+ - Invitation auto-accept remains off until explicitly enabled, consumes both
212
+ its dedicated daily cap and the shared LinkedIn action cap, and disables
213
+ itself after three consecutive parse or provider errors.
121
214
  - Do not parallelize sends or work around a rate limit.
215
+ - Do not bypass paid-discovery previews, pacing, request caps, or cost caps.
122
216
  - Stop on a provider warning, checkpoint, restriction, HTTP 403, or HTTP 429.
123
217
  - Avoid bulk profile reads, copied message bursts, cold-account automation,
124
218
  and unsolicited WhatsApp messaging.
125
219
 
126
- SignalDash exposes no LinkedIn invitation tool.
220
+ LinkedIn invitation writes are single-object actions. SignalDash serializes
221
+ them with other LinkedIn writes, requires an exact preview or list read,
222
+ applies the shared daily action cap, and enforces a separate 100-attempt weekly
223
+ policy by default. For a new invitation, jitter completes before the final
224
+ preflight and action reservation; that preflight verifies relationship state,
225
+ pending invitations in both directions, and absence of an existing one-to-one
226
+ chat. Provider timeouts become non-retryable `outcome_unknown` locks.
227
+
228
+ The first campaign time boundary is intentionally narrower than scheduled
229
+ messaging. Campaign invitations require a valid sender IANA timezone, run only
230
+ Monday through Friday from 09:00 to 17:00 sender-local time, and reserve a new
231
+ 90–180 second per-sender pacing interval atomically with every attempted
232
+ action. Timing is checked before final provider preflight and again during the
233
+ write reservation. Downtime never creates a catch-up burst. Future start
234
+ dates, recurring schedules, automatic follow-ups, acceptance-triggered
235
+ messages, and multi-message sequences are not exposed.
127
236
 
128
237
  ## Development
129
238
 
package/bin/sd.mjs CHANGED
@@ -4,15 +4,13 @@
4
4
  // tools an agent uses all proxy through the backend, so agents access channels
5
5
  // THROUGH SignalDash, not around it.
6
6
  import {
7
- chmodSync,
8
7
  readFileSync,
9
8
  realpathSync,
10
- writeFileSync,
11
- mkdirSync,
12
9
  } from "node:fs";
13
10
  import { homedir } from "node:os";
14
11
  import { createInterface } from "node:readline";
15
12
  import { fileURLToPath } from "node:url";
13
+ import { readConfigFile, updateConfigFile } from "../lib/config-file.js";
16
14
 
17
15
  // Lazy-load presentation deps so `mcp` (stdio, machine-facing) stays clean/fast.
18
16
  async function ui() {
@@ -22,6 +20,9 @@ async function ui() {
22
20
  }
23
21
 
24
22
  const DEFAULT_BACKEND = process.env.SIGNALDASH_BACKEND || "https://signaldash-api.floom.dev";
23
+ const PACKAGE_VERSION = JSON.parse(
24
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
25
+ ).version;
25
26
 
26
27
  function configPaths() {
27
28
  const directory = process.env.SIGNALDASH_HOME || `${homedir()}/.signaldash`;
@@ -29,28 +30,41 @@ function configPaths() {
29
30
  }
30
31
  function loadCfg() {
31
32
  try {
32
- return JSON.parse(readFileSync(configPaths().file, "utf8"));
33
+ return readConfigFile(configPaths().file);
33
34
  } catch {
34
35
  return {};
35
36
  }
36
37
  }
37
- function saveCfg(c) {
38
- const config = configPaths();
39
- mkdirSync(config.directory, { recursive: true, mode: 0o700 });
40
- chmodSync(config.directory, 0o700);
41
- writeFileSync(config.file, JSON.stringify(c, null, 2), { mode: 0o600 });
42
- chmodSync(config.file, 0o600);
38
+ function updateCfg(update) {
39
+ return updateConfigFile(configPaths().file, update);
43
40
  }
44
41
 
45
- async function api(path, body, { auth = true, method = "POST" } = {}) {
42
+ async function api(
43
+ path,
44
+ body,
45
+ { auth = true, method = "POST", backend, token } = {},
46
+ ) {
46
47
  const cfg = loadCfg();
47
- const backend = cfg.backend || DEFAULT_BACKEND;
48
- const r = await fetch(backend + path, {
48
+ const targetBackend = backend || cfg.backend || DEFAULT_BACKEND;
49
+ const targetToken = token || cfg.token;
50
+ const r = await fetch(targetBackend + path, {
49
51
  method,
50
- headers: { "Content-Type": "application/json", ...(auth && cfg.token ? { Authorization: `Bearer ${cfg.token}` } : {}) },
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ ...(auth && targetToken ? { Authorization: `Bearer ${targetToken}` } : {}),
55
+ },
51
56
  ...(method === "GET" ? {} : { body: JSON.stringify(body || {}) }),
52
57
  });
53
- return { status: r.status, json: await r.json().catch(() => ({})) };
58
+ const json = await r.json().catch(() => ({}));
59
+ if (
60
+ json &&
61
+ typeof json === "object" &&
62
+ typeof json.approval_path === "string" &&
63
+ json.approval_path.startsWith("/")
64
+ ) {
65
+ json.approval_url = new URL(json.approval_path, targetBackend).toString();
66
+ }
67
+ return { status: r.status, json };
54
68
  }
55
69
 
56
70
  const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
@@ -66,10 +80,12 @@ export async function cmdLogin(code, backend, dependencies = {}) {
66
80
  const request = dependencies.request || api;
67
81
  const log = dependencies.log || console.log;
68
82
  const error = dependencies.error || console.error;
69
- const cfg = loadCfg();
70
- cfg.backend = backend || cfg.backend || DEFAULT_BACKEND;
71
- saveCfg(cfg);
72
- const r = await request("/login", { code }, { auth: false });
83
+ const targetBackend = backend || loadCfg().backend || DEFAULT_BACKEND;
84
+ const r = await request(
85
+ "/login",
86
+ { code },
87
+ { auth: false, backend: targetBackend },
88
+ );
73
89
  if (r.status !== 200) {
74
90
  const msg = String(r.json.error || r.status);
75
91
  if (/used|invalid/i.test(msg) && loadCfg().token) {
@@ -80,8 +96,12 @@ export async function cmdLogin(code, backend, dependencies = {}) {
80
96
  if (/used/i.test(msg)) error("Invite codes are single-use. If you already ran this, try: signaldash status");
81
97
  process.exitCode = 1; return;
82
98
  }
83
- cfg.token = r.json.token; saveCfg(cfg);
84
- log(`Logged in to SignalDash (${cfg.backend}).`);
99
+ updateCfg(current => ({
100
+ ...current,
101
+ backend: targetBackend,
102
+ token: r.json.token,
103
+ }));
104
+ log(`Logged in to SignalDash (${targetBackend}).`);
85
105
  log("Your LinkedIn/WhatsApp/email credentials live on that server, not on this");
86
106
  log("machine, and are never exposed to your agent. Only you can see your data.");
87
107
  }
@@ -95,14 +115,24 @@ export async function cmdLogout(dependencies = {}) {
95
115
  log("Already logged out.");
96
116
  return;
97
117
  }
98
- const response = await request("/logout", {});
118
+ const response = await request(
119
+ "/logout",
120
+ {},
121
+ { backend: cfg.backend, token: cfg.token },
122
+ );
99
123
  if (response.status !== 200 && response.status !== 401) {
100
124
  error("logout failed:", response.json.error || response.status);
101
125
  process.exitCode = 1;
102
126
  return;
103
127
  }
104
- delete cfg.token;
105
- saveCfg(cfg);
128
+ updateCfg(current => {
129
+ if (current.backend !== cfg.backend || current.token !== cfg.token) {
130
+ return current;
131
+ }
132
+ const next = { ...current };
133
+ delete next.token;
134
+ return next;
135
+ });
106
136
  log(
107
137
  response.status === 200
108
138
  ? "Logged out of SignalDash. The session was revoked."
@@ -187,6 +217,345 @@ const TOOLS = [
187
217
  { name: "li_list_chats", ch: "li", action: "list_chats", description: "List your LinkedIn chats." },
188
218
  { name: "li_read_messages", ch: "li", action: "read", description: "Read messages in a LinkedIn chat. args: chat_id" },
189
219
  { name: "li_send_message", ch: "li", action: "send", description: "Send a LinkedIn message (rate-safe). args: chat_id, text" },
220
+ {
221
+ name: "li_send_invitation",
222
+ path: "/li/send_invitation",
223
+ description: "Preview or send one exact LinkedIn invitation. First call without confirm, obtain approval, then repeat the exact provider_id and note with confirm:true.",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {
227
+ provider_id: { type: "string", minLength: 1, maxLength: 500 },
228
+ note: { type: "string", maxLength: 300 },
229
+ confirm: { type: "boolean", default: false },
230
+ },
231
+ required: ["provider_id"],
232
+ additionalProperties: false,
233
+ },
234
+ },
235
+ {
236
+ name: "li_invitations_received",
237
+ path: "/li/invitations/received",
238
+ description: "List a bounded page of received LinkedIn invitations and authorize exact later accept actions.",
239
+ inputSchema: {
240
+ type: "object",
241
+ properties: {
242
+ limit: { type: "integer", minimum: 1, maximum: 100 },
243
+ cursor: { type: "string", maxLength: 1000 },
244
+ },
245
+ additionalProperties: false,
246
+ },
247
+ },
248
+ {
249
+ name: "li_accept_invitation",
250
+ path: "/li/accept_invitation",
251
+ description: "Accept one exact received LinkedIn invitation after listing current received invitations and obtaining approval.",
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ invitation_id: { type: "string", minLength: 1, maxLength: 500 },
256
+ confirm: { type: "boolean" },
257
+ },
258
+ required: ["invitation_id", "confirm"],
259
+ additionalProperties: false,
260
+ },
261
+ },
262
+ {
263
+ name: "li_invitations_sent",
264
+ path: "/li/invitations/sent",
265
+ description: "List a bounded page of sent LinkedIn invitations and authorize exact later withdrawal actions.",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ limit: { type: "integer", minimum: 1, maximum: 100 },
270
+ cursor: { type: "string", maxLength: 1000 },
271
+ },
272
+ additionalProperties: false,
273
+ },
274
+ },
275
+ {
276
+ name: "li_withdraw_invitation",
277
+ path: "/li/withdraw_invitation",
278
+ description: "Withdraw one exact sent LinkedIn invitation after listing current sent invitations and obtaining approval.",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ invitation_id: { type: "string", minLength: 1, maxLength: 500 },
283
+ confirm: { type: "boolean" },
284
+ },
285
+ required: ["invitation_id", "confirm"],
286
+ additionalProperties: false,
287
+ },
288
+ },
289
+ {
290
+ name: "sd_contact_state",
291
+ path: "/sd/contact_state",
292
+ description: "Inspect exact per-user, per-channel contact state or add a protective suppression. SignalDash never infers cross-channel identity.",
293
+ inputSchema: {
294
+ type: "object",
295
+ properties: {
296
+ channel: {
297
+ type: "string",
298
+ enum: ["linkedin", "whatsapp", "email"],
299
+ },
300
+ identifiers: {
301
+ type: "array",
302
+ minItems: 1,
303
+ maxItems: 8,
304
+ items: {
305
+ type: "object",
306
+ properties: {
307
+ kind: {
308
+ type: "string",
309
+ enum: [
310
+ "provider_id",
311
+ "public_identifier",
312
+ "profile_url",
313
+ "member_urn",
314
+ "chat_id",
315
+ "phone",
316
+ "email",
317
+ ],
318
+ },
319
+ value: { type: "string", minLength: 1, maxLength: 1000 },
320
+ },
321
+ required: ["kind", "value"],
322
+ additionalProperties: false,
323
+ },
324
+ },
325
+ action: {
326
+ type: "string",
327
+ enum: ["get", "suppress"],
328
+ default: "get",
329
+ },
330
+ reason: {
331
+ type: "string",
332
+ enum: [
333
+ "opt_out",
334
+ "not_interested",
335
+ "provider_block",
336
+ "manual",
337
+ "legal",
338
+ ],
339
+ },
340
+ confirm: { type: "boolean", default: false },
341
+ },
342
+ required: ["channel", "identifiers"],
343
+ additionalProperties: false,
344
+ },
345
+ },
346
+ {
347
+ name: "sd_budget_status",
348
+ path: "/sd/budget_status",
349
+ description: "Read authoritative SignalDash LinkedIn attempt capacity for this UTC day and week across manual work and all campaigns. Reports the campaign-excluded portion without promising a reserved manual slot.",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {},
353
+ additionalProperties: false,
354
+ },
355
+ },
356
+ {
357
+ name: "sd_settings_get",
358
+ path: "/sd/settings/get",
359
+ description: "Read this SignalDash user's persistent settings. Auto-accept is the first supported setting and is false by default for every existing user.",
360
+ inputSchema: {
361
+ type: "object",
362
+ properties: {},
363
+ additionalProperties: false,
364
+ },
365
+ },
366
+ {
367
+ name: "sd_settings_set",
368
+ path: "/sd/settings/set",
369
+ description: "Update this SignalDash user's persistent settings. Currently supports opt-in, rate-capped LinkedIn invitation auto-accept and exact optional filters. Changing a persistent setting requires explicit approval and confirm:true.",
370
+ inputSchema: {
371
+ type: "object",
372
+ properties: {
373
+ auto_accept_linkedin: { type: "boolean" },
374
+ auto_accept_linkedin_filters: {
375
+ type: "object",
376
+ properties: {
377
+ public_identifiers: {
378
+ type: "array",
379
+ maxItems: 100,
380
+ items: {
381
+ type: "string",
382
+ minLength: 1,
383
+ maxLength: 200,
384
+ },
385
+ },
386
+ description_keywords: {
387
+ type: "array",
388
+ maxItems: 20,
389
+ items: {
390
+ type: "string",
391
+ minLength: 1,
392
+ maxLength: 100,
393
+ },
394
+ },
395
+ },
396
+ additionalProperties: false,
397
+ },
398
+ confirm: { type: "boolean" },
399
+ },
400
+ required: ["auto_accept_linkedin", "confirm"],
401
+ additionalProperties: false,
402
+ },
403
+ },
404
+ {
405
+ name: "sd_auto_accept_status",
406
+ path: "/sd/auto_accept/status",
407
+ description: "Inspect this user's persistent auto-accept setting, filters, accepted/failed attempts today, remaining dedicated cap, repeated-error stop state, and any unparseable invitation records.",
408
+ inputSchema: {
409
+ type: "object",
410
+ properties: {},
411
+ additionalProperties: false,
412
+ },
413
+ },
414
+ {
415
+ name: "li_search_connections",
416
+ path: "/li/connections/search",
417
+ description: "Search only your locally stored LinkedIn connections by name, headline, or company and join exact local contact state. Makes zero LinkedIn, Unipile, or paid discovery API calls.",
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ query: { type: "string", minLength: 1, maxLength: 200 },
422
+ filters: {
423
+ type: "object",
424
+ properties: {
425
+ company: { type: "string", minLength: 1, maxLength: 200 },
426
+ headline_keyword: {
427
+ type: "string",
428
+ minLength: 1,
429
+ maxLength: 200,
430
+ },
431
+ connected_after: { type: "string", format: "date" },
432
+ connected_before: { type: "string", format: "date" },
433
+ },
434
+ additionalProperties: false,
435
+ },
436
+ limit: {
437
+ type: "integer",
438
+ minimum: 1,
439
+ maximum: 100,
440
+ default: 20,
441
+ },
442
+ },
443
+ required: ["query"],
444
+ additionalProperties: false,
445
+ },
446
+ },
447
+ {
448
+ name: "li_discover_people",
449
+ path: "/li/discover_people",
450
+ description: "Discover public LinkedIn profile cards beyond your completed stored network through one capped HarvestAPI page. Local matches always win. First preview the exact paid request without confirm, obtain approval, then repeat with confirm:true. Each preview authorizes one provider request.",
451
+ inputSchema: {
452
+ type: "object",
453
+ properties: {
454
+ query: {
455
+ type: "string",
456
+ minLength: 1,
457
+ maxLength: 200,
458
+ description: "Role or title to search.",
459
+ },
460
+ filters: {
461
+ type: "object",
462
+ properties: {
463
+ company: { type: "string", minLength: 1, maxLength: 200 },
464
+ location: { type: "string", minLength: 1, maxLength: 200 },
465
+ },
466
+ additionalProperties: false,
467
+ },
468
+ limit: {
469
+ type: "integer",
470
+ minimum: 1,
471
+ maximum: 10,
472
+ default: 10,
473
+ },
474
+ confirm: { type: "boolean", default: false },
475
+ },
476
+ required: ["query"],
477
+ additionalProperties: false,
478
+ },
479
+ },
480
+ {
481
+ name: "li_create_invitation_batch",
482
+ path: "/li/invitation_batch/create",
483
+ description: "Create one immutable LinkedIn Classic invitation-batch preview from exact user-approved profile URLs, inclusion reasons, and optional notes. The agent structures the natural-language request; the server never generates targets or text. Maximum 10 targets.",
484
+ inputSchema: {
485
+ type: "object",
486
+ properties: {
487
+ source_label: {
488
+ type: "string",
489
+ minLength: 1,
490
+ maxLength: 120,
491
+ },
492
+ time_zone: {
493
+ type: "string",
494
+ minLength: 1,
495
+ description: "Sender IANA timezone.",
496
+ },
497
+ targets: {
498
+ type: "array",
499
+ minItems: 1,
500
+ maxItems: 10,
501
+ items: {
502
+ type: "object",
503
+ properties: {
504
+ profile_url: {
505
+ type: "string",
506
+ format: "uri",
507
+ description: "Exact LinkedIn Classic https://www.linkedin.com/in/... URL.",
508
+ },
509
+ inclusion_reason: {
510
+ type: "string",
511
+ minLength: 1,
512
+ maxLength: 240,
513
+ },
514
+ note: {
515
+ type: "string",
516
+ maxLength: 200,
517
+ },
518
+ },
519
+ required: ["profile_url", "inclusion_reason"],
520
+ additionalProperties: false,
521
+ },
522
+ },
523
+ },
524
+ required: ["source_label", "time_zone", "targets"],
525
+ additionalProperties: false,
526
+ },
527
+ },
528
+ {
529
+ name: "li_get_invitation_batch",
530
+ path: "/li/invitation_batch/get",
531
+ description: "Inspect one exact immutable invitation batch, every target and exclusion, approval hash, timing, capacity, and execution result. This read authorizes a later exact cancel; MCP cannot approve.",
532
+ inputSchema: {
533
+ type: "object",
534
+ properties: {
535
+ batch_id: { type: "string", minLength: 36, maxLength: 36 },
536
+ },
537
+ required: ["batch_id"],
538
+ additionalProperties: false,
539
+ },
540
+ },
541
+ {
542
+ name: "li_cancel_invitation_batch",
543
+ path: "/li/invitation_batch/cancel",
544
+ description: "Permanently cancel one freshly inspected invitation batch. Cancels only unstarted targets and cannot recall an executing invitation. Restart requires a new preview and human approval.",
545
+ inputSchema: {
546
+ type: "object",
547
+ properties: {
548
+ batch_id: { type: "string", minLength: 36, maxLength: 36 },
549
+ approval_view_hash: {
550
+ type: ["string", "null"],
551
+ pattern: "^[0-9a-f]{64}$",
552
+ },
553
+ confirm: { type: "boolean" },
554
+ },
555
+ required: ["batch_id", "approval_view_hash", "confirm"],
556
+ additionalProperties: false,
557
+ },
558
+ },
190
559
  { name: "wa_list_chats", ch: "wa", action: "list_chats", description: "List your WhatsApp chats." },
191
560
  { name: "wa_read_messages", ch: "wa", action: "read", description: "Read messages in a WhatsApp chat. args: chat_id" },
192
561
  { name: "wa_send_message", ch: "wa", action: "send", description: "Send a WhatsApp message (rate-safe). args: chat_id, text" },
@@ -250,7 +619,7 @@ export async function runMcp(dependencies = {}) {
250
619
  for await (const line of rl) {
251
620
  let msg; try { msg = JSON.parse(line); } catch { continue; }
252
621
  const { id, method, params } = msg;
253
- if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: "0.1.0" } });
622
+ if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: PACKAGE_VERSION } });
254
623
  else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)) });
255
624
  else if (method === "tools/call") {
256
625
  const t = TOOLS.find(x => x.name === params.name);
@@ -417,9 +786,10 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
417
786
  else if (cmd === "mcp") await runMcp(dependencies);
418
787
  else if (cmd === "status") await cmdStatus(dependencies);
419
788
  else if (cmd === "connections" || (cmd === "export" && a === "connections")) await cmdConnections(cmd === "export" ? b : a, dependencies);
420
- else if (cmd === "--version" || cmd === "-v") log("0.7.0");
421
- else if (cmd && !["help","--help","-h"].includes(cmd) && !/^[0-9a-f]{8,}$/i.test(cmd)) { (dependencies.error || console.error)(`unknown command: ${cmd}`); printHelp(log); process.exitCode = 1; }
789
+ else if (cmd === "--version" || cmd === "-v") log(PACKAGE_VERSION);
422
790
  else if (cmd === "skill") await cmdSkill(dependencies);
791
+ else if (cmd === "logout") await cmdLogout(dependencies);
792
+ else if (cmd && !["help","--help","-h"].includes(cmd) && !/^[0-9a-f]{8,}$/i.test(cmd)) { (dependencies.error || console.error)(`unknown command: ${cmd}`); printHelp(log); process.exitCode = 1; }
423
793
  else printHelp(log);
424
794
  }
425
795