@profullstack/qryptchat 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,7 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread
49
49
  ```sh
50
50
  qc chats # id and name of every chat
51
51
  qc read alice -n 20 # the last 20 messages, decrypted
52
+ qc dm @alice "hey, it's me" # start (or reopen) a direct chat with anyone, by @username
52
53
  qc send alice "on my way" # encrypted to every participant
53
54
  qc send ops "@ri deploy is done" # an @partial matching one person is sent as @riotcoder
54
55
  qc mentions ops @al # who @al completes to: @alice, @alina
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  openBrowser2,
3
3
  login2
4
- } from "./chunk-hmyhm26d.js";
4
+ } from "./chunk-jfete02h.js";
5
5
  export {
6
6
  login2 as login,
7
7
  openBrowser2 as openBrowser
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CALL_TYPE,
3
3
  parseCall
4
- } from "./chunk-hmyhm26d.js";
4
+ } from "./chunk-jfete02h.js";
5
5
 
6
6
  // src/cli/voice-agent.js
7
7
  import { spawn } from "node:child_process";
@@ -16,6 +16,13 @@ import {
16
16
  expandMentions
17
17
  } from "./chunk-4ea1xy2c.js";
18
18
 
19
+ // src/cli/login.js
20
+ import { createHash, randomBytes } from "node:crypto";
21
+ import { createServer } from "node:http";
22
+ import { hostname } from "node:os";
23
+ import { spawn } from "node:child_process";
24
+ import { createInterface } from "node:readline/promises";
25
+
19
26
  // src/cli/crypto.js
20
27
  import { MlKem1024 as MlKem10242 } from "mlkem";
21
28
 
@@ -222,6 +229,17 @@ class PostQuantumEncryptionService {
222
229
  console.error("\uD83D\uDD10 Failed to load ML-KEM-768 keys:", error);
223
230
  }
224
231
  }
232
+ async storedUserKeys(alg) {
233
+ const is768 = alg === this.kemName768;
234
+ if (is768 ? !this.userKeys768 : !this.userKeys) {
235
+ await (is768 ? this.loadUserKeys768() : this.loadUserKeys());
236
+ }
237
+ const keys = is768 ? this.userKeys768 : this.userKeys;
238
+ if (!keys?.privateKey) {
239
+ throw new Error("No encryption keys on this device. Restore them in Settings > Keys (backup PIN).");
240
+ }
241
+ return keys;
242
+ }
225
243
  async getPublicKey() {
226
244
  const keys = await this.getUserKeys();
227
245
  return keys.publicKey;
@@ -346,7 +364,7 @@ class PostQuantumEncryptionService {
346
364
  return "[Legacy encrypted message - please delete]";
347
365
  }
348
366
  const open = async (alg) => {
349
- const keys = alg === this.kemName768 ? await this.getUserKeys768() : await this.getUserKeys();
367
+ const keys = await this.storedUserKeys(alg);
350
368
  const privateKeyBytes = this.stripKeyHeaderIfPresent(Base64.decode(keys.privateKey));
351
369
  const expected = alg === this.kemName768 ? 2400 : 3168;
352
370
  if (privateKeyBytes.length !== expected) {
@@ -553,6 +571,188 @@ async function openKeyBlob(blob, ephemeral) {
553
571
  return { keys1024: keys.keys1024, keys768: keys.keys768 };
554
572
  }
555
573
 
574
+ // src/lib/auth/cli-match.js
575
+ var ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
576
+ async function matchCode(challenge, kemPublicKey) {
577
+ const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`qc-login
578
+ ${challenge}
579
+ ${kemPublicKey}`)));
580
+ let out = "";
581
+ for (let i = 0;i < 8; i++)
582
+ out += ALPHABET[bytes[i] % ALPHABET.length];
583
+ return `${out.slice(0, 4)}-${out.slice(4)}`;
584
+ }
585
+
586
+ // src/cli/login.js
587
+ var b64url = (buf) => Buffer.from(buf).toString("base64url");
588
+ var TIMEOUT_MS = 5 * 60 * 1000;
589
+ var PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
590
+ <style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;background:#0b0d12;color:#e6e8ee}main{max-width:28rem;text-align:center}</style>
591
+ <main><h1>${title}</h1><p>${body}</p></main>`;
592
+ function isRemote({ platform = process.platform, env = process.env } = {}) {
593
+ if (env.QC_NO_BROWSER)
594
+ return true;
595
+ if (platform === "darwin" || platform === "win32")
596
+ return false;
597
+ return !env.DISPLAY && !env.WAYLAND_DISPLAY;
598
+ }
599
+ function parsePasted(input, state) {
600
+ const text = String(input || "").trim();
601
+ if (!text)
602
+ return null;
603
+ if (/^https?:\/\//i.test(text)) {
604
+ let url;
605
+ try {
606
+ url = new URL(text);
607
+ } catch {
608
+ return null;
609
+ }
610
+ const got = url.searchParams.get("state");
611
+ if (got && got !== state)
612
+ throw new Error("That link belongs to a different login. Run qc login again.");
613
+ return url.searchParams.get("code");
614
+ }
615
+ return /^[A-Za-z0-9_-]{20,}$/.test(text) ? text : null;
616
+ }
617
+ function openBrowser2(url, { platform = process.platform, env = process.env } = {}) {
618
+ if (isRemote({ platform, env }))
619
+ return false;
620
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
621
+ try {
622
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
623
+ child.on("error", () => {});
624
+ child.unref();
625
+ return true;
626
+ } catch {
627
+ return false;
628
+ }
629
+ }
630
+ function waitForCallback(server, state) {
631
+ return new Promise((resolve, reject) => {
632
+ const timer = setTimeout(() => reject(new Error("Timed out waiting for the browser (5 minutes). Run qc login again.")), TIMEOUT_MS);
633
+ server.on("request", (req, res) => {
634
+ const url = new URL(req.url, "http://127.0.0.1");
635
+ if (url.pathname !== "/callback") {
636
+ res.writeHead(404).end();
637
+ return;
638
+ }
639
+ const done = (status, title, body) => {
640
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }).end(PAGE(title, body));
641
+ };
642
+ if (url.searchParams.get("state") !== state) {
643
+ done(400, "That was not this login", "The state did not match. Go back to your terminal.");
644
+ return;
645
+ }
646
+ clearTimeout(timer);
647
+ const error = url.searchParams.get("error");
648
+ if (error) {
649
+ done(200, "Not approved", "Nothing was shared. You can close this tab.");
650
+ reject(new Error(error === "access_denied" ? "Login was denied in the browser." : `Login failed: ${error}`));
651
+ return;
652
+ }
653
+ done(200, "Signed in", "You can close this tab and go back to your terminal.");
654
+ resolve(url.searchParams.get("code"));
655
+ });
656
+ });
657
+ }
658
+ async function login2(options = {}) {
659
+ const env = options.env ?? process.env;
660
+ const print = options.print ?? ((line) => process.stderr.write(`${line}
661
+ `));
662
+ const base = (options.base ?? baseUrl(null, env)).replace(/\/+$/, "");
663
+ const verifier = b64url(randomBytes(32));
664
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
665
+ const state = b64url(randomBytes(16));
666
+ const ephemeral = await ephemeralKeypair();
667
+ let server;
668
+ let redirectUri = "oob";
669
+ const oob = options.oob || isRemote({ env });
670
+ if (!oob) {
671
+ server = createServer();
672
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
673
+ redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
674
+ }
675
+ const url = new URL("/cli/authorize", base);
676
+ for (const [k, v] of Object.entries({
677
+ response_type: "code",
678
+ code_challenge: challenge,
679
+ code_challenge_method: "S256",
680
+ redirect_uri: redirectUri,
681
+ state,
682
+ client_name: `qc on ${hostname()}`,
683
+ kem: ephemeral.publicKey
684
+ })) {
685
+ url.searchParams.set(k, v);
686
+ }
687
+ const match = await matchCode(challenge, ephemeral.publicKey);
688
+ print("");
689
+ print(` Confirmation code: ${match}`);
690
+ print(" Check that the browser shows the same code before you approve.");
691
+ print("");
692
+ const opened = !oob && openBrowser2(url.toString(), { env });
693
+ print(opened ? " Opened your browser. If it did not, open:" : " Open this link in a browser where you are signed in to qrypt.chat:");
694
+ print(` ${url.toString()}`);
695
+ print("");
696
+ let code;
697
+ let rl;
698
+ try {
699
+ if (server) {
700
+ const waits = [waitForCallback(server, state)];
701
+ if (process.stdin.isTTY) {
702
+ rl = createInterface({ input: process.stdin, output: process.stderr });
703
+ waits.push((async () => {
704
+ for (;; ) {
705
+ let line;
706
+ try {
707
+ line = await rl.question(" Waiting for the browser… or paste the URL it ended up on: ");
708
+ } catch {
709
+ return new Promise(() => {});
710
+ }
711
+ const pasted = parsePasted(line, state);
712
+ if (pasted)
713
+ return pasted;
714
+ print(" That is not a qc login code or callback URL.");
715
+ }
716
+ })());
717
+ }
718
+ code = await Promise.race(waits);
719
+ } else {
720
+ rl = createInterface({ input: process.stdin, output: process.stderr });
721
+ for (;; ) {
722
+ code = parsePasted(await rl.question(" Paste the code (or the URL) from the browser: "), state);
723
+ if (code)
724
+ break;
725
+ print(" That is not a qc login code.");
726
+ }
727
+ }
728
+ } finally {
729
+ rl?.close();
730
+ server?.close();
731
+ }
732
+ if (!code)
733
+ throw new Error("No code came back from the browser.");
734
+ const res = await fetch(`${base}/api/cli/token`, {
735
+ method: "POST",
736
+ headers: { "Content-Type": "application/json" },
737
+ body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: redirectUri })
738
+ });
739
+ const token = await res.json().catch(() => ({}));
740
+ if (!res.ok)
741
+ throw new Error(token.error_description || token.error || `Sign-in failed (HTTP ${res.status})`);
742
+ const keys = await openKeyBlob(token.key_blob, ephemeral);
743
+ const session = {
744
+ base,
745
+ access_token: token.access_token,
746
+ refresh_token: token.refresh_token,
747
+ expires_at: token.expires_at,
748
+ user: token.user,
749
+ keys,
750
+ created_at: new Date().toISOString()
751
+ };
752
+ const save = await storeSession(session, env, options.vault);
753
+ return { session, save };
754
+ }
755
+
556
756
  // src/lib/chat/reactions.js
