@profullstack/qryptchat 0.12.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,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-94qf0rg1.js";
5
5
  export {
6
6
  login2 as login,
7
7
  openBrowser2 as openBrowser
@@ -795,6 +795,24 @@ class QcClient {
795
795
  }));
796
796
  return { messages: out, hasMore };
797
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
+ }
798
816
  async joinGroup(code) {
799
817
  return this.request("/api/conversations/join", { method: "POST", body: { code } });
800
818
  }
@@ -922,6 +940,18 @@ function sleep(ms, signal) {
922
940
  });
923
941
  });
924
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
+ }
925
955
  function conversationTitle(c, me) {
926
956
  if (c.name)
927
957
  return c.name;
@@ -1285,10 +1315,12 @@ Usage:
1285
1315
  qc read <chat> [-n 20] print the last messages of a chat
1286
1316
  qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
1287
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
1288
1320
  qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
1289
1321
  qc react <chat> <message-id> <emoji> [--remove] react to a message
1290
1322
  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)
1323
+ qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, start_direct_chat, list_mentions)
1292
1324
  qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
1293
1325
  qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
1294
1326
 
@@ -1388,7 +1420,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1388
1420
  }
1389
1421
  case "agent": {
1390
1422
  if (rest[0] === "call") {
1391
- const { runVoiceAgent } = await import("./chunk-ycvwj8wy.js");
1423
+ const { runVoiceAgent } = await import("./chunk-qcszcng8.js");
1392
1424
  return runVoiceAgent(await client(flags), {
1393
1425
  brain: typeof flags.brain === "string" ? flags.brain : undefined,
1394
1426
  voice: typeof flags.voice === "string" ? flags.voice : undefined,
@@ -1552,7 +1584,7 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1552
1584
  throw new Error('Usage: qc call <number> (e.g. qc call "(415) 555-1234" or qc call +442079460958)');
1553
1585
  const c = await client(flags);
1554
1586
  const url = `${c.base}/phone?to=${encodeURIComponent(rest.join(" "))}`;
1555
- const { openBrowser } = await import("./chunk-zh85gmtq.js");
1587
+ const { openBrowser } = await import("./chunk-8frhjf08.js");
1556
1588
  const opened = openBrowser(url);
1557
1589
  return out(`${mark("send")}${opened === false ? "Open" : "Opened"} ${url}
1558
1590
  Calls to phones need your browser for the audio; tap Call there.`);
@@ -1653,6 +1685,20 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
1653
1685
  out(warn(`@${a.query} could be ${a.matches.join(", ")}: sent as typed.`));
1654
1686
  return out(`${mark("send")}Sent to ${chat.title}.`);
1655
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
+ }
1656
1702
  case "listen": {
1657
1703
  const c = await client(flags);
1658
1704
  const chats = await c.conversations();
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CALL_TYPE,
3
3
  parseCall
4
- } from "./chunk-hmyhm26d.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-hmyhm26d.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-hmyhm26d.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
  ],
@@ -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.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": {