wazap-mcp 0.9.0 → 0.9.2

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/AGENT.md ADDED
@@ -0,0 +1,17 @@
1
+ # wazap setup, driven by an agent
2
+
3
+ You are setting up WhatsApp access for the person you are working with. Use the pairing-code flow (it is plain text); never the QR.
4
+
5
+ wazap keeps stdout for the MCP protocol and writes every human-readable line to stderr, so read stderr as well: run each command with `2>&1`.
6
+
7
+ 1. Run `npx wazap-mcp status --json`. If `linked` is true, skip to step 5.
8
+ 2. Ask the person for their WhatsApp number in international format (e.g. +15550100) and whether the agent may send messages (default: no).
9
+ 3. Start the link IN THE BACKGROUND and keep reading its output:
10
+ `npx wazap-mcp login --phone <number> --no-writes --yes 2>&1` (or `--writes`).
11
+ Within ~10 s the output contains a line `pairing code: XXXX-XXXX`.
12
+ 4. Show the person exactly: "On your phone: WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead → enter XXXX-XXXX". Keep reading the output until `Linked as …` and then `Synced N chats …` appear (up to 3 minutes). If the output says the code expired, run step 3 again.
13
+ 5. Register wazap with the client you are running in: `npx wazap-mcp connect <client>` where <client> is claude-code, claude-desktop, cursor, codex, vscode or gemini. Tell the person what the command printed as `Next`.
14
+ 6. Verify: `npx wazap-mcp status --json` must show `linked: true` and no failing check. If the client needs a restart, say so; otherwise call the `get_status` tool and then `learn`.
15
+ 7. Done when `get_status` returns `connected`. Then offer: "what did I miss on WhatsApp today?"
16
+
17
+ Errors: every wazap error prints `✗ message` and `→ fix`; do what the fix says, do not retry blindly. One process owns the session, so while the `login` of step 3 runs, no server and no other wazap command may touch the same data dir; two servers on one data dir refuse to start. Stop the other one first.
package/README.md CHANGED
@@ -19,18 +19,30 @@ WhatsApp multi-device protocol over a WebSocket.
19
19
  The npm package is `wazap-mcp`; the command it installs is `wazap`.
20
20
 
21
21
  ```bash
22
- npx wazap-mcp login # link your account with a pairing code
23
- npx wazap-mcp connect claude-code # write the MCP entry for your agent
22
+ npx wazap-mcp setup
24
23
  ```
25
24
 
25
+ That is the whole install. It links your account, finds the MCP clients
26
+ installed on this machine, writes their config and tells you what to restart.
27
+
28
+ Or have your agent do it. Paste this:
29
+
30
+ *Set up WhatsApp for me: run `npx wazap-mcp setup --agent` and follow what it prints.*
31
+
26
32
  Then ask your agent: *"what did I miss on WhatsApp today?"*
27
33
 
28
- `login` asks for your number in international format, prints an 8-character
29
- code, and you enter it on your phone under **Settings → Linked devices → Link a
30
- device → Link with phone number instead**. Prefer a QR code? `npx wazap-mcp login --qr`.
34
+ Below are the steps `setup` runs for you. Each is still its own command when you
35
+ want to run it by hand.
36
+
37
+ `npx wazap-mcp login` shows a QR code; scan it from **Settings → Linked devices
38
+ → Link a device**. No camera handy, or linking over SSH? `npx wazap-mcp login --phone +15550100`
39
+ prints an 8-character code you type under *Link with phone number instead*.
31
40
  It ends by asking whether the agent may send messages; the answer is no unless
32
41
  you say yes, and `npx wazap-mcp config writes on` changes it later.
33
42
 
43
+ `npx wazap-mcp connect claude-code` writes the MCP entry for one client. The
44
+ table under **Connect a client** has the rest.
45
+
34
46
  `npx wazap-mcp` on its own is safe to run: it prints where you stand and what to do
35
47
  next, and starts no server. When something is off, `npx wazap-mcp status` is the
36
48
  first thing to run — it checks Node, the data directory, the lock, the