557
757
  var REACTION_TYPE = "reaction";
558
758
  var MAX_EMOJI_LENGTH = 32;
@@ -795,6 +995,24 @@ class QcClient {
795
995
  }));
796
996
  return { messages: out, hasMore };
797
997
  }
998
+ async searchUsers(query) {
999
+ const q = String(query ?? "").trim().replace(/^@/, "");
1000
+ if (!q)
1001
+ return [];
1002
+ const { users = [] } = await this.request(`/api/users/search?q=${encodeURIComponent(q)}`);
1003
+ return users;
1004
+ }
1005
+ async startDirect(who) {
1006
+ const user = pickUser(await this.searchUsers(who), who);
1007
+ const { conversation_id: id } = await this.request("/api/chat/conversations", {
1008
+ method: "POST",
1009
+ body: { type: "direct", participant_ids: [user.id] }
1010
+ });
1011
+ if (!id)
1012
+ throw new QcError(`Could not start a chat with @${user.username}`);
1013
+ const chat = (await this.conversations()).find((c) => c.id === id);
1014
+ return chat ?? { id, title: user.display_name || user.username, participants: [] };
1015
+ }
798
1016
  async joinGroup(code) {
799
1017
  return this.request("/api/conversations/join", { method: "POST", body: { code } });
800
1018
  }
