@profullstack/qryptchat 0.11.0 → 0.13.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,12 +49,14 @@ 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
55
56
  echo "deploy done" | qc send ops -
56
57
  qc listen --json # new messages as NDJSON, until you stop it
57
58
  qc whoami
59
+ qc keys # are this terminal's keys your account's? (qc keys publish puts them back)
58
60
  ```
59
61
 
60
62
  `<chat>` is a chat id or any unique part of its name. Add `--json` for machine output.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  openBrowser2,
3
3
  login2
4
- } from "./chunk-ywyhy3sp.js";
4
+ } from "./chunk-94qf0rg1.js";
5
5
  export {
6
6
  login2 as login,
7
7
  openBrowser2 as openBrowser
@@ -736,6 +736,13 @@ class QcClient {
736
736
  return "unknown";
737
737
  }
738
738
  }
739
+ async publishKey() {
740
+ const mine = this.session.keys?.keys1024?.publicKey;
741
+ if (!mine)
742
+ throw new QcError("This terminal holds no keys. Run qc login.");
743
+ await this.request("/api/crypto/public-keys", { method: "PUT", body: { public_key: mine, key_type: "ML-KEM-1024" } });
744
+ return this.keyCheck();
745
+ }
739
746
  async profile() {
740
747
  const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? "")}`);
741
748
  return user;
@@ -788,6 +795,24 @@ class QcClient {
788
795
  }));
789
796
  return { messages: out, hasMore };
790
797
  }
798
+ async searchUsers(query) {
799
+ const q = String(query ?? "").trim().replace(/^@/, "");
800
+ if (!q)
801
+ return [];
802
+ const { users = [] } = await this.request(`/api/users/search?q=${encodeURIComponent(q)}`);
803
+ return users;
804
+ }
805
+ async startDirect(who) {
806
+ const user = pickUser(await this.searchUsers(who), who);
807
+ const { conversation_id: id } = await this.request("/api/chat/conversations", {
808
+ method: "POST",
809
+ body: { type: "direct", participant_ids: [user.id] }
810
+ });
811
+ if (!id)
812
+ throw new QcError(`Could not start a chat with @${user.username}`);
813
+ const chat = (await this.conversations()).find((c) => c.id === id);
814
+ return chat ?? { id, title: user.display_name || user.username, participants: [] };
815
+ }
791
816
  async joinGroup(code) {
792
817
  return this.request("/api/conversations/join", { method: "POST", body: { code } });
793
818
  }
@@ -915,6 +940,18 @@ function sleep(ms, signal) {
915
940
  });
916
941
  });
917
942
  }
943
+ function pickUser(users, query) {
944
+ const q = String(query ?? "").trim().replace(/^@/, "").toLowerCase();
945
+ const exact = users.filter((u) => u.username?.toLowerCase() === q || u.unique_identifier?.toLowerCase() === q);
946
+ if (exact.length === 1)
947
+ return exact[0];
948
+ if (users.length === 1)
949
+ return users[0];
950
+ if (!users.length)
951
+ throw new QcError(`Nobody on qrypt.chat matches "${query}"`, 404);
952
+ const names = users.slice(0, 8).map((u) => `@${u.username}${u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ""}`);
953
+ throw new QcError(`"${query}" matches ${users.length} people: ${names.join(", ")}${users.length > 8 ? ", …" : ""}. Use their @username.`, 409);
954
+ }
918
955
  function conversationTitle(c, me) {
919
956
  if (c.name)
920
957
  return c.name;
@@ -1262,6 +1299,8 @@ Usage:
1262
1299
  qc logout forget this terminal's session and keys
1263
1300
  qc update [--check] update qc to the latest release (--check only says whether there is one)
1264
1301
  qc whoami who this terminal is signed in as
1302
+ qc keys [publish] whether this terminal's keys are your account's; publish makes them the
1303
+ account's again (when another device published keys qc does not hold)
1265
1304
  qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
1266
1305
  show or set your public profile (an empty value clears it);
1267
1306
  --openprofile prints it as OpenProfile.md
@@ -1276,10 +1315,12 @@ Usage:
1276
1315
  qc read <chat> [-n 20] print the last messages of a chat
1277
1316
  qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
1278
1317
  an @partial that matches one person becomes their @username
1318
+ qc dm <@user> [text] start (or reopen) a direct chat with anyone on qrypt.chat and send
1319
+ text if given ("-" reads stdin); qc msg is the same. In the client: /msg @user
1279
1320
  qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
1280
1321
  qc react <chat> <message-id> <emoji> [--remove] react to a message
1281
1322
  qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
1282
- qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
1323
+ qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, start_direct_chat, list_mentions)
1283
1324
  qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
1284
1325
  qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
1285
1326
 
@@ -1379,7 +1420,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1379
1420
  }
