@profullstack/qryptchat 0.1.1 → 0.3.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
@@ -73,7 +73,12 @@ claude mcp add qryptchat -- qc mcp
73
73
 
74
74
  Over SSH, `qc login --oob` shows a code in the browser for you to paste instead of redirecting back.
75
75
 
76
- The session and keys are kept in `~/.config/qc/session.json` (or `$QC_HOME`), mode 0600. Sessions refresh themselves; `qc logout` deletes them. Point at another server with `--url` or `QC_URL`.
76
+ Your keys and tokens are **never stored in plaintext**. `~/.config/qc/session.json` (or `$QC_HOME`) keeps only your username and the server URL in the clear. Everything else is sealed with ChaCha20-Poly1305 under a key from:
77
+
78
+ - your OS keychain (macOS Keychain, or libsecret's `secret-tool` on a Linux desktop), so you're never asked; or
79
+ - a passphrase, stretched with scrypt. qc asks for it once per run; scripts and `qc mcp` read it from `QC_PASSPHRASE`.
80
+
81
+ Sessions refresh themselves and are re-sealed each time. `qc logout` deletes them. Point at another server with `--url` or `QC_URL`.
77
82
 
78
83
  ## Licence
79
84
 
@@ -2,9 +2,10 @@ import {
2
2
  sessionPath,
3
3
  baseUrl,
4
4
  loadSession,
5
- saveSession,
5
+ unlockSession,
6
+ storeSession,
6
7
  clearSession
7
- } from "./chunk-fb6zrz6m.js";
8
+ } from "./chunk-vfahhydf.js";
8
9
 
9
10
  // src/cli/crypto.js
10
11
  import { MlKem1024 as MlKem10242 } from "mlkem";
@@ -822,6 +823,81 @@ async function openKeyBlob(blob, ephemeral) {
822
823
  return { keys1024: keys.keys1024, keys768: keys.keys768 };
823
824
  }
824
825
 
826
+ // src/lib/chat/reactions.js
827
+ var REACTION_TYPE = "reaction";
828
+ var MAX_EMOJI_LENGTH = 32;
829
+ function reactionEnvelope(targetId, emoji, remove = false) {
830
+ return JSON.stringify({ v: 1, type: REACTION_TYPE, target: String(targetId), emoji: String(emoji), remove: !!remove });
831
+ }
832
+ function parseReaction(content) {
833
+ try {
834
+ const d = JSON.parse(content);
835
+ if (d?.type !== REACTION_TYPE || typeof d.target !== "string" || typeof d.emoji !== "string")
836
+ return null;
837
+ if (!d.emoji || d.emoji.length > MAX_EMOJI_LENGTH)
838
+ return null;
839
+ return { target: d.target, emoji: d.emoji, remove: !!d.remove };
840
+ } catch {
841
+ return null;
842
+ }
843
+ }
844
+ var senderName = (m) => m?.sender?.display_name || m?.sender?.username || "Someone";
845
+ function snippet(m, max = 90) {
846
+ if (!m)
847
+ return "";
848
+ if (m.message_type === "file" || m.has_attachments)
849
+ return "\uD83D\uDCCE Attachment";
850
+ const text = String(m.content ?? "").replace(/\s+/g, " ").trim();
851
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
852
+ }
853
+ function foldMessages(messages, myUserId) {
854
+ const visible = [];
855
+ const byId = new Map;
856
+ const picks = new Map;
857
+ for (const m of messages) {
858
+ if (m.message_type === REACTION_TYPE) {
859
+ const r = parseReaction(m.content);
860
+ if (!r)
861
+ continue;
862
+ const forTarget = picks.get(r.target) ?? new Map;
863
+ const current = forTarget.get(m.sender_id);
864
+ if (r.remove) {
865
+ if (current && current.emoji === r.emoji)
866
+ forTarget.delete(m.sender_id);
867
+ } else {
868
+ forTarget.set(m.sender_id, { emoji: r.emoji, name: senderName(m), at: m.created_at });
869
+ }
870
+ picks.set(r.target, forTarget);
871
+ continue;
872
+ }
873
+ visible.push(m);
874
+ byId.set(m.id, m);
875
+ }
876
+ return visible.map((m) => {
877
+ const out = { ...m, reactions: [] };
878
+ const forTarget = picks.get(m.id);
879
+ if (forTarget?.size) {
880
+ const groups = new Map;
881
+ for (const [senderId, p] of forTarget) {
882
+ const g = groups.get(p.emoji) ?? { emoji: p.emoji, count: 0, mine: false, names: [], first: p.at };
883
+ g.count += 1;
884
+ g.names.push(senderId === myUserId ? "You" : p.name);
885
+ if (senderId === myUserId)
886
+ g.mine = true;
887
+ if (p.at < g.first)
888
+ g.first = p.at;
889
+ groups.set(p.emoji, g);
890
+ }
891
+ out.reactions = [...groups.values()].sort((a, b) => b.count - a.count || String(a.first).localeCompare(String(b.first)));
892
+ }
893
+ if (m.reply_to_id) {
894
+ const target = byId.get(m.reply_to_id);
895
+ out.replyTo = target ? { id: target.id, name: target.sender_id === myUserId ? "You" : senderName(target), snippet: snippet(target), missing: false } : { id: m.reply_to_id, name: "", snippet: "Original message not loaded", missing: true };
896
+ }
897
+ return out;
898
+ });
899
+ }
900
+
825
901
  // src/cli/api.js