@@ -922,6 +1140,18 @@ function sleep(ms, signal) {
922
1140
  });
923
1141
  });
924
1142
  }
1143
+ function pickUser(users, query) {
1144
+ const q = String(query ?? "").trim().replace(/^@/, "").toLowerCase();
1145
+ const exact = users.filter((u) => u.username?.toLowerCase() === q || u.unique_identifier?.toLowerCase() === q);
1146
+ if (exact.length === 1)
1147
+ return exact[0];
1148
+ if (users.length === 1)
1149
+ return users[0];
1150
+ if (!users.length)
1151
+ throw new QcError(`Nobody on qrypt.chat matches "${query}"`, 404);
1152
+ const names = users.slice(0, 8).map((u) => `@${u.username}${u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ""}`);
1153
+ throw new QcError(`"${query}" matches ${users.length} people: ${names.join(", ")}${users.length > 8 ? ", …" : ""}. Use their @username.`, 409);
1154
+ }
925
1155
  function conversationTitle(c, me) {
926
1156
  if (c.name)
927
1157
  return c.name;
@@ -929,195 +1159,6 @@ function conversationTitle(c, me) {
929
1159
  return others.length ? others.join(", ") : "Just you";
930
1160
  }
931
1161
 
932
- // src/cli/login.js
933
- import { createHash, randomBytes } from "node:crypto";
934
- import { createServer } from "node:http";
935
- import { hostname } from "node:os";
936
- import { spawn } from "node:child_process";
937
- import { createInterface } from "node:readline/promises";
938
-
939
- // src/lib/auth/cli-match.js
940
- var ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
941
- async function matchCode(challenge, kemPublicKey) {
942
- const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(`qc-login
943
- ${challenge}
944
- ${kemPublicKey}`)));
945
- let out = "";
946
- for (let i = 0;i < 8; i++)
947
- out += ALPHABET[bytes[i] % ALPHABET.length];
948
- return `${out.slice(0, 4)}-${out.slice(4)}`;
949
- }
950
-
951
- // src/cli/login.js
952
- var b64url = (buf) => Buffer.from(buf).toString("base64url");
953
- var TIMEOUT_MS = 5 * 60 * 1000;
954
- var PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
955
- <style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;background:#0b0d12;color:#e6e8ee}main{max-width:28rem;text-align:center}</style>
956
- <main><h1>${title}</h1><p>${body}</p></main>`;
957
- function isRemote({ platform = process.platform, env = process.env } = {}) {
958
- if (env.QC_NO_BROWSER)
959
- return true;
960
- if (platform === "darwin" || platform === "win32")
961
- return false;
962
- return !env.DISPLAY && !env.WAYLAND_DISPLAY;
963
- }
964
- function parsePasted(input, state) {
965
- const text = String(input || "").trim();
966
- if (!text)
967
- return null;
968
- if (/^https?:\/\//i.test(text)) {
969
- let url;
970
- try {
971
- url = new URL(text);
972
- } catch {
973
- return null;
974
- }
975
- const got = url.searchParams.get("state");
976
- if (got && got !== state)
977
- throw new Error("That link belongs to a different login. Run qc login again.");
978
- return url.searchParams.get("code");
979
- }
980
- return /^[A-Za-z0-9_-]{20,}$/.test(text) ? text : null;
981
- }
982
- function openBrowser2(url, { platform = process.platform, env = process.env } = {}) {
983
- if (isRemote({ platform, env }))
984
- return false;
985
- const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
986
- try {
987
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
988
- child.on("error", () => {});
989
- child.unref();
990
- return true;
991
- } catch {
992
- return false;
993
- }
994
- }
995
- function waitForCallback(server, state) {
996
- return new Promise((resolve, reject) => {
997
- const timer = setTimeout(() => reject(new Error("Timed out waiting for the browser (5 minutes). Run qc login again.")), TIMEOUT_MS);
998
- server.on("request", (req, res) => {
999
- const url = new URL(req.url, "http://127.0.0.1");
1000
- if (url.pathname !== "/callback") {
1001
- res.writeHead(404).end();
1002
- return;
1003
- }
1004
- const done = (status, title, body) => {
1005
- res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }).end(PAGE(title, body));
1006
- };
1007
- if (url.searchParams.get("state") !== state) {
1008
- done(400, "That was not this login", "The state did not match. Go back to your terminal.");
1009
- return;
1010
- }
1011
- clearTimeout(timer);
1012
- const error = url.searchParams.get("error");
1013
- if (error) {
1014
- done(200, "Not approved", "Nothing was shared. You can close this tab.");
1015
- reject(new Error(error === "access_denied" ? "Login was denied in the browser." : `Login failed: ${error}`));
1016
- return;
1017
- }
1018
- done(200, "Signed in", "You can close this tab and go back to your terminal.");
1019
- resolve(url.searchParams.get("code"));
1020
- });
1021
- });
1022
- }
1023
- async function login2(options = {}) {
1024
- const env = options.env ?? process.env;
1025
- const print = options.print ?? ((line) => process.stderr.write(`${line}
1026
- `));
1027
- const base = (options.base ?? baseUrl(null, env)).replace(/\/+$/, "");
1028
- const verifier = b64url(randomBytes(32));
1029
- const challenge = b64url(createHash("sha256").update(verifier).digest());
1030
- const state = b64url(randomBytes(16));
1031
- const ephemeral = await ephemeralKeypair();
1032
- let server;
1033
- let redirectUri = "oob";
1034
- const oob = options.oob || isRemote({ env });
1035
- if (!oob) {
1036
- server = createServer();
1037
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1038
- redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
1039
- }
1040
- const url = new URL("/cli/authorize", base);
1041
- for (const [k, v] of Object.entries({
1042
- response_type: "code",
1043
- code_challenge: challenge,
1044
- code_challenge_method: "S256",
1045
- redirect_uri: redirectUri,
1046
- state,
1047
- client_name: `qc on ${hostname()}`,
1048
- kem: ephemeral.publicKey
1049
- })) {
1050
- url.searchParams.set(k, v);
1051
- }
1052
- const match = await matchCode(challenge, ephemeral.publicKey);
1053
- print("");
1054
- print(` Confirmation code: ${match}`);
1055
- print(" Check that the browser shows the same code before you approve.");
1056
- print("");
1057
- const opened = !oob && openBrowser2(url.toString(), { env });
1058
- print(opened ? " Opened your browser. If it did not, open:" : " Open this link in a browser where you are signed in to qrypt.chat:");
1059
- print(` ${url.toString()}`);
1060
- print("");
1061
- let code;
1062
- let rl;
1063
- try {
1064
- if (server) {
1065
- const waits = [waitForCallback(server, state)];
1066
- if (process.stdin.isTTY) {
1067
- rl = createInterface({ input: process.stdin, output: process.stderr });
1068
- waits.push((async () => {
1069
- for (;; ) {
1070
- let line;
1071
- try {
1072
- line = await rl.question(" Waiting for the browser… or paste the URL it ended up on: ");
1073
- } catch {
1074
- return new Promise(() => {});
1075
- }
1076
- const pasted = parsePasted(line, state);
1077
- if (pasted)
1078
- return pasted;
1079
- print(" That is not a qc login code or callback URL.");
1080
- }
1081
- })());
1082
- }
1083
- code = await Promise.race(waits);
1084
- } else {
1085
- rl = createInterface({ input: process.stdin, output: process.stderr });
1086
- for (;; ) {
1087
- code = parsePasted(await rl.question(" Paste the code (or the URL) from the browser: "), state);
1088
- if (code)
1089
- break;
1090
- print(" That is not a qc login code.");
1091
- }
1092
- }
1093
- } finally {
1094
- rl?.close();
1095
- server?.close();
1096
- }
1097
- if (!code)
1098
- throw new Error("No code came back from the browser.");
1099
- const res = await fetch(`${base}/api/cli/token`, {
1100
- method: "POST",
1101
- headers: { "Content-Type": "application/json" },
1102
- body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: verifier, redirect_uri: redirectUri })
1103
- });
1104
- const token = await res.json().catch(() => ({}));
1105
- if (!res.ok)
1106
- throw new Error(token.error_description || token.error || `Sign-in failed (HTTP ${res.status})`);
1107
- const keys = await openKeyBlob(token.key_blob, ephemeral);
1108
- const session = {
1109
- base,
1110
- access_token: token.access_token,
1111
- refresh_token: token.refresh_token,
1112
- expires_at: token.expires_at,
1113
- user: token.user,
1114
- keys,
1115
- created_at: new Date().toISOString()
1116
- };
1117
- const save = await storeSession(session, env, options.vault);
1118
- return { session, save };
1119
- }
1120
-
1121
1162
  // src/cli/agent.js
1122
1163
  import { randomBytes as randomBytes2 } from "node:crypto";
1123
1164
  function parseInvite(input) {
@@ -1271,7 +1312,7 @@ Usage:
1271
1312
  qc whoami who this terminal is signed in as
1272
1313
  qc keys [publish] whether this terminal's keys are your account's; publish makes them the
1273
1314
  account's again (when another device published keys qc does not hold)
1274
- qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
1315
+ qc profile [--name "Ada L"] [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
1275
1316
  show or set your public profile (an empty value clears it);
1276
1317
  --openprofile prints it as OpenProfile.md
1277
1318
  qc chats [--archived] list your chats (archived ones only with --archived); qc list is the same
@@ -1285,10 +1326,12 @@ Usage:
1285
1326
  qc read <chat> [-n 20] print the last messages of a chat
1286
1327
  qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
1287
1328
  an @partial that matches one person becomes their @username
1329
+ qc dm <@user> [text] start (or reopen) a direct chat with anyone on qrypt.chat and send
1330
+ text if given ("-" reads stdin); qc msg is the same. In the client: /msg @user
1288
1331
  qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
1289
1332
  qc react <chat> <message-id> <emoji> [--remove] react to a message
1290
1333
  qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
1291
- qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
1334
+ qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, start_direct_chat, list_mentions)
1292
1335
  qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
1293
1336
  qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
1294
1337
 
@@ -1388,7 +1431,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1388
1431
  }
1389
1432
  case "agent": {
1390
1433
  if (rest[0] === "call") {
1391
- const { runVoiceAgent } = await import("./chunk-ycvwj8wy.js");
1434
+ const { runVoiceAgent } = await import("./chunk-amknn876.js");
1392
1435
  return runVoiceAgent(await client(flags), {
1393
1436
  brain: typeof flags.brain === "string" ? flags.brain : undefined,
1394
1437
  voice: typeof flags.voice === "string" ? flags.voice : undefined,
@@ -1497,9 +1540,11 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
1497
1540
  for (const k of ["emoji", "pronouns", "website", "bio"])
1498
1541
  if (flags[k] !== undefined)
1499
1542
  fields[k] = flags[k] === true ? "" : String(flags[k]);
1543
+ if (flags.name !== undefined && flags.name !== true)
1544
+ fields.displayName = String(flags.name);
1500
1545
  const p = Object.keys(fields).length ? await c.updateProfile(fields) : await c.profile();
1501
1546
  if (flags.json)
1502
- return json({ username: p.username, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null });
1547
+ return json({ username: p.username, displayName: p.displayName ?? p.display_name ?? null, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null });
1503
1548
  out(who(p));
1504
1549
  if (p.website)
1505
1550
  out(`${mark("link")}${p.website}`);
@@ -1507,7 +1552,7 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
1507
1552
  out(p.bio);
1508
1553
  if (!Object.keys(fields).length)
1509
1554
  out(`
