@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 +1 -0
- package/dist/{chunk-zh85gmtq.js → chunk-4mx8vtcw.js} +1 -1
- package/dist/{chunk-ycvwj8wy.js → chunk-amknn876.js} +1 -1
- package/dist/{chunk-hmyhm26d.js → chunk-jfete02h.js} +255 -196
- package/dist/commands.js +1 -1
- package/dist/mcp.js +21 -1
- package/dist/tui.js +49 -8
- package/package.json +1 -1
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
|
|
@@ -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 =
|
|
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-
|
|
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(
|
|
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-
|
|
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
package/dist/mcp.js
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
findChat2,
|
|
4
4
|
mentionMatches2,
|
|
5
5
|
withMentions2
|
|
6
|
-
} from "./chunk-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 === "
|
|
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.
|
|
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": {
|