@@ -122,7 +134,7 @@ wazap ships five [Agent Skills](https://agentskills.io) that teach an agent the
122
134
 
123
135
  | Skill | What the agent does |
124
136
  | --- | --- |
125
- | `wazap-setup` | Diagnose with `wazap status`, link by pairing code, connect a client with `wazap connect`, repair an expired session |
137
+ | `wazap-setup` | Diagnose with `wazap status`, link by QR or pairing code, connect a client with `wazap connect`, repair an expired session |
126
138
  | `whatsapp-inbox` | "What did I miss?" Triage into *needs you / FYI / noise*, ranked, plus forgotten replies. Read-only |
127
139
  | `whatsapp-recall` | "Find the invoice Dan sent." Search with query variants, page back in time, download and read the file. Read-only |
128
140
  | `whatsapp-groups` | Catch up on a 300-message group: decisions, dates, what is asked of you. Read-only |
@@ -218,7 +230,7 @@ Run wazap on a server of your own when the agent is not on your laptop: another
218
230
  ```bash
219
231
  npm install -g wazap-mcp
220
232
  sudo useradd --system --home /var/lib/wazap --create-home wazap
221
- sudo -u wazap WAZAP_DATA_DIR=/var/lib/wazap wazap login --phone +40722123456 # pairing code works over SSH
233
+ sudo -u wazap WAZAP_DATA_DIR=/var/lib/wazap wazap login --phone +15550100 # pairing code works over SSH
222
234
  sudo -u wazap tee /var/lib/wazap/.env >/dev/null <<END
223
235
  WAZAP_READ_TOKEN=$(openssl rand -hex 32)
224
236
  WAZAP_WRITE_TOKEN=$(openssl rand -hex 32)
@@ -235,7 +247,7 @@ The unit binds loopback only. Put TLS in front with the two-line [`deploy/Caddyf
235
247
  ```bash
236
248
  git clone https://github.com/razvangirgiz/wazap && cd wazap
237
249
  printf 'WAZAP_READ_TOKEN=%s\nWAZAP_WRITE_TOKEN=%s\n' $(openssl rand -hex 32) $(openssl rand -hex 32) > .env
238
- docker compose run --rm wazap login --phone +40722123456 # once; the session lands in the wazap-data volume
250
+ docker compose run --rm wazap login --phone +15550100 # once; the session lands in the wazap-data volume
239
251
  docker compose up -d
240
252
  curl -s http://127.0.0.1:8766/healthz
241
253
  ```
@@ -106,7 +106,10 @@ export function readLinkedAccount(dir) {
106
106
  catch {
107
107
  throw new WazapError("SESSION_CORRUPT", `Stored credentials in ${dir} are unreadable.`, RESET_FIX);
108
108
  }
109
- if (!creds.registered || !creds.me?.id)
109
+ // Baileys itself treats an account with `me` as linked (socket.js: `if (!creds.me)`
110
+ // pairs a device, else logs in). `registered` is only ever set by the
111
+ // pairing-code flow, so a QR-linked session would look unlinked forever.
112
+ if (!creds.me?.id)
110
113
  return null;
111
114
  const number = creds.me.id.split(":")[0].split("@")[0];
112
115
  return { id: `${number}@s.whatsapp.net`, name: creds.me.name || "", number };
package/dist/banner.js CHANGED
@@ -1,7 +1,13 @@
1
- export const BANNER = `██╗ ██╗ █████╗ ███████╗ █████╗ ██████╗
1
+ import { brand, dim } from "./ui.js";
2
+ export const BANNER_ART = `██╗ ██╗ █████╗ ███████╗ █████╗ ██████╗
2
3
  ██║ ██║██╔══██╗╚══███╔╝██╔══██╗██╔══██╗
3
4
  ██║ █╗ ██║███████║ ███╔╝ ███████║██████╔╝
4
5
  ██║███╗██║██╔══██║ ███╔╝ ██╔══██║██╔═══╝
5
6
  ╚███╔███╔╝██║ ██║███████╗██║ ██║██║
6
- ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝
7
- WhatsApp for your AI agent.`;
7
+ ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝`;
8
+ export const TAGLINE = "WhatsApp for your AI agent.";
9
+ export const BANNER = `${BANNER_ART}\n${TAGLINE}`;
10
+ /** Painted per line, so a wrapped terminal cannot bleed the colour onward. */
11
+ export function banner() {
12
+ return `${BANNER_ART.split("\n").map(brand).join("\n")}\n${dim(TAGLINE)}`;
13
+ }
package/dist/cli.js CHANGED
@@ -5,10 +5,10 @@ import makeWASocket, { DisconnectReason, } from "baileys";
5
5
  import qrcode from "qrcode";
6
6
  import qrcodeTerminal from "qrcode-terminal";
7
7
  import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
8
- import { BANNER } from "./banner.js";
8
+ import { banner } from "./banner.js";
9
9
  import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
10
- import { CONNECT_HINT } from "./connect.js";
11
- import { checkLine, runChecks } from "./doctor.js";
10
+ import { connectNext } from "./connect.js";
11
+ import { checkLine, checkLines, runChecks } from "./doctor.js";
12
12
  import { RELINK_FIX, WazapError, asWazapError } from "./errors.js";
13
13
  import { normalizePhone } from "./ids.js";
14
14
  import { lockHolder, releaseLock, writeLock } from "./lock.js";
@@ -16,6 +16,7 @@ import { log, logError, say } from "./logger.js";
16
16
  import { formatAge } from "./messages.js";
17
17
  import { runHttp, runStdio } from "./server.js";
18
18
  import { applyWrites } from "./settings.js";
19
+ import { bold, box, brand, humanLayout, dim, fail, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
19
20
  import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
20
21
  const LOGIN_TIMEOUT_MS = 120_000;
21
22
  const LIVE_TIMEOUT_MS = 15_000;
@@ -66,24 +67,8 @@ export async function runStatus(config) {
66
67
  process.exit(0);
67
68
  return report;
68
69
  }
69
- say(`data dir: ${report.data_dir}`);
70
- if (unreadable) {
71
- say("linked: no (credentials unreadable — run `wazap logout` then `wazap login`)");
72
- }
73
- else if (account) {
74
- say("linked: yes");
75
- say(`account: ${describeAccount(account)}`);
76
- }
77
- else {
78
- say("linked: no");
79
- }
80
- say(`wazap: ${report.wazap_version}`);
81
- say(`baileys: ${report.baileys_version}`);
82
- say(report.server_pid === null ? "server: not running" : `server: running (pid ${report.server_pid})`);
83
- say("");
84
- say("checks:");
85
- for (const check of report.checks)
86
- say(checkLine(check));
70
+ for (const line of humanLayout() ? richStatus(report) : plainStatus(report))
71
+ say(line);
87
72
  if (report.live) {
88
73
  say("");
89
74
  for (const line of liveLines(report.live))
@@ -92,6 +77,40 @@ export async function runStatus(config) {
92
77
  }
93
78
  return report;
94
79
  }
80
+ /** Today's phrasing, kept verbatim so pipes and log captures keep parsing. */
81
+ function plainStatus(report) {
82
+ const lines = [`data dir: ${report.data_dir}`];
83
+ if (!report.credentials_readable) {
84
+ lines.push("linked: no (credentials unreadable — run `wazap logout` then `wazap login`)");
85
+ }
86
+ else if (report.account) {
87
+ lines.push("linked: yes", `account: ${describeAccount(report.account)}`);
88
+ }
89
+ else {
90
+ lines.push("linked: no");
91
+ }
92
+ lines.push(`wazap: ${report.wazap_version}`, `baileys: ${report.baileys_version}`, report.server_pid === null ? "server: not running" : `server: running (pid ${report.server_pid})`, "", "checks:", ...report.checks.map(checkLine));
93
+ return lines;
94
+ }
95
+ const LABEL_WIDTH = 8;
96
+ function row(label, value) {
97
+ return `${dim(label.padEnd(LABEL_WIDTH))} ${value}`;
98
+ }
99
+ function richStatus(report) {
100
+ const account = !report.credentials_readable
101
+ ? "credentials unreadable"
102
+ : report.account
103
+ ? describeAccount(report.account)
104
+ : "not linked";
105
+ return [
106
+ `${bold(`wazap ${report.wazap_version}`)}${dim(` · baileys ${report.baileys_version}`)}`,
107
+ row("data dir", tilde(report.data_dir)),
108
+ row("account", account),
109
+ row("server", report.server_pid === null ? "not running" : `running (pid ${report.server_pid})`),
110
+ "",
111
+ ...report.checks.flatMap(checkLines),
112
+ ];
113
+ }
95
114
  function liveLines(live) {
96
115
  if (!live.reachable)
97
116
  return [`live: no connection (${live.reason ?? "unknown"})`];
@@ -114,13 +133,13 @@ async function runLiveProbe(config) {
114
133
  const deadline = Date.now() + LIVE_TIMEOUT_MS;
115
134
  try {
116
135
  await wa.start();
117
- let info = wa.getStatus();
118
- while (!SETTLED_STATUSES.includes(info.status) && Date.now() < deadline) {
136
+ let probe = wa.getStatus();
137
+ while (!SETTLED_STATUSES.includes(probe.status) && Date.now() < deadline) {
119
138
  await sleep(250);
120
- info = wa.getStatus();
139
+ probe = wa.getStatus();
121
140
  }
122
- if (info.status !== "connected") {
123
- return { reachable: false, chats: null, last_message_age: null, reason: info.last_error ?? info.status };
141
+ if (probe.status !== "connected") {
142
+ return { reachable: false, chats: null, last_message_age: null, reason: probe.last_error ?? probe.status };
124
143
  }
125
144
  // listChats waits on its own sync gate, which would outlast the deadline.
126
145
  const chats = await Promise.race([
@@ -144,32 +163,32 @@ async function runLiveProbe(config) {
144
163
  }
145
164
  /** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
146
165
  export async function runGreet(config) {
147
- say(BANNER);
166
+ say(banner());
148
167
  say("");
149
168
  const report = await runStatus(config);
150
169
  say("");
151
170
  if (report.server_pid !== null) {
152
- say(`A server is already running (pid ${report.server_pid}).`);
171
+ say(info(`A server is already running (pid ${report.server_pid}).`));
153
172
  return;
154
173
  }
155
174
  if (!report.credentials_readable) {
156
- say("Next: wazap logout (then wazap login)");
175
+ say(next("wazap logout", "(then wazap login)"));
157
176
  return;
158
177
  }
159
178
  say(report.linked
160
- ? 'Next: wazap connect claude-code (then ask your agent: "what did I miss on WhatsApp today?")'
161
- : "Next: wazap login");
179
+ ? next("wazap connect claude-code", '(then ask your agent: "what did I miss on WhatsApp today?")')
180
+ : next("wazap login"));
162
181
  }
163
182
  export async function runServe(config) {
164
183
  const p = paths(config.dataDir);
165
184
  const running = lockHolder(p.lockFile);
166
185
  if (running !== null) {
167
- say(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`);
186
+ say(fail(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`));
168
187
  process.exit(2);
169
188
  }
170
189
  // Loopback with no token only gets runHttp's warning; off-loopback is refused.
171
190
  if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
172
- say(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`);
191
+ say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
173
192
  process.exit(1);
174
193
  }
175
194
  mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
@@ -192,45 +211,193 @@ export async function runServe(config) {
192
211
  else
193
212
  await runStdio(wa, config);
194
213
  }
214
+ export function stepper(total) {
215
+ let n = 0;
216
+ return (title) => say(step(++n, total, title));
217
+ }
195
218
  export async function runLogin(config) {
196
219
  const p = paths(config.dataDir);
220
+ say(banner());
197
221
  const linked = readLinkedAccount(p.authDir);
198
222
  if (linked) {
199
- say(`Already linked as ${describeAccount(linked)}. Run \`wazap logout\` to relink.`);
200
- return;
201
- }
202
- const phone = config.loginQr ? null : normalizePhone(config.loginPhone ?? (await askPhone()));
203
- let requested = false;
204
- const onQr = async (qr, sock) => {
205
- if (phone === null) {
206
- qrcodeTerminal.generate(qr, { small: true }, (art) => say(art));
207
- await qrcode.toFile(p.qrFile, qr);
208
- say(`WhatsApp → Settings → Linked devices → Link a device, then scan the code above (also saved to ${p.qrFile}).`);
209
- return;
210
- }
211
- if (requested)
212
- return;
213
- requested = true;
214
- const code = await sock.requestPairingCode(phone);
215
223
  say("");
216
- say("WhatsApp Settings → Linked devices → Link a device → Link with phone number instead");
217
- say("");
218
- say(` ${code.slice(0, 4)}-${code.slice(4)}`);
224
+ say(ok(`Already linked as ${describeAccount(linked)}`));
225
+ say(info("Run `wazap logout` to relink."));
219
226
  say("");
227
+ say(connectNext());
228
+ return;
229
+ }
230
+ await linkAndSync(config, stepper(config.loginCode ? 4 : 3));
231
+ say("");
232
+ say(connectNext());
233
+ process.exit(0);
234
+ }
235
+ /**
236
+ * Pairing owns the session the way the server does, so the lock is held from
237
+ * here until the history has landed. Writes are offered after it is released:
238
+ * that question edits .env, not the session, and it waits on a human.
239
+ */
240
+ export async function linkAndSync(config, announce = () => { }) {
241
+ const p = paths(config.dataDir);
242
+ const running = lockHolder(p.lockFile);
243
+ if (running !== null) {
244
+ say(fail(`wazap is running (pid ${running}). Stop it first (or quit the client that launched it), then run this again.`));
245
+ process.exit(1);
246
+ }
247
+ mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
248
+ writeLock(p.lockFile);
249
+ const release = () => releaseLock(p.lockFile);
250
+ const onInterrupt = () => {
251
+ release();
252
+ process.exit(130);
253
+ };
254
+ const onTerminate = () => {
255
+ release();
256
+ process.exit(143);
220
257
  };
221
- const sock = await linkSession(p.authDir, { deadline: Date.now() + LOGIN_TIMEOUT_MS, onQr });
222
- const account = await settledAccount(sock, p.authDir);
223
- say(`Linked ✅ as ${describeAccount(account)}`);
224
- await sock.end(undefined);
258
+ process.on("exit", release);
259
+ process.on("SIGINT", onInterrupt);
260
+ process.on("SIGTERM", onTerminate);
261
+ try {
262
+ let phone = null;
263
+ if (config.loginCode) {
264
+ announce("Your number");
265
+ phone = config.loginPhone === undefined ? await askPhone() : normalizePhone(config.loginPhone);
266
+ say(ok(maskNumber(phone)));
267
+ }
268
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
269
+ const waiting = new Countdown(deadline);
270
+ announce("Link your phone");
271
+ let requested = false;
272
+ const onQr = async (qr, sock) => {
273
+ if (phone === null) {
274
+ qrcodeTerminal.generate(qr, { small: true }, (art) => say(art));
275
+ await qrcode.toFile(p.qrFile, qr);
276
+ say(` Scan it with WhatsApp → Settings → Linked devices → Link a device (also saved to ${shortPath(p.qrFile)})`);
277
+ say(dim(" Prefer typing a code? Press Ctrl+C and run `wazap login --phone +15550100`."));
278
+ say("");
279
+ waiting.start();
280
+ return;
281
+ }
282
+ if (requested)
283
+ return;
284
+ requested = true;
285
+ const code = await sock.requestPairingCode(phone);
286
+ const pretty = `${code.slice(0, 4)}-${code.slice(4)}`;
287
+ if (!humanLayout())
288
+ say(`pairing code: ${pretty}`);
289
+ say(" On your phone: WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead");
290
+ say("");
291
+ say(box(pretty));
292
+ say("");
293
+ waiting.start();
294
+ };
295
+ let account;
296
+ try {
297
+ const sock = await linkSession(p.authDir, { deadline, onQr });
298
+ account = await settledAccount(sock, p.authDir);
299
+ await sock.end(undefined);
300
+ }
301
+ catch (err) {
302
+ waiting.stop();
303
+ throw err;
304
+ }
305
+ waiting.stop(ok(`Linked as ${describeAccount(account)}`));
306
+ announce("Sync your chats");
307
+ await syncAfterLink(config);
308
+ }
309
+ finally {
310
+ release();
311
+ process.off("exit", release);
312
+ process.off("SIGINT", onInterrupt);
313
+ process.off("SIGTERM", onTerminate);
314
+ }
315
+ announce("Permissions");
225
316
  await offerWrites(config);
226
- say(CONNECT_HINT);
227
- process.exit(0);
317
+ }
318
+ const HISTORY_WAIT_MS = 90_000;
319
+ const HISTORY_QUIET_MS = 3_000;
320
+ /**
321
+ * WhatsApp sends the chat history exactly once, right after pairing, to the
322
+ * socket that paired. The link socket has no store, so the real service takes
323
+ * over here and stays up until the history has landed and gone quiet.
324
+ */
325
+ async function syncAfterLink(config) {
326
+ const wa = new WhatsAppService(config);
327
+ const spin = spinner("Syncing your chats…");
328
+ try {
329
+ await wa.start();
330
+ const deadline = Date.now() + HISTORY_WAIT_MS;
331
+ let seen = "";
332
+ let quietSince = Date.now();
333
+ while (Date.now() < deadline) {
334
+ await new Promise((resolve) => setTimeout(resolve, 500));
335
+ const counts = wa.storeCounts();
336
+ const key = `${counts.chats}/${counts.contacts}/${counts.messages}`;
337
+ spin.update(`Syncing your chats… ${counts.chats} chats, ${counts.contacts} contacts, ${counts.messages} messages`);
338
+ if (!wa.hasHistory())
339
+ continue;
340
+ if (key !== seen) {
341
+ seen = key;
342
+ quietSince = Date.now();
343
+ }
344
+ else if (Date.now() - quietSince >= HISTORY_QUIET_MS) {
345
+ break;
346
+ }
347
+ }
348
+ }
349
+ finally {
350
+ const counts = wa.storeCounts();
351
+ const got = wa.hasHistory();
352
+ await wa.stop();
353
+ spin.stop(got
354
+ ? ok(`Synced ${counts.chats} chats, ${counts.contacts} contacts, ${counts.messages} messages`)
355
+ : warn("No history arrived in 90s. The server keeps listening; if chats stay empty, run `wazap logout` then `wazap login`."));
356
+ }
357
+ }
358
+ /**
359
+ * The spinner shown while WhatsApp is asked to accept the code, counting the
360
+ * pairing code down to the deadline it expires on.
361
+ */
362
+ class Countdown {
363
+ deadline;
364
+ #spinner = null;
365
+ #ticker = null;
366
+ #stopped = false;
367
+ constructor(deadline) {
368
+ this.deadline = deadline;
369
+ }
370
+ /** No-op once stopped: the socket can settle while the code request is still in flight. */
371
+ start() {
372
+ if (this.#stopped)
373
+ return;
374
+ this.#spinner = spinner(this.#line());
375
+ this.#ticker = setInterval(() => this.#spinner?.update(this.#line()), 1_000);
376
+ this.#ticker.unref();
377
+ }
378
+ stop(final) {
379
+ this.#stopped = true;
380
+ if (this.#ticker !== null)
381
+ clearInterval(this.#ticker);
382
+ this.#ticker = null;
383
+ if (this.#spinner === null) {
384
+ if (final !== undefined)
385
+ say(final);
386
+ return;
387
+ }
388
+ this.#spinner.stop(final);
389
+ this.#spinner = null;
390
+ }
391
+ #line() {
392
+ const left = Math.max(0, Math.round((this.deadline - Date.now()) / 1_000));
393
+ return `Waiting for your phone… (expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")})`;
394
+ }
228
395
  }
229
396
  export async function runLogout(config) {
230
397
  const p = paths(config.dataDir);
231
398
  const running = lockHolder(p.lockFile);
232
399
  if (running !== null) {
233
- say(`wazap is running (pid ${running}). Stop it first, then run \`wazap logout\`.`);
400
+ say(fail(`wazap is running (pid ${running}). Stop it first.`));
234
401
  process.exit(1);
235
402
  }
236
403
  let linked = null;
@@ -243,7 +410,7 @@ export async function runLogout(config) {
243
410
  unreadable = true;
244
411
  }
245
412
  if (!linked && !unreadable) {
246
- say("Not linked.");
413
+ say(info("Not linked."));
247
414
  return;
248
415
  }
249
416
  if (linked) {
@@ -253,17 +420,34 @@ export async function runLogout(config) {
253
420
  await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
254
421
  }
255
422
  catch (err) {
256
- logError("unlink from WhatsApp", err);
257
- say("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed.");
423
+ if (alreadyUnlinked(err)) {
424
+ say(info("WhatsApp had already unlinked this device."));
425
+ }
426
+ else {
427
+ logError("unlink from WhatsApp", err);
428
+ say(warn("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed."));
429
+ }
258
430
  }
259
431
  }
260
432
  clearAuth(p.authDir);
261
433
  rmSync(p.storeFile, { force: true });
262
- say("Logged out. Local credentials deleted.");
434
+ say(ok("Logged out. Local credentials deleted."));
263
435
  process.exit(0);
264
436
  }
265
- function describeAccount(account) {
266
- return account.name ? `${account.name} (${account.number})` : account.number;
437
+ /**
438
+ * WhatsApp answers 401 both when a pairing code was wrong and when the phone has
439
+ * already removed this device. At logout the second reading is the true one, so
440
+ * the pairing-time wording must not surface as an error here.
441
+ */
442
+ export function alreadyUnlinked(err) {
443
+ if (err instanceof WazapError)
444
+ return err.code === "SESSION_EXPIRED";
445
+ return err?.output?.statusCode === DisconnectReason.loggedOut;
446
+ }
447
+ /** The number is masked: a status screenshot should not carry it. */
448
+ export function describeAccount(account) {
449
+ const number = maskNumber(account.number);
450
+ return account.name ? `${account.name} (${number})` : number;
267
451
  }
268
452
  /**
269
453
  * Writes stay off unless the user says otherwise, so a fresh link cannot message
@@ -276,10 +460,10 @@ export async function offerWrites(config) {
276
460
  }
277
461
  if (config.assumeYes || !process.stdin.isTTY)
278
462
  return;
279
- const answer = await ask("Allow the agent to send messages, react and manage chats? [y/N] ");
463
+ const answer = await ask(`${brand("?")} Allow the agent to send messages, react and manage chats? [y/N] `);
280
464
  applyWrites(config, /^y(es)?$/i.test(answer.trim()));
281
465
  }
282
- async function ask(question) {
466
+ export async function ask(question) {
283
467
  const rl = createInterface({ input: process.stdin, output: process.stderr });
284
468
  try {
285
469
  return await rl.question(question);
@@ -288,8 +472,20 @@ async function ask(question) {
288
472
  rl.close();
289
473
  }
290
474
  }
291
- function askPhone() {
292
- return ask("Phone number in international format (e.g. +40722123456): ");
475
+ const PHONE_ATTEMPTS = 3;
476
+ /** A typo costs another prompt, not the whole login. */
477
+ async function askPhone() {
478
+ for (let attempt = 1;; attempt++) {
479
+ const answer = await ask(`${brand("?")} WhatsApp number, international format (e.g. +15550100): `);
480
+ try {
481
+ return normalizePhone(answer);
482
+ }
483
+ catch (err) {
484
+ if (attempt === PHONE_ATTEMPTS)
485
+ throw err;
486
+ say(fail("Use international format, e.g. +15550100"));
487
+ }
488
+ }
293
489
  }
294
490
  /**
295
491
  * A socket run until it is open, retrying across the restart WhatsApp demands
package/dist/config.js CHANGED
@@ -23,6 +23,7 @@ export function paths(dataDir) {
23
23
  const COMMAND_ARGS = {
24
24
  serve: [0],
25
25
  login: [0],
26
+ setup: [0],
26
27
  status: [0],
27
28
  logout: [0],
28
29
  connect: [1],
@@ -63,11 +64,14 @@ export function parseCli(argv = process.argv.slice(2)) {
63
64
  port: { type: "string" },
64
65
  phone: { type: "string" },
65
66
  qr: { type: "boolean" },
67
+ code: { type: "boolean" },
66
68
  "dry-run": { type: "boolean" },
67
69
  live: { type: "boolean" },
68
70
  json: { type: "boolean" },
69
71
  writes: { type: "boolean" },
70
72
  "no-writes": { type: "boolean" },
73
+ agent: { type: "boolean" },
74
+ client: { type: "string", multiple: true },
71
75
  yes: { type: "boolean", short: "y" },
72
76
  help: { type: "boolean", short: "h" },
73
77
  version: { type: "boolean", short: "v" },
@@ -130,8 +134,10 @@ export function parseCli(argv = process.argv.slice(2)) {
130
134
  live: values.live === true,
131
135
  json: values.json === true,
132
136
  loginPhone: values.phone,
133
- loginQr: values.qr === true,
137
+ loginCode: values.code === true || values.phone !== undefined,
134
138
  writesAnswer: values.writes === true ? true : values["no-writes"] === true ? false : null,
139
+ agent: values.agent === true,
140
+ clients: values.client ?? [],
135
141
  assumeYes: values.yes === true,
136
142
  },
137
143
  };
package/dist/connect.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { delimiter, dirname, join, resolve } from "node:path";
4
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
5
5
  import { defaultDataDir } from "./config.js";
6
6
  import { WazapError } from "./errors.js";
7
7
  import { say } from "./logger.js";
8
+ import { dim, fail, fix, info, next, nextHint, ok, shortPath } from "./ui.js";
8
9
  function claudeDesktopFile() {
9
10
  if (process.platform === "darwin") {
10
11
  return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
@@ -22,6 +23,7 @@ export const CLIENTS = [
22
23
  format: "command",
23
24
  keyPath: [],
24
25
  next: "Run `claude mcp list` to confirm.",
26
+ detect: (probe) => probe.onPath("claude"),
25
27
  },
26
28
  {
27
29
  name: "claude-desktop",
@@ -30,6 +32,7 @@ export const CLIENTS = [
30
32
  format: "json",
31
33
  keyPath: ["mcpServers", "whatsapp"],
32
34
  next: "Restart Claude Desktop.",
35
+ detect: (probe) => probe.exists(dirname(claudeDesktopFile())),
33
36
  },
34
37
  {
35
38
  name: "cursor",
@@ -38,6 +41,7 @@ export const CLIENTS = [
38
41
  format: "json",
39
42
  keyPath: ["mcpServers", "whatsapp"],
40
43
  next: "Reload the Cursor window.",
44
+ detect: (probe) => probe.exists(join(homedir(), ".cursor")),
41
45
  },
42
46
  {
43
47
  name: "codex",
@@ -46,6 +50,7 @@ export const CLIENTS = [
46
50
  format: "toml",
47
51
  keyPath: ["mcp_servers", "whatsapp"],
48
52
  next: "Restart Codex.",
53
+ detect: (probe) => probe.exists(join(homedir(), ".codex")),
49
54
  },
50
55
  {
51
56
  name: "vscode",
@@ -55,6 +60,7 @@ export const CLIENTS = [
55
60
  keyPath: ["servers", "whatsapp"],
56
61
  extra: { type: "stdio" },
57
62
  next: "Written to ./.vscode/mcp.json for this workspace. Reload the VS Code window.",
63
+ detect: (probe) => probe.onPath("code"),
58
64
  },
59
65
  {
60
66
  name: "gemini",
@@ -63,21 +69,30 @@ export const CLIENTS = [
63
69
  format: "json",
64
70
  keyPath: ["mcpServers", "whatsapp"],
65
71
  next: "Restart the Gemini CLI.",
72
+ detect: (probe) => probe.exists(join(homedir(), ".gemini")),
66
73
  },
67
74
  ];
68
75
  export const CLIENT_NAMES = CLIENTS.map((client) => client.name).join(", ");
69
- export const CONNECT_HINT = `Next: wazap connect claude-code (or ${CLIENTS.slice(1)
76
+ const OTHER_CLIENTS = CLIENTS.slice(1)
70
77
  .map((client) => client.name)
71
- .join(", ")})`;
78
+ .join(", ");
79
+ export function connectNext() {
80
+ return next("wazap connect claude-code", `(or ${OTHER_CLIENTS})`);
81
+ }
72
82
  export function isNpxPath(binPath) {
73
83
  return /[\\/]_npx[\\/]/.test(binPath);
74
84
  }
75
- const PATH_NAMES = process.platform === "win32" ? ["wazap.cmd", "wazap"] : ["wazap"];
76
- export function onPath(pathEnv, exists = existsSync) {
85
+ const PATH_EXTENSIONS = process.platform === "win32" ? [".cmd", ".exe", ""] : [""];
86
+ export function commandOnPath(name, pathEnv = process.env.PATH ?? "", exists = existsSync) {
77
87
  return pathEnv
78
88
  .split(delimiter)
79
89
  .filter(Boolean)
80
- .some((dir) => PATH_NAMES.some((name) => exists(join(dir, name))));
90
+ .some((dir) => PATH_EXTENSIONS.some((ext) => exists(join(dir, `${name}${ext}`))));
91
+ }
92
+ export const REAL_PROBES = { exists: existsSync, onPath: (command) => commandOnPath(command) };
93
+ /** The installed clients, in table order. */
94
+ export function detectClients(probe = REAL_PROBES) {
95
+ return CLIENTS.filter((client) => client.detect(probe));
81
96
  }
82
97
  /**
83
98
  * How the client should launch wazap, from how it was launched now: through
@@ -88,7 +103,7 @@ export function onPath(pathEnv, exists = existsSync) {
88
103
  export function launcher(binPath, pathEnv, exists) {
89
104
  if (isNpxPath(binPath))
90
105
  return { command: "npx", args: ["-y", "wazap-mcp"] };
91
- if (onPath(pathEnv, exists))
106
+ if (commandOnPath("wazap", pathEnv, exists))
92
107
  return { command: "wazap", args: [] };
93
108
  return { command: "node", args: [resolve(binPath)] };
94
109
  }
@@ -105,14 +120,21 @@ const WRITERS = {
105
120
  json: writeJsonEntry,
106
121
  toml: writeTomlEntry,
107
122
  };
108
- export function runConnect(config) {
109
- const name = config.args[0] ?? "";
123
+ export function findClient(name) {
110
124
  const spec = CLIENTS.find((client) => client.name === name);
111
125
  if (!spec) {
112
126
  throw new WazapError("INVALID_ID", `Unknown client "${name}".`, `Pick one of: ${CLIENT_NAMES}`);
113
127
  }
128
+ return spec;
129
+ }
130
+ export function connectClient(spec, config) {
114
131
  WRITERS[spec.format](spec, mcpEntry(config), config.dryRun);
115
132
  }
133
+ export function runConnect(config) {
134
+ const spec = findClient(config.args[0] ?? "");
135
+ connectClient(spec, config);
136
+ say(nextHint(spec.next));
137
+ }
116
138
  /** Null only when the file is absent; an unreadable file must never be overwritten. */
117
139
  function readTextOrNull(file) {
118
140
  try {
@@ -153,7 +175,9 @@ function writeJsonEntry(spec, entry, dryRun) {
153
175
  }
154
176
  const value = { ...spec.extra, command: entry.command, args: entry.args };
155
177
  setIn(doc, spec.keyPath, value);
156
- apply(spec, file, `${JSON.stringify(doc, null, 2)}\n`, current, dryRun, JSON.stringify(value, null, 2));
178
+ // Indent 1 collapsed to one line: short enough to read, still spaced like JSON.
179
+ const shown = JSON.stringify(value, null, 1).replace(/\n\s*/g, " ");
180
+ apply(spec, file, `${JSON.stringify(doc, null, 2)}\n`, current, dryRun, shown);
157
181
  }
158
182
  function writeTomlEntry(spec, entry, dryRun) {
159
183
  const file = spec.file();
@@ -180,43 +204,45 @@ function spliceTomlTable(text, header, block) {
180
204
  const merged = [...lines.slice(0, start), ...block.trimEnd().split("\n"), ...lines.slice(end)].join("\n");
181
205
  return merged.endsWith("\n") ? merged : `${merged}\n`;
182
206
  }
183
- function apply(spec, file, next, current, dryRun, shown) {
207
+ function apply(spec, file, content, current, dryRun, shown) {
208
+ const where = shortPath(file);
209
+ const entry = shown.split("\n").map((line) => ` ${dim(line)}`);
184
210
  if (dryRun) {
185
- say(`${spec.describe}: would write ${file}`);
186
- say(shown);
187
- say(`Next: ${spec.next}`);
211
+ say(info(`${spec.describe} \u00b7 would write ${where}`));
212
+ for (const line of entry)
213
+ say(line);
188
214
  return;
189
215
  }
190
- if (next === current) {
191
- say(`${spec.describe}: ${file} already has this entry.`);
192
- say(`Next: ${spec.next}`);
216
+ if (content === current) {
217
+ say(ok(`${spec.describe} \u00b7 ${where} already has this entry`));
193
218
  return;
194
219
  }
195
220
  mkdirSync(dirname(file), { recursive: true });
196
- if (current !== null && !existsSync(`${file}.bak`))
221
+ let backup = "";
222
+ if (current !== null && !existsSync(`${file}.bak`)) {
197
223
  copyFileSync(file, `${file}.bak`);
198
- writeFileSync(file, next);
199
- say(`${spec.describe}: wrote ${file}`);
200
- say(shown);
201
- say(`Next: ${spec.next}`);
224
+ backup = ` (backup: ${basename(file)}.bak)`;
225
+ }
226
+ writeFileSync(file, content);
227
+ say(ok(`${spec.describe} \u00b7 wrote ${where}${backup}`));
228
+ for (const line of entry)
229
+ say(line);
202
230
  }
203
231
  function runClientCommand(spec, entry, dryRun) {
204
232
  const argv = ["mcp", "add", "whatsapp", "--", entry.command, ...entry.args];
205
233
  const shown = `claude ${argv.join(" ")}`;
206
234
  if (dryRun) {
207
- say(`${spec.describe}: would run`);
208
- say(shown);
209
- say(`Next: ${spec.next}`);
235
+ say(info(`${spec.describe} \u00b7 would run`));
236
+ say(` ${dim(shown)}`);
210
237
  return;
211
238
  }
212
239
  const result = spawnSync("claude", argv, { stdio: "inherit" });
213
240
  if (result.error !== undefined) {
214
- say("`claude` is not on PATH. Run this yourself where Claude Code is installed:");
215
- say(shown);
241
+ say(fail("`claude` is not on PATH."));
242
+ say(fix(`Run this where Claude Code is installed: ${shown}`));
216
243
  process.exit(1);
217
244
  }
218
245
  if (result.status !== 0)
219
246
  process.exit(result.status ?? 1);
220
- say(`${spec.describe}: registered the whatsapp MCP server.`);
221
- say(`Next: ${spec.next}`);
247
+ say(ok(`${spec.describe} \u00b7 registered via claude mcp add`));
222
248
  }
package/dist/doctor.js CHANGED
@@ -2,7 +2,10 @@ import { accessSync, constants, statSync } from "node:fs";
2
2
  import { readLinkedAccount } from "./auth-state.js";
3
3
  import { WAZAP_VERSION, paths } from "./config.js";
4
4
  import { lockHolder, lockPid } from "./lock.js";
5
+ import { dim, fail, fix, green, info, ok, red } from "./ui.js";
5
6
  export const MARK = { ok: "✓", fail: "✗", info: "–" };
7
+ const GLYPH = { ok, fail, info };
8
+ const TINT = { ok: green, fail: red, info: dim };
6
9
  const UPDATE_TIMEOUT_MS = 2_000;
7
10
  const MIN_NODE_MAJOR = 20;
8
11
  const CHECKS = [checkNode, checkDataDir, checkLock, checkCredentials, checkWrites, checkUpdate];
@@ -12,8 +15,19 @@ export async function runChecks(config) {
12
15
  checks.push(await check(config));
13
16
  return checks;
14
17
  }
18
+ /**
19
+ * One line, everything on it. What pipes, logs and captured output get. Colour
20
+ * wraps the whole line rather than just the glyph: an escape landing between
21
+ * the mark and the name would split phrases that callers grep for.
22
+ */
15
23
  export function checkLine(check) {
16
- return `${MARK[check.state]} ${check.name}: ${check.detail}${check.fix ? ` — ${check.fix}` : ""}`;
24
+ const body = `${MARK[check.state]} ${check.name}: ${check.detail}${check.fix ? ` — ${check.fix}` : ""}`;
25
+ return TINT[check.state](body);
26
+ }
27
+ /** The same check for a human: no colon, and the repair on its own line. */
28
+ export function checkLines(check) {
29
+ const head = GLYPH[check.state](`${check.name} ${check.detail}`);
30
+ return check.fix === undefined ? [head] : [head, fix(check.fix)];
17
31
  }
18
32
  function checkNode() {
19
33
  const version = process.versions.node;
@@ -66,9 +80,10 @@ function checkCredentials(config) {
66
80
  const authDir = paths(config.dataDir).authDir;
67
81
  try {
68
82
  const account = readLinkedAccount(authDir);
83
+ // The number is deliberately absent: status is the thing people screenshot.
69
84
  return account === null
70
85
  ? { name: "credentials", state: "info", detail: "no account linked yet" }
71
- : { name: "credentials", state: "ok", detail: `readable (${account.number})` };
86
+ : { name: "credentials", state: "ok", detail: "readable" };
72
87
  }
73
88
  catch (err) {
74
89
  const wazap = err;
package/dist/errors.js CHANGED
@@ -17,7 +17,7 @@ export const ERROR_GUIDE = {
17
17
  SESSION_CORRUPT: "Stored credentials are unreadable. Tell the user to run `npx wazap-mcp logout` then `npx wazap-mcp login`.",
18
18
  NOT_CONNECTED: "The socket is still connecting or reconnecting. Call get_status, wait, retry once.",
19
19
  SYNC_IN_PROGRESS: "History sync has not finished. Retry in a few seconds; earlier messages may be missing until then.",
20
- INVALID_PHONE: "The number is not in international format. Ask the user for a number with a country code, e.g. +40722123456.",
20
+ INVALID_PHONE: "The number is not in international format. Ask the user for a number with a country code, e.g. +15550100.",
21
21
  INVALID_ID: "The id is not a WhatsApp chat, contact or group id. Use an id exactly as returned by another tool.",
22
22
  NOT_ON_WHATSAPP: "That number has no WhatsApp account. Do not retry; confirm the number with the user.",
23
23
  CHAT_NOT_FOUND: "No such chat is known. Call list_chats or search_contacts to get a valid chat_id.",
package/dist/ids.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { WazapError } from "./errors.js";
2
- const PHONE_EXAMPLE = "Use international format, e.g. +40722123456";
2
+ const PHONE_EXAMPLE = "Use international format, e.g. +15550100";
3
3
  /** Digits of a phone number in international format, or INVALID_PHONE. */
4
4
  export function normalizePhone(input) {
5
5
  const digits = input.trim().replace(/^\+/, "").replace(/[\s\-().]/g, "");
package/dist/index.js CHANGED
@@ -3,14 +3,17 @@ import { BANNER } from "./banner.js";
3
3
  import { runGreet, runLogin, runLogout, runServe, runStatus } from "./cli.js";
4
4
  import { WAZAP_VERSION, parseCli, pickDefaultAction } from "./config.js";
5
5
  import { CLIENT_NAMES, runConnect } from "./connect.js";
6
+ import { runSetup } from "./setup.js";
6
7
  import { runConfig } from "./settings.js";
7
8
  import { WazapError } from "./errors.js";
8
9
  import { say } from "./logger.js";
10
+ import { fail, fix } from "./ui.js";
9
11
  const USAGE = `${BANNER}
10
12
 
11
13
  Usage:
12
14
  wazap [serve] [--http] [--host <host>] [--port <port>] Run the MCP server (default: stdio)
13
- wazap login [--phone +40722123456] [--qr] Link a WhatsApp account
15
+ wazap login [--phone +15550100] [--code] Link a WhatsApp account (QR by default)
16
+ wazap setup [--agent] [--client <name>] Link, connect your client and finish, in one command
14
17
  wazap connect <client> [--dry-run] Register wazap with an MCP client
15
18
  wazap config [writes on|off] Show the effective settings, or allow/refuse writes
16
19
  wazap status [--live] [--json] Check the install, the session and the server
@@ -24,8 +27,10 @@ Options:
24
27
  --http Serve Streamable HTTP instead of stdio
25
28
  --host <host> HTTP bind address (default 127.0.0.1)
26
29
  --port <port> HTTP port (default 8766)
27
- --phone <number> Phone number in international format, for login
28
- --qr Log in by QR code instead of a pairing code
30
+ --code Log in with an 8-character pairing code instead of the QR
31
+ --phone <number> Your number in international format; implies --code
32
+ --agent With setup: print the procedure for an AI agent on stdout, then exit
33
+ --client <name> With setup: connect this client instead of the detected ones (repeatable)
29
34
  --dry-run With connect: print what would be written, and write nothing
30
35
  --live With status: reach WhatsApp for real, then close the connection
31
36
  --json With status: print the whole report as one JSON object on stdout
@@ -61,6 +66,9 @@ async function main() {
61
66
  case "login":
62
67
  await runLogin(config);
63
68
  return;
69
+ case "setup":
70
+ await runSetup(config);
71
+ return;
64
72
  case "connect":
65
73
  runConnect(config);
66
74
  return;
@@ -76,13 +84,8 @@ async function main() {
76
84
  }
77
85
  }
78
86
  main().catch((err) => {
79
- if (err instanceof WazapError) {
80
- say(err.message);
81
- if (err.fix)
82
- say(err.fix);
83
- }
84
- else {
85
- say(err instanceof Error ? err.message : String(err));
86
- }
87
+ say(fail(err instanceof Error ? err.message : String(err)));
88
+ if (err instanceof WazapError && err.fix)
89
+ say(fix(err.fix));
87
90
  process.exit(1);
88
91
  });
package/dist/settings.js CHANGED
@@ -4,6 +4,7 @@ import { paths } from "./config.js";
4
4
  import { WazapError } from "./errors.js";
5
5
  import { lockHolder } from "./lock.js";
6
6
  import { say } from "./logger.js";
7
+ import { dim, ok, shortPath, warn } from "./ui.js";
7
8
  /** Replace `KEY=` in place, keeping every other line, or append it. */
8
9
  export function setEnvSetting(envFile, key, value) {
9
10
  let text = "";
@@ -51,7 +52,7 @@ export function runConfig(config) {
51
52
  for (const row of SETTINGS)
52
53
  say(`${row.label}: ${row.value(config)} (${config.sources[row.source]})`);
53
54
  say("");
54
- say("Change writes with `wazap config writes on|off`.");
55
+ say(dim("Change writes with `wazap config writes on|off`."));
55
56
  return;
56
57
  }
57
58
  const [setting, value] = config.args;
@@ -64,11 +65,11 @@ export function runConfig(config) {
64
65
  export function applyWrites(config, allowWrites) {
65
66
  const p = paths(config.dataDir);
66
67
  setEnvSetting(p.envFile, "WAZAP_READ_ONLY", allowWrites ? "0" : "1");
67
- say(allowWrites
68
- ? `writes: on — the agent can send messages, react and manage chats. Turn it off with \`wazap config writes off\`.`
69
- : `writes: off — the agent can only read. Turn it on with \`wazap config writes on\`.`);
70
- say(`Stored in ${p.envFile}.`);
68
+ say(ok(allowWrites
69
+ ? "writes: on — the agent can send messages, react and manage chats. Turn it off with `wazap config writes off`."
70
+ : "writes: off — the agent can only read. Turn it on with `wazap config writes on`."));
71
+ say(dim(`Stored in ${shortPath(p.envFile)}.`));
71
72
  const running = lockHolder(p.lockFile);
72
73
  if (running !== null)
73
- say(`A server is running (pid ${running}); restart it for this to apply.`);
74
+ say(warn(`A server is running (pid ${running}); restart it for this to apply.`));
74
75
  }
package/dist/setup.js ADDED
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readLinkedAccount } from "./auth-state.js";
3
+ import { banner } from "./banner.js";
4
+ import { ask, describeAccount, linkAndSync, stepper } from "./cli.js";
5
+ import { paths } from "./config.js";
6
+ import { CLIENTS, connectClient, connectNext, detectClients, findClient } from "./connect.js";
7
+ import { say } from "./logger.js";
8
+ import { brand, fail, info, ok } from "./ui.js";
9
+ export async function runSetup(config) {
10
+ // The whole output is the document and this command never serves, so stdout is
11
+ // the right channel. AGENT.md sits at the package root, which is what `files`
12
+ // publishes.
13
+ if (config.agent) {
14
+ process.stdout.write(readFileSync(new URL("../AGENT.md", import.meta.url), "utf8"));
15
+ return;
16
+ }
17
+ say(banner());
18
+ const announce = stepper(3);
19
+ announce("Link");
20
+ const account = readLinkedAccount(paths(config.dataDir).authDir);
21
+ if (account)
22
+ say(ok(`Already linked as ${describeAccount(account)}`));
23
+ else
24
+ await linkAndSync(config);
25
+ announce("Connect");
26
+ const chosen = await chooseClients(config);
27
+ if (chosen.length === 0) {
28
+ say(info("No client connected yet."));
29
+ say(connectNext());
30
+ }
31
+ for (const spec of chosen)
32
+ connectClient(spec, config);
33
+ announce("Finish");
34
+ say(ok("Setup complete"));
35
+ for (const spec of chosen)
36
+ say(info(spec.next));
37
+ say("");
38
+ say('Ask your agent: "what did I miss on WhatsApp today?"');
39
+ }
40
+ const CHOICE_ATTEMPTS = 3;
41
+ /**
42
+ * The whole table is printed, not only what was found: that is what makes the
43
+ * numbers and "all" mean something, and it lets someone pick a client the
44
+ * probes missed.
45
+ */
46
+ async function chooseClients(config) {
47
+ if (config.clients.length > 0)
48
+ return config.clients.map(findClient);
49
+ const detected = detectClients();
50
+ if (config.assumeYes || process.stdin.isTTY !== true)
51
+ return detected;
52
+ CLIENTS.forEach((spec, index) => {
53
+ say(` ${index + 1}. [${detected.includes(spec) ? "x" : " "}] ${spec.describe}`);
54
+ });
55
+ const suggested = detected.map((spec) => CLIENTS.indexOf(spec) + 1).join(",");
56
+ for (let attempt = 1;; attempt++) {
57
+ const answer = await ask(`${brand("?")} Connect to: [${suggested}] (enter to accept, or type numbers, "all", "none") `);
58
+ const picked = parseChoice(answer, detected);
59
+ if (picked !== null)
60
+ return picked;
61
+ if (attempt === CHOICE_ATTEMPTS)
62
+ return detected;
63
+ say(fail('Type numbers from the list, "all", or "none".'));
64
+ }
65
+ }
66
+ /** Null when the answer names something that is not on the list. */
67
+ export function parseChoice(answer, detected) {
68
+ const text = answer.trim();
69
+ if (text === "")
70
+ return [...detected];
71
+ if (/^all$/i.test(text))
72
+ return [...CLIENTS];
73
+ if (/^none$/i.test(text))
74
+ return [];
75
+ const picked = new Set();
76
+ for (const token of text.split(/[\s,]+/)) {
77
+ const n = Number(token);
78
+ if (!Number.isInteger(n) || n < 1 || n > CLIENTS.length)
79
+ return null;
80
+ picked.add(CLIENTS[n - 1]);
81
+ }
82
+ return [...picked];
83
+ }
package/dist/tools.js CHANGED
@@ -32,7 +32,7 @@ contacts and groups. Call get_status first if anything looks wrong.
32
32
 
33
33
  ## Identifiers
34
34
  - chat_id — individual: \`<digits>@s.whatsapp.net\`; group: \`<id>@g.us\`. A phone
35
- number in international format (+40722123456 or 40722123456) also works. Pass
35
+ number in international format (+15550100 or 15550100) also works. Pass
36
36
  ids back exactly as a tool returned them.
37
37
  - message_id — the full id from read_messages / search_messages. Needed for
38
38
  get_message, download_media, react_to_message, edit_message, forward_message,
package/dist/ui.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Every ANSI escape wazap emits lives here. Helpers return plain text when
3
+ * colour is off, so piped and captured output keeps the same visible words.
4
+ */
5
+ import { homedir } from "node:os";
6
+ import { sep } from "node:path";
7
+ const RESET = "\x1b[0m";
8
+ const CLEAR_LINE = "\r\x1b[2K";
9
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
+ const FRAME_MS = 80;
11
+ /** stderr carries every human-readable line, so stderr decides colour. */
12
+ export function colorEnabled() {
13
+ const forced = process.env.FORCE_COLOR;
14
+ if (forced !== undefined)
15
+ return forced !== "0";
16
+ const disabled = process.env.NO_COLOR;
17
+ if (disabled !== undefined && disabled !== "")
18
+ return false;
19
+ return process.stderr.isTTY === true;
20
+ }
21
+ /**
22
+ * Whether to draw for a person rather than for a pipe. Deliberately not
23
+ * colorEnabled(): NO_COLOR at a terminal still wants the roomy layout, and
24
+ * FORCE_COLOR in a pipe must not reshape output something else is parsing.
25
+ */
26
+ export function humanLayout() {
27
+ return process.stderr.isTTY === true;
28
+ }
29
+ function paint(code, text) {
30
+ return colorEnabled() ? `${code}${text}${RESET}` : text;
31
+ }
32
+ function brandCode() {
33
+ const rich = (process.env.TERM ?? "").includes("256color") || /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
34
+ return rich ? "\x1b[38;5;42m" : "\x1b[32m";
35
+ }
36
+ export function brand(text) {
37
+ return paint(brandCode(), text);
38
+ }
39
+ export function dim(text) {
40
+ return paint("\x1b[2m", text);
41
+ }
42
+ export function bold(text) {
43
+ return paint("\x1b[1m", text);
44
+ }
45
+ export function red(text) {
46
+ return paint("\x1b[31m", text);
47
+ }
48
+ export function yellow(text) {
49
+ return paint("\x1b[33m", text);
50
+ }
51
+ /** Anything the user is meant to type. */
52
+ export function cmd(text) {
53
+ return paint("\x1b[36m", text);
54
+ }
55
+ export function green(text) {
56
+ return paint("\x1b[32m", text);
57
+ }
58
+ export function ok(text) {
59
+ return `${green("✓")} ${text}`;
60
+ }
61
+ export function fail(text) {
62
+ return `${red("✗")} ${text}`;
63
+ }
64
+ export function info(text) {
65
+ return `${dim("–")} ${text}`;
66
+ }
67
+ export function warn(text) {
68
+ return `${yellow("!")} ${text}`;
69
+ }
70
+ /** A repair the user can run, under the line that reported the problem. */
71
+ export function fix(text) {
72
+ return ` ${yellow(`→ ${text}`)}`;
73
+ }
74
+ export function step(n, total, title) {
75
+ return `\nStep ${n} of ${total} · ${bold(title)}`;
76
+ }
77
+ function charWidth(code) {
78
+ if (code >= 0x0300 && code <= 0x036f)
79
+ return 0;
80
+ const wide = (code >= 0x1100 && code <= 0x115f) ||
81
+ (code >= 0x2e80 && code <= 0xa4cf) ||
82
+ (code >= 0xac00 && code <= 0xd7a3) ||
83
+ (code >= 0xf900 && code <= 0xfaff) ||
84
+ (code >= 0xfe30 && code <= 0xfe6f) ||
85
+ (code >= 0xff00 && code <= 0xff60) ||
86
+ (code >= 0xffe0 && code <= 0xffe6) ||
87
+ (code >= 0x1f300 && code <= 0x1f64f) ||
88
+ (code >= 0x1f900 && code <= 0x1f9ff);
89
+ return wide ? 2 : 1;
90
+ }
91
+ export function width(text) {
92
+ let total = 0;
93
+ for (const char of text)
94
+ total += charWidth(char.codePointAt(0));
95
+ return total;
96
+ }
97
+ export function box(text) {
98
+ const rule = "─".repeat(width(text) + 2);
99
+ return [` ╭${rule}╮`, ` │ ${text} │`, ` ╰${rule}╯`].join("\n");
100
+ }
101
+ function nextLine(action, tail) {
102
+ return humanLayout() ? `Next ${action}${tail}` : `Next: ${action}${tail}`;
103
+ }
104
+ /** A command to type. */
105
+ export function next(command, note) {
106
+ return nextLine(cmd(command), note === undefined ? "" : ` ${dim(note)}`);
107
+ }
108
+ /** An instruction to follow. Not cyan: cyan means "type this". */
109
+ export function nextHint(instruction) {
110
+ return nextLine(bold(instruction), "");
111
+ }
112
+ export function tilde(path) {
113
+ const home = homedir();
114
+ if (home === "")
115
+ return path;
116
+ if (path === home)
117
+ return "~";
118
+ return path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
119
+ }
120
+ /** Home-relative for a human; pipes and tests get the path they can act on. */
121
+ export function shortPath(path) {
122
+ return humanLayout() ? tilde(path) : path;
123
+ }
124
+ /** All but the first three digits, so a screenshot cannot leak the number. */
125
+ export function maskNumber(digits) {
126
+ const kept = digits.slice(0, 3);
127
+ const body = kept + "x".repeat(Math.max(0, digits.length - kept.length));
128
+ const groups = (body.slice(2).match(/.{1,3}/g) ?? []).join(" ");
129
+ return `+${body.slice(0, 2)}${groups === "" ? "" : ` ${groups}`}`;
130
+ }
131
+ /** A wrapped line would outlive the erase, which clears one physical line. */
132
+ function clamp(text) {
133
+ const room = (process.stderr.columns ?? 80) - 2;
134
+ if (width(text) <= room)
135
+ return text;
136
+ let out = "";
137
+ let used = 0;
138
+ for (const char of text) {
139
+ const size = charWidth(char.codePointAt(0));
140
+ if (used + size > room - 1)
141
+ break;
142
+ out += char;
143
+ used += size;
144
+ }
145
+ return `${out}…`;
146
+ }
147
+ /**
148
+ * Animated only at a terminal: a `\r` rewrite is noise in a pipe, so there the
149
+ * text is printed once and the final line replaces nothing.
150
+ */
151
+ export function spinner(text) {
152
+ if (process.stderr.isTTY !== true) {
153
+ process.stderr.write(`${text}\n`);
154
+ return {
155
+ update: () => { },
156
+ stop: (final) => {
157
+ if (final !== undefined)
158
+ process.stderr.write(`${final}\n`);
159
+ },
160
+ };
161
+ }
162
+ let current = text;
163
+ let frame = 0;
164
+ const render = () => {
165
+ process.stderr.write(`${CLEAR_LINE}${brand(FRAMES[frame % FRAMES.length])} ${clamp(current)}`);
166
+ };
167
+ render();
168
+ const timer = setInterval(() => {
169
+ frame++;
170
+ render();
171
+ }, FRAME_MS);
172
+ timer.unref();
173
+ return {
174
+ update: (next) => {
175
+ current = next;
176
+ render();
177
+ },
178
+ stop: (final) => {
179
+ clearInterval(timer);
180
+ process.stderr.write(CLEAR_LINE);
181
+ if (final !== undefined)
182
+ process.stderr.write(`${final}\n`);
183
+ },
184
+ };
185
+ }
package/dist/whatsapp.js CHANGED
@@ -165,6 +165,7 @@ export class WhatsAppService {
165
165
  account = null;
166
166
  lastInboundAt = null;
167
167
  initialSyncDone = false;
168
+ historyReceived = false;
168
169
  syncDeadline = null;
169
170
  syncWaiters = [];
170
171
  historyWaiters = [];
@@ -231,6 +232,13 @@ export class WhatsAppService {
231
232
  await this.flushStore();
232
233
  this.teardownSocket();
233
234
  }
235
+ /** True once WhatsApp has delivered at least one history-sync batch. */
236
+ hasHistory() {
237
+ return this.historyReceived;
238
+ }
239
+ storeCounts() {
240
+ return { chats: this.store.chats.size, contacts: this.store.contacts.size, messages: this.store.messages.size };
241
+ }
234
242
  getStatus() {
235
243
  const info = {
236
244
  status: this.status,
@@ -255,7 +263,7 @@ export class WhatsAppService {
255
263
  return this.guarded(async () => {
256
264
  this.ensureConnected();
257
265
  await this.waitForSync();
258
- const chats = [...this.store.chats.values()]
266
+ const chats = this.knownChats()
259
267
  .filter((chat) => this.matchesChatFilter(chat, filter))
260
268
  .sort((a, b) => this.chatActivity(b) - this.chatActivity(a))
261
269
  .slice(0, limit)
@@ -727,6 +735,7 @@ export class WhatsAppService {
727
735
  this.ingestChat(chat);
728
736
  const stored = this.ingestMessages(messages ?? []);
729
737
  void this.appendHistory(stored);
738
+ this.historyReceived = true;
730
739
  this.releaseHistoryWaiters();
731
740
  if (isLatest === true || progress === 100)
732
741
  this.markSyncDone();
@@ -1136,8 +1145,24 @@ export class WhatsAppService {
1136
1145
  const last = ring && ring.length > 0 ? this.store.messages.get(ring[ring.length - 1]) : undefined;
1137
1146
  return last ?? null;
1138
1147
  }
1148
+ /**
1149
+ * Every chat WhatsApp described, plus one for any chat that only ever
1150
+ * arrived as messages: a chat with messages must never be invisible.
1151
+ */
1152
+ knownChats() {
1153
+ const chats = [...this.store.chats.values()];
1154
+ for (const jid of this.store.byChat.keys()) {
1155
+ if (!this.store.chats.has(jid))
1156
+ chats.push({ id: jid });
1157
+ }
1158
+ return chats;
1159
+ }
1139
1160
  chatActivity(chat) {
1140
- return protoNumber(chat.conversationTimestamp) ?? 0;
1161
+ const described = protoNumber(chat.conversationTimestamp);
1162
+ if (described !== undefined && described !== null)
1163
+ return described;
1164
+ const last = this.lastMessageOf(this.canonical(chat.id ?? ""));
1165
+ return last ? Math.floor(messageTimestampMs(last) / 1000) : 0;
1141
1166
  }
1142
1167
  matchesChatFilter(chat, filter) {
1143
1168
  const archived = Boolean(chat.archived);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 22 tools, stdio or token-protected HTTP.",
5
5
  "license": "MIT",
6
6
  "author": "Răzvan Girgiz",
@@ -27,6 +27,7 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "README.md",
30
+ "AGENT.md",
30
31
  "LICENSE"
31
32
  ],
32
33
  "scripts": {