1510
- ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)")}`);
1555
+ ${info('Set: qc profile --name "Ada L" --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`);
1511
1556
  return;
1512
1557
  }
1513
1558
  case "chats":
@@ -1552,7 +1597,7 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1552
1597
  throw new Error('Usage: qc call <number> (e.g. qc call "(415) 555-1234" or qc call +442079460958)');
1553
1598
  const c = await client(flags);
1554
1599
  const url = `${c.base}/phone?to=${encodeURIComponent(rest.join(" "))}`;
1555
- const { openBrowser } = await import("./chunk-zh85gmtq.js");
1600
+ const { openBrowser } = await import("./chunk-4mx8vtcw.js");
1556
1601
  const opened = openBrowser(url);
1557
1602
  return out(`${mark("send")}${opened === false ? "Open" : "Opened"} ${url}
1558
1603
  Calls to phones need your browser for the audio; tap Call there.`);
@@ -1653,6 +1698,20 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1653
1698
  out(warn(`@${a.query} could be ${a.matches.join(", ")}: sent as typed.`));
1654
1699
  return out(`${mark("send")}Sent to ${chat.title}.`);
1655
1700
  }
1701
+ case "dm":
1702
+ case "msg": {
1703
+ if (!rest[0])
1704
+ throw new Error(`Usage: qc ${cmd} <@user> [text] (a username, or a name only one person has)`);
1705
+ const text = rest[1] === "-" && rest.length === 2 ? (await readStdin()).trim() : rest.slice(1).join(" ");
1706
+ const c = await client(flags);
1707
+ const chat = await c.startDirect(rest[0]);
1708
+ const sent = text ? await c.send(chat.id, text) : null;
1709
+ if (flags.json)
1710
+ return json({ conversation: chat.id, title: chat.title, ...sent ? { id: sent.message?.id, skipped: sent.skipped } : {} });
1711
+ if (sent?.skipped)
1712
+ out(warn(`${chat.title} has no key yet and will not see it until they sign in.`));
1713
+ return out(`${mark("send")}${sent ? `Sent to ${chat.title}` : `Chat with ${chat.title} is open (qc send "${chat.title}" <text>)`}.`);
1714
+ }
1656
1715
  case "listen": {
1657
1716
  const c = await client(flags);
1658
1717
  const chats = await c.conversations();
package/dist/commands.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  mentionMatches2,
8
8
  withMentions2,
9
9
  main2
10
- } from "./chunk-hmyhm26d.js";
10
+ } from "./chunk-jfete02h.js";
11
11
  import"./chunk-4ea1xy2c.js";
