@floomhq/signaldash 0.22.0 → 0.28.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/bin/sd.mjs CHANGED
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
11
11
  import { createInterface } from "node:readline";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { readConfigFile, updateConfigFile } from "../lib/config-file.js";
14
+ import { renderSkillTemplate } from "../lib/skill-template.cjs";
14
15
 
15
16
  // Lazy-load presentation deps so `mcp` (stdio, machine-facing) stays clean/fast.
16
17
  async function ui() {
@@ -20,6 +21,25 @@ async function ui() {
20
21
  }
21
22
 
22
23
  const DEFAULT_BACKEND = process.env.SIGNALDASH_BACKEND || "https://signaldash-api.floom.dev";
24
+ // The shape of an invite code, deliberately a hand-kept copy of
25
+ // INVITE_CODE_SHAPE in server/server.cjs rather than an import: server/ is
26
+ // not in package.json's "files" allowlist, so it never ships in the
27
+ // published npm tarball and bin/sd.mjs cannot depend on it at runtime. Two
28
+ // copies of this shape can therefore never be textually merged into one;
29
+ // test/invite-code-shortening.test.js requires server.cjs (test-only, not
30
+ // shipped) to assert the CLI still accepts every code the live server
31
+ // actually issues, which is the guarantee that matters here.
32
+ //
33
+ // The floor here (8 hex chars) is intentionally HIGHER than the server's
34
+ // own floor (6): the server keeps 6 low for backward compatibility with
35
+ // already-issued short codes it must still honor, but 6 hex chars is only
36
+ // 24 bits of entropy for a value an attacker can brute-force against the
37
+ // login endpoint, so the CLI does not need to (and should not) accept
38
+ // anything shorter than the 32-bit floor this project has always minted
39
+ // against. Every code production has ever issued is 10+ hex chars, or the
40
+ // sd-XXXX-XXXX form below; the shape census is in
41
+ // test/invite-code-shortening.test.js.
42
+ export const INVITE_CODE_REGEX = /^(?:[0-9a-f]{8,32}|sd-[0-9a-f]{4}-[0-9a-f]{4})$/i;
23
43
  const PACKAGE_VERSION = JSON.parse(
24
44
  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
25
45
  ).version;
@@ -87,13 +107,18 @@ export async function cmdLogin(code, backend, dependencies = {}) {
87
107
  { auth: false, backend: targetBackend },
88
108
  );
89
109
  if (r.status !== 200) {
110
+ // Previously any error whose text loosely matched /used|invalid|unknown/i
111
+ // was swallowed into "you are already set up" whenever a local token
112
+ // existed. That was never precise: the server's /login route treats a
113
+ // revoked code exactly like an unknown one on purpose (same 403, see
114
+ // server.cjs, so an attacker can't tell them apart), and it does NOT
115
+ // reject a code that was already redeemed -- re-running `login` with the
116
+ // SAME valid code succeeds with a fresh 200 token, it never reaches this
117
+ // branch. So a 403/other error here, even with a local token present,
118
+ // means the code just typed is wrong, expired, or revoked -- never "you
119
+ // already did this". Report it honestly instead of masking it as success.
90
120
  const msg = String(r.json.error || r.status);
91
- if (/used|invalid/i.test(msg) && loadCfg().token) {
92
- log("You are already set up on this machine. Run `signaldash status` to see what is connected.");
93
- return;
94
- }
95
121
  error("login failed:", msg);
96
- if (/used/i.test(msg)) error("Invite codes are single-use. If you already ran this, try: signaldash status");
97
122
  process.exitCode = 1; return;
98
123
  }
99
124
  updateCfg(current => ({
@@ -106,6 +131,46 @@ export async function cmdLogin(code, backend, dependencies = {}) {
106
131
  log("machine, and are never exposed to your agent. Only you can see your data.");
107
132
  }
108
133
 
134
+ // Store a session token you already hold. The /i/<code> page used to print
135
+ // one as the final step of web onboarding and this command did not exist, so
136
+ // people hit "unknown command" at the one point where they wire up their
137
+ // agent. That page no longer hands out tokens at all (reopening it would have
138
+ // traded a leaked invite code for someone else's live session), so the normal
139
+ // way in is `login <code>`. This stays for a token handed over out of band.
140
+ export async function cmdLoginToken(token, backend, dependencies = {}) {
141
+ const request = dependencies.request || api;
142
+ const log = dependencies.log || console.log;
143
+ const error = dependencies.error || console.error;
144
+ if (!token) {
145
+ error("usage: signaldash login-token <token>");
146
+ process.exitCode = 1; return;
147
+ }
148
+ const targetBackend = backend || loadCfg().backend || DEFAULT_BACKEND;
149
+ // Verify before persisting. A mistyped or expired token written to disk would
150
+ // fail later as a confusing "not connected" on every command instead of here.
151
+ const probe = await request(
152
+ "/connect/whatsapp/status",
153
+ undefined,
154
+ { method: "GET", backend: targetBackend, token },
155
+ );
156
+ if (probe.status === 401 || probe.status === 403) {
157
+ error("login failed: that token is not valid. It may have expired, or a newer login replaced it.");
158
+ process.exitCode = 1; return;
159
+ }
160
+ if (probe.status >= 400) {
161
+ error("login failed:", probe.json.error || probe.status);
162
+ process.exitCode = 1; return;
163
+ }
164
+ updateCfg(current => ({
165
+ ...current,
166
+ backend: targetBackend,
167
+ token,
168
+ }));
169
+ log(`Logged in to SignalDash (${targetBackend}).`);
170
+ log("Your LinkedIn/WhatsApp/email credentials live on that server, not on this");
171
+ log("machine, and are never exposed to your agent. Only you can see your data.");
172
+ }
173
+
109
174
  export async function cmdLogout(dependencies = {}) {
110
175
  const request = dependencies.request || api;
111
176
  const log = dependencies.log || console.log;
@@ -214,9 +279,52 @@ export async function cmdClaim(provider, accountId, dependencies = {}) {
214
279
 
215
280
  // ---- MCP (stdio). Every tool proxies through the backend with the token. ----
216
281
  const TOOLS = [
217
- { name: "li_list_chats", ch: "li", action: "list_chats", description: "List your LinkedIn chats." },
218
- { name: "li_read_messages", ch: "li", action: "read", description: "Read messages in a LinkedIn chat. args: chat_id" },
219
- { name: "li_send_message", ch: "li", action: "send", description: "Send a LinkedIn message (rate-safe). args: chat_id, text" },
282
+ {
283
+ name: "li_list_chats",
284
+ ch: "li",
285
+ action: "list_chats",
286
+ description: "List or search your LinkedIn chats. `search` filters on identifiers, never on a person name (a 1:1 chat has no name); the response reports how far it scanned.",
287
+ inputSchema: {
288
+ type: "object",
289
+ properties: {
290
+ limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
291
+ cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
292
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. A 1:1 chat has no name, so search by identifier or phone number, not by person name." },
293
+ max_scan: { type: "integer", minimum: 1, maximum: 500, description: "How many chats the search may scan before it stops and reports exhaustive:false. Default 200, and never more than five provider pages: bulk reading is the top account-restriction trigger, so go deeper with the returned cursor rather than with a bigger scan." },
294
+ },
295
+ additionalProperties: false,
296
+ },
297
+ },
298
+ {
299
+ name: "li_read_messages",
300
+ ch: "li",
301
+ action: "read",
302
+ description: "Read messages in a LinkedIn chat. args: chat_id",
303
+ inputSchema: {
304
+ type: "object",
305
+ properties: {
306
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
307
+ limit: { type: "integer", minimum: 1, maximum: 100 },
308
+ },
309
+ required: ["chat_id"],
310
+ additionalProperties: false,
311
+ },
312
+ },
313
+ {
314
+ name: "li_send_message",
315
+ ch: "li",
316
+ action: "send",
317
+ description: "Send a LinkedIn message (rate-safe). args: chat_id, text",
318
+ inputSchema: {
319
+ type: "object",
320
+ properties: {
321
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
322
+ text: { type: "string", minLength: 1, maxLength: 5000 },
323
+ },
324
+ required: ["chat_id", "text"],
325
+ additionalProperties: false,
326
+ },
327
+ },
220
328
  {
221
329
  name: "li_send_invitation",
222
330
  path: "/li/send_invitation",
@@ -411,6 +519,20 @@ const TOOLS = [
411
519
  additionalProperties: false,
412
520
  },
413
521
  },
522
+ {
523
+ name: "sd_voice_profile",
524
+ path: "/sd/voice/profile",
525
+ description: "Build or fetch this user's personal writing-voice profile for one channel, mined ONLY from up to ~50 of their own SENT messages on that channel (never the other side of any conversation, never another user's data). Returns compact markdown: hard length stats (median/p75/p90 characters), 5-10 verbatim redacted exemplars, negative constraints, and language behavior. Computed once and cached; call with force_recompute:true only when explicitly asked to refresh. Call this before drafting on the user's behalf and match the returned stats and exemplars.",
526
+ inputSchema: {
527
+ type: "object",
528
+ properties: {
529
+ channel: { type: "string", enum: ["linkedin", "whatsapp", "email"] },
530
+ force_recompute: { type: "boolean", default: false },
531
+ },
532
+ required: ["channel"],
533
+ additionalProperties: false,
534
+ },
535
+ },
414
536
  {
415
537
  name: "li_search_connections",
416
538
  path: "/li/connections/search",
@@ -556,16 +678,448 @@ const TOOLS = [
556
678
  additionalProperties: false,
557
679
  },
558
680
  },
559
- { name: "wa_list_chats", ch: "wa", action: "list_chats", description: "List your WhatsApp chats." },
560
- { name: "wa_read_messages", ch: "wa", action: "read", description: "Read messages in a WhatsApp chat. args: chat_id" },
561
- { name: "wa_send_message", ch: "wa", action: "send", description: "Send a WhatsApp message (rate-safe). args: chat_id, text" },
681
+ {
682
+ name: "sd_campaign_create",
683
+ path: "/sd/campaign/create",
684
+ description: "Create one campaign: a paced connection request to each exact person, then the exact approved message(s) once that person is PROVEN to have accepted, then an optional follow-up that stops the moment they reply. Nothing is sent until a human approves this exact recipient list and this exact message text on the approval page. Targets come from an explicit list you supply (for example one you built with li_search_connections or li_post_reactions) or from your own post engagers, which costs zero profile fetches. If the user already sent someone a connection request by hand and just wants the follow-up automated, set adopt_existing_invitation on that target instead of leaving them out. Draft the messages in the user's own voice and keep them short: the on-acceptance group is 2-3 separate short sends, never one block.",
685
+ inputSchema: {
686
+ type: "object",
687
+ properties: {
688
+ source_label: { type: "string", minLength: 1, maxLength: 120 },
689
+ time_zone: {
690
+ type: "string",
691
+ minLength: 1,
692
+ description: "Sender IANA timezone. Execution is Mon-Fri 09:00-17:00 sender-local.",
693
+ },
694
+ target_source: {
695
+ type: "string",
696
+ enum: ["explicit", "post_engagers"],
697
+ default: "explicit",
698
+ },
699
+ targets: {
700
+ type: "array",
701
+ minItems: 1,
702
+ maxItems: 150,
703
+ description: "Explicit targets. Each needs a canonical linkedin.com/in/ URL or an exact provider_id.",
704
+ items: {
705
+ type: "object",
706
+ properties: {
707
+ profile_url: { type: "string", format: "uri" },
708
+ provider_id: { type: "string", minLength: 5, maxLength: 500 },
709
+ inclusion_reason: { type: "string", minLength: 1, maxLength: 240 },
710
+ note: {
711
+ type: "string",
712
+ maxLength: 200,
713
+ description: "Optional invitation note.",
714
+ },
715
+ display_name: { type: "string", maxLength: 160 },
716
+ headline: { type: "string", maxLength: 300 },
717
+ adopt_existing_invitation: {
718
+ type: "boolean",
719
+ default: false,
720
+ description: "Set true ONLY when the user already sent this exact person a connection request by hand and now wants the follow-up automated. SignalDash then REQUIRES a still-pending sent invitation to that person and sends none of its own: it adopts the existing one, watches for acceptance, and runs the message sequence. If no pending invitation is found the person is dropped, never invited. Incompatible with note, because no invitation goes out.",
721
+ },
722
+ },
723
+ required: ["inclusion_reason"],
724
+ additionalProperties: false,
725
+ },
726
+ },
727
+ engagers: {
728
+ type: "object",
729
+ description: "Only with target_source post_engagers. The server reads your own recent posts and their reactions and comments, which return member id and network distance directly, so no profiles are fetched.",
730
+ properties: {
731
+ post_limit: { type: "integer", minimum: 1, maximum: 10, default: 5 },
732
+ max_targets: { type: "integer", minimum: 1, maximum: 150, default: 50 },
733
+ inclusion_reason: { type: "string", minLength: 1, maxLength: 240 },
734
+ note: { type: "string", maxLength: 200 },
735
+ },
736
+ additionalProperties: false,
737
+ },
738
+ messages: {
739
+ type: "array",
740
+ minItems: 1,
741
+ maxItems: 5,
742
+ description: "The exact frozen texts. after_days 0 means sent once the invitation is accepted (1-3 of these, sent as separate consecutive messages); a later step needs after_days 1-30 and only goes out if there has been no reply.",
743
+ items: {
744
+ type: "object",
745
+ properties: {
746
+ text: { type: "string", minLength: 1, maxLength: 1200 },
747
+ after_days: { type: "integer", minimum: 0, maximum: 30 },
748
+ },
749
+ required: ["text"],
750
+ additionalProperties: false,
751
+ },
752
+ },
753
+ invite_ttl_days: {
754
+ type: "integer",
755
+ minimum: 1,
756
+ maximum: 60,
757
+ default: 21,
758
+ description: "An invitation not accepted within this many days is dropped and never messaged.",
759
+ },
760
+ },
761
+ required: ["source_label", "time_zone", "messages"],
762
+ additionalProperties: false,
763
+ },
764
+ },
765
+ {
766
+ name: "sd_campaign_preview",
767
+ path: "/sd/campaign/preview",
768
+ description: "Inspect one campaign before approval: every exact recipient, every exclusion and its reason, the exact message text for each step, the timing, the shared daily budget, and the approval_url to send the human. Show the human this content. This read also authorizes a later exact cancel. An agent cannot approve a campaign; only the authenticated human page can, and it can hand you a one-time code for sd_campaign_approve.",
769
+ inputSchema: {
770
+ type: "object",
771
+ properties: {
772
+ campaign_id: { type: "string", minLength: 36, maxLength: 36 },
773
+ },
774
+ required: ["campaign_id"],
775
+ additionalProperties: false,
776
+ },
777
+ },
778
+ {
779
+ name: "sd_campaign_approve",
780
+ path: "/sd/campaign/approve",
781
+ description: "Record the human's approval of one campaign using the one-time code they generated on the authenticated approval page. You cannot create that code, guess it, or approve without it, and it only ever approves the exact payload and exact recipients they reviewed.",
782
+ inputSchema: {
783
+ type: "object",
784
+ properties: {
785
+ campaign_id: { type: "string", minLength: 36, maxLength: 36 },
786
+ confirm_token: {
787
+ type: "string",
788
+ pattern: "^sd-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$",
789
+ description: "The code the human read off the approval page.",
790
+ },
791
+ },
792
+ required: ["campaign_id", "confirm_token"],
793
+ additionalProperties: false,
794
+ },
795
+ },
796
+ {
797
+ name: "sd_campaign_status",
798
+ path: "/sd/campaign/status",
799
+ description: "Monitor one campaign: invited, acceptance proven, messaged, replied and halted, expired, or excluded per person, plus every message step and any provider response SignalDash could not parse.",
800
+ inputSchema: {
801
+ type: "object",
802
+ properties: {
803
+ campaign_id: { type: "string", minLength: 36, maxLength: 36 },
804
+ },
805
+ required: ["campaign_id"],
806
+ additionalProperties: false,
807
+ },
808
+ },
809
+ {
810
+ name: "sd_campaign_cancel",
811
+ path: "/sd/campaign/cancel",
812
+ description: "Revoke the human approval and cancel one freshly inspected campaign. Stops every unsent invitation and every unsent message. Cannot recall an executing write. Restarting requires a new preview and a new human approval.",
813
+ inputSchema: {
814
+ type: "object",
815
+ properties: {
816
+ campaign_id: { type: "string", minLength: 36, maxLength: 36 },
817
+ approval_view_hash: {
818
+ type: ["string", "null"],
819
+ pattern: "^[0-9a-f]{64}$",
820
+ },
821
+ confirm: { type: "boolean" },
822
+ },
823
+ required: ["campaign_id", "approval_view_hash", "confirm"],
824
+ additionalProperties: false,
825
+ },
826
+ },
827
+ {
828
+ name: "sd_withdrawal_batch_create",
829
+ path: "/li/withdrawal_batch/create",
830
+ description:
831
+ "Clear a backlog of old PENDING SENT LinkedIn invitations. Freezes the exact invitation ids matching an age rule, then one human approval withdraws them one at a time. Two things you must tell the user, because both are LinkedIn's own documented behaviour: (1) this does NOT free up sending capacity, withdrawing does not lift an active sending restriction, it only clears a stale backlog; (2) after withdrawing you cannot re-invite that person for UP TO THREE WEEKS. `exclude` is REQUIRED, not optional: ask the user who must NOT be withdrawn before you call this, because a blanket age rule will otherwise catch people they wanted to keep pending. Passing an empty array is allowed but it means nobody is protected. Age labels are buckets ('sent 4 months ago'), not dates, so the filter is bucket-accurate at best and deliberately holds back anyone whose bucket straddles the threshold.",
832
+ inputSchema: {
833
+ type: "object",
834
+ properties: {
835
+ account: {
836
+ type: "string",
837
+ enum: ["linkedin"],
838
+ description: "Only LinkedIn sent invitations can be swept.",
839
+ },
840
+ older_than_days: {
841
+ type: "integer",
842
+ minimum: 30,
843
+ maximum: 3650,
844
+ default: 90,
845
+ description:
846
+ "Withdraw invitations older than this. Bucket-accurate at best. Minimum 30.",
847
+ },
848
+ exclude: {
849
+ type: "array",
850
+ maxItems: 100,
851
+ description:
852
+ "REQUIRED. People to protect from the sweep, matched exactly, never fuzzily. Ask the user for these by name before calling. An empty array means nobody is protected.",
853
+ items: {
854
+ type: "object",
855
+ properties: {
856
+ kind: {
857
+ type: "string",
858
+ enum: [
859
+ "invitation_id",
860
+ "provider_id",
861
+ "public_identifier",
862
+ "profile_url",
863
+ "member_urn",
864
+ "display_name",
865
+ ],
866
+ },
867
+ value: { type: "string", minLength: 1, maxLength: 1000 },
868
+ },
869
+ required: ["kind", "value"],
870
+ additionalProperties: false,
871
+ },
872
+ },
873
+ time_zone: {
874
+ type: "string",
875
+ minLength: 1,
876
+ maxLength: 64,
877
+ description:
878
+ "IANA sender timezone. Withdrawals only run Monday-Friday 09:00-17:00 in this zone.",
879
+ },
880
+ limit: {
881
+ type: "integer",
882
+ minimum: 1,
883
+ maximum: 1500,
884
+ description: "Optional ceiling on how many to withdraw.",
885
+ },
886
+ source_label: { type: "string", minLength: 1, maxLength: 120 },
887
+ allow_unmatched_exclusions: {
888
+ type: "boolean",
889
+ description:
890
+ "By default an exclusion that matches nobody fails the preview, because that is what a typo looks like and a typo means the protected person gets withdrawn. Set true only when the user confirms the mismatch is expected.",
891
+ },
892
+ },
893
+ required: ["account", "exclude", "time_zone"],
894
+ additionalProperties: false,
895
+ },
896
+ },
897
+ {
898
+ name: "sd_withdrawal_batch_status",
899
+ path: "/li/withdrawal_batch/status",
900
+ description:
901
+ "Inspect or monitor one withdrawal sweep: how many are done and remaining, the per-day pace and this sweep's own daily allowance (it never consumes your send budget), the stop reason if it stopped, any provider response SignalDash could not parse, the exact people your exclusions protected, and the approval_url to send the human. This read also authorizes a later exact cancel.",
902
+ inputSchema: {
903
+ type: "object",
904
+ properties: {
905
+ withdrawal_batch_id: { type: "string", minLength: 36, maxLength: 36 },
906
+ },
907
+ required: ["withdrawal_batch_id"],
908
+ additionalProperties: false,
909
+ },
910
+ },
911
+ {
912
+ name: "sd_withdrawal_batch_approve",
913
+ path: "/li/withdrawal_batch/approve",
914
+ description:
915
+ "Record the human's approval of one withdrawal sweep using the one-time code they generated on the authenticated approval page. You cannot create that code, guess it, or approve without it, and it only ever approves the exact invitations they left ticked.",
916
+ inputSchema: {
917
+ type: "object",
918
+ properties: {
919
+ withdrawal_batch_id: { type: "string", minLength: 36, maxLength: 36 },
920
+ confirm_token: {
921
+ type: "string",
922
+ pattern: "^sd-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$",
923
+ description: "The code the human read off the approval page.",
924
+ },
925
+ },
926
+ required: ["withdrawal_batch_id", "confirm_token"],
927
+ additionalProperties: false,
928
+ },
929
+ },
930
+ {
931
+ name: "sd_withdrawal_batch_cancel",
932
+ path: "/li/withdrawal_batch/cancel",
933
+ description:
934
+ "Revoke the human approval and stop one freshly inspected withdrawal sweep. Stops every withdrawal that has not started. Cannot recall one already submitted, and cannot restore the three-week re-invite block for people already withdrawn.",
935
+ inputSchema: {
936
+ type: "object",
937
+ properties: {
938
+ withdrawal_batch_id: { type: "string", minLength: 36, maxLength: 36 },
939
+ approval_view_hash: {
940
+ type: ["string", "null"],
941
+ pattern: "^[0-9a-f]{64}$",
942
+ },
943
+ confirm: { type: "boolean" },
944
+ },
945
+ required: ["withdrawal_batch_id", "approval_view_hash", "confirm"],
946
+ additionalProperties: false,
947
+ },
948
+ },
949
+ {
950
+ name: "wa_list_chats",
951
+ ch: "wa",
952
+ action: "list_chats",
953
+ description: "List or search your WhatsApp chats. `search` matches the phone number (chats carry it as attendee_public_identifier), not a person name; the response reports how far it scanned.",
954
+ inputSchema: {
955
+ type: "object",
956
+ properties: {
957
+ limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
958
+ cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
959
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. A 1:1 chat has no name, so search by identifier or phone number, not by person name." },
960
+ max_scan: { type: "integer", minimum: 1, maximum: 500, description: "How many chats the search may scan before it stops and reports exhaustive:false. Default 200, and never more than five provider pages: bulk reading is the top account-restriction trigger, so go deeper with the returned cursor rather than with a bigger scan." },
961
+ },
962
+ additionalProperties: false,
963
+ },
964
+ },
965
+ {
966
+ name: "wa_read_messages",
967
+ ch: "wa",
968
+ action: "read",
969
+ description: "Read messages in a WhatsApp chat. args: chat_id",
970
+ inputSchema: {
971
+ type: "object",
972
+ properties: {
973
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
974
+ limit: { type: "integer", minimum: 1, maximum: 100 },
975
+ },
976
+ required: ["chat_id"],
977
+ additionalProperties: false,
978
+ },
979
+ },
980
+ {
981
+ name: "wa_get_attachment",
982
+ path: "/wa/get_attachment",
983
+ description: "Download one attachment of one WhatsApp message this account owns and store it on the SignalDash host. Read the chat first to obtain the exact message_id and attachment_id. Returns the stored path, mimetype, byte size and sha256.",
984
+ inputSchema: {
985
+ type: "object",
986
+ properties: {
987
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
988
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
989
+ attachment_id: { type: "string", minLength: 1, maxLength: 500 },
990
+ },
991
+ required: ["chat_id", "message_id", "attachment_id"],
992
+ additionalProperties: false,
993
+ },
994
+ },
995
+ {
996
+ name: "wa_transcribe_voice",
997
+ path: "/wa/transcribe_voice",
998
+ description: "Transcribe one WhatsApp voice note or audio attachment this account owns. Fetches the audio through SignalDash, transcribes it on the SignalDash host, and returns the transcript text, the stored audio path, and the `backend` that produced the text. Optional `backend` picks the engine: `gemini` (default, accurate on German with English terms mixed in) or `whisper` (local small model, much weaker on code-switching). A `gemini` request that cannot reach Gemini falls back to the local model and says so in `backend` and `fallback_reason`.",
999
+ inputSchema: {
1000
+ type: "object",
1001
+ properties: {
1002
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1003
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
1004
+ attachment_id: { type: "string", minLength: 1, maxLength: 500 },
1005
+ backend: { type: "string", enum: ["gemini", "whisper"] },
1006
+ },
1007
+ required: ["chat_id", "message_id", "attachment_id"],
1008
+ additionalProperties: false,
1009
+ },
1010
+ },
1011
+ {
1012
+ name: "wa_send_message",
1013
+ ch: "wa",
1014
+ action: "send",
1015
+ description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments. Read the chat first: a send into a thread this account has not read recently is refused. The send re-reads the thread immediately before sending and refuses with `409 thread_changed` if the conversation moved after that read, because a draft written against the old thread may now be deaf or wrong; re-read, revise, and send again. A re-read that fails is `502 thread_preflight_unavailable` and nothing was sent. `attachments` optionally carries up to 4 base64 files as exact {filename, content_type, content_base64} objects, at most 16 MiB per file and 16 MiB per message, and accepts images, PDF, CSV, plain text, JSON, xlsx and zip. `text` is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. An attachment send is rate-limited, deduplicated and recorded exactly like a text send, and spends the same daily budget. If a send times out or the provider never confirms it, the message may still have been delivered: SignalDash records it and refuses an identical retry with `409 send_outcome_unknown`. Read the chat, and only if the message is genuinely absent, resend the identical payload with `confirm_resend:true`.",
1016
+ inputSchema: {
1017
+ type: "object",
1018
+ properties: {
1019
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1020
+ text: { type: "string", minLength: 1, maxLength: 5000 },
1021
+ attachments: {
1022
+ type: "array", maxItems: 4,
1023
+ items: {
1024
+ type: "object",
1025
+ properties: {
1026
+ filename: { type: "string", minLength: 1, maxLength: 180 },
1027
+ content_type: {
1028
+ type: "string",
1029
+ enum: [
1030
+ "image/png",
1031
+ "image/jpeg",
1032
+ "image/webp",
1033
+ "image/gif",
1034
+ "application/pdf",
1035
+ "text/csv",
1036
+ "text/plain",
1037
+ "application/json",
1038
+ "application/zip",
1039
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1040
+ ],
1041
+ },
1042
+ content_base64: { type: "string", minLength: 1 },
1043
+ },
1044
+ required: ["filename", "content_type", "content_base64"],
1045
+ },
1046
+ },
1047
+ confirm_resend: { type: "boolean", const: true },
1048
+ },
1049
+ // `text` alone is no longer required: a document with no caption is a
1050
+ // legitimate message.
1051
+ //
1052
+ // Die Regel stand hier bis zum 11.08. als `anyOf` auf oberster Ebene.
1053
+ // Das hat das Werkzeug bei einem Client gekostet, der Schemata lokal
1054
+ // kompiliert: er hat es kommentarlos aus seiner Liste geworfen. Ueber
1055
+ // Tage war wa_send_message das EINZIGE der 45 Werkzeuge, das dort fehlte,
1056
+ // und zugleich das einzige mit einem top-level anyOf. Der Server hat es
1057
+ // die ganze Zeit ausgeliefert.
1058
+ //
1059
+ // Die Regel geht dadurch nicht verloren: das Backend weist einen Aufruf
1060
+ // ohne Text und ohne Anhang ohnehin ab, unabhaengig davon, ob ein Client
1061
+ // vorher geprueft hat. Sie steht in der Beschreibung, wo jeder Client sie
1062
+ // lesen kann, statt in einem Konstrukt, an dem einer von ihnen erstickt.
1063
+ required: ["chat_id"],
1064
+ additionalProperties: false,
1065
+ },
1066
+ },
1067
+ {
1068
+ name: "wa_delete_message",
1069
+ path: "/wa/delete_message",
1070
+ description: "Delete one WhatsApp message this account SENT, in a chat this account owns. Read the chat first: the exact `message_id` comes from `wa_read_messages`. Only your own messages can be deleted; someone else's is refused with `403 message_not_own`. This is irreversible and is never retried: a delete already recorded for this exact chat and message is refused with `409 duplicate_delete` rather than replayed. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so re-read the chat afterwards to confirm. Deletes spend their own daily budget and never consume your send budget.",
1071
+ inputSchema: {
1072
+ type: "object",
1073
+ properties: {
1074
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1075
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
1076
+ },
1077
+ required: ["chat_id", "message_id"],
1078
+ additionalProperties: false,
1079
+ },
1080
+ },
1081
+ {
1082
+ name: "wa_delete_messages",
1083
+ path: "/wa/delete_messages",
1084
+ description: "Delete several WhatsApp messages this account sent, at most 200 per call. Every entry runs the exact same ownership, budget and audit path as `wa_delete_message`, one at a time with a pause between them, never in parallel. Returns a per-entry `ok` with the refusal `code` and `error` for each one that did not go through, so a partial result is readable rather than all-or-nothing. Entries the batch never reached before its time limit come back with `skipped:true` and `code:batch_deadline`; resend exactly those to resume.",
1085
+ inputSchema: {
1086
+ type: "object",
1087
+ properties: {
1088
+ messages: {
1089
+ type: "array",
1090
+ minItems: 1,
1091
+ maxItems: 200,
1092
+ items: {
1093
+ type: "object",
1094
+ properties: {
1095
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1096
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
1097
+ },
1098
+ required: ["chat_id", "message_id"],
1099
+ additionalProperties: false,
1100
+ },
1101
+ },
1102
+ },
1103
+ required: ["messages"],
1104
+ additionalProperties: false,
1105
+ },
1106
+ },
562
1107
  {
563
1108
  name: "email_list",
564
1109
  path: "/email/list",
565
- description: "List the newest message from each recent email thread. args: limit",
1110
+ description: "List the newest message from each recent email thread. args: limit, cursor",
566
1111
  inputSchema: {
567
1112
  type: "object",
568
- properties: { limit: { type: "integer", minimum: 1, maximum: 100 } },
1113
+ properties: {
1114
+ limit: { type: "integer", minimum: 1, maximum: 100 },
1115
+ cursor: {
1116
+ type: "string",
1117
+ minLength: 1,
1118
+ maxLength: 4096,
1119
+ description: "Fetch the next page. Use the cursor returned by a previous email_list.",
1120
+ },
1121
+ },
1122
+ additionalProperties: false,
569
1123
  },
570
1124
  },
571
1125
  {
@@ -579,12 +1133,13 @@ const TOOLS = [
579
1133
  limit: { type: "integer", minimum: 1, maximum: 100 },
580
1134
  },
581
1135
  required: ["thread_id"],
1136
+ additionalProperties: false,
582
1137
  },
583
1138
  },
584
1139
  {
585
1140
  name: "email_send",
586
1141
  path: "/email/send",
587
- description: "Send one approved email after reading that recipient's thread. args: to, subject, body",
1142
+ description: "Send one approved email after reading that recipient's thread. Pass thread_id from email_read to reply inside that thread rather than starting a new one. args: to, subject, body, thread_id",
588
1143
  inputSchema: {
589
1144
  type: "object",
590
1145
  properties: {
@@ -596,19 +1151,217 @@ const TOOLS = [
596
1151
  },
597
1152
  subject: { type: "string", minLength: 1, maxLength: 998 },
598
1153
  body: { type: "string", minLength: 1, maxLength: 5000 },
1154
+ thread_id: {
1155
+ type: "string",
1156
+ minLength: 1,
1157
+ maxLength: 500,
1158
+ description: "Reply inside this thread. Use the thread_id returned by email_read.",
1159
+ },
1160
+ confirm_resend: {
1161
+ type: "boolean",
1162
+ description: "Only after a send_outcome_unknown refusal, and only once you have read the thread again and confirmed the email is genuinely absent.",
1163
+ },
599
1164
  },
600
1165
  required: ["to", "subject", "body"],
1166
+ additionalProperties: false,
1167
+ },
1168
+ },
1169
+ {
1170
+ name: "li_my_posts",
1171
+ path: "/li/posts",
1172
+ description: "List the user's own LinkedIn posts with engagement counts (reactions, comments, impressions). args: limit",
1173
+ inputSchema: {
1174
+ type: "object",
1175
+ properties: {
1176
+ limit: { type: "integer", minimum: 1, maximum: 50 },
1177
+ member_id: { type: "string", minLength: 1, maxLength: 500, description: "Whose posts to list. Defaults to your own." },
1178
+ },
1179
+ additionalProperties: false,
1180
+ },
1181
+ },
1182
+ {
1183
+ name: "li_post_reactions",
1184
+ path: "/li/post_reactions",
1185
+ description: "Who reacted to a post — name + headline. These are warm inbound signals. args: post_id, limit",
1186
+ inputSchema: {
1187
+ type: "object",
1188
+ properties: {
1189
+ post_id: { type: "string", minLength: 1, maxLength: 500 },
1190
+ limit: { type: "integer", minimum: 1, maximum: 100 },
1191
+ },
1192
+ required: ["post_id"],
1193
+ additionalProperties: false,
1194
+ },
1195
+ },
1196
+ {
1197
+ name: "li_post_comments",
1198
+ path: "/li/post_comments",
1199
+ description: "Comments on a post, with author. args: post_id, limit",
1200
+ inputSchema: {
1201
+ type: "object",
1202
+ properties: {
1203
+ post_id: { type: "string", minLength: 1, maxLength: 500 },
1204
+ limit: { type: "integer", minimum: 1, maximum: 100 },
1205
+ },
1206
+ required: ["post_id"],
1207
+ additionalProperties: false,
1208
+ },
1209
+ },
1210
+ {
1211
+ name: "li_draft_post",
1212
+ path: "/li/create_post",
1213
+ description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. Optional mentions, base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
1214
+ inputSchema: {
1215
+ type: "object",
1216
+ properties: {
1217
+ text: { type: "string", minLength: 1, maxLength: 3000 },
1218
+ publish: { type: "boolean" },
1219
+ scheduled_at: { type: "string", format: "date-time" },
1220
+ first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1221
+ mentions: {
1222
+ type: "array", maxItems: 20,
1223
+ items: {
1224
+ type: "object",
1225
+ properties: {
1226
+ name: { type: "string", minLength: 1, maxLength: 120 },
1227
+ profile_id: { type: "string", minLength: 1, maxLength: 250 },
1228
+ },
1229
+ required: ["name", "profile_id"],
1230
+ },
1231
+ },
1232
+ attachments: {
1233
+ type: "array", maxItems: 4,
1234
+ items: {
1235
+ type: "object",
1236
+ properties: {
1237
+ filename: { type: "string", minLength: 1, maxLength: 180 },
1238
+ content_type: { type: "string", enum: ["image/png", "image/jpeg", "image/webp", "image/gif"] },
1239
+ content_base64: { type: "string", minLength: 1 },
1240
+ },
1241
+ required: ["filename", "content_type", "content_base64"],
1242
+ },
1243
+ },
1244
+ },
1245
+ required: ["text"],
1246
+ additionalProperties: false,
1247
+ },
1248
+ },
1249
+ {
1250
+ name: "li_set_scheduled_post_first_comment",
1251
+ path: "/li/set_scheduled_post_first_comment",
1252
+ description: "Attach one exact approved first comment to an existing scheduled LinkedIn post. SignalDash publishes it through the same connected account immediately after the post. Requires id, first_comment, and confirm:true.",
1253
+ inputSchema: {
1254
+ type: "object",
1255
+ properties: {
1256
+ id: { type: "string", format: "uuid" },
1257
+ first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1258
+ confirm: { type: "boolean", const: true },
1259
+ },
1260
+ required: ["id", "first_comment", "confirm"],
1261
+ additionalProperties: false,
1262
+ },
1263
+ },
1264
+ {
1265
+ name: "li_scheduled_posts",
1266
+ path: "/li/scheduled_posts",
1267
+ description: "List this authenticated user's scheduled LinkedIn posts and their current states.",
1268
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1269
+ },
1270
+ {
1271
+ name: "li_cancel_scheduled_post",
1272
+ path: "/li/cancel_scheduled_post",
1273
+ description: "Cancel one exact scheduled LinkedIn post before execution. Requires id and confirm:true.",
1274
+ inputSchema: {
1275
+ type: "object",
1276
+ properties: {
1277
+ id: { type: "string", format: "uuid" },
1278
+ confirm: { type: "boolean", const: true },
1279
+ },
1280
+ required: ["id", "confirm"],
1281
+ additionalProperties: false,
1282
+ },
1283
+ },
1284
+ // sd_, not wa_ or li_, because these three carry `channel` and act on either
1285
+ // one. The prefixes here are a claim about scope: wa_ and li_ tools reach
1286
+ // exactly one network and their arguments say so, and every tool that spans
1287
+ // both or belongs to SignalDash itself is sd_ already, from sd_contact_state
1288
+ // through the sd_campaign_ and sd_withdrawal_batch_ families.
1289
+ {
1290
+ name: "sd_schedule_message",
1291
+ path: "/sd/schedule_message",
1292
+ description: "Schedule ONE exact message into ONE chat you have already read, on WhatsApp or LinkedIn. Read that exact chat first, at limit 10 or more on LinkedIn: SignalDash records what the thread looked like and refuses to send if the conversation moved before the scheduled time. Requires confirm:true after the human approves the exact channel, chat, text, and time. Text only, no attachments. This is not a follow-up sequence and there is no recurrence: one message, one time.",
1293
+ inputSchema: {
1294
+ type: "object",
1295
+ properties: {
1296
+ channel: { type: "string", enum: ["whatsapp", "linkedin"] },
1297
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1298
+ text: { type: "string", minLength: 1, maxLength: 5000 },
1299
+ scheduled_at: { type: "string", format: "date-time" },
1300
+ confirm: { type: "boolean", const: true },
1301
+ },
1302
+ required: ["channel", "chat_id", "text", "scheduled_at", "confirm"],
1303
+ additionalProperties: false,
1304
+ },
1305
+ },
1306
+ {
1307
+ name: "sd_scheduled_messages",
1308
+ path: "/sd/scheduled_messages",
1309
+ description: "List this authenticated user's scheduled messages and their durable states, optionally filtered by state or channel. Always reports needs_review_count outside that filter: a message in needs_review stopped at send time and is waiting on a human.",
1310
+ inputSchema: {
1311
+ type: "object",
1312
+ properties: {
1313
+ state: {
1314
+ type: "string",
1315
+ enum: [
1316
+ "scheduled",
1317
+ "executing",
1318
+ "sent",
1319
+ "cancelled",
1320
+ "failed",
1321
+ "needs_review",
1322
+ ],
1323
+ },
1324
+ channel: { type: "string", enum: ["whatsapp", "linkedin"] },
1325
+ },
1326
+ additionalProperties: false,
1327
+ },
1328
+ },
1329
+ {
1330
+ name: "sd_cancel_scheduled_message",
1331
+ path: "/sd/cancel_scheduled_message",
1332
+ description: "Cancel one exact scheduled message while it is still scheduled. Requires id and confirm:true. It cannot stop a message already being sent, and it cannot unsend one that has been sent.",
1333
+ inputSchema: {
1334
+ type: "object",
1335
+ properties: {
1336
+ id: { type: "string", format: "uuid" },
1337
+ confirm: { type: "boolean", const: true },
1338
+ },
1339
+ required: ["id", "confirm"],
1340
+ additionalProperties: false,
601
1341
  },
602
1342
  },
603
- { name: "li_my_posts", path: "/li/posts", description: "List the user's own LinkedIn posts with engagement counts (reactions, comments, impressions). args: limit" },
604
- { name: "li_post_reactions", path: "/li/post_reactions", description: "Who reacted to a post — name + headline. These are warm inbound signals. args: post_id, limit" },
605
- { name: "li_post_comments", path: "/li/post_comments", description: "Comments on a post, with author. args: post_id, limit" },
606
- { name: "li_draft_post", path: "/li/create_post", description: "Draft a LinkedIn post. Returns the draft WITHOUT publishing. Publishing requires the human to approve and re-send with publish:true. args: text" },
607
1343
  ];
1344
+ // No catch-all fallback. It advertised one union of keys for every tool that
1345
+ // had no schema of its own -- which is how `li_list_chats` came to offer `text`
1346
+ // and `member_id` that the route never reads, and how a caller could not tell a
1347
+ // filtered search from an unfiltered list.
1348
+ //
1349
+ // A tool with no schema is dropped from the listing rather than shipped with a
1350
+ // promise the backend does not keep. Dropped, not thrown: tools/list maps every
1351
+ // tool through here, so throwing would take the whole LinkedIn AND WhatsApp
1352
+ // surface down over one bad entry. The test suite is what fails on a missing
1353
+ // schema; the live surface degrades by exactly one tool.
608
1354
  function mcpTool(name) {
609
1355
  const tool = TOOLS.find(t => t.name === name);
610
- return { name, description: tool.description,
611
- inputSchema: tool.inputSchema || { type: "object", properties: { chat_id: { type: "string" }, text: { type: "string" }, limit: { type: "number" }, post_id: { type: "string" }, member_id: { type: "string" }, publish: { type: "boolean" } } } };
1356
+ if (!tool || !tool.inputSchema) {
1357
+ process.stderr.write(`[signaldash] tool ${name} has no inputSchema; omitted\n`);
1358
+ return null;
1359
+ }
1360
+ return {
1361
+ name,
1362
+ description: tool.description,
1363
+ inputSchema: tool.inputSchema,
1364
+ };
612
1365
  }
613
1366
  export async function runMcp(dependencies = {}) {
614
1367
  const input = dependencies.input || process.stdin;
@@ -620,7 +1373,7 @@ export async function runMcp(dependencies = {}) {
620
1373
  let msg; try { msg = JSON.parse(line); } catch { continue; }
621
1374
  const { id, method, params } = msg;
622
1375
  if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: PACKAGE_VERSION } });
623
- else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)) });
1376
+ else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)).filter(Boolean) });
624
1377
  else if (method === "tools/call") {
625
1378
  const t = TOOLS.find(x => x.name === params.name);
626
1379
  if (!t) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
@@ -640,7 +1393,14 @@ export async function cmdSkill(dependencies = {}) {
640
1393
  if (!ex(src)) { log("skill file not found in package"); process.exitCode = 1; return; }
641
1394
  const dest = join(homedir(), ".claude", "skills", "signaldash");
642
1395
  mk(dest, { recursive: true });
643
- wf(join(dest, "SKILL.md"), rf(src, "utf8"));
1396
+ // The template pins the bootstrap command to a version placeholder rather
1397
+ // than a typed-in string: package.json is the only source of truth, so an
1398
+ // installed skill can never advertise a stale published release. Rendered
1399
+ // by the shared helper (lib/skill-template.cjs) so this substitution has a
1400
+ // single source of truth shared with the public skill page and the
1401
+ // server's own /skill and /skill.md routes.
1402
+ const template = rf(src, "utf8");
1403
+ wf(join(dest, "SKILL.md"), renderSkillTemplate(template, PACKAGE_VERSION));
644
1404
  log(`Installed the SignalDash skill to ${dest}/SKILL.md`);
645
1405
  log("Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.");
646
1406
  }
@@ -664,7 +1424,7 @@ export async function cmdSetup(code, dependencies = {}) {
664
1424
  log(" " + chalk.green("+") + " agent skill installed");
665
1425
 
666
1426
  try {
667
- execSync("claude mcp add signaldash -- npx -y @floomhq/signaldash mcp", { stdio: "ignore" });
1427
+ execSync("claude mcp add signaldash -s user -- npx -y @floomhq/signaldash mcp", { stdio: "ignore" });
668
1428
  log(" " + chalk.green("+") + " MCP registered with Claude Code");
669
1429
  } catch {
670
1430
  log(" " + chalk.yellow("!") + " Claude Code not found. For Cursor, add to .cursor/mcp.json:");
@@ -763,6 +1523,7 @@ function printHelp(log = console.log) {
763
1523
  log(`SignalDash \u2014 secure LinkedIn, WhatsApp and email access for your AI agent.
764
1524
 
765
1525
  signaldash <invite-code> set up everything in one go
1526
+ signaldash login-token <token> store a session token you were given
766
1527
  signaldash status show what is connected
767
1528
  signaldash connect linkedin|whatsapp|email
768
1529
  signaldash connections [file.csv] export your LinkedIn connections
@@ -778,8 +1539,9 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
778
1539
  const [cmd, a, b, c] = argv;
779
1540
  const log = dependencies.log || console.log;
780
1541
  if (cmd === "setup") await cmdSetup(a, dependencies);
781
- else if (cmd && /^[0-9a-f]{8,}$/i.test(cmd) && !["login","logout","connect","mcp","skill"].includes(cmd)) await cmdSetup(cmd, dependencies);
1542
+ else if (cmd && INVITE_CODE_REGEX.test(cmd) && !["login","logout","connect","mcp","skill"].includes(cmd)) await cmdSetup(cmd, dependencies);
782
1543
  else if (cmd === "login") await cmdLogin(a, b === "--backend" ? c : undefined, dependencies);
1544
+ else if (cmd === "login-token") await cmdLoginToken(a, b === "--backend" ? c : undefined, dependencies);
783
1545
  else if (cmd === "logout") await cmdLogout(dependencies);
784
1546
  else if (cmd === "connect" && b === "claim") await cmdClaim(a, c, dependencies);
785
1547
  else if (cmd === "connect") await cmdConnect(a, dependencies);
@@ -788,8 +1550,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
788
1550
  else if (cmd === "connections" || (cmd === "export" && a === "connections")) await cmdConnections(cmd === "export" ? b : a, dependencies);
789
1551
  else if (cmd === "--version" || cmd === "-v") log(PACKAGE_VERSION);
790
1552
  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; }
1553
+ else if (cmd && !["help","--help","-h"].includes(cmd) && !INVITE_CODE_REGEX.test(cmd)) { (dependencies.error || console.error)(`unknown command: ${cmd}`); printHelp(log); process.exitCode = 1; }
793
1554
  else printHelp(log);
794
1555
  }
795
1556