1380
1421
  case "agent": {
1381
1422
  if (rest[0] === "call") {
1382
- const { runVoiceAgent } = await import("./chunk-wkjs9anq.js");
1423
+ const { runVoiceAgent } = await import("./chunk-qcszcng8.js");
1383
1424
  return runVoiceAgent(await client(flags), {
1384
1425
  brain: typeof flags.brain === "string" ? flags.brain : undefined,
1385
1426
  voice: typeof flags.voice === "string" ? flags.voice : undefined,
@@ -1442,6 +1483,35 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
1442
1483
  }
1443
1484
  return;
1444
1485
  }
1486
+ case "keys": {
1487
+ const c = await client(flags);
1488
+ const status = await c.keyCheck();
1489
+ if (rest[0] !== "publish") {
1490
+ if (flags.json)
1491
+ return json({ keys: status });
1492
+ if (status === "ok")
1493
+ return out(ok("This terminal's keys are your account's keys."));
1494
+ if (status === "mismatch")
1495
+ return out(warn("Your account publishes a different key, so messages sent to you since then won't decrypt here. If older messages read fine in qc read, run qc keys publish."));
1496
+ return out(info("Could not compare keys (no key on file, or the check failed)."));
1497
+ }
1498
+ if (status === "ok")
1499
+ return out(ok("Already published: nothing to do."));
1500
+ if (!flags.yes) {
1501
+ if (!process.stdin.isTTY)
1502
+ throw new Error("qc keys publish replaces the key everyone encrypts to. Add --yes to confirm.");
1503
+ const { createInterface } = await import("node:readline/promises");
1504
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1505
+ const answer = await rl.question("Publish this terminal's key? Devices holding the other key will stop decrypting new messages until they restore your keys. [y/N] ");
1506
+ rl.close();
1507
+ if (!/^y(es)?$/i.test(answer.trim()))
1508
+ return out(info("Left the published key alone."));
1509
+ }
1510
+ const after = await c.publishKey();
1511
+ if (flags.json)
1512
+ return json({ published: true, keys: after });
1513
+ return out(ok("Published. New messages are encrypted to this terminal's key again; other devices on the other key should restore from Settings > Keys."));
1514
+ }
1445
1515
  case "logout":
1446
1516
  clearSession();
1447
1517
  return out(ok("Signed out. This terminal no longer holds your keys."));
@@ -1514,7 +1584,7 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1514
1584
  throw new Error('Usage: qc call <number> (e.g. qc call "(415) 555-1234" or qc call +442079460958)');
1515
1585
  const c = await client(flags);
1516
1586
  const url = `${c.base}/phone?to=${encodeURIComponent(rest.join(" "))}`;
1517
- const { openBrowser } = await import("./chunk-nsn86c8y.js");
1587
+ const { openBrowser } = await import("./chunk-8frhjf08.js");
1518
1588
  const opened = openBrowser(url);
1519
1589
  return out(`${mark("send")}${opened === false ? "Open" : "Opened"} ${url}
1520
1590
  Calls to phones need your browser for the audio; tap Call there.`);
@@ -1615,6 +1685,20 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1615
1685
  out(warn(`@${a.query} could be ${a.matches.join(", ")}: sent as typed.`));
1616
1686
  return out(`${mark("send")}Sent to ${chat.title}.`);
1617
1687
  }
1688
+ case "dm":
1689
+ case "msg": {
1690
+ if (!rest[0])
1691
+ throw new Error(`Usage: qc ${cmd} <@user> [text] (a username, or a name only one person has)`);
1692
+ const text = rest[1] === "-" && rest.length === 2 ? (await readStdin()).trim() : rest.slice(1).join(" ");
1693
+ const c = await client(flags);
1694
+ const chat = await c.startDirect(rest[0]);
1695
+ const sent = text ? await c.send(chat.id, text) : null;
1696
+ if (flags.json)
1697
+ return json({ conversation: chat.id, title: chat.title, ...sent ? { id: sent.message?.id, skipped: sent.skipped } : {} });
1698
+ if (sent?.skipped)
1699
+ out(warn(`${chat.title} has no key yet and will not see it until they sign in.`));
1700
+ return out(`${mark("send")}${sent ? `Sent to ${chat.title}` : `Chat with ${chat.title} is open (qc send "${chat.title}" <text>)`}.`);
1701
+ }
1618
1702
  case "listen": {
1619
1703
  const c = await client(flags);
1620
1704
  const chats = await c.conversations();
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CALL_TYPE,
3
3
  parseCall
4
- } from "./chunk-ywyhy3sp.js";
4
+ } from "./chunk-94qf0rg1.js";
5
5
 
6
6
  // src/cli/voice-agent.js
7
7
  import { spawn } from "node:child_process";
package/dist/commands.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  mentionMatches2,
8
8
  withMentions2,
9
9
  main2
10
- } from "./chunk-ywyhy3sp.js";
10
+ } from "./chunk-94qf0rg1.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-ywyhy3sp.js";
6
+ } from "./chunk-94qf0rg1.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
  ],
@@ -262,7 +268,7 @@ function saveRecent(list) {
262
268
  writeFileSync(recentFile(), JSON.stringify(list), { mode: 384 });
263
269
  } catch {}
264
270
  }
265
- var KEY_MISMATCH = "Keys don't match your account, so messages can't decrypt. Run qc login and approve from a browser that can read your chats.";
271
+ var KEY_MISMATCH = "Keys don't match your account, so new messages can't decrypt. If your history reads here, run qc keys publish; else qc login from a browser that reads your chats.";
266
272
  async function runTui(client, { initialChat } = {}) {
267
273
  const state = initialState(client.me);
268
274
  state.recent = loadRecent();
@@ -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.11.0",
3
+ "version": "0.13.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": {