12
12
  export {
13
13
  HELP2 as HELP,
package/dist/mcp.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  findChat2,
4
4
  mentionMatches2,
5
5
  withMentions2
6
- } from "./chunk-hmyhm26d.js";
6
+ } from "./chunk-jfete02h.js";
7
7
 
8
8
  // src/cli/mcp.js
9
9
  import { createInterface } from "node:readline";
@@ -40,6 +40,19 @@ var TOOLS = [
40
40
  additionalProperties: false
41
41
  }
42
42
  },
43
+ {
44
+ name: "start_direct_chat",
45
+ description: "Start (or reopen) an end-to-end encrypted direct chat with anyone on qrypt.chat, by @username or a name only one person has, and optionally send the first message. Returns the chat id for read_chat/send_message.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ user: { type: "string", description: "Their @username (or a name that matches only them)" },
50
+ text: { type: "string", description: "Optional: the first message" }
51
+ },
52
+ required: ["user"],
53
+ additionalProperties: false
54
+ }
55
+ },
43
56
  {
44
57
  name: "list_mentions",
45
58
  description: "Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.",
@@ -98,6 +111,13 @@ async function callTool(client, name, args = {}) {
98
111
  const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined });
99
112
  return { sent: true, id: message?.id, chat: chat.title, skipped, ...m.mentions.length ? { mentions: m.mentions } : {}, ...m.ambiguous.length ? { ambiguous: m.ambiguous } : {} };