826
902
  class QcError extends Error {
827
903
  constructor(message, status) {
@@ -835,7 +911,7 @@ class QcClient {
835
911
  constructor(session, options = {}) {
836
912
  this.session = session;
837
913
  this.base = baseUrl(session, options.env);
838
- this.save = options.save ?? ((s) => saveSession(s, options.env));
914
+ this.save = options.save ?? (() => {});
839
915
  this.fetch = options.fetch ?? globalThis.fetch;
840
916
  this.ring = keyring(session.keys);
841
917
  this.refreshing = null;
@@ -889,34 +965,37 @@ class QcClient {
889
965
  const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
890
966
  return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
891
967
  }
892
- async messages(conversationId, { limit = 100, before } = {}) {
968
+ async messages(conversationId, { limit = 200, before } = {}) {
893
969
  const { messages = [], hasMore = false } = await this.request("/api/messages/load", {
894
970
  method: "POST",
895
971
  body: { conversationId, limit, ...before ? { before } : {} }
896
972
  });
897
- const out = [];
973
+ const raw = [];
898
974
  for (const m of messages) {
899
- let text;
975
+ let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
900
976
  if (m.message_type === "file" || m.has_attachments) {
901
- text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
902
- text = `\uD83D\uDCCE ${text && text !== "[File attachment]" ? `${text} ` : ""}(attachment: open qrypt.chat to download)`;
903
- } else {
904
- text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
977
+ content = `\uD83D\uDCCE ${content && content !== "[File attachment]" ? `${content} ` : ""}(attachment: open qrypt.chat to download)`;
905
978
  }
906
- out.push({
907
- id: m.id,
908
- conversationId: m.conversation_id,
909
- senderId: m.sender_id,
910
- sender: m.sender?.display_name || m.sender?.username || "unknown",
911
- username: m.sender?.username || "",
912
- mine: m.sender_id === this.me?.id,
913
- text,
914
- at: m.created_at
915
- });
979
+ raw.push({ ...m, content });
916
980
  }
981
+ const out = foldMessages(raw, this.me?.id).map((m) => ({
982
+ id: m.id,
983
+ conversationId: m.conversation_id,
984
+ senderId: m.sender_id,
985
+ sender: m.sender?.display_name || m.sender?.username || "unknown",
986
+ username: m.sender?.username || "",
987
+ mine: m.sender_id === this.me?.id,
988
+ text: m.content,
989
+ at: m.created_at,
990
+ reactions: m.reactions.map(({ emoji, count, mine, names }) => ({ emoji, count, mine, names })),
991
+ ...m.replyTo ? { replyTo: m.replyTo } : {}
992
+ }));
917
993
  return { messages: out, hasMore };
918
994
  }
919
- async send(conversationId, text) {
995
+ async react(conversationId, messageId, emoji, remove = false) {
996
+ return this.send(conversationId, reactionEnvelope(messageId, emoji, remove), { messageType: REACTION_TYPE });
997
+ }
998
+ async send(conversationId, text, { replyTo, messageType = "text" } = {}) {
920
999
  const { participants = [] } = await this.request(`/api/chat/conversations/${encodeURIComponent(conversationId)}/participants`);
921
1000
  const userIds = participants.map((p) => p.user_id);
922
1001
  if (!userIds.length)
@@ -935,7 +1014,7 @@ class QcClient {
935
1014
  throw new QcError("No participant has a public key yet");
936
1015
  const { message } = await this.request("/api/messages/send", {
937
1016
  method: "POST",
938
- body: { conversationId, encryptedContents, messageType: "text" }
1017
+ body: { conversationId, encryptedContents, messageType, ...replyTo ? { replyToId: replyTo } : {} }
939
1018
  });
940
1019
  return { message, skipped: missing.length };
941
1020
  }
@@ -1207,8 +1286,8 @@ async function login(options = {}) {
1207
1286
  keys,
1208
1287
  created_at: new Date().toISOString()
1209
1288
  };
1210
- saveSession(session, env);
1211
- return session;
1289
+ const save = await storeSession(session, env, options.vault);
1290
+ return { session, save };
1212
1291
  }
1213
1292
 
1214
1293
  // src/cli/commands.js
@@ -1221,12 +1300,14 @@ Usage:
1221
1300
  qc whoami who this terminal is signed in as
1222
1301
  qc chats list your chats
1223
1302
  qc read <chat> [-n 20] print the last messages of a chat
1224
- qc send <chat> <text> send a message (text "-" reads stdin)
1303
+ qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
1304
+ qc react <chat> <message-id> <emoji> [--remove] react to a message
1225
1305
  qc listen print new messages as they arrive (NDJSON with --json)
1226
1306
  qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
1227
1307
 
1228
1308
  <chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
1229
- Keys and session live in ${"$"}QC_HOME or ~/.config/qc (mode 0600).`;
1309
+ Keys and tokens are sealed (ChaCha20-Poly1305) in ${"$"}QC_HOME or ~/.config/qc; the key is in your
1310
+ OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).`;
1230
1311
  function parseArgs2(argv) {
1231
1312
  const args = { _: [], flags: {} };
1232
1313
  for (let i = 0;i < argv.length; i++) {
@@ -1239,7 +1320,7 @@ function parseArgs2(argv) {
1239
1320
  const [k, v] = a.slice(2).split("=", 2);
1240
1321
  if (v !== undefined)
1241
1322
  args.flags[k] = v;
1242
- else if (["url", "n", "limit"].includes(k) && argv[i + 1] !== undefined)
1323
+ else if (["url", "n", "limit", "reply"].includes(k) && argv[i + 1] !== undefined)
1243
1324
  args.flags[k] = argv[++i];
1244
1325
  else
1245
1326
  args.flags[k] = true;
@@ -1273,13 +1354,13 @@ function findChat2(chats, query) {
1273
1354
  async function client(flags, { interactive = false } = {}) {
1274
1355
  if (flags.url)
1275
1356
  process.env.QC_URL = flags.url;
1276
- let session = loadSession();
1277
- if (!session) {
1357
+ let opened = await unlockSession();
1358
+ if (!opened) {
1278
1359
  if (!interactive)
1279
1360
  throw new Error("Not signed in. Run qc login.");
1280
- session = await login({ oob: !!flags.oob });
1361
+ opened = await login({ oob: !!flags.oob });
1281
1362
  }
1282
- return new QcClient(session);
1363
+ return new QcClient(opened.session, { save: opened.save });
1283
1364
  }
1284
1365
  var stamp = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
1285
1366
  async function readStdin() {
@@ -1309,8 +1390,8 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1309
1390
  return runTui(c, { initialChat: rest[0] });
1310
1391
  }
1311
1392
  case "login": {
1312
- const session = await login({ oob: !!flags.oob });
1313
- return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys saved to ${sessionPath()}.`);
1393
+ const { session } = await login({ oob: !!flags.oob });
1394
+ return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
1314
1395
  }
1315
1396
  case "logout":
1316
1397
  clearSession();
@@ -1339,10 +1420,27 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1339
1420
  const last = messages.slice(-Math.max(1, Number(flags.n || flags.limit || 20)));
1340
1421
  if (flags.json)
1341
1422
  return json(last);
1342
- for (const m of last)
1343
- out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}`);
1423
+ for (const m of last) {
1424
+ if (m.replyTo)
1425
+ out(` ┃ ${m.replyTo.name ? `${m.replyTo.name}: ` : ""}${m.replyTo.snippet}`);
1426
+ const reactions = m.reactions?.length ? ` [${m.reactions.map((r) => `${r.emoji}${r.count > 1 ? r.count : ""}`).join(" ")}]` : "";
1427
+ out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}${reactions} (${m.id.slice(0, 8)})`);
1428
+ }
1344
1429
  return;
1345
1430
  }
1431
+ case "react": {
1432
+ const [chatQuery, messageId, emoji] = rest;
1433
+ if (!chatQuery || !messageId || !emoji)
1434
+ throw new Error("Usage: qc react <chat> <message-id> <emoji> [--remove]");
1435
+ const c = await client(flags);
1436
+ const chat = findChat2(await c.conversations(), chatQuery);
1437
+ const { messages } = await c.messages(chat.id);
1438
+ const target = messages.filter((m) => m.id === messageId || m.id.startsWith(messageId));
1439
+ if (target.length !== 1)
1440
+ throw new Error(target.length ? `"${messageId}" matches ${target.length} messages` : `No message "${messageId}" in ${chat.title}`);
1441
+ await c.react(chat.id, target[0].id, emoji, !!flags.remove);
1442
+ return out(`${flags.remove ? "Removed" : "Reacted"} ${emoji} on ${chat.title}.`);
1443
+ }
1346
1444
  case "send": {
1347
1445
  if (!rest[0] || rest.length < 2)
1348
1446
  throw new Error('Usage: qc send <chat> <text> (text "-" reads stdin)');
@@ -1351,7 +1449,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1351
1449
  throw new Error("Nothing to send.");
1352
1450
  const c = await client(flags);
1353
1451
  const chat = findChat2(await c.conversations(), rest[0]);
1354
- const { message, skipped } = await c.send(chat.id, text);
1452
+ const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
1355
1453
  if (flags.json)
1356
1454
  return json({ id: message?.id, conversation: chat.id, skipped });
1357
1455
  return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ""}.`);
@@ -0,0 +1,190 @@
1
+ // src/cli/config.js
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // src/cli/vault.js
7
+ import { randomBytes, scryptSync } from "node:crypto";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createInterface } from "node:readline";
10
+ import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
11
+ var SERVICE = "qrypt-qc";
12
+ var SCRYPT = { N: 1 << 16, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
13
+ var SECRET_FIELDS = ["access_token", "refresh_token", "expires_at", "expires_in", "token_type", "keys"];
14
+ var b64 = (u8) => Buffer.from(u8).toString("base64");
15
+ var unb64 = (s) => Uint8Array.from(Buffer.from(s, "base64"));
16
+ function deriveKey(passphrase, salt) {
17
+ return new Uint8Array(scryptSync(String(passphrase).normalize("NFKC"), Buffer.from(salt), 32, SCRYPT));
18
+ }
19
+ function seal(session, key, source) {
20
+ const secret = {};
21
+ const open = {};
22
+ for (const [k, v] of Object.entries(session))
23
+ (SECRET_FIELDS.includes(k) ? secret : open)[k] = v;
24
+ const nonce = Uint8Array.from(randomBytes(12));
25
+ const ct = chacha20poly1305(Uint8Array.from(key), nonce).encrypt(Uint8Array.from(Buffer.from(JSON.stringify(secret), "utf8")));
26
+ return { v: 2, ...open, sealed: { alg: "chacha20-poly1305", source: source.kind, ...source.salt ? { salt: source.salt } : {}, nonce: b64(nonce), ct: b64(ct) } };
27
+ }
28
+ function unseal(file, key) {
29
+ const { nonce, ct } = file.sealed;
30
+ let plain;
31
+ try {
32
+ plain = chacha20poly1305(Uint8Array.from(key), unb64(nonce)).decrypt(unb64(ct));
33
+ } catch {
34
+ throw new Error("Wrong passphrase, or the session was tampered with.");
35
+ }
36
+ const { v, sealed, ...open } = file;
37
+ return { ...open, ...JSON.parse(Buffer.from(plain).toString("utf8")) };
38
+ }
39
+ function run(cmd, args, input) {
40
+ try {
41
+ const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout: 1e4 });
42
+ return r.status === 0 ? { ok: true, out: (r.stdout || "").trim() } : { ok: false };
43
+ } catch {
44
+ return { ok: false };
45
+ }
46
+ }
47
+ function keychain({ platform = process.platform, env = process.env } = {}) {
48
+ if (env.QC_NO_KEYCHAIN)
49
+ return null;
50
+ if (platform === "darwin") {
51
+ return {
52
+ get: (account) => {
53
+ const r = run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
54
+ return r.ok && r.out ? r.out : null;
55
+ },
56
+ set: (account, secret) => run("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret]).ok
57
+ };
58
+ }
59
+ if (platform === "linux" && (env.DBUS_SESSION_BUS_ADDRESS || env.DISPLAY || env.WAYLAND_DISPLAY) && run("secret-tool", ["--version"]).ok !== false) {
60
+ const probe = spawnSync("which", ["secret-tool"], { encoding: "utf8" });
61
+ if (probe.status !== 0)
62
+ return null;
63
+ return {
64
+ get: (account) => {
65
+ const r = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
66
+ return r.ok && r.out ? r.out : null;
67
+ },
68
+ set: (account, secret) => run("secret-tool", ["store", "--label", "qc (qrypt.chat) session key", "service", SERVICE, "account", account], secret).ok
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+ function askHidden(question) {
74
+ return new Promise((resolve, reject) => {
75
+ if (!process.stdin.isTTY) {
76
+ reject(new Error("qc needs a passphrase to unlock your keys: set QC_PASSPHRASE, or run it on a terminal."));
77
+ return;
78
+ }
79
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
80
+ rl._writeToOutput = (s) => {
81
+ if (s.includes(question))
82
+ process.stderr.write(s);
83
+ };
84
+ rl.question(question, (answer) => {
85
+ rl.close();
86
+ process.stderr.write(`
87
+ `);
88
+ resolve(answer);
89
+ });
90
+ });
91
+ }
92
+ async function dataKey(account, { existing, env = process.env, ask = askHidden, chain = keychain({ env }) } = {}) {
93
+ if (existing) {
94
+ if (existing.source === "keychain") {
95
+ const k = chain?.get(account);
96
+ if (!k)
97
+ throw new Error("Your session key is not in the keychain any more. Run qc login again.");
98
+ return { key: unb64(k), source: { kind: "keychain" } };
99
+ }
100
+ const pass = env.QC_PASSPHRASE ?? await ask(" Passphrase for your qc keys: ");
101
+ return { key: deriveKey(pass, existing.salt), source: { kind: "passphrase", salt: existing.salt } };
102
+ }
103
+ if (env.QC_PASSPHRASE) {
104
+ const salt = b64(randomBytes(16));
105
+ return { key: deriveKey(env.QC_PASSPHRASE, salt), source: { kind: "passphrase", salt } };
106
+ }
107
+ if (chain) {
108
+ const raw = randomBytes(32);
109
+ if (chain.set(account, b64(raw)))
110
+ return { key: new Uint8Array(raw), source: { kind: "keychain" } };
111
+ }
112
+ process.stderr.write(`
113
+ qc keeps your keys encrypted. Choose a passphrase (you will need it each time qc starts).
114
+ `);
115
+ for (;; ) {
116
+ const a = await ask(" New passphrase: ");
117
+ if (a.length < 8) {
118
+ process.stderr.write(` At least 8 characters, please.
119
+ `);
120
+ continue;
121
+ }
122
+ const b = await ask(" Again: ");
123
+ if (a !== b) {
124
+ process.stderr.write(` Those did not match.
125
+ `);
126
+ continue;
127
+ }
128
+ const salt = b64(randomBytes(16));
129
+ return { key: deriveKey(a, salt), source: { kind: "passphrase", salt } };
130
+ }
131
+ }
132
+
133
+ // src/cli/config.js
134
+ var DEFAULT_URL = "https://qrypt.chat";
135
+ function configDir(env = process.env) {
136
+ if (env.QC_HOME)
137
+ return env.QC_HOME;
138
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "qc");
139
+ }
140
+ var sessionPath = (env = process.env) => join(configDir(env), "session.json");
141
+ function baseUrl(session, env = process.env) {
142
+ return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, "");
143
+ }
144
+ function loadSession(env = process.env) {
145
+ try {
146
+ const file = JSON.parse(readFileSync(sessionPath(env), "utf8"));
147
+ if (file?.v === 2 && file.sealed && file.user)
148
+ return file;
149
+ return file && file.access_token && file.keys ? file : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+ function writeFile(obj, env) {
155
+ const dir = configDir(env);
156
+ mkdirSync(dir, { recursive: true, mode: 448 });
157
+ try {
158
+ chmodSync(dir, 448);
159
+ } catch {}
160
+ const file = sessionPath(env);
161
+ const tmp = `${file}.${process.pid}.tmp`;
162
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 384 });
163
+ renameSync(tmp, file);
164
+ }
165
+ var accountOf = (session) => String(session.user?.id || session.user?.username || "default");
166
+ async function unlockSession(env = process.env, opts = {}) {
167
+ const file = loadSession(env);
168
+ if (!file)
169
+ return null;
170
+ const existing = file.v === 2 ? file.sealed : undefined;
171
+ const { key, source } = await dataKey(accountOf(file), { existing, env, ...opts });
172
+ const session = file.v === 2 ? unseal(file, key) : file;
173
+ const save = (s) => writeFile(seal(s, key, source), env);
174
+ if (file.v !== 2)
175
+ save(session);
176
+ return { session, save };
177
+ }
178
+ async function storeSession(session, env = process.env, opts = {}) {
179
+ const { key, source } = await dataKey(accountOf(session), { env, ...opts });
180
+ const save = (s) => writeFile(seal(s, key, source), env);
181
+ save(session);
182
+ return save;
183
+ }
184
+ function clearSession(env = process.env) {
185
+ const file = sessionPath(env);
186
+ if (existsSync(file))
187
+ rmSync(file);
188
+ }
189
+
190
+ export { configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession };
package/dist/commands.js CHANGED
@@ -3,7 +3,8 @@ import {
3
3
  parseArgs2,
4
4
  findChat2,
5
5
  main2
6
- } from "./chunk-12bbzs4r.js";
6
+ } from "./chunk-4r99spcc.js";
7
+ import"./chunk-vfahhydf.js";
7
8
  export {
8
9
  HELP2 as HELP,
9
10
  findChat2 as findChat,
package/dist/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
+ import"./chunk-vfahhydf.js";
1
2
  import {
2
3
  findChat2
3
- } from "./chunk-12bbzs4r.js";
4
+ } from "./chunk-4r99spcc.js";
4
5
 
5
6
  // src/cli/mcp.js
6
7
  import { createInterface } from "node:readline";
@@ -30,11 +31,27 @@ var TOOLS = [
30
31
  type: "object",
31
32
  properties: {
32
33
  chat: { type: "string", description: "Conversation id or part of its name" },
33
- text: { type: "string", description: "The message" }
34
+ text: { type: "string", description: "The message" },
35
+ reply_to: { type: "string", description: "Optional: id of the message this replies to (from read_chat)" }
34
36
  },
35
37
  required: ["chat", "text"],
36
38
  additionalProperties: false
37
39
  }
40
+ },
41
+ {
42
+ name: "react_to_message",
43
+ description: "React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ chat: { type: "string", description: "Conversation id or part of its name" },
48
+ message_id: { type: "string", description: "The message id, from read_chat" },
49
+ emoji: { type: "string", description: "The emoji, e.g. ❤️" },
50
+ remove: { type: "boolean", description: "Withdraw this reaction instead" }
51
+ },
52
+ required: ["chat", "message_id", "emoji"],
53
+ additionalProperties: false
54
+ }
38
55
  }
39
56
  ];
40
57
  async function callTool(client, name, args = {}) {
@@ -46,15 +63,32 @@ async function callTool(client, name, args = {}) {
46
63
  const chat = findChat2(await client.conversations(), args.chat);
47
64
  const { messages } = await client.messages(chat.id, { limit: 100 });
48
65
  const limit = Math.min(100, Math.max(1, Number(args.limit) || 20));
49
- return { chat: { id: chat.id, title: chat.title }, messages: messages.slice(-limit).map(({ sender, mine, text, at }) => ({ from: mine ? "me" : sender, text, at })) };
66
+ return {
67
+ chat: { id: chat.id, title: chat.title },
68
+ messages: messages.slice(-limit).map(({ id, sender, mine, text, at, reactions, replyTo }) => ({
69
+ id,
70
+ from: mine ? "me" : sender,
71
+ text,
72
+ at,
73
+ ...reactions?.length ? { reactions: reactions.map(({ emoji, count, mine: m }) => ({ emoji, count, mine: m })) } : {},
74
+ ...replyTo ? { reply_to: { id: replyTo.id, from: replyTo.name, text: replyTo.snippet } } : {}
75
+ }))
76
+ };
50
77
  }
51
78
  if (name === "send_message") {
52
79
  if (!args.text || typeof args.text !== "string")
53
80
  throw new Error("text is required");
54
81
  const chat = findChat2(await client.conversations(), args.chat);
55
- const { message, skipped } = await client.send(chat.id, args.text);
82
+ const { message, skipped } = await client.send(chat.id, args.text, { replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined });
56
83
  return { sent: true, id: message?.id, chat: chat.title, skipped };
57
84
  }
85
+ if (name === "react_to_message") {
86
+ if (!args.emoji || typeof args.emoji !== "string")
87
+ throw new Error("emoji is required");
88
+ const chat = findChat2(await client.conversations(), args.chat);
89
+ await client.react(chat.id, String(args.message_id), args.emoji, !!args.remove);
90
+ return { reacted: !args.remove, emoji: args.emoji, chat: chat.title };
91
+ }
58
92
  throw new Error(`Unknown tool ${name}`);
59
93
  }
60
94
  async function handle(client, msg, { version }) {
package/dist/tui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  configDir
3
- } from "./chunk-fb6zrz6m.js";
3
+ } from "./chunk-vfahhydf.js";
4
4
 
5
5
  // src/cli/tui.js
6
6
  import { createApp, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
@@ -61,11 +61,19 @@ function transcriptLines(messages, width, theme) {
61
61
  ]
62
62
  });
63
63
  }
64
+ if (m.replyTo) {
65
+ const who = m.replyTo.name ? `${m.replyTo.name}: ` : "";
66
+ lines.push({ text: truncate(` ┃ ${who}${m.replyTo.snippet}`, width), fg: theme.muted });
67
+ }
64
68
  for (const part of String(m.text ?? "").split(`
65
69
  `)) {
66
70
  for (const line of wrap(part, Math.max(4, width - 2)))
67
71
  lines.push({ text: ` ${line}`, fg: theme.foreground });
68
72
  }
73
+ if (m.reactions?.length) {
74
+ const summary = m.reactions.map((r) => `${r.emoji}${r.count > 1 ? ` ${r.count}` : ""}`).join(" ");
75
+ lines.push({ text: truncate(` ${summary}`, width), fg: m.reactions.some((r) => r.mine) ? theme.accent : theme.muted });
76
+ }
69
77
  last = m;
70
78
  }
71
79
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.1.1",
3
+ "version": "0.3.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": {
@@ -1,40 +0,0 @@
1
- // src/cli/config.js
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- var DEFAULT_URL = "https://qrypt.chat";
6
- function configDir(env = process.env) {
7
- if (env.QC_HOME)
8
- return env.QC_HOME;
9
- return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "qc");
10
- }
11
- var sessionPath = (env = process.env) => join(configDir(env), "session.json");
12
- function baseUrl(session, env = process.env) {
13
- return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, "");
14
- }
15
- function loadSession(env = process.env) {
16
- try {
17
- const session = JSON.parse(readFileSync(sessionPath(env), "utf8"));
18
- return session && session.access_token && session.keys ? session : null;
19
- } catch {
20
- return null;
21
- }
22
- }
23
- function saveSession(session, env = process.env) {
24
- const dir = configDir(env);
25
- mkdirSync(dir, { recursive: true, mode: 448 });
26
- try {
27
- chmodSync(dir, 448);
28
- } catch {}
29
- const file = sessionPath(env);
30
- const tmp = `${file}.${process.pid}.tmp`;
31
- writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 384 });
32
- renameSync(tmp, file);
33
- }
34
- function clearSession(env = process.env) {
35
- const file = sessionPath(env);
36
- if (existsSync(file))
37
- rmSync(file);
38
- }
39
-
40
- export { configDir, sessionPath, baseUrl, loadSession, saveSession, clearSession };