@floomhq/signaldash 0.1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/sd.mjs +85 -0
- package/bin/signaldash.js +10 -0
- package/lib/cli.js +358 -0
- package/lib/mcp.js +332 -0
- package/lib/rate-guard.js +219 -0
- package/lib/secrets.js +96 -0
- package/lib/unipile.js +166 -0
- package/package.json +31 -0
- package/skills/signaldash-safe-usage/SKILL.md +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Floom
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SignalDash
|
|
2
|
+
|
|
3
|
+
Secure LinkedIn and WhatsApp access for AI agents.
|
|
4
|
+
|
|
5
|
+
`@floomhq/signaldash` connects your own accounts through Unipile and exposes
|
|
6
|
+
six account-scoped MCP tools. There is no email integration, CRM cockpit,
|
|
7
|
+
signals engine, campaigns layer, sync database, or web UI in this package.
|
|
8
|
+
|
|
9
|
+
## Connect
|
|
10
|
+
|
|
11
|
+
SignalDash requires Node.js 20+ and a Unipile account. In the
|
|
12
|
+
[Unipile dashboard](https://developer.unipile.com/docs/getting-started), copy
|
|
13
|
+
your DSN and generate an Access Token. The API URL is your DSN followed by
|
|
14
|
+
`/api/v1`.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export SIGNALDASH_UNIPILE_BASE="https://apiXXX.unipile.com:XXXXX/api/v1"
|
|
18
|
+
export SIGNALDASH_UNIPILE_KEY="..."
|
|
19
|
+
|
|
20
|
+
npx -y @floomhq/signaldash connect linkedin
|
|
21
|
+
npx -y @floomhq/signaldash connect whatsapp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Each command creates a short-lived
|
|
25
|
+
[Unipile hosted-auth](https://developer.unipile.com/docs/hosted-auth) URL.
|
|
26
|
+
Open it to complete LinkedIn login or scan the live WhatsApp QR, then return
|
|
27
|
+
to the terminal. SignalDash verifies the account through `GET /accounts` and
|
|
28
|
+
stores its account ID.
|
|
29
|
+
|
|
30
|
+
Without environment variables, run either command in a terminal. SignalDash
|
|
31
|
+
prompts for the API URL and hides API-key input.
|
|
32
|
+
|
|
33
|
+
Credentials live under `~/.signaldash` by default:
|
|
34
|
+
|
|
35
|
+
- `device.key`: local 256-bit encryption key, mode `0600`
|
|
36
|
+
- `secrets.enc`: AES-256-GCM credential envelope, mode `0600`
|
|
37
|
+
- `config.json`: non-secret account references, mode `0600`
|
|
38
|
+
|
|
39
|
+
The Unipile key is never written to config, printed, returned by an MCP tool,
|
|
40
|
+
or passed to an MCP caller. Set `SIGNALDASH_HOME` to use another workspace.
|
|
41
|
+
|
|
42
|
+
## MCP
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx -y @floomhq/signaldash mcp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The command starts a stdio MCP server. Its one-line registration hint is
|
|
49
|
+
written to stderr so stdout remains valid JSON-RPC.
|
|
50
|
+
|
|
51
|
+
Claude Code:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add signaldash -- npx -y @floomhq/signaldash mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Cursor, `.cursor/mcp.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"signaldash": {
|
|
63
|
+
"command": "npx",
|
|
64
|
+
"args": ["-y", "@floomhq/signaldash", "mcp"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The entire MCP surface is:
|
|
71
|
+
|
|
72
|
+
- `li_list_chats`
|
|
73
|
+
- `li_read_messages(chat)`
|
|
74
|
+
- `li_send_message(chat, text)`
|
|
75
|
+
- `wa_list_chats`
|
|
76
|
+
- `wa_read_messages(chat)`
|
|
77
|
+
- `wa_send_message(chat, text)`
|
|
78
|
+
|
|
79
|
+
Every read and send verifies that the chat belongs to the configured account.
|
|
80
|
+
List tools use chat-list endpoints only and never fetch profiles.
|
|
81
|
+
|
|
82
|
+
## Safety is a feature
|
|
83
|
+
|
|
84
|
+
The package includes
|
|
85
|
+
[`skills/signaldash-safe-usage/SKILL.md`](skills/signaldash-safe-usage/SKILL.md).
|
|
86
|
+
Install or give that skill to the agent using SignalDash.
|
|
87
|
+
|
|
88
|
+
The runtime also enforces non-configurable safeguards:
|
|
89
|
+
|
|
90
|
+
- LinkedIn: 18 send attempts per account per UTC day; 45 to 90 seconds
|
|
91
|
+
between sends.
|
|
92
|
+
- WhatsApp: 30 send attempts per account per UTC day; 15 to 35 seconds
|
|
93
|
+
between sends.
|
|
94
|
+
- The first send also receives randomized pre-send jitter.
|
|
95
|
+
- Every send re-reads the recent thread and blocks an exact duplicate.
|
|
96
|
+
- Provider warning headers, checkpoints, restrictions, HTTP 403, and HTTP 429
|
|
97
|
+
fail closed and persistently disable sends for that account.
|
|
98
|
+
- A file lock serializes sends across concurrent MCP processes.
|
|
99
|
+
|
|
100
|
+
LinkedIn invitation ceilings vary by account and often appear around 100 per
|
|
101
|
+
week. SignalDash exposes no invitation tool. Keep invitation automation out
|
|
102
|
+
of agent loops.
|
|
103
|
+
|
|
104
|
+
Avoid bulk profile reads, copied message bursts, cold-account automation, and
|
|
105
|
+
repeated retries after a checkpoint. Let the agent act slowly and human-like.
|
|
106
|
+
On WhatsApp, use established accounts, keep the phone online, and avoid bulk
|
|
107
|
+
or unsolicited messaging.
|
|
108
|
+
|
|
109
|
+
After a provider warning, stop. Review the provider account manually and wait
|
|
110
|
+
for it to return to good standing. The warning lock is recorded in
|
|
111
|
+
`~/.signaldash/safety/send-state.json`; only a human may clear the relevant
|
|
112
|
+
account entry after resolving the restriction.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm test
|
|
118
|
+
npm run check
|
|
119
|
+
npm pack --dry-run
|
|
120
|
+
```
|
package/bin/sd.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SignalDash thin client. Talks ONLY to the SignalDash backend (the gate) with a
|
|
3
|
+
// bearer token. Never holds a Unipile key, never calls Unipile directly. The MCP
|
|
4
|
+
// tools an agent uses all proxy through the backend, so agents access channels
|
|
5
|
+
// THROUGH SignalDash, not around it.
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
|
|
10
|
+
const CFG_DIR = process.env.SIGNALDASH_HOME || `${homedir()}/.signaldash`;
|
|
11
|
+
const CFG = `${CFG_DIR}/config.json`;
|
|
12
|
+
const DEFAULT_BACKEND = process.env.SIGNALDASH_BACKEND || "https://signaldash-api.floom.dev";
|
|
13
|
+
|
|
14
|
+
function loadCfg() { try { return JSON.parse(readFileSync(CFG, "utf8")); } catch { return {}; } }
|
|
15
|
+
function saveCfg(c) { mkdirSync(CFG_DIR, { recursive: true }); writeFileSync(CFG, JSON.stringify(c, null, 2), { mode: 0o600 }); }
|
|
16
|
+
|
|
17
|
+
async function api(path, body, { auth = true } = {}) {
|
|
18
|
+
const cfg = loadCfg();
|
|
19
|
+
const backend = cfg.backend || DEFAULT_BACKEND;
|
|
20
|
+
const r = await fetch(backend + path, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json", ...(auth && cfg.token ? { Authorization: `Bearer ${cfg.token}` } : {}) },
|
|
23
|
+
body: JSON.stringify(body || {}),
|
|
24
|
+
});
|
|
25
|
+
return { status: r.status, json: await r.json().catch(() => ({})) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function cmdLogin(code, backend) {
|
|
29
|
+
const cfg = loadCfg();
|
|
30
|
+
cfg.backend = backend || cfg.backend || DEFAULT_BACKEND;
|
|
31
|
+
saveCfg(cfg);
|
|
32
|
+
const r = await api("/login", { code }, { auth: false });
|
|
33
|
+
if (r.status !== 200) { console.error("login failed:", r.json.error || r.status); process.exit(1); }
|
|
34
|
+
cfg.token = r.json.token; saveCfg(cfg);
|
|
35
|
+
console.log("Logged in to SignalDash. Token stored (no channel keys on your side).");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function cmdConnect(provider) {
|
|
39
|
+
if (!["linkedin", "whatsapp"].includes(provider)) { console.error("usage: sd connect linkedin|whatsapp"); process.exit(1); }
|
|
40
|
+
const r = await api(`/connect/${provider}`, {});
|
|
41
|
+
if (r.status >= 300) { console.error("connect failed:", r.json.error || r.status); process.exit(1); }
|
|
42
|
+
console.log(`Open this to connect your ${provider}:\n\n ${r.json.url}\n`);
|
|
43
|
+
console.log(`After authenticating, run:\n sd connect ${provider} claim <account_id>`);
|
|
44
|
+
}
|
|
45
|
+
async function cmdClaim(provider, accountId) {
|
|
46
|
+
const r = await api(`/connect/${provider}/claim`, { account_id: accountId });
|
|
47
|
+
console.log(r.status === 200 ? `Connected ${provider}: ${r.json.name}` : `claim failed: ${r.json.error}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- MCP (stdio). Every tool proxies through the backend with the token. ----
|
|
51
|
+
const TOOLS = [
|
|
52
|
+
{ name: "li_list_chats", ch: "li", action: "list_chats", description: "List your LinkedIn chats." },
|
|
53
|
+
{ name: "li_read_messages", ch: "li", action: "read", description: "Read messages in a LinkedIn chat. args: chat_id" },
|
|
54
|
+
{ name: "li_send_message", ch: "li", action: "send", description: "Send a LinkedIn message (rate-safe). args: chat_id, text" },
|
|
55
|
+
{ name: "wa_list_chats", ch: "wa", action: "list_chats", description: "List your WhatsApp chats." },
|
|
56
|
+
{ name: "wa_read_messages", ch: "wa", action: "read", description: "Read messages in a WhatsApp chat. args: chat_id" },
|
|
57
|
+
{ name: "wa_send_message", ch: "wa", action: "send", description: "Send a WhatsApp message (rate-safe). args: chat_id, text" },
|
|
58
|
+
];
|
|
59
|
+
function mcpTool(name) {
|
|
60
|
+
return { name, description: TOOLS.find(t => t.name === name).description,
|
|
61
|
+
inputSchema: { type: "object", properties: { chat_id: { type: "string" }, text: { type: "string" }, limit: { type: "number" } } } };
|
|
62
|
+
}
|
|
63
|
+
async function runMcp() {
|
|
64
|
+
const rl = createInterface({ input: process.stdin });
|
|
65
|
+
const reply = (id, result, error) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, ...(error ? { error } : { result }) }) + "\n");
|
|
66
|
+
for await (const line of rl) {
|
|
67
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
68
|
+
const { id, method, params } = msg;
|
|
69
|
+
if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: "0.1.0" } });
|
|
70
|
+
else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)) });
|
|
71
|
+
else if (method === "tools/call") {
|
|
72
|
+
const t = TOOLS.find(x => x.name === params.name);
|
|
73
|
+
if (!t) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
|
|
74
|
+
const r = await api(`/${t.ch}/${t.action}`, params.arguments || {});
|
|
75
|
+
reply(id, { content: [{ type: "text", text: JSON.stringify(r.json) }], isError: r.status >= 300 });
|
|
76
|
+
} else if (id !== undefined) reply(id, {});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [cmd, a, b, c] = process.argv.slice(2);
|
|
81
|
+
if (cmd === "login") await cmdLogin(a, b === "--backend" ? c : undefined);
|
|
82
|
+
else if (cmd === "connect" && b === "claim") await cmdClaim(a, c);
|
|
83
|
+
else if (cmd === "connect") await cmdConnect(a);
|
|
84
|
+
else if (cmd === "mcp") await runMcp();
|
|
85
|
+
else console.log(`SignalDash — secure LinkedIn + WhatsApp access for your agent.\n\n sd login <invite-code> [--backend URL]\n sd connect linkedin|whatsapp\n sd connect linkedin|whatsapp claim <account_id>\n sd mcp\n\nThe agent reaches channels only through SignalDash. No keys on your machine.`);
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readSecrets, writeSecrets } from "./secrets.js";
|
|
6
|
+
import { runMcp } from "./mcp.js";
|
|
7
|
+
import { UnipileClient, accountIsReady } from "./unipile.js";
|
|
8
|
+
|
|
9
|
+
const VERSION = "0.1.0";
|
|
10
|
+
const CHANNELS = {
|
|
11
|
+
linkedin: { label: "LinkedIn", provider: "LINKEDIN" },
|
|
12
|
+
whatsapp: { label: "WhatsApp", provider: "WHATSAPP" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const options = {};
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
19
|
+
const arg = argv[index];
|
|
20
|
+
if (!arg.startsWith("--")) {
|
|
21
|
+
positional.push(arg);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const [key, inline] = arg.slice(2).split("=", 2);
|
|
25
|
+
if (inline !== undefined) {
|
|
26
|
+
options[key] = inline;
|
|
27
|
+
} else if (argv[index + 1] && !argv[index + 1].startsWith("--")) {
|
|
28
|
+
options[key] = argv[index + 1];
|
|
29
|
+
index += 1;
|
|
30
|
+
} else {
|
|
31
|
+
options[key] = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { options, positional };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function workspaceFor(options = {}) {
|
|
38
|
+
return path.resolve(
|
|
39
|
+
String(
|
|
40
|
+
options.workspace ||
|
|
41
|
+
process.env.SIGNALDASH_HOME ||
|
|
42
|
+
path.join(os.homedir(), ".signaldash"),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function ensureWorkspace(workspace) {
|
|
48
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
49
|
+
await chmod(workspace, 0o700);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function writeJsonPrivate(target, value) {
|
|
53
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
54
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
55
|
+
mode: 0o600,
|
|
56
|
+
});
|
|
57
|
+
await chmod(temporary, 0o600);
|
|
58
|
+
await rename(temporary, target);
|
|
59
|
+
await chmod(target, 0o600);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readConfig(workspace, { optional = false } = {}) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(
|
|
65
|
+
await readFile(path.join(workspace, "config.json"), "utf8"),
|
|
66
|
+
);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === "ENOENT" && optional) {
|
|
69
|
+
return { version: 1, channels: {} };
|
|
70
|
+
}
|
|
71
|
+
if (error.code === "ENOENT") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"no connected accounts; run `signaldash connect linkedin` or `signaldash connect whatsapp`",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`cannot read SignalDash config: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateBase(value) {
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(value);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error("Unipile API URL must be an absolute HTTPS URL");
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
url.protocol !== "https:" ||
|
|
89
|
+
url.username ||
|
|
90
|
+
url.password ||
|
|
91
|
+
url.search ||
|
|
92
|
+
url.hash ||
|
|
93
|
+
!url.hostname.endsWith(".unipile.com") ||
|
|
94
|
+
!url.pathname.replace(/\/+$/, "").endsWith("/api/v1")
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"Unipile API URL must be an https://*.unipile.com URL ending in /api/v1",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return url.toString().replace(/\/+$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readHidden(prompt, stdin, stdout) {
|
|
104
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
|
|
105
|
+
return Promise.resolve("");
|
|
106
|
+
}
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
let value = "";
|
|
109
|
+
const previousRaw = stdin.isRaw;
|
|
110
|
+
const finish = (error) => {
|
|
111
|
+
stdin.off("data", onData);
|
|
112
|
+
stdin.setRawMode(Boolean(previousRaw));
|
|
113
|
+
stdout.write("\n");
|
|
114
|
+
if (error) {
|
|
115
|
+
reject(error);
|
|
116
|
+
} else {
|
|
117
|
+
resolve(value);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const onData = (chunk) => {
|
|
121
|
+
for (const byte of chunk) {
|
|
122
|
+
if (byte === 3) {
|
|
123
|
+
finish(new Error("connection cancelled"));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (byte === 13 || byte === 10) {
|
|
127
|
+
finish();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (byte === 8 || byte === 127) {
|
|
131
|
+
value = value.slice(0, -1);
|
|
132
|
+
} else if (byte >= 32 && byte <= 126) {
|
|
133
|
+
value += String.fromCharCode(byte);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
stdout.write(prompt);
|
|
138
|
+
stdin.setRawMode(true);
|
|
139
|
+
stdin.resume();
|
|
140
|
+
stdin.on("data", onData);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function credentials(workspace, options, io, environment) {
|
|
145
|
+
const stored = await readSecrets(workspace);
|
|
146
|
+
let base = String(
|
|
147
|
+
options.base ||
|
|
148
|
+
environment.SIGNALDASH_UNIPILE_BASE ||
|
|
149
|
+
stored.unipileBase ||
|
|
150
|
+
"",
|
|
151
|
+
).trim();
|
|
152
|
+
let key = String(
|
|
153
|
+
environment.SIGNALDASH_UNIPILE_KEY || stored.unipileKey || "",
|
|
154
|
+
).trim();
|
|
155
|
+
let prompt;
|
|
156
|
+
if ((!base || !key) && io.stdin.isTTY && io.stdout.isTTY) {
|
|
157
|
+
prompt = createInterface({ input: io.stdin, output: io.stdout });
|
|
158
|
+
if (!base) {
|
|
159
|
+
base = (
|
|
160
|
+
await prompt.question("Unipile API URL (ending in /api/v1): ")
|
|
161
|
+
).trim();
|
|
162
|
+
}
|
|
163
|
+
if (!key) {
|
|
164
|
+
prompt.close();
|
|
165
|
+
prompt = undefined;
|
|
166
|
+
key = (await readHidden("Unipile API key (hidden): ", io.stdin, io.stdout)).trim();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
prompt?.close();
|
|
170
|
+
if (!base || !key) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"set SIGNALDASH_UNIPILE_BASE and SIGNALDASH_UNIPILE_KEY, or run connect in a terminal",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return { base: validateBase(base), key };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function matchingAccounts(payload, provider) {
|
|
179
|
+
return (payload.items || []).filter(
|
|
180
|
+
(account) =>
|
|
181
|
+
String(account.type || "").toUpperCase() === provider &&
|
|
182
|
+
accountIsReady(account),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function selectAccount(accounts, requestedId) {
|
|
187
|
+
if (requestedId) {
|
|
188
|
+
const selected = accounts.find((account) => account.id === requestedId);
|
|
189
|
+
if (!selected) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`account ${requestedId} is not a connected account for this channel`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return selected;
|
|
195
|
+
}
|
|
196
|
+
return accounts.length === 1 ? accounts[0] : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function waitForHuman(label, io) {
|
|
200
|
+
if (!io.stdin.isTTY || !io.stdout.isTTY) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const prompt = createInterface({ input: io.stdin, output: io.stdout });
|
|
204
|
+
await prompt.question(
|
|
205
|
+
`Complete ${label} in the hosted page, then press Enter to verify: `,
|
|
206
|
+
);
|
|
207
|
+
prompt.close();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function connectCommand(channel, options, dependencies = {}) {
|
|
211
|
+
const definition = CHANNELS[channel];
|
|
212
|
+
if (!definition) {
|
|
213
|
+
throw new Error("channel must be linkedin or whatsapp");
|
|
214
|
+
}
|
|
215
|
+
const io = {
|
|
216
|
+
stdin: dependencies.stdin || process.stdin,
|
|
217
|
+
stdout: dependencies.stdout || process.stdout,
|
|
218
|
+
};
|
|
219
|
+
const workspace = workspaceFor(options);
|
|
220
|
+
await ensureWorkspace(workspace);
|
|
221
|
+
const secret = await credentials(
|
|
222
|
+
workspace,
|
|
223
|
+
options,
|
|
224
|
+
io,
|
|
225
|
+
dependencies.environment || process.env,
|
|
226
|
+
);
|
|
227
|
+
const createClient =
|
|
228
|
+
dependencies.createClient ||
|
|
229
|
+
((base, key) => new UnipileClient({ base, key }));
|
|
230
|
+
const client = createClient(secret.base, secret.key);
|
|
231
|
+
const config = await readConfig(workspace, { optional: true });
|
|
232
|
+
const configuredId = config.channels?.[channel]?.accountId;
|
|
233
|
+
|
|
234
|
+
const before = await client.listAccounts();
|
|
235
|
+
const hostedUrl = await client.createHostedAuthLink(definition.provider);
|
|
236
|
+
io.stdout.write(`${definition.label} hosted auth: ${hostedUrl}\n`);
|
|
237
|
+
|
|
238
|
+
let available = matchingAccounts(before, definition.provider);
|
|
239
|
+
let selected = selectAccount(
|
|
240
|
+
available,
|
|
241
|
+
String(options["account-id"] || configuredId || "").trim(),
|
|
242
|
+
);
|
|
243
|
+
if (!selected && !options["no-wait"]) {
|
|
244
|
+
await waitForHuman(definition.label, io);
|
|
245
|
+
available = matchingAccounts(
|
|
246
|
+
await client.listAccounts(),
|
|
247
|
+
definition.provider,
|
|
248
|
+
);
|
|
249
|
+
selected = selectAccount(
|
|
250
|
+
available,
|
|
251
|
+
String(options["account-id"] || configuredId || "").trim(),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await writeSecrets(workspace, {
|
|
256
|
+
unipileBase: secret.base,
|
|
257
|
+
unipileKey: secret.key,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!selected) {
|
|
261
|
+
if (available.length > 1) {
|
|
262
|
+
const choices = available
|
|
263
|
+
.map((account) => `${account.id} (${account.name || definition.label})`)
|
|
264
|
+
.join(", ");
|
|
265
|
+
io.stdout.write(
|
|
266
|
+
`Multiple connected ${definition.label} accounts found: ${choices}\n`,
|
|
267
|
+
);
|
|
268
|
+
io.stdout.write(
|
|
269
|
+
`Re-run with --account-id <id> after completing hosted auth.\n`,
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
io.stdout.write(
|
|
273
|
+
`No connected ${definition.label} account detected yet. Complete hosted auth, then rerun this command.\n`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return { connected: false, hostedUrl };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const nextConfig = {
|
|
280
|
+
version: 1,
|
|
281
|
+
channels: {
|
|
282
|
+
...(config.channels || {}),
|
|
283
|
+
[channel]: {
|
|
284
|
+
accountId: selected.id,
|
|
285
|
+
name: selected.name || null,
|
|
286
|
+
connectedAt: new Date().toISOString(),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
await writeJsonPrivate(path.join(workspace, "config.json"), nextConfig);
|
|
291
|
+
io.stdout.write(
|
|
292
|
+
`${definition.label} connected: ${selected.name || selected.id} (${selected.id})\n`,
|
|
293
|
+
);
|
|
294
|
+
io.stdout.write(`Credentials encrypted in ${workspace}\n`);
|
|
295
|
+
return { connected: true, account: selected, hostedUrl };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function registrationLine() {
|
|
299
|
+
return "Register: claude mcp add signaldash -- npx -y @floomhq/signaldash mcp | Cursor command: npx -y @floomhq/signaldash mcp";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function mcpCommand(options, dependencies = {}) {
|
|
303
|
+
const workspace = workspaceFor(options);
|
|
304
|
+
const config = await readConfig(workspace);
|
|
305
|
+
const secrets = await readSecrets(workspace);
|
|
306
|
+
if (!secrets.unipileBase || !secrets.unipileKey) {
|
|
307
|
+
throw new Error("encrypted Unipile credentials are missing; reconnect a channel");
|
|
308
|
+
}
|
|
309
|
+
const stderr = dependencies.stderr || process.stderr;
|
|
310
|
+
stderr.write(`${registrationLine()}\n`);
|
|
311
|
+
await runMcp({
|
|
312
|
+
workspace,
|
|
313
|
+
config,
|
|
314
|
+
secrets,
|
|
315
|
+
input: dependencies.stdin || process.stdin,
|
|
316
|
+
output: dependencies.stdout || process.stdout,
|
|
317
|
+
createClient: dependencies.createClient,
|
|
318
|
+
guard: dependencies.guard,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function help(output = process.stdout) {
|
|
323
|
+
output.write(`SignalDash ${VERSION}
|
|
324
|
+
|
|
325
|
+
Secure LinkedIn + WhatsApp access for AI agents.
|
|
326
|
+
|
|
327
|
+
Usage:
|
|
328
|
+
signaldash connect linkedin [--workspace PATH] [--account-id ID]
|
|
329
|
+
signaldash connect whatsapp [--workspace PATH] [--account-id ID]
|
|
330
|
+
signaldash mcp [--workspace PATH]
|
|
331
|
+
|
|
332
|
+
Credentials:
|
|
333
|
+
Set SIGNALDASH_UNIPILE_BASE and SIGNALDASH_UNIPILE_KEY, or run connect
|
|
334
|
+
interactively. The API key is encrypted locally and never exposed over MCP.
|
|
335
|
+
`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function main(argv, dependencies = {}) {
|
|
339
|
+
const { options, positional } = parseArgs(argv);
|
|
340
|
+
const command = positional[0];
|
|
341
|
+
if (options.version || command === "version") {
|
|
342
|
+
(dependencies.stdout || process.stdout).write(`${VERSION}\n`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (options.help || !command || command === "help") {
|
|
346
|
+
help(dependencies.stdout || process.stdout);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (command === "connect") {
|
|
350
|
+
await connectCommand(positional[1], options, dependencies);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (command === "mcp") {
|
|
354
|
+
await mcpCommand(options, dependencies);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
throw new Error(`unknown command: ${command}`);
|
|
358
|
+
}
|