100
113
  }
114
+ if (name === "start_direct_chat") {
115
+ if (!args.user || typeof args.user !== "string")
116
+ throw new Error("user is required");
117
+ const chat = await client.startDirect(args.user);
118
+ const sent = typeof args.text === "string" && args.text.trim() ? await client.send(chat.id, args.text.trim()) : null;
119
+ return { chat: chat.title, id: chat.id, ...sent ? { sent: true, message_id: sent.message?.id, skipped: sent.skipped } : {} };
120
+ }
101
121
  if (name === "list_mentions") {
102
122
  const chat = findChat2(await client.conversations(), args.chat);
103
123
  const matches = mentionMatches2(chat, client.me, typeof args.partial === "string" ? args.partial : "");
package/dist/tui.js CHANGED
@@ -74,7 +74,12 @@ function chatsWithNews(prev, next) {
74
74
  const seen = new Map(prev.map((c) => [c.id, c.updated_at ?? ""]));
75
75
  return next.filter((c) => !seen.has(c.id) || (c.updated_at ?? "") > seen.get(c.id)).map((c) => c.id);
76
76
  }
77
- var SLASH_COMMANDS = ["voice", "archive", "unarchive", "archived", "names", "list"];
77
+ var SLASH_COMMANDS = ["voice", "archive", "unarchive", "archived", "names", "list", "msg", "dm", "query", "new"];
78
+ var NEW_CHAT_COMMANDS = ["msg", "dm", "query", "new"];
79
+ function parseNewChat(args) {
80
+ const m = /^(\S+)(?:\s+([\s\S]*))?$/.exec(String(args ?? "").trim());
81
+ return m ? { who: m[1], text: (m[2] ?? "").trim() } : null;
82
+ }
78
83
  var pad2 = (n) => String(n).padStart(2, "0");
79
84
  var timeOf = (iso) => {
80
85
  const d = new Date(iso);
@@ -132,7 +137,8 @@ function drawTranscript(surface, state) {
132
137
  const theme = surface.theme;
133
138
  const messages = state.messages[state.activeId] || [];
134
139
  if (!state.activeId) {
135
- surface.text(1, 1, state.loading ? "Loading your chats…" : "Pick a chat on the left.", { fg: theme.muted });
140
+ const hint = state.loading ? "Loading your chats…" : state.conversations.length ? "Pick a chat on the left, or type /msg @name to start one." : "No chats yet. Type /msg @name to message someone.";
141
+ surface.text(1, 1, hint, { fg: theme.muted });
136
142
  return;
137
143
  }
138
144
  if (!messages.length) {
@@ -217,7 +223,7 @@ function render({ ui, width, height, theme }, state, on = {}) {
217
223
  p.textInput({
218
224
  value: state.field.value,
219
225
  cursor: state.field.cursor,
220
- placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : "Pick a chat first",
226
+ placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, /msg @name for a new chat)` : "/msg @name to start a chat",
221
227
  focused: state.focus === "compose" && !state.picker,
222
228
  size: 1
223
229
  });
@@ -229,7 +235,7 @@ function render({ ui, width, height, theme }, state, on = {}) {
229
235
  { key: "Ctrl+E", label: "emoji" },
230
236
  { key: "Tab", label: state.focus === "list" ? "compose" : mention?.matches.length ? "complete @" : "chats" },
231
237
  { key: "PgUp", label: "scroll" },
232
- ...state.focus === "list" ? [{ key: "a", label: state.showArchived ? "unarchive" : "archive" }] : [],
238
+ ...state.focus === "list" ? [{ key: "n", label: "new chat" }, { key: "a", label: state.showArchived ? "unarchive" : "archive" }] : [],
233
239
  { key: "Ctrl+O", label: state.showArchived ? "chats" : "archived" },
234
240
  { key: "Ctrl+C", label: "quit" }
235
241
  ],
@@ -381,13 +387,43 @@ async function runTui(client, { initialChat } = {}) {
381
387
  note(`Could not ${on ? "archive" : "unarchive"}: ${err.message}`);
382
388
  }
383
389
  }
390
+ function openById(id) {
391
+ const chat = state.conversations.find((c) => c.id === id);
392
+ if (!chat)
393
+ return;
394
+ state.showArchived = !!chat.is_archived;
395
+ openAt(listed(state).findIndex((c) => c.id === id));
396
+ state.focus = "compose";
397
+ }
398
+ async function startChat(args) {
399
+ const want = parseNewChat(args);
400
+ if (!want)
401
+ return note("Usage: /msg @name [message] (a username, or a name only one person has)");
402
+ note(`Looking up ${want.who}…`, { sticky: true });
403
+ try {
404
+ const chat = await client.startDirect(want.who);
405
+ if (!state.conversations.some((c) => c.id === chat.id))
406
+ state.conversations = [chat, ...state.conversations];
407
+ openById(chat.id);
408
+ note(`Chat with ${chat.title} is open`);
409
+ if (want.text) {
410
+ state.field = { value: want.text, cursor: want.text.length };
411
+ await send({ text: true });
412
+ }
413
+ } catch (err) {
414
+ note(err.message, { sticky: err.status === 409 });
415
+ }
416
+ redraw();
417
+ }
384
418
  function runSlash(raw) {
385
419
  const cmd = parseSlashCommand(raw, SLASH_COMMANDS);
386
420
  if (!cmd)
387
421
  return false;
388
422
  const active = state.conversations.find((c) => c.id === state.activeId);
389
423
  state.field = { value: "", cursor: 0 };
390
- if (cmd.name === "archived")
424
+ if (NEW_CHAT_COMMANDS.includes(cmd.name))
425
+ startChat(cmd.args);
426
+ else if (cmd.name === "archived")
391
427
  toggleArchivedView();
392
428
  else if (!active)
393
429
  note("Pick a chat first");
@@ -407,8 +443,8 @@ async function runTui(client, { initialChat } = {}) {
407
443
  redraw();
408
444
  return true;
409
445
  }
410
- async function send({ raw = false } = {}) {
411
- if (!raw && runSlash(state.field.value))
446
+ async function send({ raw = false, text: plain = false } = {}) {
447
+ if (!raw && !plain && runSlash(state.field.value))
412
448
  return;
413
449
  const text = raw ? state.field.value.trim() : emojify(state.field.value.trim(), { mode: "emoji" });
414
450
  if (!text || !state.activeId)
@@ -492,7 +528,10 @@ async function runTui(client, { initialChat } = {}) {
492
528
  state.selected = Math.min(listed(state).length - 1, state.selected + 1);
493
529
  else if (event.key === "enter" || event.key === "right" || event.key === "l")
494
530
  handlers.open(state.selected);
495
- else if (event.key === "a") {
531
+ else if (event.key === "n") {
532
+ state.field = { value: "/msg @", cursor: 6 };
533
+ state.focus = "compose";
534
+ } else if (event.key === "a") {
496
535
  const c = listed(state)[state.selected];
497
536
  if (c)
498
537
  setArchived(c, !c.is_archived);
@@ -585,11 +624,13 @@ async function runTui(client, { initialChat } = {}) {
585
624
  }
586
625
  export {
587
626
  CHAT_LIST_REFRESH_MS,
627
+ NEW_CHAT_COMMANDS,
588
628
  SLASH_COMMANDS,
589
629
  chatsWithNews,
590
630
  initialState,
591
631
  listed,
592
632
  mentionSuggestions,
633
+ parseNewChat,
593
634
  render,
594
635
  runTui,
595
636
  tabComplete,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
5
5
  "type": "module",
6
6
  "bin": {