@threahq/cli 0.1.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/LICENSE +21 -0
- package/README.md +196 -0
- package/cli.js +3884 -0
- package/package.json +43 -0
- package/skills/threa-cli/SKILL.md +108 -0
package/cli.js
ADDED
|
@@ -0,0 +1,3884 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// src/cli.ts
|
|
6
|
+
import { text } from "node:stream/consumers";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
// src/commands/skill.ts
|
|
10
|
+
import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
11
|
+
import { homedir as homedir2 } from "node:os";
|
|
12
|
+
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
// src/config.ts
|
|
16
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join as join2 } from "node:path";
|
|
19
|
+
|
|
20
|
+
// ../../extensions/bot-runtime-client/src/keyring.ts
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
var E2E_KEY_SCOPES = ["host", "identity", "instance", "stream"];
|
|
26
|
+
var E2E_KEY_STORE_KINDS = ["keychain", "file"];
|
|
27
|
+
function hash16(value) {
|
|
28
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
function e2eKeyAccount(params) {
|
|
31
|
+
switch (params.scope) {
|
|
32
|
+
case "identity":
|
|
33
|
+
return `identity-${hash16(params.identitySeed)}`;
|
|
34
|
+
case "instance":
|
|
35
|
+
return `instance-${params.instanceId.replace(/[^A-Za-z0-9_-]+/g, "-")}`.slice(0, 96);
|
|
36
|
+
case "host":
|
|
37
|
+
return `host-${hash16(params.hostname)}`;
|
|
38
|
+
case "stream":
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function e2eStreamKeyAccount(streamId) {
|
|
43
|
+
return `stream-${hash16(streamId)}`;
|
|
44
|
+
}
|
|
45
|
+
function e2eUserKeyAccount(workspaceId, userId) {
|
|
46
|
+
return `user-${hash16(`${workspaceId}:${userId}`)}`;
|
|
47
|
+
}
|
|
48
|
+
function decodeRecord(raw) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (typeof parsed.keyId === "string" && typeof parsed.publicKey === "string" && typeof parsed.privateKey === "string") {
|
|
52
|
+
return { keyId: parsed.keyId, publicKey: parsed.publicKey, privateKey: parsed.privateKey };
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class FileKeyStore {
|
|
59
|
+
kind = "file";
|
|
60
|
+
describe;
|
|
61
|
+
dir;
|
|
62
|
+
constructor(opts) {
|
|
63
|
+
this.dir = opts.dir;
|
|
64
|
+
this.describe = opts.dir;
|
|
65
|
+
}
|
|
66
|
+
path(account) {
|
|
67
|
+
return join(this.dir, `${account}.json`);
|
|
68
|
+
}
|
|
69
|
+
read(account) {
|
|
70
|
+
const path = this.path(account);
|
|
71
|
+
if (!existsSync(path))
|
|
72
|
+
return;
|
|
73
|
+
return decodeRecord(readFileSync(path, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
hasAny() {
|
|
76
|
+
if (!existsSync(this.dir))
|
|
77
|
+
return false;
|
|
78
|
+
return readdirSync(this.dir).some((entry) => entry.endsWith(".json"));
|
|
79
|
+
}
|
|
80
|
+
createExclusive(account, record) {
|
|
81
|
+
const path = this.path(account);
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
try {
|
|
84
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}
|
|
85
|
+
`, { mode: 384, flag: "wx" });
|
|
86
|
+
return record;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== "EEXIST")
|
|
89
|
+
throw error;
|
|
90
|
+
const winner = this.read(account);
|
|
91
|
+
if (!winner)
|
|
92
|
+
throw new Error(`${path} exists but could not be read`);
|
|
93
|
+
return winner;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
write(account, record) {
|
|
97
|
+
const path = this.path(account);
|
|
98
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
99
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}
|
|
100
|
+
`, { mode: 384 });
|
|
101
|
+
}
|
|
102
|
+
remove(account) {
|
|
103
|
+
rmSync(this.path(account), { force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
var KEYCHAIN_COMMAND_TIMEOUT_MS = 5000;
|
|
107
|
+
function runKeychainCommand(command, args, input, timeoutMs = KEYCHAIN_COMMAND_TIMEOUT_MS) {
|
|
108
|
+
const result = spawnSync(command, args, { encoding: "utf8", input, timeout: timeoutMs });
|
|
109
|
+
if (result.error?.code === "ETIMEDOUT") {
|
|
110
|
+
return {
|
|
111
|
+
status: -1,
|
|
112
|
+
stdout: "",
|
|
113
|
+
stderr: `${command} gave no answer within ${timeoutMs}ms; the keyring is probably locked and waiting on a password prompt`,
|
|
114
|
+
unavailable: true
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (result.error) {
|
|
118
|
+
return { status: -1, stdout: "", stderr: String(result.error), unavailable: true };
|
|
119
|
+
}
|
|
120
|
+
return { status: result.status ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", unavailable: false };
|
|
121
|
+
}
|
|
122
|
+
function encodeSecret(record) {
|
|
123
|
+
return Buffer.from(JSON.stringify(record), "utf8").toString("base64");
|
|
124
|
+
}
|
|
125
|
+
function decodeSecret(raw) {
|
|
126
|
+
const trimmed = raw.trim();
|
|
127
|
+
if (!trimmed)
|
|
128
|
+
return;
|
|
129
|
+
return decodeRecord(Buffer.from(trimmed, "base64").toString("utf8"));
|
|
130
|
+
}
|
|
131
|
+
var KEYCHAIN_SERVICE = "threa-e2e";
|
|
132
|
+
var sameRecord = (a, b) => a.keyId === b.keyId && a.publicKey === b.publicKey && a.privateKey === b.privateKey;
|
|
133
|
+
|
|
134
|
+
class MacKeychainStore {
|
|
135
|
+
kind = "keychain";
|
|
136
|
+
describe = `macOS keychain (service ${KEYCHAIN_SERVICE})`;
|
|
137
|
+
exec;
|
|
138
|
+
constructor(opts = {}) {
|
|
139
|
+
this.exec = opts.exec ?? runKeychainCommand;
|
|
140
|
+
}
|
|
141
|
+
read(account) {
|
|
142
|
+
const result = this.exec("/usr/bin/security", [
|
|
143
|
+
"find-generic-password",
|
|
144
|
+
"-s",
|
|
145
|
+
KEYCHAIN_SERVICE,
|
|
146
|
+
"-a",
|
|
147
|
+
account,
|
|
148
|
+
"-w"
|
|
149
|
+
]);
|
|
150
|
+
if (result.unavailable)
|
|
151
|
+
throw new Error(`macOS keychain unavailable: ${result.stderr}`);
|
|
152
|
+
if (result.status !== 0)
|
|
153
|
+
return;
|
|
154
|
+
return decodeSecret(result.stdout);
|
|
155
|
+
}
|
|
156
|
+
createExclusive(account, record) {
|
|
157
|
+
const result = this.exec("/usr/bin/security", ["-i"], `add-generic-password -s ${KEYCHAIN_SERVICE} -a ${account} -w ${encodeSecret(record)}
|
|
158
|
+
`);
|
|
159
|
+
if (result.unavailable)
|
|
160
|
+
throw new Error(`macOS keychain unavailable: ${result.stderr}`);
|
|
161
|
+
const stored = this.read(account);
|
|
162
|
+
if (!stored)
|
|
163
|
+
throw new Error(`macOS keychain accepted no key for ${account}: ${result.stderr || result.stdout}`);
|
|
164
|
+
return stored;
|
|
165
|
+
}
|
|
166
|
+
write(account, record) {
|
|
167
|
+
const result = this.exec("/usr/bin/security", ["-i"], `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${account} -w ${encodeSecret(record)}
|
|
168
|
+
`);
|
|
169
|
+
if (result.unavailable)
|
|
170
|
+
throw new Error(`macOS keychain unavailable: ${result.stderr}`);
|
|
171
|
+
if (result.status !== 0) {
|
|
172
|
+
throw new Error(`macOS keychain rejected the key for ${account}: ${result.stderr || result.stdout}`);
|
|
173
|
+
}
|
|
174
|
+
const stored = this.read(account);
|
|
175
|
+
if (!stored || !sameRecord(stored, record)) {
|
|
176
|
+
throw new Error(`macOS keychain did not store the key for ${account}: ${result.stderr || result.stdout}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
remove(account) {
|
|
180
|
+
const result = this.exec("/usr/bin/security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account]);
|
|
181
|
+
if (result.unavailable)
|
|
182
|
+
throw new Error(`macOS keychain unavailable: ${result.stderr}`);
|
|
183
|
+
if (this.read(account))
|
|
184
|
+
throw new Error(`macOS keychain kept the key for ${account}: ${result.stderr}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
class SecretServiceStore {
|
|
189
|
+
kind = "keychain";
|
|
190
|
+
describe = `Secret Service keyring (service ${KEYCHAIN_SERVICE})`;
|
|
191
|
+
exec;
|
|
192
|
+
constructor(opts = {}) {
|
|
193
|
+
this.exec = opts.exec ?? runKeychainCommand;
|
|
194
|
+
}
|
|
195
|
+
read(account) {
|
|
196
|
+
const result = this.exec("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", account]);
|
|
197
|
+
if (result.unavailable)
|
|
198
|
+
throw new Error(`Secret Service unavailable: ${result.stderr}`);
|
|
199
|
+
if (result.status !== 0)
|
|
200
|
+
return;
|
|
201
|
+
return decodeSecret(result.stdout);
|
|
202
|
+
}
|
|
203
|
+
createExclusive(account, record) {
|
|
204
|
+
const existing = this.read(account);
|
|
205
|
+
if (existing)
|
|
206
|
+
return existing;
|
|
207
|
+
const result = this.exec("secret-tool", ["store", "--label", `Threa E2E key ${account}`, "service", KEYCHAIN_SERVICE, "account", account], encodeSecret(record));
|
|
208
|
+
if (result.unavailable)
|
|
209
|
+
throw new Error(`Secret Service unavailable: ${result.stderr}`);
|
|
210
|
+
const stored = this.read(account);
|
|
211
|
+
if (!stored)
|
|
212
|
+
throw new Error(`Secret Service stored no key for ${account}: ${result.stderr || result.stdout}`);
|
|
213
|
+
return stored;
|
|
214
|
+
}
|
|
215
|
+
write(account, record) {
|
|
216
|
+
const result = this.exec("secret-tool", ["store", "--label", `Threa E2E key ${account}`, "service", KEYCHAIN_SERVICE, "account", account], encodeSecret(record));
|
|
217
|
+
if (result.unavailable)
|
|
218
|
+
throw new Error(`Secret Service unavailable: ${result.stderr}`);
|
|
219
|
+
if (result.status !== 0) {
|
|
220
|
+
throw new Error(`Secret Service rejected the key for ${account}: ${result.stderr || result.stdout}`);
|
|
221
|
+
}
|
|
222
|
+
const stored = this.read(account);
|
|
223
|
+
if (!stored || !sameRecord(stored, record)) {
|
|
224
|
+
throw new Error(`Secret Service did not store the key for ${account}: ${result.stderr || result.stdout}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
remove(account) {
|
|
228
|
+
const result = this.exec("secret-tool", ["clear", "service", KEYCHAIN_SERVICE, "account", account]);
|
|
229
|
+
if (result.unavailable)
|
|
230
|
+
throw new Error(`Secret Service unavailable: ${result.stderr}`);
|
|
231
|
+
if (this.read(account))
|
|
232
|
+
throw new Error(`Secret Service kept the key for ${account}: ${result.stderr}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function resolveKeyStore(input) {
|
|
236
|
+
const keychain = () => input.platform === "darwin" ? new MacKeychainStore({ exec: input.exec }) : new SecretServiceStore({ exec: input.exec });
|
|
237
|
+
if (input.requested === "file")
|
|
238
|
+
return new FileKeyStore({ dir: input.dir });
|
|
239
|
+
if (input.requested === "keychain") {
|
|
240
|
+
const store2 = keychain();
|
|
241
|
+
store2.read("threa-probe");
|
|
242
|
+
return store2;
|
|
243
|
+
}
|
|
244
|
+
if (input.hasExistingFileKey)
|
|
245
|
+
return new FileKeyStore({ dir: input.dir });
|
|
246
|
+
const store = keychain();
|
|
247
|
+
try {
|
|
248
|
+
store.read("threa-probe");
|
|
249
|
+
return store;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new Error(`No OS keychain available for Threa's end-to-end keys (${String(error)}). ` + `Set keyStore to "keychain" once one is installed and unlocked, or "file" to keep them in ${input.dir} at mode 0600.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
class E2eKeyring {
|
|
256
|
+
opts;
|
|
257
|
+
log;
|
|
258
|
+
held = [];
|
|
259
|
+
inFlight = new Map;
|
|
260
|
+
loaded = false;
|
|
261
|
+
constructor(opts) {
|
|
262
|
+
this.opts = opts;
|
|
263
|
+
this.log = opts.log ?? ((message) => console.error(message));
|
|
264
|
+
}
|
|
265
|
+
get current() {
|
|
266
|
+
return this.held;
|
|
267
|
+
}
|
|
268
|
+
async ensure() {
|
|
269
|
+
if (this.loaded)
|
|
270
|
+
return this.held;
|
|
271
|
+
const account = this.opts.account;
|
|
272
|
+
if (account === null) {
|
|
273
|
+
this.loaded = true;
|
|
274
|
+
return this.held;
|
|
275
|
+
}
|
|
276
|
+
await this.loadAccount(account, undefined);
|
|
277
|
+
this.loaded = this.held.length > 0;
|
|
278
|
+
return this.held;
|
|
279
|
+
}
|
|
280
|
+
async ensureForStream(streamId) {
|
|
281
|
+
if (this.opts.account !== null)
|
|
282
|
+
return this.ensure();
|
|
283
|
+
return this.loadAccount(e2eStreamKeyAccount(streamId), streamId);
|
|
284
|
+
}
|
|
285
|
+
dropStream(streamId) {
|
|
286
|
+
const held = this.held.find((key) => key.streamId === streamId);
|
|
287
|
+
if (!held)
|
|
288
|
+
return this.held;
|
|
289
|
+
this.held = this.held.filter((key) => key !== held);
|
|
290
|
+
this.opts.store.remove(held.account);
|
|
291
|
+
this.log(`Threa sealed: dropped ${held.keyId}, the key for revoked stream ${streamId}`);
|
|
292
|
+
return this.held;
|
|
293
|
+
}
|
|
294
|
+
forStream(streamId) {
|
|
295
|
+
if (this.opts.account !== null)
|
|
296
|
+
return this.held.find((key) => !key.streamId);
|
|
297
|
+
return this.held.find((key) => key.streamId === streamId);
|
|
298
|
+
}
|
|
299
|
+
presenceFields() {
|
|
300
|
+
if (this.held.length === 0)
|
|
301
|
+
return {};
|
|
302
|
+
const e2eKeys = this.held.map((key) => ({
|
|
303
|
+
keyId: key.keyId,
|
|
304
|
+
publicKey: key.publicKey,
|
|
305
|
+
...key.streamId ? { streamId: key.streamId } : {}
|
|
306
|
+
}));
|
|
307
|
+
const unscoped = this.held.find((key) => !key.streamId);
|
|
308
|
+
return unscoped ? { e2eKeys, publicKey: unscoped.publicKey, publicKeyId: unscoped.keyId } : { e2eKeys };
|
|
309
|
+
}
|
|
310
|
+
async loadAccount(account, streamId) {
|
|
311
|
+
const existing = this.held.find((key) => key.account === account);
|
|
312
|
+
if (existing)
|
|
313
|
+
return this.held;
|
|
314
|
+
let attempt = this.inFlight.get(account);
|
|
315
|
+
if (!attempt) {
|
|
316
|
+
attempt = this.mintAccount(account, streamId).finally(() => this.inFlight.delete(account));
|
|
317
|
+
this.inFlight.set(account, attempt);
|
|
318
|
+
}
|
|
319
|
+
return attempt;
|
|
320
|
+
}
|
|
321
|
+
async mintAccount(account, streamId) {
|
|
322
|
+
const record = this.opts.store.read(account) ?? await this.createRecord(account);
|
|
323
|
+
if (!this.held.some((key) => key.account === account)) {
|
|
324
|
+
this.held = [...this.held, { ...record, account, ...streamId ? { streamId } : {} }];
|
|
325
|
+
}
|
|
326
|
+
return this.held;
|
|
327
|
+
}
|
|
328
|
+
async createRecord(account) {
|
|
329
|
+
const legacy = account === this.opts.account ? this.opts.legacy?.() : undefined;
|
|
330
|
+
if (legacy) {
|
|
331
|
+
const adopted = this.opts.store.createExclusive(account, legacy);
|
|
332
|
+
this.log(`Threa sealed: adopted this install's existing key ${adopted.keyId} into ${this.opts.store.describe} as ${account}`);
|
|
333
|
+
return adopted;
|
|
334
|
+
}
|
|
335
|
+
const minted = this.opts.store.createExclusive(account, await this.opts.mint());
|
|
336
|
+
this.log(`Threa sealed: end-to-end key ${minted.keyId} is in ${this.opts.store.describe} as ${account}`);
|
|
337
|
+
return minted;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/config.ts
|
|
342
|
+
var OUTPUT_MODES = ["text", "json"];
|
|
343
|
+
var PRINCIPAL_KINDS = ["bot", "user"];
|
|
344
|
+
var DEFAULT_BASE_URL = "https://app.threa.io";
|
|
345
|
+
function readFileConfig() {
|
|
346
|
+
const explicit = process.env.THREA_CONFIG;
|
|
347
|
+
const home = process.env.HOME ?? homedir();
|
|
348
|
+
const candidates = explicit ? [explicit] : [join2(home, ".threa", "config.json"), join2(home, ".threa", "mcp.json")];
|
|
349
|
+
let raw;
|
|
350
|
+
let path;
|
|
351
|
+
for (const candidate of candidates) {
|
|
352
|
+
try {
|
|
353
|
+
raw = readFileSync2(candidate, "utf8");
|
|
354
|
+
path = candidate;
|
|
355
|
+
break;
|
|
356
|
+
} catch {}
|
|
357
|
+
}
|
|
358
|
+
if (raw === undefined || path === undefined) {
|
|
359
|
+
if (explicit) {
|
|
360
|
+
throw new Error(`[threa] THREA_CONFIG points to ${explicit}, but it could not be read.`);
|
|
361
|
+
}
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
if (path.endsWith("mcp.json")) {
|
|
365
|
+
process.stderr.write(`[threa] Using legacy ${path} — rename it to ~/.threa/config.json.
|
|
366
|
+
`);
|
|
367
|
+
}
|
|
368
|
+
let parsed;
|
|
369
|
+
try {
|
|
370
|
+
parsed = JSON.parse(raw);
|
|
371
|
+
} catch {
|
|
372
|
+
throw new Error(`[threa] Config file ${path} is not valid JSON.`);
|
|
373
|
+
}
|
|
374
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
375
|
+
throw new Error(`[threa] Config file ${path} must contain a JSON object with { apiKey, workspaceId, baseUrl }.`);
|
|
376
|
+
}
|
|
377
|
+
return parsed;
|
|
378
|
+
}
|
|
379
|
+
function loadConfig() {
|
|
380
|
+
const file = readFileConfig();
|
|
381
|
+
const explicit = Boolean(process.env.THREA_CONFIG);
|
|
382
|
+
const apiKey = explicit ? file.apiKey || process.env.THREA_API_KEY : process.env.THREA_API_KEY || file.apiKey;
|
|
383
|
+
const workspaceId = explicit ? file.workspaceId || process.env.THREA_WORKSPACE_ID : process.env.THREA_WORKSPACE_ID || file.workspaceId;
|
|
384
|
+
const baseUrl = (explicit ? file.baseUrl ?? process.env.THREA_BASE_URL : process.env.THREA_BASE_URL ?? file.baseUrl) ?? DEFAULT_BASE_URL;
|
|
385
|
+
const output = file.output ?? "text";
|
|
386
|
+
const principal = explicit ? file.principal ?? process.env.THREA_PRINCIPAL : process.env.THREA_PRINCIPAL ?? file.principal;
|
|
387
|
+
const keyScope = pick(explicit, file.keyScope, process.env.THREA_E2E_KEY_SCOPE);
|
|
388
|
+
const keyStore = pick(explicit, file.keyStore, process.env.THREA_E2E_KEY_STORE);
|
|
389
|
+
const keyDir = pick(explicit, file.keyDir, process.env.THREA_E2E_KEY_DIR);
|
|
390
|
+
const instanceId = pick(explicit, file.instanceId, process.env.THREA_INSTANCE_ID);
|
|
391
|
+
const missing = [];
|
|
392
|
+
if (!apiKey)
|
|
393
|
+
missing.push("THREA_API_KEY");
|
|
394
|
+
if (!workspaceId)
|
|
395
|
+
missing.push("THREA_WORKSPACE_ID");
|
|
396
|
+
if (missing.length > 0) {
|
|
397
|
+
throw new Error(`[threa] Missing required config: ${missing.join(", ")}. ` + `Set them as environment variables, or provide a JSON file at ~/.threa/config.json ` + `(or the path in THREA_CONFIG) with { apiKey, workspaceId, baseUrl } and optionally { principal }. ` + `Environment variables win over ~/.threa/config.json; a THREA_CONFIG file wins over them.`);
|
|
398
|
+
}
|
|
399
|
+
if (!OUTPUT_MODES.includes(output)) {
|
|
400
|
+
throw new Error(`[threa] Config "output" must be one of ${OUTPUT_MODES.join(", ")} — got "${output}".`);
|
|
401
|
+
}
|
|
402
|
+
if (principal !== undefined && !PRINCIPAL_KINDS.includes(principal)) {
|
|
403
|
+
throw new Error(`[threa] Config "principal" must be one of ${PRINCIPAL_KINDS.join(", ")} — got "${principal}".`);
|
|
404
|
+
}
|
|
405
|
+
if (keyScope !== undefined && !E2E_KEY_SCOPES.includes(keyScope)) {
|
|
406
|
+
throw new Error(`[threa] Config "keyScope" must be one of ${E2E_KEY_SCOPES.join(", ")} — got "${keyScope}".`);
|
|
407
|
+
}
|
|
408
|
+
if (keyStore !== undefined && !E2E_KEY_STORE_KINDS.includes(keyStore)) {
|
|
409
|
+
throw new Error(`[threa] Config "keyStore" must be one of ${E2E_KEY_STORE_KINDS.join(", ")} — got "${keyStore}".`);
|
|
410
|
+
}
|
|
411
|
+
assertSafeBaseUrl(baseUrl);
|
|
412
|
+
return {
|
|
413
|
+
apiKey,
|
|
414
|
+
workspaceId,
|
|
415
|
+
baseUrl,
|
|
416
|
+
output,
|
|
417
|
+
...principal === undefined ? {} : { principal },
|
|
418
|
+
...keyScope === undefined ? {} : { keyScope },
|
|
419
|
+
...keyStore === undefined ? {} : { keyStore },
|
|
420
|
+
...keyDir === undefined ? {} : { keyDir },
|
|
421
|
+
...instanceId === undefined ? {} : { instanceId }
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function pick(explicit, fromFile, fromEnv) {
|
|
425
|
+
return explicit ? fromFile ?? fromEnv : fromEnv ?? fromFile;
|
|
426
|
+
}
|
|
427
|
+
var LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
428
|
+
function assertSafeBaseUrl(baseUrl) {
|
|
429
|
+
let url;
|
|
430
|
+
try {
|
|
431
|
+
url = new URL(baseUrl);
|
|
432
|
+
} catch {
|
|
433
|
+
throw new Error(`[threa] THREA_BASE_URL is not a valid URL: ${baseUrl}`);
|
|
434
|
+
}
|
|
435
|
+
if (url.protocol === "https:")
|
|
436
|
+
return;
|
|
437
|
+
if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))
|
|
438
|
+
return;
|
|
439
|
+
throw new Error(`[threa] THREA_BASE_URL must be https:// (http:// is allowed only for localhost) — got ${baseUrl}. ` + `The API key is sent as a bearer token to this host on every request.`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/api-client.ts
|
|
443
|
+
var FETCH_TIMEOUT_MS = 30000;
|
|
444
|
+
var RETRY_DELAYS_MS = [2000, 4000, 8000];
|
|
445
|
+
function deriveHint(status) {
|
|
446
|
+
if (status === 404) {
|
|
447
|
+
return "A 404 can mean the resource does not exist OR that this API key lacks the scope required to see it.";
|
|
448
|
+
}
|
|
449
|
+
if (status === 429) {
|
|
450
|
+
return "Rate limited after retrying with backoff; retry later.";
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
class ThreaApiError extends Error {
|
|
456
|
+
status;
|
|
457
|
+
code;
|
|
458
|
+
hint;
|
|
459
|
+
constructor(args) {
|
|
460
|
+
super(args.message);
|
|
461
|
+
this.name = "ThreaApiError";
|
|
462
|
+
this.status = args.status;
|
|
463
|
+
this.code = args.code;
|
|
464
|
+
this.hint = deriveHint(args.status);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
class ThreaApiClient {
|
|
469
|
+
baseUrl;
|
|
470
|
+
workspaceId;
|
|
471
|
+
apiKey;
|
|
472
|
+
sleep;
|
|
473
|
+
timeoutMs;
|
|
474
|
+
constructor(opts) {
|
|
475
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
476
|
+
this.workspaceId = opts.workspaceId;
|
|
477
|
+
this.apiKey = opts.apiKey;
|
|
478
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
479
|
+
this.timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
480
|
+
}
|
|
481
|
+
get(path) {
|
|
482
|
+
return this.request("GET", path);
|
|
483
|
+
}
|
|
484
|
+
post(path, body, headers) {
|
|
485
|
+
return this.request("POST", path, body, headers);
|
|
486
|
+
}
|
|
487
|
+
patch(path, body) {
|
|
488
|
+
return this.request("PATCH", path, body);
|
|
489
|
+
}
|
|
490
|
+
delete(path) {
|
|
491
|
+
return this.request("DELETE", path);
|
|
492
|
+
}
|
|
493
|
+
workspacePath(path) {
|
|
494
|
+
return `${this.baseUrl}/api/v1/workspaces/${this.workspaceId}${path}`;
|
|
495
|
+
}
|
|
496
|
+
async request(method, path, body, extraHeaders) {
|
|
497
|
+
const url = this.workspacePath(path);
|
|
498
|
+
const hasBody = body !== undefined;
|
|
499
|
+
for (let attempt = 0;; attempt++) {
|
|
500
|
+
const controller = new AbortController;
|
|
501
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
502
|
+
let retryAfter429 = false;
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetch(url, {
|
|
505
|
+
method,
|
|
506
|
+
signal: controller.signal,
|
|
507
|
+
headers: {
|
|
508
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
509
|
+
...hasBody ? { "Content-Type": "application/json" } : {},
|
|
510
|
+
...extraHeaders ?? {}
|
|
511
|
+
},
|
|
512
|
+
...hasBody ? { body: JSON.stringify(body) } : {}
|
|
513
|
+
});
|
|
514
|
+
if (response.status === 429 && attempt < RETRY_DELAYS_MS.length) {
|
|
515
|
+
retryAfter429 = true;
|
|
516
|
+
} else {
|
|
517
|
+
if (!response.ok) {
|
|
518
|
+
throw await this.toError(response);
|
|
519
|
+
}
|
|
520
|
+
if (response.status === 204)
|
|
521
|
+
return;
|
|
522
|
+
return await response.json();
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
526
|
+
throw new ThreaApiError({
|
|
527
|
+
status: 0,
|
|
528
|
+
code: "TIMEOUT",
|
|
529
|
+
message: `Threa API ${method} ${path} timed out after ${this.timeoutMs}ms`
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
throw error;
|
|
533
|
+
} finally {
|
|
534
|
+
clearTimeout(timeout);
|
|
535
|
+
}
|
|
536
|
+
if (retryAfter429)
|
|
537
|
+
await this.sleep(RETRY_DELAYS_MS[attempt]);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async toError(response) {
|
|
541
|
+
let code;
|
|
542
|
+
let message;
|
|
543
|
+
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
544
|
+
try {
|
|
545
|
+
const parsed = JSON.parse((await response.text()).slice(0, 4000));
|
|
546
|
+
if (typeof parsed.error === "string")
|
|
547
|
+
message = parsed.error;
|
|
548
|
+
if (typeof parsed.code === "string")
|
|
549
|
+
code = parsed.code;
|
|
550
|
+
} catch {}
|
|
551
|
+
}
|
|
552
|
+
return new ThreaApiError({
|
|
553
|
+
status: response.status,
|
|
554
|
+
code,
|
|
555
|
+
message: message ?? `Threa API ${response.status} ${response.statusText}`
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/tools/result.ts
|
|
561
|
+
class UnresolvedRefError extends Error {
|
|
562
|
+
constructor(message) {
|
|
563
|
+
super(message);
|
|
564
|
+
this.name = "UnresolvedRefError";
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
class ToolInputError extends Error {
|
|
569
|
+
code;
|
|
570
|
+
constructor(code, message) {
|
|
571
|
+
super(message);
|
|
572
|
+
this.name = "ToolInputError";
|
|
573
|
+
this.code = code;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function jsonResult(payload) {
|
|
577
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
578
|
+
}
|
|
579
|
+
function buildQuery(params) {
|
|
580
|
+
const search = new URLSearchParams;
|
|
581
|
+
for (const [key, value] of Object.entries(params)) {
|
|
582
|
+
if (value === undefined || value === null)
|
|
583
|
+
continue;
|
|
584
|
+
if (Array.isArray(value)) {
|
|
585
|
+
for (const item of value)
|
|
586
|
+
search.append(key, item);
|
|
587
|
+
} else {
|
|
588
|
+
search.append(key, String(value));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const qs = search.toString();
|
|
592
|
+
return qs ? `?${qs}` : "";
|
|
593
|
+
}
|
|
594
|
+
async function runTool(fn) {
|
|
595
|
+
try {
|
|
596
|
+
return jsonResult(await fn());
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return errorResult(error);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function toErrorShape(error) {
|
|
602
|
+
if (error instanceof UnresolvedRefError)
|
|
603
|
+
return { code: "UNRESOLVED_REF", message: error.message };
|
|
604
|
+
if (error instanceof ToolInputError)
|
|
605
|
+
return { code: error.code, message: error.message };
|
|
606
|
+
if (error instanceof ThreaApiError) {
|
|
607
|
+
const shape = { code: error.code ?? `HTTP_${error.status}`, message: error.message };
|
|
608
|
+
if (error.hint)
|
|
609
|
+
shape.hint = error.hint;
|
|
610
|
+
return shape;
|
|
611
|
+
}
|
|
612
|
+
return { code: "ERROR", message: error instanceof Error ? error.message : String(error) };
|
|
613
|
+
}
|
|
614
|
+
function errorResult(error) {
|
|
615
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify(toErrorShape(error), null, 2) }] };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/output.ts
|
|
619
|
+
class UsageError extends Error {
|
|
620
|
+
constructor(message) {
|
|
621
|
+
super(message);
|
|
622
|
+
this.name = "UsageError";
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
var errorObject = toErrorShape;
|
|
626
|
+
function formatSuccess(spec, payload, opts) {
|
|
627
|
+
if (opts.mode === "json" || !spec.render)
|
|
628
|
+
return JSON.stringify(payload, null, 2);
|
|
629
|
+
try {
|
|
630
|
+
return spec.render(payload);
|
|
631
|
+
} catch {
|
|
632
|
+
return JSON.stringify(payload, null, 2);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
var GLOBAL_OPTIONS = {
|
|
636
|
+
help: { type: "boolean", short: "h" }
|
|
637
|
+
};
|
|
638
|
+
function extractOutputMode(argv) {
|
|
639
|
+
const rest = [];
|
|
640
|
+
let mode;
|
|
641
|
+
for (let i = 0;i < argv.length; i++) {
|
|
642
|
+
const token = argv[i];
|
|
643
|
+
if (token === "--") {
|
|
644
|
+
rest.push(...argv.slice(i));
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (token === "--json") {
|
|
648
|
+
mode = "json";
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
let value;
|
|
652
|
+
if (token === "-o" || token === "--output") {
|
|
653
|
+
value = argv[++i];
|
|
654
|
+
if (value === undefined)
|
|
655
|
+
throw new UsageError(`${token} requires a value: ${OUTPUT_MODES.join("|")}`);
|
|
656
|
+
} else if (token.startsWith("-o=") || token.startsWith("--output=")) {
|
|
657
|
+
value = token.slice(token.indexOf("=") + 1);
|
|
658
|
+
} else {
|
|
659
|
+
rest.push(token);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (!OUTPUT_MODES.includes(value)) {
|
|
663
|
+
throw new UsageError(`-o/--output must be one of ${OUTPUT_MODES.join("|")}, got "${value}"`);
|
|
664
|
+
}
|
|
665
|
+
mode = value;
|
|
666
|
+
}
|
|
667
|
+
return { argv: rest, mode };
|
|
668
|
+
}
|
|
669
|
+
function withGlobalOptions(options) {
|
|
670
|
+
return { ...options, ...GLOBAL_OPTIONS };
|
|
671
|
+
}
|
|
672
|
+
function intFlag(values, name) {
|
|
673
|
+
const v = values[name];
|
|
674
|
+
if (v === undefined)
|
|
675
|
+
return;
|
|
676
|
+
const n = Number(v);
|
|
677
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
678
|
+
throw new UsageError(`--${name} must be a positive integer, got "${String(v)}"`);
|
|
679
|
+
}
|
|
680
|
+
return n;
|
|
681
|
+
}
|
|
682
|
+
function stringFlag(values, name) {
|
|
683
|
+
const v = values[name];
|
|
684
|
+
return typeof v === "string" ? v : undefined;
|
|
685
|
+
}
|
|
686
|
+
function boolFlag(values, name) {
|
|
687
|
+
return values[name] === true;
|
|
688
|
+
}
|
|
689
|
+
function arrayFlag(values, name) {
|
|
690
|
+
const v = values[name];
|
|
691
|
+
if (Array.isArray(v) && v.length > 0)
|
|
692
|
+
return v;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
function kvPairs(pairs, label = "metadata") {
|
|
696
|
+
const out = {};
|
|
697
|
+
for (const pair of pairs) {
|
|
698
|
+
const eq = pair.indexOf("=");
|
|
699
|
+
if (eq <= 0)
|
|
700
|
+
throw new UsageError(`${label} must be key=value, got "${pair}"`);
|
|
701
|
+
out[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
function stderrJson(obj) {
|
|
706
|
+
return `${JSON.stringify(obj, null, 2)}
|
|
707
|
+
`;
|
|
708
|
+
}
|
|
709
|
+
function enumFlag(values, name, allowed) {
|
|
710
|
+
const v = stringFlag(values, name);
|
|
711
|
+
if (v === undefined)
|
|
712
|
+
return;
|
|
713
|
+
if (!allowed.includes(v)) {
|
|
714
|
+
throw new UsageError(`--${name} must be one of ${allowed.join("|")}, got "${v}"`);
|
|
715
|
+
}
|
|
716
|
+
return v;
|
|
717
|
+
}
|
|
718
|
+
function enumArrayFlag(values, name, allowed) {
|
|
719
|
+
const arr = arrayFlag(values, name);
|
|
720
|
+
if (arr === undefined)
|
|
721
|
+
return;
|
|
722
|
+
for (const v of arr) {
|
|
723
|
+
if (!allowed.includes(v)) {
|
|
724
|
+
throw new UsageError(`--${name} must be one of ${allowed.join("|")}, got "${v}"`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return arr;
|
|
728
|
+
}
|
|
729
|
+
function fmtTimestamp(iso) {
|
|
730
|
+
if (typeof iso !== "string")
|
|
731
|
+
return "";
|
|
732
|
+
const d = new Date(iso);
|
|
733
|
+
if (Number.isNaN(d.getTime()))
|
|
734
|
+
return "";
|
|
735
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
736
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
737
|
+
}
|
|
738
|
+
function streamLabel(row) {
|
|
739
|
+
const name = row.stream?.name ?? row.stream?.id ?? (typeof row.streamId === "string" ? row.streamId : undefined);
|
|
740
|
+
if (!name)
|
|
741
|
+
return "";
|
|
742
|
+
const root = row.rootStream?.name ?? row.rootStream?.id;
|
|
743
|
+
return root && root !== name ? `${root} › ${name}` : name;
|
|
744
|
+
}
|
|
745
|
+
function snippet(text, max = 160) {
|
|
746
|
+
if (typeof text !== "string")
|
|
747
|
+
return "";
|
|
748
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
749
|
+
return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed;
|
|
750
|
+
}
|
|
751
|
+
function cursorFooter(p, flag) {
|
|
752
|
+
return p.hasMore && p.cursor ? [`… more (--${flag} ${p.cursor})`] : [];
|
|
753
|
+
}
|
|
754
|
+
function renderList(payload, formatRow, opts) {
|
|
755
|
+
const p = payload;
|
|
756
|
+
const lines = (p.data ?? []).map(formatRow);
|
|
757
|
+
if (opts.cursorFlag)
|
|
758
|
+
lines.push(...cursorFooter(p, opts.cursorFlag));
|
|
759
|
+
return lines.join(`
|
|
760
|
+
`) || opts.empty;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/commands/skill.ts
|
|
764
|
+
var SKILL_NAME = "threa-cli";
|
|
765
|
+
var here = dirname2(fileURLToPath(import.meta.url));
|
|
766
|
+
var SKILL_SOURCE = import.meta.url.endsWith(".ts") ? resolve(here, "../../../..", ".agents/skills", SKILL_NAME, "SKILL.md") : join3(here, "skills", SKILL_NAME, "SKILL.md");
|
|
767
|
+
var skillCommand = {
|
|
768
|
+
name: "skill",
|
|
769
|
+
usage: "threa skill install [--target dir] | threa skill print",
|
|
770
|
+
summary: "Install the threa-cli agent skill into ~/.claude/skills, or print it",
|
|
771
|
+
help: `threa skill install [flags]
|
|
772
|
+
` + `threa skill print
|
|
773
|
+
|
|
774
|
+
` + `install copies the threa-cli skill (how an agent uses this CLI well) into the global Claude Code
|
|
775
|
+
` + `skills directory so every session on this machine can load it, in any project.
|
|
776
|
+
` + `print writes the skill markdown to stdout — pipe it wherever a skills directory is not the target.
|
|
777
|
+
|
|
778
|
+
` + `Flags:
|
|
779
|
+
` + ` --target dir install: destination skills directory (default ~/.claude/skills)
|
|
780
|
+
` + " --help show this help",
|
|
781
|
+
options: {
|
|
782
|
+
target: { type: "string" }
|
|
783
|
+
},
|
|
784
|
+
run: async (_ctx, positionals, values) => {
|
|
785
|
+
const sub = positionals[0];
|
|
786
|
+
if (sub !== "install" && sub !== "print") {
|
|
787
|
+
throw new UsageError(`skill supports two subcommands: install, print (got "${sub ?? ""}")`);
|
|
788
|
+
}
|
|
789
|
+
if (!existsSync2(SKILL_SOURCE)) {
|
|
790
|
+
throw new UsageError(`Skill source not found at ${SKILL_SOURCE}. Reinstall @threahq/cli.`);
|
|
791
|
+
}
|
|
792
|
+
if (sub === "print") {
|
|
793
|
+
return { skill: SKILL_NAME, source: SKILL_SOURCE, content: readFileSync3(SKILL_SOURCE, "utf8") };
|
|
794
|
+
}
|
|
795
|
+
const targetRoot = stringFlag(values, "target") ?? join3(homedir2(), ".claude", "skills");
|
|
796
|
+
const destination = join3(targetRoot, SKILL_NAME, "SKILL.md");
|
|
797
|
+
mkdirSync2(dirname2(destination), { recursive: true });
|
|
798
|
+
copyFileSync(SKILL_SOURCE, destination);
|
|
799
|
+
return { installed: true, skill: SKILL_NAME, source: SKILL_SOURCE, destination };
|
|
800
|
+
},
|
|
801
|
+
render: (payload) => {
|
|
802
|
+
const p = payload;
|
|
803
|
+
if (typeof p.content === "string")
|
|
804
|
+
return p.content;
|
|
805
|
+
return `installed ${SKILL_NAME} → ${p.destination ?? "?"}
|
|
806
|
+
Re-run after updating the CLI to refresh it.`;
|
|
807
|
+
},
|
|
808
|
+
noConfig: true
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
// src/token-store.ts
|
|
812
|
+
import { chmodSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
813
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
814
|
+
import { homedir as homedir3 } from "node:os";
|
|
815
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
816
|
+
var LOCK_WAIT_MS = 5000;
|
|
817
|
+
function openLockDatabase(path) {
|
|
818
|
+
const require2 = createRequire2(import.meta.url);
|
|
819
|
+
if (process.versions.bun) {
|
|
820
|
+
const { Database } = require2("bun:sqlite");
|
|
821
|
+
return new Database(path, { create: true });
|
|
822
|
+
}
|
|
823
|
+
const { DatabaseSync } = require2("node:sqlite");
|
|
824
|
+
return new DatabaseSync(path);
|
|
825
|
+
}
|
|
826
|
+
function defaultPath() {
|
|
827
|
+
return process.env.THREA_STATE_FILE ?? join4(homedir3(), ".threa", "state.json");
|
|
828
|
+
}
|
|
829
|
+
function emptyState() {
|
|
830
|
+
return { claimTokens: {} };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
class TokenStore {
|
|
834
|
+
path;
|
|
835
|
+
state = emptyState();
|
|
836
|
+
warned = false;
|
|
837
|
+
constructor(pathOverride) {
|
|
838
|
+
this.path = pathOverride ?? defaultPath();
|
|
839
|
+
}
|
|
840
|
+
readState() {
|
|
841
|
+
let raw;
|
|
842
|
+
try {
|
|
843
|
+
raw = readFileSync4(this.path, "utf8");
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (error.code !== "ENOENT") {
|
|
846
|
+
this.warnOnce(`[threa] state file ${this.path} could not be read; starting with an empty claim-token store
|
|
847
|
+
`);
|
|
848
|
+
}
|
|
849
|
+
return emptyState();
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
const parsed = JSON.parse(raw);
|
|
853
|
+
if (parsed && typeof parsed.claimTokens === "object" && parsed.claimTokens !== null) {
|
|
854
|
+
return { claimTokens: parsed.claimTokens };
|
|
855
|
+
}
|
|
856
|
+
} catch {
|
|
857
|
+
this.warnOnce(`[threa] state file ${this.path} is corrupt; starting with an empty claim-token store
|
|
858
|
+
`);
|
|
859
|
+
}
|
|
860
|
+
return emptyState();
|
|
861
|
+
}
|
|
862
|
+
warnOnce(message) {
|
|
863
|
+
if (this.warned)
|
|
864
|
+
return;
|
|
865
|
+
this.warned = true;
|
|
866
|
+
process.stderr.write(message);
|
|
867
|
+
}
|
|
868
|
+
get(workspaceId, delegationId) {
|
|
869
|
+
this.state = this.readState();
|
|
870
|
+
return this.state.claimTokens[workspaceId]?.[delegationId];
|
|
871
|
+
}
|
|
872
|
+
set(workspaceId, delegationId, token) {
|
|
873
|
+
this.mutate((state) => {
|
|
874
|
+
(state.claimTokens[workspaceId] ??= {})[delegationId] = token;
|
|
875
|
+
return true;
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
delete(workspaceId, delegationId) {
|
|
879
|
+
this.mutate((state) => this.remove(state, workspaceId, delegationId));
|
|
880
|
+
}
|
|
881
|
+
deleteIfMatches(workspaceId, delegationId, observedToken) {
|
|
882
|
+
return this.mutate((state) => {
|
|
883
|
+
if (state.claimTokens[workspaceId]?.[delegationId] !== observedToken)
|
|
884
|
+
return false;
|
|
885
|
+
this.remove(state, workspaceId, delegationId);
|
|
886
|
+
return true;
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
remove(state, workspaceId, delegationId) {
|
|
890
|
+
const ws = state.claimTokens[workspaceId];
|
|
891
|
+
if (ws?.[delegationId] === undefined)
|
|
892
|
+
return false;
|
|
893
|
+
delete ws[delegationId];
|
|
894
|
+
if (Object.keys(ws).length === 0)
|
|
895
|
+
delete state.claimTokens[workspaceId];
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
mutate(change) {
|
|
899
|
+
const unlock = this.lock();
|
|
900
|
+
try {
|
|
901
|
+
const state = this.readState();
|
|
902
|
+
const result = change(state);
|
|
903
|
+
this.state = state;
|
|
904
|
+
if (result !== false)
|
|
905
|
+
this.persist(state);
|
|
906
|
+
return result;
|
|
907
|
+
} finally {
|
|
908
|
+
unlock();
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
lock() {
|
|
912
|
+
mkdirSync3(dirname3(this.path), { recursive: true });
|
|
913
|
+
const lockPath = `${this.path}.lock.sqlite`;
|
|
914
|
+
const db = openLockDatabase(lockPath);
|
|
915
|
+
try {
|
|
916
|
+
chmodSync(lockPath, 384);
|
|
917
|
+
db.exec(`PRAGMA busy_timeout = ${LOCK_WAIT_MS}`);
|
|
918
|
+
db.exec("BEGIN IMMEDIATE");
|
|
919
|
+
} catch (error) {
|
|
920
|
+
db.close();
|
|
921
|
+
throw error;
|
|
922
|
+
}
|
|
923
|
+
return () => {
|
|
924
|
+
try {
|
|
925
|
+
db.exec("COMMIT");
|
|
926
|
+
} finally {
|
|
927
|
+
db.close();
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
persist(state) {
|
|
932
|
+
const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
933
|
+
writeFileSync2(tmp, JSON.stringify(state), { mode: 384 });
|
|
934
|
+
renameSync(tmp, this.path);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/commands/attachments.ts
|
|
939
|
+
import { existsSync as existsSync3, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
940
|
+
import { basename, extname, join as join5 } from "node:path";
|
|
941
|
+
|
|
942
|
+
// src/enrich.ts
|
|
943
|
+
async function loadUsersById(resolver) {
|
|
944
|
+
try {
|
|
945
|
+
const users = await resolver.allUsers();
|
|
946
|
+
return new Map(users.map((u) => [u.id, { name: u.name, slug: u.slug }]));
|
|
947
|
+
} catch (error) {
|
|
948
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
949
|
+
process.stderr.write(`[threa] enrichment skipped: users fetch failed: ${detail}
|
|
950
|
+
`);
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function enrichOne(row, usersById) {
|
|
955
|
+
if (!row || typeof row !== "object")
|
|
956
|
+
return row;
|
|
957
|
+
const m = row;
|
|
958
|
+
if (typeof m.authorId !== "string")
|
|
959
|
+
return row;
|
|
960
|
+
const user = usersById.get(m.authorId);
|
|
961
|
+
const name = user?.name ?? (typeof m.authorDisplayName === "string" ? m.authorDisplayName : undefined);
|
|
962
|
+
const author = { id: m.authorId };
|
|
963
|
+
if (typeof m.authorType === "string")
|
|
964
|
+
author.type = m.authorType;
|
|
965
|
+
if (name != null)
|
|
966
|
+
author.name = name;
|
|
967
|
+
if (user?.slug)
|
|
968
|
+
author.slug = user.slug;
|
|
969
|
+
return { ...m, author };
|
|
970
|
+
}
|
|
971
|
+
async function enrichMessages(rows, resolver) {
|
|
972
|
+
if (!Array.isArray(rows))
|
|
973
|
+
return rows;
|
|
974
|
+
if (!rows.some((row) => row && typeof row === "object" && typeof row.authorId === "string")) {
|
|
975
|
+
return rows;
|
|
976
|
+
}
|
|
977
|
+
const usersById = await loadUsersById(resolver);
|
|
978
|
+
if (!usersById)
|
|
979
|
+
return rows;
|
|
980
|
+
return rows.map((row) => enrichOne(row, usersById));
|
|
981
|
+
}
|
|
982
|
+
function toStreamRef(info, id) {
|
|
983
|
+
const ref = { id };
|
|
984
|
+
if (info?.displayName ?? info?.slug)
|
|
985
|
+
ref.name = info.displayName ?? info.slug;
|
|
986
|
+
if (info?.type)
|
|
987
|
+
ref.type = info.type;
|
|
988
|
+
return ref;
|
|
989
|
+
}
|
|
990
|
+
async function enrichStreamContext(rows, resolver) {
|
|
991
|
+
if (!Array.isArray(rows))
|
|
992
|
+
return rows;
|
|
993
|
+
const streamIds = new Set;
|
|
994
|
+
for (const row of rows) {
|
|
995
|
+
if (!row || typeof row !== "object")
|
|
996
|
+
continue;
|
|
997
|
+
const { streamId, rootStreamId } = row;
|
|
998
|
+
if (typeof streamId === "string")
|
|
999
|
+
streamIds.add(streamId);
|
|
1000
|
+
if (typeof rootStreamId === "string")
|
|
1001
|
+
streamIds.add(rootStreamId);
|
|
1002
|
+
}
|
|
1003
|
+
if (streamIds.size === 0)
|
|
1004
|
+
return rows;
|
|
1005
|
+
const infos = new Map;
|
|
1006
|
+
await Promise.all([...streamIds].map(async (id) => infos.set(id, await resolver.streamInfo(id))));
|
|
1007
|
+
const rootIds = new Set;
|
|
1008
|
+
for (const [id, info] of infos) {
|
|
1009
|
+
if (info?.rootStreamId && info.rootStreamId !== id && !infos.has(info.rootStreamId)) {
|
|
1010
|
+
rootIds.add(info.rootStreamId);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
await Promise.all([...rootIds].map(async (id) => infos.set(id, await resolver.streamInfo(id))));
|
|
1014
|
+
return rows.map((row) => {
|
|
1015
|
+
if (!row || typeof row !== "object")
|
|
1016
|
+
return row;
|
|
1017
|
+
const r = row;
|
|
1018
|
+
if (typeof r.streamId !== "string")
|
|
1019
|
+
return row;
|
|
1020
|
+
const info = infos.get(r.streamId) ?? null;
|
|
1021
|
+
const out = { ...r, stream: toStreamRef(info, r.streamId) };
|
|
1022
|
+
const rootId = typeof r.rootStreamId === "string" ? r.rootStreamId : info?.rootStreamId;
|
|
1023
|
+
if (rootId && rootId !== r.streamId) {
|
|
1024
|
+
out.rootStream = toStreamRef(infos.get(rootId) ?? null, rootId);
|
|
1025
|
+
}
|
|
1026
|
+
return out;
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
async function enrichConversation(conversation, resolver) {
|
|
1030
|
+
if (!conversation || typeof conversation !== "object")
|
|
1031
|
+
return conversation;
|
|
1032
|
+
const c = conversation;
|
|
1033
|
+
if (!Array.isArray(c.participantIds) || c.participantIds.length === 0)
|
|
1034
|
+
return conversation;
|
|
1035
|
+
const usersById = await loadUsersById(resolver);
|
|
1036
|
+
if (!usersById)
|
|
1037
|
+
return conversation;
|
|
1038
|
+
const participants = c.participantIds.map((id) => {
|
|
1039
|
+
if (typeof id !== "string")
|
|
1040
|
+
return { id };
|
|
1041
|
+
const user = usersById.get(id);
|
|
1042
|
+
const entry = { id };
|
|
1043
|
+
if (user?.name)
|
|
1044
|
+
entry.name = user.name;
|
|
1045
|
+
if (user?.slug)
|
|
1046
|
+
entry.slug = user.slug;
|
|
1047
|
+
return entry;
|
|
1048
|
+
});
|
|
1049
|
+
return { ...c, participants };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/tools/constants.ts
|
|
1053
|
+
var CALLBACK_TOKEN_HEADER = "X-Threa-Callback-Token";
|
|
1054
|
+
var STREAM_TYPES = ["scratchpad", "channel", "dm", "thread", "system", "aside"];
|
|
1055
|
+
var CONVERSATION_STATUSES = ["active", "stalled", "resolved"];
|
|
1056
|
+
var MEMO_TYPES = ["message", "conversation"];
|
|
1057
|
+
var KNOWLEDGE_TYPES = ["decision", "learning", "procedure", "context", "reference"];
|
|
1058
|
+
var MEMO_SCOPES = ["user", "stream", "workspace"];
|
|
1059
|
+
var EXTRACTION_CONTENT_TYPES = [
|
|
1060
|
+
"chart",
|
|
1061
|
+
"table",
|
|
1062
|
+
"diagram",
|
|
1063
|
+
"screenshot",
|
|
1064
|
+
"photo",
|
|
1065
|
+
"document",
|
|
1066
|
+
"other"
|
|
1067
|
+
];
|
|
1068
|
+
|
|
1069
|
+
// src/ops.ts
|
|
1070
|
+
async function whoami(client, config) {
|
|
1071
|
+
const { data } = await client.get("/me");
|
|
1072
|
+
return {
|
|
1073
|
+
principal: data,
|
|
1074
|
+
declaredPrincipal: config.principal ?? null,
|
|
1075
|
+
baseUrl: config.baseUrl,
|
|
1076
|
+
workspaceId: config.workspaceId
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
function listStreams(client, p) {
|
|
1080
|
+
return client.get(`/streams${buildQuery({
|
|
1081
|
+
type: p.type,
|
|
1082
|
+
query: p.query,
|
|
1083
|
+
after: p.after,
|
|
1084
|
+
limit: p.limit,
|
|
1085
|
+
includeArchived: p.includeArchived ? "true" : undefined
|
|
1086
|
+
})}`);
|
|
1087
|
+
}
|
|
1088
|
+
async function openSealedRows(sealed, streamId, rows) {
|
|
1089
|
+
if (!Array.isArray(rows))
|
|
1090
|
+
return rows;
|
|
1091
|
+
const opened = [];
|
|
1092
|
+
for (const row of rows) {
|
|
1093
|
+
const candidate = row;
|
|
1094
|
+
if (!row || typeof row !== "object" || !candidate.sealed) {
|
|
1095
|
+
opened.push(row);
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
const { sealed: envelope, ...rest } = candidate;
|
|
1099
|
+
const body = await sealed.open(streamId, envelope);
|
|
1100
|
+
opened.push({
|
|
1101
|
+
...rest,
|
|
1102
|
+
content: body.contentMarkdown,
|
|
1103
|
+
...body.attachmentRefs.length > 0 ? { sealedAttachmentRefs: body.attachmentRefs } : {},
|
|
1104
|
+
...body.unreadableReason === undefined ? {} : { unreadableReason: body.unreadableReason }
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return opened;
|
|
1108
|
+
}
|
|
1109
|
+
async function readStream(client, resolver, p) {
|
|
1110
|
+
const streamId = await resolver.resolveStream(p.streamId);
|
|
1111
|
+
const id = encodeURIComponent(streamId);
|
|
1112
|
+
const streamReq = client.get(`/streams/${id}`);
|
|
1113
|
+
const messagesReq = client.get(`/streams/${id}/messages${buildQuery({ before: p.before, after: p.after, limit: p.limit })}`);
|
|
1114
|
+
const membersReq = p.includeMembers ? client.get(`/streams/${id}/members`) : undefined;
|
|
1115
|
+
const [streamResp, messagesResp, membersResp] = await Promise.all([
|
|
1116
|
+
streamReq,
|
|
1117
|
+
messagesReq,
|
|
1118
|
+
membersReq ?? Promise.resolve(undefined)
|
|
1119
|
+
]);
|
|
1120
|
+
const enriched = await enrichMessages(messagesResp.data, resolver);
|
|
1121
|
+
const messages = p.sealed ? await openSealedRows(p.sealed, streamId, enriched) : enriched;
|
|
1122
|
+
const result = {
|
|
1123
|
+
stream: streamResp.data,
|
|
1124
|
+
messages: { data: messages, hasMore: messagesResp.hasMore ?? false }
|
|
1125
|
+
};
|
|
1126
|
+
if (membersResp) {
|
|
1127
|
+
result.members = {
|
|
1128
|
+
data: membersResp.data,
|
|
1129
|
+
hasMore: membersResp.hasMore ?? false,
|
|
1130
|
+
cursor: membersResp.cursor ?? null
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
return result;
|
|
1134
|
+
}
|
|
1135
|
+
async function setStreamArchived(client, resolver, p) {
|
|
1136
|
+
const id = encodeURIComponent(await resolver.resolveStream(p.streamRef));
|
|
1137
|
+
return client.post(`/streams/${id}/${p.archived ? "archive" : "unarchive"}`);
|
|
1138
|
+
}
|
|
1139
|
+
function listUsers(client, p) {
|
|
1140
|
+
return client.get(`/users${buildQuery({ query: p.query, after: p.after, limit: p.limit })}`);
|
|
1141
|
+
}
|
|
1142
|
+
async function findMessagesByMetadata(client, resolver, p) {
|
|
1143
|
+
const streamId = p.streamRef ? await resolver.resolveStream(p.streamRef) : undefined;
|
|
1144
|
+
const response = await client.post("/messages/find-by-metadata", {
|
|
1145
|
+
metadata: p.metadata,
|
|
1146
|
+
streamId,
|
|
1147
|
+
limit: p.limit
|
|
1148
|
+
});
|
|
1149
|
+
return { ...response, data: await enrichMessages(response.data, resolver) };
|
|
1150
|
+
}
|
|
1151
|
+
async function listConversations(client, resolver, p) {
|
|
1152
|
+
const streamId = p.streamRef ? await resolver.resolveStream(p.streamRef) : undefined;
|
|
1153
|
+
const response = await client.get(`/conversations${buildQuery({ streamId, status: p.status, after: p.cursor, limit: p.limit })}`);
|
|
1154
|
+
const withParticipants = await Promise.all(response.data.map((c) => enrichConversation(c, resolver)));
|
|
1155
|
+
return { ...response, data: await enrichStreamContext(withParticipants, resolver) };
|
|
1156
|
+
}
|
|
1157
|
+
async function readConversation(client, resolver, p) {
|
|
1158
|
+
const id = encodeURIComponent(p.conversationId);
|
|
1159
|
+
const [conversationResp, messagesResp] = await Promise.all([
|
|
1160
|
+
client.get(`/conversations/${id}`),
|
|
1161
|
+
client.get(`/conversations/${id}/messages${buildQuery({ after: p.cursor, limit: p.limit })}`)
|
|
1162
|
+
]);
|
|
1163
|
+
const [conversation] = await enrichStreamContext([await enrichConversation(conversationResp.data, resolver)], resolver);
|
|
1164
|
+
return {
|
|
1165
|
+
conversation,
|
|
1166
|
+
messages: {
|
|
1167
|
+
data: await enrichMessages(messagesResp.data, resolver),
|
|
1168
|
+
hasMore: messagesResp.hasMore ?? false,
|
|
1169
|
+
cursor: messagesResp.cursor ?? null
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
function getMemo(client, memoId) {
|
|
1174
|
+
return client.get(`/memos/${encodeURIComponent(memoId)}`);
|
|
1175
|
+
}
|
|
1176
|
+
function getAttachment(client, attachmentId) {
|
|
1177
|
+
return client.get(`/attachments/${encodeURIComponent(attachmentId)}`);
|
|
1178
|
+
}
|
|
1179
|
+
function getAttachmentDownloadUrl(client, attachmentId) {
|
|
1180
|
+
return client.get(`/attachments/${encodeURIComponent(attachmentId)}/url`);
|
|
1181
|
+
}
|
|
1182
|
+
var SEARCH_WHATS = ["messages", "memos", "attachments"];
|
|
1183
|
+
var ALLOWED_FILTERS = {
|
|
1184
|
+
messages: ["query", "semantic", "exact", "stream_ids", "type", "before", "after", "limit"],
|
|
1185
|
+
memos: ["query", "exact", "stream_ids", "memo_type", "knowledge_type", "tags", "scope", "before", "after", "limit"],
|
|
1186
|
+
attachments: ["query", "stream_ids", "content_types", "limit"]
|
|
1187
|
+
};
|
|
1188
|
+
var FILTER_KEYS = [
|
|
1189
|
+
"query",
|
|
1190
|
+
"semantic",
|
|
1191
|
+
"exact",
|
|
1192
|
+
"stream_ids",
|
|
1193
|
+
"type",
|
|
1194
|
+
"memo_type",
|
|
1195
|
+
"knowledge_type",
|
|
1196
|
+
"tags",
|
|
1197
|
+
"scope",
|
|
1198
|
+
"content_types",
|
|
1199
|
+
"before",
|
|
1200
|
+
"after",
|
|
1201
|
+
"limit"
|
|
1202
|
+
];
|
|
1203
|
+
async function search(client, resolver, args) {
|
|
1204
|
+
const { what } = args;
|
|
1205
|
+
const allowed = ALLOWED_FILTERS[what];
|
|
1206
|
+
const raw = args;
|
|
1207
|
+
const offending = FILTER_KEYS.filter((key) => raw[key] !== undefined && !allowed.includes(key));
|
|
1208
|
+
if (offending.length > 0) {
|
|
1209
|
+
const validList = allowed.filter((f) => f !== "query" && f !== "limit").join(", ");
|
|
1210
|
+
throw new ToolInputError("UNSUPPORTED_FILTER", `search what="${what}" does not support: ${offending.join(", ")}. ` + `Valid filters for "${what}": ${validList || "none"} (plus query, limit).`);
|
|
1211
|
+
}
|
|
1212
|
+
const query = args.query;
|
|
1213
|
+
if (what === "messages" && (query === undefined || query.trim() === "")) {
|
|
1214
|
+
throw new ToolInputError("INVALID_ARGUMENT", `search what="messages" requires a non-empty query. Omitting the query (recent-first browse) is only ` + `supported for what="memos" and what="attachments".`);
|
|
1215
|
+
}
|
|
1216
|
+
if (what === "attachments" && query !== undefined && query.trim() === "") {
|
|
1217
|
+
throw new ToolInputError("INVALID_ARGUMENT", `search what="attachments" got a blank query. Pass a non-empty query, or omit it entirely to browse the most recent attachments.`);
|
|
1218
|
+
}
|
|
1219
|
+
if (what !== "memos" && args.limit !== undefined && args.limit > 50) {
|
|
1220
|
+
throw new ToolInputError("INVALID_ARGUMENT", `search what="${what}" caps limit at 50 (only what="memos" allows up to 100).`);
|
|
1221
|
+
}
|
|
1222
|
+
if (what === "messages") {
|
|
1223
|
+
const streams2 = args.stream_ids ? await resolver.resolveStreams(args.stream_ids) : undefined;
|
|
1224
|
+
const response2 = await client.post("/messages/search", {
|
|
1225
|
+
query,
|
|
1226
|
+
semantic: args.semantic,
|
|
1227
|
+
exact: args.exact,
|
|
1228
|
+
streams: streams2,
|
|
1229
|
+
type: args.type,
|
|
1230
|
+
before: args.before,
|
|
1231
|
+
after: args.after,
|
|
1232
|
+
limit: args.limit
|
|
1233
|
+
});
|
|
1234
|
+
const data = await enrichStreamContext(await enrichMessages(response2.data, resolver), resolver);
|
|
1235
|
+
return { ...response2, data };
|
|
1236
|
+
}
|
|
1237
|
+
if (what === "memos") {
|
|
1238
|
+
const streams2 = args.stream_ids ? await resolver.resolveStreams(args.stream_ids) : undefined;
|
|
1239
|
+
return client.post("/memos/search", {
|
|
1240
|
+
query,
|
|
1241
|
+
exact: args.exact,
|
|
1242
|
+
streams: streams2,
|
|
1243
|
+
memoType: args.memo_type,
|
|
1244
|
+
knowledgeType: args.knowledge_type,
|
|
1245
|
+
tags: args.tags,
|
|
1246
|
+
scope: args.scope,
|
|
1247
|
+
before: args.before,
|
|
1248
|
+
after: args.after,
|
|
1249
|
+
limit: args.limit
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
const streams = args.stream_ids ? await resolver.resolveStreams(args.stream_ids) : undefined;
|
|
1253
|
+
const response = await client.post("/attachments/search", {
|
|
1254
|
+
query,
|
|
1255
|
+
streams,
|
|
1256
|
+
contentTypes: args.content_types,
|
|
1257
|
+
limit: args.limit
|
|
1258
|
+
});
|
|
1259
|
+
return { ...response, data: await enrichStreamContext(response.data, resolver) };
|
|
1260
|
+
}
|
|
1261
|
+
function sealedSendArgError(p) {
|
|
1262
|
+
if (p.metadata)
|
|
1263
|
+
return "An end-to-end-encrypted stream takes no metadata: it would travel in the clear.";
|
|
1264
|
+
if (p.conversationId || p.startConversation) {
|
|
1265
|
+
return "Conversations are not available in an end-to-end-encrypted stream.";
|
|
1266
|
+
}
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
function sendConversationArgError(p) {
|
|
1270
|
+
if (p.conversationId && p.startConversation) {
|
|
1271
|
+
return "Pass either conversation_id (resume an existing conversation) or start_conversation (open a new one), not both.";
|
|
1272
|
+
}
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
async function sendMessage(client, resolver, p) {
|
|
1276
|
+
const argError = sendConversationArgError(p);
|
|
1277
|
+
if (argError) {
|
|
1278
|
+
throw new ToolInputError("INVALID_ARGUMENT", argError);
|
|
1279
|
+
}
|
|
1280
|
+
const streamId = await resolver.resolveStream(p.streamRef);
|
|
1281
|
+
if (p.sealed) {
|
|
1282
|
+
const stream = await client.get(`/streams/${encodeURIComponent(streamId)}`);
|
|
1283
|
+
if (stream.data.e2eEnabled === true) {
|
|
1284
|
+
const sealedArgError = sealedSendArgError(p);
|
|
1285
|
+
if (sealedArgError)
|
|
1286
|
+
throw new ToolInputError("INVALID_ARGUMENT", sealedArgError);
|
|
1287
|
+
const sent = await p.sealed.send(streamId, p.content, {
|
|
1288
|
+
...p.clientMessageId === undefined ? {} : { clientMessageId: p.clientMessageId }
|
|
1289
|
+
});
|
|
1290
|
+
return { data: { id: sent.messageId }, clientMessageId: sent.clientMessageId, sealed: true };
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const clientMessageId = p.clientMessageId ?? `mcp-${crypto.randomUUID()}`;
|
|
1294
|
+
const body = { content: p.content, clientMessageId };
|
|
1295
|
+
if (p.metadata)
|
|
1296
|
+
body.metadata = p.metadata;
|
|
1297
|
+
if (p.conversationId)
|
|
1298
|
+
body.conversation = { intent: "existing", conversationId: p.conversationId };
|
|
1299
|
+
else if (p.startConversation)
|
|
1300
|
+
body.conversation = { intent: "new" };
|
|
1301
|
+
const response = await client.post(`/streams/${encodeURIComponent(streamId)}/messages`, body);
|
|
1302
|
+
return { ...response, clientMessageId, ...p.sealed ? { sealed: false } : {} };
|
|
1303
|
+
}
|
|
1304
|
+
function updateMessage(client, messageId, content) {
|
|
1305
|
+
return client.patch(`/messages/${encodeURIComponent(messageId)}`, { content });
|
|
1306
|
+
}
|
|
1307
|
+
async function deleteMessage(client, messageId) {
|
|
1308
|
+
await client.delete(`/messages/${encodeURIComponent(messageId)}`);
|
|
1309
|
+
return { deleted: true, message_id: messageId };
|
|
1310
|
+
}
|
|
1311
|
+
function listLabels(client) {
|
|
1312
|
+
return client.get("/labels");
|
|
1313
|
+
}
|
|
1314
|
+
async function applyLabel(client, resolver, p) {
|
|
1315
|
+
return client.post("/labels/assignments", {
|
|
1316
|
+
name: p.name,
|
|
1317
|
+
color: p.color,
|
|
1318
|
+
emoji: p.emoji,
|
|
1319
|
+
description: p.description,
|
|
1320
|
+
resourceType: "stream",
|
|
1321
|
+
resourceId: await resolver.resolveStream(p.streamRef)
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
async function removeLabel(client, resolver, p) {
|
|
1325
|
+
const resourceId = await resolver.resolveStream(p.streamRef);
|
|
1326
|
+
await client.delete(`/labels/assignments${buildQuery({ name: p.name, resourceType: "stream", resourceId })}`);
|
|
1327
|
+
return { removed: true, name: p.name, stream_id: resourceId };
|
|
1328
|
+
}
|
|
1329
|
+
function listDelegations(client, p) {
|
|
1330
|
+
return client.get(`/delegations${buildQuery({ status: p.status, since: p.since })}`);
|
|
1331
|
+
}
|
|
1332
|
+
function getDelegation(client, delegationId) {
|
|
1333
|
+
return client.get(`/delegations/${encodeURIComponent(delegationId)}`);
|
|
1334
|
+
}
|
|
1335
|
+
async function claimDelegation(client, store, workspaceId, p) {
|
|
1336
|
+
const response = await client.post(`/delegations/${encodeURIComponent(p.delegationId)}/claim`, { claimedByLabel: p.claimedByLabel, idempotencyKey: p.idempotencyKey });
|
|
1337
|
+
const token = response.data?.claimToken;
|
|
1338
|
+
if (token)
|
|
1339
|
+
store.set(workspaceId, p.delegationId, token);
|
|
1340
|
+
return response;
|
|
1341
|
+
}
|
|
1342
|
+
function resolveDelegationToken(store, workspaceId, delegationId, explicit) {
|
|
1343
|
+
const token = explicit ?? store.get(workspaceId, delegationId);
|
|
1344
|
+
if (!token) {
|
|
1345
|
+
throw new ToolInputError("MISSING_CLAIM_TOKEN", "No claim token for this delegation. Pass claim_token — the value the claim returned once. The persistent " + "store has no token for this id (you never claimed it, or the claim was already finished and cleared).");
|
|
1346
|
+
}
|
|
1347
|
+
return token;
|
|
1348
|
+
}
|
|
1349
|
+
async function releaseDelegation(client, store, workspaceId, p) {
|
|
1350
|
+
const token = resolveDelegationToken(store, workspaceId, p.delegationId, p.claimToken);
|
|
1351
|
+
const headers = { [CALLBACK_TOKEN_HEADER]: token };
|
|
1352
|
+
const response = await client.post(`/delegations/${encodeURIComponent(p.delegationId)}/release`, undefined, headers);
|
|
1353
|
+
store.deleteIfMatches(workspaceId, p.delegationId, token);
|
|
1354
|
+
return response;
|
|
1355
|
+
}
|
|
1356
|
+
function updateDelegation(client, store, workspaceId, p) {
|
|
1357
|
+
const headers = { [CALLBACK_TOKEN_HEADER]: resolveDelegationToken(store, workspaceId, p.delegationId, p.claimToken) };
|
|
1358
|
+
const id = encodeURIComponent(p.delegationId);
|
|
1359
|
+
return p.statusNote ? client.post(`/delegations/${id}/status`, { statusNote: p.statusNote }, headers) : client.post(`/delegations/${id}/heartbeat`, undefined, headers);
|
|
1360
|
+
}
|
|
1361
|
+
function finishDelegationArgError(p) {
|
|
1362
|
+
if (p.outcome === "fail") {
|
|
1363
|
+
if (!p.errorMessage)
|
|
1364
|
+
return "outcome=fail requires error_message.";
|
|
1365
|
+
const misplaced = [];
|
|
1366
|
+
if (p.resultMarkdown !== undefined)
|
|
1367
|
+
misplaced.push("result_markdown");
|
|
1368
|
+
if (p.metadata !== undefined)
|
|
1369
|
+
misplaced.push("metadata");
|
|
1370
|
+
if (misplaced.length > 0) {
|
|
1371
|
+
return `outcome=fail does not accept ${misplaced.join(", ")} — those belong to outcome=complete.`;
|
|
1372
|
+
}
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (p.errorMessage !== undefined) {
|
|
1376
|
+
return "outcome=complete does not accept error_message — that belongs to outcome=fail.";
|
|
1377
|
+
}
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
async function finishDelegation(client, store, workspaceId, p) {
|
|
1381
|
+
const argError = finishDelegationArgError(p);
|
|
1382
|
+
if (argError)
|
|
1383
|
+
throw new ToolInputError("INVALID_ARGUMENT", argError);
|
|
1384
|
+
const headers = { [CALLBACK_TOKEN_HEADER]: resolveDelegationToken(store, workspaceId, p.delegationId, p.claimToken) };
|
|
1385
|
+
const id = encodeURIComponent(p.delegationId);
|
|
1386
|
+
const body = {};
|
|
1387
|
+
let path;
|
|
1388
|
+
if (p.outcome === "fail") {
|
|
1389
|
+
path = `/delegations/${id}/fail`;
|
|
1390
|
+
body.errorMessage = p.errorMessage;
|
|
1391
|
+
} else {
|
|
1392
|
+
path = `/delegations/${id}/complete`;
|
|
1393
|
+
if (p.resultMarkdown)
|
|
1394
|
+
body.resultMarkdown = p.resultMarkdown;
|
|
1395
|
+
if (p.metadata)
|
|
1396
|
+
body.metadata = p.metadata;
|
|
1397
|
+
}
|
|
1398
|
+
const response = await client.post(path, body, headers);
|
|
1399
|
+
store.delete(workspaceId, p.delegationId);
|
|
1400
|
+
return response;
|
|
1401
|
+
}
|
|
1402
|
+
function requestDelegationAccess(client, delegationId) {
|
|
1403
|
+
return client.post(`/delegations/${encodeURIComponent(delegationId)}/request-access`, {});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/commands/search.ts
|
|
1407
|
+
var searchCommand = {
|
|
1408
|
+
name: "search",
|
|
1409
|
+
summary: "Search the workspace across messages, memos, or attachments",
|
|
1410
|
+
usage: "threa search [query] --what messages|memos|attachments [per-what filters]",
|
|
1411
|
+
help: `threa search [query] --what messages|memos|attachments [flags]
|
|
1412
|
+
|
|
1413
|
+
` + "One search routed by --what. Only the filters valid for the chosen --what are accepted; passing another " + "is an error naming the offending flag. query is required for messages; for memos and attachments it is " + `optional (omit it to browse the most recent, newest first).
|
|
1414
|
+
|
|
1415
|
+
` + `Common flags:
|
|
1416
|
+
` + ` --what w messages | memos | attachments (required)
|
|
1417
|
+
` + ` --stream ref limit to a source stream (stream_ id or #slug); repeatable
|
|
1418
|
+
` + ` --limit n max results (messages/attachments <= 50, memos <= 100; default 20)
|
|
1419
|
+
` + ` --before iso only results before this ISO-8601 datetime (messages and memos only)
|
|
1420
|
+
` + ` --after iso only results after this ISO-8601 datetime (messages and memos only)
|
|
1421
|
+
` + "messages: --semantic --exact --type " + STREAM_TYPES.join("|") + ` (repeatable)
|
|
1422
|
+
` + "memos: --exact --memo-type " + MEMO_TYPES.join("|") + " (repeatable) --knowledge-type " + KNOWLEDGE_TYPES.join("|") + " (repeatable) --tag t (repeatable) --scope " + MEMO_SCOPES.join("|") + `
|
|
1423
|
+
` + "attachments: --content-type " + EXTRACTION_CONTENT_TYPES.join("|") + ` (repeatable)
|
|
1424
|
+
` + ` --json force JSON output
|
|
1425
|
+
` + " --help show this help",
|
|
1426
|
+
options: {
|
|
1427
|
+
what: { type: "string" },
|
|
1428
|
+
semantic: { type: "boolean" },
|
|
1429
|
+
exact: { type: "boolean" },
|
|
1430
|
+
stream: { type: "string", multiple: true },
|
|
1431
|
+
type: { type: "string", multiple: true },
|
|
1432
|
+
"memo-type": { type: "string", multiple: true },
|
|
1433
|
+
"knowledge-type": { type: "string", multiple: true },
|
|
1434
|
+
tag: { type: "string", multiple: true },
|
|
1435
|
+
scope: { type: "string" },
|
|
1436
|
+
"content-type": { type: "string", multiple: true },
|
|
1437
|
+
before: { type: "string" },
|
|
1438
|
+
after: { type: "string" },
|
|
1439
|
+
limit: { type: "string" }
|
|
1440
|
+
},
|
|
1441
|
+
run: (ctx, positionals, values) => {
|
|
1442
|
+
const what = stringFlag(values, "what");
|
|
1443
|
+
if (!what)
|
|
1444
|
+
throw new UsageError("search requires --what (messages, memos, or attachments)");
|
|
1445
|
+
if (!SEARCH_WHATS.includes(what)) {
|
|
1446
|
+
throw new UsageError(`--what must be one of ${SEARCH_WHATS.join(", ")}, got "${what}"`);
|
|
1447
|
+
}
|
|
1448
|
+
const args = { what };
|
|
1449
|
+
const query = positionals[0];
|
|
1450
|
+
if (query !== undefined)
|
|
1451
|
+
args.query = query;
|
|
1452
|
+
if (values.semantic === true)
|
|
1453
|
+
args.semantic = true;
|
|
1454
|
+
if (values.exact === true)
|
|
1455
|
+
args.exact = true;
|
|
1456
|
+
const streams = arrayFlag(values, "stream");
|
|
1457
|
+
if (streams)
|
|
1458
|
+
args.stream_ids = streams;
|
|
1459
|
+
const type = arrayFlag(values, "type");
|
|
1460
|
+
if (type)
|
|
1461
|
+
args.type = type;
|
|
1462
|
+
const memoType = arrayFlag(values, "memo-type");
|
|
1463
|
+
if (memoType)
|
|
1464
|
+
args.memo_type = memoType;
|
|
1465
|
+
const knowledgeType = arrayFlag(values, "knowledge-type");
|
|
1466
|
+
if (knowledgeType)
|
|
1467
|
+
args.knowledge_type = knowledgeType;
|
|
1468
|
+
const tags = arrayFlag(values, "tag");
|
|
1469
|
+
if (tags)
|
|
1470
|
+
args.tags = tags;
|
|
1471
|
+
const scope = stringFlag(values, "scope");
|
|
1472
|
+
if (scope)
|
|
1473
|
+
args.scope = scope;
|
|
1474
|
+
const contentTypes = arrayFlag(values, "content-type");
|
|
1475
|
+
if (contentTypes)
|
|
1476
|
+
args.content_types = contentTypes;
|
|
1477
|
+
const before = stringFlag(values, "before");
|
|
1478
|
+
if (before)
|
|
1479
|
+
args.before = before;
|
|
1480
|
+
const after = stringFlag(values, "after");
|
|
1481
|
+
if (after)
|
|
1482
|
+
args.after = after;
|
|
1483
|
+
const limit = intFlag(values, "limit");
|
|
1484
|
+
if (limit !== undefined)
|
|
1485
|
+
args.limit = limit;
|
|
1486
|
+
return search(ctx.client, ctx.resolver, args);
|
|
1487
|
+
},
|
|
1488
|
+
render: (payload) => {
|
|
1489
|
+
const p = payload;
|
|
1490
|
+
const rows = p.data ?? [];
|
|
1491
|
+
if (rows.length === 0)
|
|
1492
|
+
return "(no results)";
|
|
1493
|
+
return rows.map(renderSearchResult).join(`
|
|
1494
|
+
`);
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
function joinFields(fields) {
|
|
1498
|
+
return fields.filter(Boolean).join(" ");
|
|
1499
|
+
}
|
|
1500
|
+
function renderSearchResult(r) {
|
|
1501
|
+
if (r.memo && typeof r.memo === "object")
|
|
1502
|
+
return renderMemoResult(r);
|
|
1503
|
+
if (typeof r.filename === "string")
|
|
1504
|
+
return renderAttachmentResult(r);
|
|
1505
|
+
return renderMessageResult(r);
|
|
1506
|
+
}
|
|
1507
|
+
function renderMessageResult(r) {
|
|
1508
|
+
const author = r.author;
|
|
1509
|
+
const who = author?.name ?? (typeof r.authorDisplayName === "string" ? r.authorDisplayName : undefined);
|
|
1510
|
+
const header = joinFields([
|
|
1511
|
+
String(r.id ?? "?"),
|
|
1512
|
+
fmtTimestamp(r.createdAt),
|
|
1513
|
+
streamLabel(r),
|
|
1514
|
+
who
|
|
1515
|
+
]);
|
|
1516
|
+
return `${header}
|
|
1517
|
+
${snippet(r.content)}`;
|
|
1518
|
+
}
|
|
1519
|
+
function renderMemoResult(r) {
|
|
1520
|
+
const memo = r.memo;
|
|
1521
|
+
const source = r.sourceStream ?? null;
|
|
1522
|
+
const root = r.rootStream ?? null;
|
|
1523
|
+
const scope = [root?.name, source?.name].filter((n) => Boolean(n));
|
|
1524
|
+
const scopeLabel = scope[0] === scope[1] ? scope[0] : scope.join(" › ");
|
|
1525
|
+
const header = joinFields([
|
|
1526
|
+
String(memo.id ?? "?"),
|
|
1527
|
+
typeof memo.knowledgeType === "string" ? memo.knowledgeType : undefined,
|
|
1528
|
+
fmtTimestamp(memo.createdAt),
|
|
1529
|
+
scopeLabel || undefined
|
|
1530
|
+
]);
|
|
1531
|
+
const title = typeof memo.title === "string" ? memo.title : "";
|
|
1532
|
+
return `${header}
|
|
1533
|
+
${snippet(`${title}${title && memo.abstract ? " — " : ""}${String(memo.abstract ?? "")}`)}`;
|
|
1534
|
+
}
|
|
1535
|
+
function renderAttachmentResult(r) {
|
|
1536
|
+
const header = joinFields([
|
|
1537
|
+
String(r.id ?? "?"),
|
|
1538
|
+
String(r.filename ?? "?"),
|
|
1539
|
+
typeof r.mimeType === "string" ? r.mimeType : undefined,
|
|
1540
|
+
fmtTimestamp(r.createdAt),
|
|
1541
|
+
streamLabel(r)
|
|
1542
|
+
]);
|
|
1543
|
+
const summary = snippet(r.summary);
|
|
1544
|
+
return summary ? `${header}
|
|
1545
|
+
${summary}` : header;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// src/commands/attachments.ts
|
|
1549
|
+
var listVerb = {
|
|
1550
|
+
name: "list",
|
|
1551
|
+
summary: "List the most recent attachments, optionally scoped to streams",
|
|
1552
|
+
usage: "threa attachments list [--stream ref]... [--limit n]",
|
|
1553
|
+
help: `threa attachments list [flags]
|
|
1554
|
+
|
|
1555
|
+
` + "Browse accessible attachments, newest first (a query-less attachment search). Filter by content with " + "`threa search --what attachments`.\n\n" + `Flags:
|
|
1556
|
+
` + ` --stream ref limit to a source stream (stream_ id or #slug); repeatable
|
|
1557
|
+
` + ` --limit n max results, <= 50 (default 20)
|
|
1558
|
+
` + ` --json force JSON output
|
|
1559
|
+
` + " --help show this help",
|
|
1560
|
+
options: {
|
|
1561
|
+
stream: { type: "string", multiple: true },
|
|
1562
|
+
limit: { type: "string" }
|
|
1563
|
+
},
|
|
1564
|
+
run: (ctx, _positionals, values) => search(ctx.client, ctx.resolver, {
|
|
1565
|
+
what: "attachments",
|
|
1566
|
+
stream_ids: arrayFlag(values, "stream"),
|
|
1567
|
+
limit: intFlag(values, "limit")
|
|
1568
|
+
}),
|
|
1569
|
+
render: (payload) => {
|
|
1570
|
+
const rows = payload.data ?? [];
|
|
1571
|
+
return rows.map(renderSearchResult).join(`
|
|
1572
|
+
`) || "(no attachments)";
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
var getVerb = {
|
|
1576
|
+
name: "get",
|
|
1577
|
+
summary: "Get an attachment's metadata and extracted text, or a signed download URL",
|
|
1578
|
+
usage: "threa attachments get <id> [--url]",
|
|
1579
|
+
help: `threa attachments get <id> [flags]
|
|
1580
|
+
|
|
1581
|
+
` + "Retrieve an attachment's metadata and extracted text. With --url, instead return a short-lived signed URL " + `to download the raw bytes.
|
|
1582
|
+
|
|
1583
|
+
` + `Flags:
|
|
1584
|
+
` + ` --url return a short-lived signed download URL instead of the metadata/text
|
|
1585
|
+
` + ` --json force JSON output
|
|
1586
|
+
` + " --help show this help",
|
|
1587
|
+
options: {
|
|
1588
|
+
url: { type: "boolean" }
|
|
1589
|
+
},
|
|
1590
|
+
run: (ctx, positionals, values) => {
|
|
1591
|
+
const id = positionals[0];
|
|
1592
|
+
if (!id)
|
|
1593
|
+
throw new UsageError("attachments get requires a <id> (an att_ id)");
|
|
1594
|
+
return boolFlag(values, "url") ? getAttachmentDownloadUrl(ctx.client, id) : getAttachment(ctx.client, id);
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
function resolveDownloadTarget(destination, filename) {
|
|
1598
|
+
const isDir = destination.endsWith("/") || existsSync3(destination) && statSync(destination).isDirectory();
|
|
1599
|
+
if (!isDir)
|
|
1600
|
+
return destination;
|
|
1601
|
+
const ext = extname(filename);
|
|
1602
|
+
const stem = basename(filename, ext);
|
|
1603
|
+
let candidate = join5(destination, filename);
|
|
1604
|
+
for (let n = 1;existsSync3(candidate); n++) {
|
|
1605
|
+
candidate = join5(destination, `${stem} (${n})${ext}`);
|
|
1606
|
+
}
|
|
1607
|
+
return candidate;
|
|
1608
|
+
}
|
|
1609
|
+
var downloadVerb = {
|
|
1610
|
+
name: "download",
|
|
1611
|
+
summary: "Download an attachment's raw bytes to a local file",
|
|
1612
|
+
usage: "threa attachments download <id> [destination]",
|
|
1613
|
+
help: `threa attachments download <id> [destination] [flags]
|
|
1614
|
+
|
|
1615
|
+
` + "Download the attachment's raw bytes via its signed URL. destination defaults to the current directory. " + "A directory destination names the file after the attachment and picks `name (1).ext` on conflict; an " + `explicit file path is written as given (overwriting).
|
|
1616
|
+
|
|
1617
|
+
` + `Flags:
|
|
1618
|
+
` + ` --json force JSON output
|
|
1619
|
+
` + " --help show this help",
|
|
1620
|
+
options: {},
|
|
1621
|
+
run: async (ctx, positionals) => {
|
|
1622
|
+
const id = positionals[0];
|
|
1623
|
+
if (!id)
|
|
1624
|
+
throw new UsageError("attachments download requires a <id> (an att_ id)");
|
|
1625
|
+
const [meta, urlResp] = await Promise.all([
|
|
1626
|
+
getAttachment(ctx.client, id),
|
|
1627
|
+
getAttachmentDownloadUrl(ctx.client, id)
|
|
1628
|
+
]);
|
|
1629
|
+
const url = urlResp.data?.url;
|
|
1630
|
+
if (!url)
|
|
1631
|
+
throw new Error(`no download URL returned for ${id}`);
|
|
1632
|
+
const target = resolveDownloadTarget(positionals[1] ?? ".", meta.data?.filename ?? id);
|
|
1633
|
+
const response = await fetch(url);
|
|
1634
|
+
if (!response.ok)
|
|
1635
|
+
throw new Error(`download failed: HTTP ${response.status}`);
|
|
1636
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
1637
|
+
writeFileSync3(target, bytes);
|
|
1638
|
+
return { downloaded: true, id, path: target, sizeBytes: bytes.byteLength };
|
|
1639
|
+
},
|
|
1640
|
+
render: (payload) => {
|
|
1641
|
+
const p = payload;
|
|
1642
|
+
return `downloaded ${p.id ?? "?"} → ${p.path ?? "?"} (${p.sizeBytes ?? 0} bytes)`;
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
var attachmentsNoun = {
|
|
1646
|
+
name: "attachments",
|
|
1647
|
+
summary: "List attachments, get one's text or URL, or download its bytes",
|
|
1648
|
+
verbs: [listVerb, getVerb, downloadVerb]
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
// src/commands/conversations.ts
|
|
1652
|
+
var listVerb2 = {
|
|
1653
|
+
name: "list",
|
|
1654
|
+
summary: "List conversations, optionally scoped to a stream",
|
|
1655
|
+
usage: "threa conversations list [--stream ref] [--status s] [--cursor c] [--limit n]",
|
|
1656
|
+
help: `threa conversations list [flags]
|
|
1657
|
+
|
|
1658
|
+
` + `List conversations (grouped runs of messages under a stream's effective root), newest activity first.
|
|
1659
|
+
|
|
1660
|
+
` + `Flags:
|
|
1661
|
+
` + ` --stream ref scope to a stream (stream_ id or #slug) and its threads
|
|
1662
|
+
` + " --status s " + CONVERSATION_STATUSES.join(" | ") + `
|
|
1663
|
+
` + ` --cursor c pagination cursor from a previous response
|
|
1664
|
+
` + ` --limit n max results, <= 100 (default 50)
|
|
1665
|
+
` + ` --json force JSON output
|
|
1666
|
+
` + " --help show this help",
|
|
1667
|
+
options: {
|
|
1668
|
+
stream: { type: "string" },
|
|
1669
|
+
status: { type: "string" },
|
|
1670
|
+
cursor: { type: "string" },
|
|
1671
|
+
limit: { type: "string" }
|
|
1672
|
+
},
|
|
1673
|
+
run: (ctx, _positionals, values) => listConversations(ctx.client, ctx.resolver, {
|
|
1674
|
+
streamRef: stringFlag(values, "stream"),
|
|
1675
|
+
status: enumFlag(values, "status", CONVERSATION_STATUSES),
|
|
1676
|
+
cursor: stringFlag(values, "cursor"),
|
|
1677
|
+
limit: intFlag(values, "limit")
|
|
1678
|
+
}),
|
|
1679
|
+
render: (payload) => renderList(payload, renderConversationRow, {
|
|
1680
|
+
empty: "(no conversations)",
|
|
1681
|
+
cursorFlag: "cursor"
|
|
1682
|
+
})
|
|
1683
|
+
};
|
|
1684
|
+
function renderConversationRow(c) {
|
|
1685
|
+
const header = [
|
|
1686
|
+
c.id ?? "?",
|
|
1687
|
+
c.status ?? "?",
|
|
1688
|
+
c.messageCount !== undefined ? `${c.messageCount} msgs` : undefined,
|
|
1689
|
+
fmtTimestamp(c.lastActivityAt) || undefined,
|
|
1690
|
+
streamLabel(c) || undefined
|
|
1691
|
+
].filter(Boolean).join(" ");
|
|
1692
|
+
const topic = snippet(c.topicSummary ?? c.summary ?? "");
|
|
1693
|
+
return topic ? `${header}
|
|
1694
|
+
${topic}` : header;
|
|
1695
|
+
}
|
|
1696
|
+
var readVerb = {
|
|
1697
|
+
name: "read",
|
|
1698
|
+
summary: "Read a conversation with a page of its messages",
|
|
1699
|
+
usage: "threa conversations read <id> [--cursor c] [--limit n]",
|
|
1700
|
+
help: `threa conversations read <id> [flags]
|
|
1701
|
+
|
|
1702
|
+
` + `Fetch a conversation and a page of its member messages. Page with --cursor from the previous response.
|
|
1703
|
+
|
|
1704
|
+
` + `Flags:
|
|
1705
|
+
` + ` --cursor c pagination cursor from a previous response
|
|
1706
|
+
` + ` --limit n max messages, <= 100 (default 50)
|
|
1707
|
+
` + ` --json force JSON output
|
|
1708
|
+
` + " --help show this help",
|
|
1709
|
+
options: {
|
|
1710
|
+
cursor: { type: "string" },
|
|
1711
|
+
limit: { type: "string" }
|
|
1712
|
+
},
|
|
1713
|
+
run: (ctx, positionals, values) => {
|
|
1714
|
+
const id = positionals[0];
|
|
1715
|
+
if (!id)
|
|
1716
|
+
throw new UsageError("conversations read requires a <id> (a conv_ id)");
|
|
1717
|
+
return readConversation(ctx.client, ctx.resolver, {
|
|
1718
|
+
conversationId: id,
|
|
1719
|
+
cursor: stringFlag(values, "cursor"),
|
|
1720
|
+
limit: intFlag(values, "limit")
|
|
1721
|
+
});
|
|
1722
|
+
},
|
|
1723
|
+
render: (payload) => {
|
|
1724
|
+
const p = payload;
|
|
1725
|
+
const lines = [];
|
|
1726
|
+
const c = p.conversation;
|
|
1727
|
+
if (c) {
|
|
1728
|
+
lines.push([c.id ?? "?", c.status ?? "?", streamLabel(c) || undefined].filter(Boolean).join(" "), [
|
|
1729
|
+
fmtTimestamp(c.createdAt) && `created ${fmtTimestamp(c.createdAt)}`,
|
|
1730
|
+
fmtTimestamp(c.lastActivityAt) && `last activity ${fmtTimestamp(c.lastActivityAt)}`,
|
|
1731
|
+
c.messageCount !== undefined && `${c.messageCount} messages`
|
|
1732
|
+
].filter(Boolean).join(" · "));
|
|
1733
|
+
const topic = snippet(c.topicSummary ?? c.summary ?? "");
|
|
1734
|
+
if (topic)
|
|
1735
|
+
lines.push(`topic: ${topic}`);
|
|
1736
|
+
}
|
|
1737
|
+
const msgs = p.messages?.data ?? [];
|
|
1738
|
+
lines.push(`messages: ${msgs.length}${p.messages?.hasMore ? " (more)" : ""}`);
|
|
1739
|
+
for (const m of msgs) {
|
|
1740
|
+
const who = m.author?.name ?? m.authorDisplayName ?? "?";
|
|
1741
|
+
const ts = fmtTimestamp(m.createdAt);
|
|
1742
|
+
lines.push(` ${ts ? `[${ts}] ` : ""}${who}: ${snippet(m.contentMarkdown ?? m.content ?? "", 120)}`);
|
|
1743
|
+
}
|
|
1744
|
+
return lines.join(`
|
|
1745
|
+
`);
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
var conversationsNoun = {
|
|
1749
|
+
name: "conversations",
|
|
1750
|
+
summary: "List conversations and read one with its messages",
|
|
1751
|
+
verbs: [listVerb2, readVerb]
|
|
1752
|
+
};
|
|
1753
|
+
|
|
1754
|
+
// src/commands/delegations.ts
|
|
1755
|
+
var OUTCOMES = ["complete", "fail"];
|
|
1756
|
+
function renderLifecycle(payload) {
|
|
1757
|
+
const data = payload.data;
|
|
1758
|
+
const lines = [];
|
|
1759
|
+
if (data?.id)
|
|
1760
|
+
lines.push(`delegation: ${data.id}`);
|
|
1761
|
+
if (data?.status)
|
|
1762
|
+
lines.push(`status: ${data.status}`);
|
|
1763
|
+
if (data?.claimToken)
|
|
1764
|
+
lines.push(`claimToken (shown once): ${data.claimToken}`);
|
|
1765
|
+
return lines.join(`
|
|
1766
|
+
`) || JSON.stringify(payload);
|
|
1767
|
+
}
|
|
1768
|
+
var listVerb3 = {
|
|
1769
|
+
name: "list",
|
|
1770
|
+
summary: "List open delegations. --since <iso> returns availability changes after that instant",
|
|
1771
|
+
usage: "threa delegations list [--since iso]",
|
|
1772
|
+
help: `threa delegations list [flags]
|
|
1773
|
+
|
|
1774
|
+
` + `List open delegations. Pass --since to poll for a cheap delta.
|
|
1775
|
+
|
|
1776
|
+
` + `Flags:
|
|
1777
|
+
` + ` --since iso tasks whose availability changed after this ISO-8601 instant
|
|
1778
|
+
` + ` --json force JSON output
|
|
1779
|
+
` + " --help show this help",
|
|
1780
|
+
options: {
|
|
1781
|
+
since: { type: "string" }
|
|
1782
|
+
},
|
|
1783
|
+
run: (ctx, _positionals, values) => listDelegations(ctx.client, { since: stringFlag(values, "since") }),
|
|
1784
|
+
render: (payload) => {
|
|
1785
|
+
return renderList(payload, (d) => `${d.id ?? "?"} ${d.title ?? d.brief ?? ""}`.trimEnd(), { empty: "(no open delegations)" });
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
var getVerb2 = {
|
|
1789
|
+
name: "get",
|
|
1790
|
+
summary: "Inspect a delegation's brief and context before claiming it",
|
|
1791
|
+
usage: "threa delegations get <id>",
|
|
1792
|
+
help: `threa delegations get <id> [--json]
|
|
1793
|
+
|
|
1794
|
+
Inspect a delegation without claiming it.
|
|
1795
|
+
`,
|
|
1796
|
+
options: {},
|
|
1797
|
+
run: (ctx, positionals) => {
|
|
1798
|
+
const id = positionals[0];
|
|
1799
|
+
if (!id)
|
|
1800
|
+
throw new UsageError("delegations get requires a <delegation-id>");
|
|
1801
|
+
return getDelegation(ctx.client, id);
|
|
1802
|
+
},
|
|
1803
|
+
render: (payload) => JSON.stringify(payload, null, 2)
|
|
1804
|
+
};
|
|
1805
|
+
var claimVerb = {
|
|
1806
|
+
name: "claim",
|
|
1807
|
+
summary: "Claim an open or historical expired task after inspection; requires --label",
|
|
1808
|
+
usage: "threa delegations claim <id> --label who [--idempotency-key k]",
|
|
1809
|
+
help: `threa delegations claim <id> --label who [flags]
|
|
1810
|
+
|
|
1811
|
+
` + "Claim an open or historical expired task after inspecting it with `delegations get`. The claim token is persisted to ~/.threa/state.json (mode 0600, keyed by workspace and " + "delegation), so update and finish reuse it across separate `threa` invocations. The token is shown once.\n\n" + `Flags:
|
|
1812
|
+
` + ` --label who human-readable identity shown on the card (required)
|
|
1813
|
+
` + ` --idempotency-key k persist before claiming to re-key a crashed claim (8-128 chars)
|
|
1814
|
+
` + ` --json force JSON output
|
|
1815
|
+
` + " --help show this help",
|
|
1816
|
+
options: {
|
|
1817
|
+
label: { type: "string" },
|
|
1818
|
+
"idempotency-key": { type: "string" }
|
|
1819
|
+
},
|
|
1820
|
+
run: (ctx, positionals, values) => {
|
|
1821
|
+
const id = positionals[0];
|
|
1822
|
+
if (!id)
|
|
1823
|
+
throw new UsageError("delegations claim requires a <delegation-id>");
|
|
1824
|
+
const label = stringFlag(values, "label");
|
|
1825
|
+
if (!label)
|
|
1826
|
+
throw new UsageError('delegations claim requires --label "who is claiming"');
|
|
1827
|
+
return claimDelegation(ctx.client, ctx.tokenStore, ctx.config.workspaceId, {
|
|
1828
|
+
delegationId: id,
|
|
1829
|
+
claimedByLabel: label,
|
|
1830
|
+
idempotencyKey: stringFlag(values, "idempotency-key")
|
|
1831
|
+
});
|
|
1832
|
+
},
|
|
1833
|
+
render: renderLifecycle
|
|
1834
|
+
};
|
|
1835
|
+
var releaseVerb = {
|
|
1836
|
+
name: "release",
|
|
1837
|
+
summary: "Release a live claim back to the open queue",
|
|
1838
|
+
usage: "threa delegations release <id> [--claim-token t]",
|
|
1839
|
+
help: `threa delegations release <id> [flags]
|
|
1840
|
+
|
|
1841
|
+
Release a live claim. On success, only a matching stored token is cleared; a replacement token is preserved.
|
|
1842
|
+
|
|
1843
|
+
` + `Flags:
|
|
1844
|
+
--claim-token <t> override the stored token
|
|
1845
|
+
--json force JSON output
|
|
1846
|
+
--help show this help`,
|
|
1847
|
+
options: { "claim-token": { type: "string" } },
|
|
1848
|
+
run: (ctx, positionals, values) => {
|
|
1849
|
+
const id = positionals[0];
|
|
1850
|
+
if (!id)
|
|
1851
|
+
throw new UsageError("delegations release requires a <delegation-id>");
|
|
1852
|
+
return releaseDelegation(ctx.client, ctx.tokenStore, ctx.config.workspaceId, {
|
|
1853
|
+
delegationId: id,
|
|
1854
|
+
claimToken: stringFlag(values, "claim-token")
|
|
1855
|
+
});
|
|
1856
|
+
},
|
|
1857
|
+
render: renderLifecycle
|
|
1858
|
+
};
|
|
1859
|
+
var updateVerb = {
|
|
1860
|
+
name: "update",
|
|
1861
|
+
summary: "Renew the claim; --note posts progress, no note is a pure heartbeat",
|
|
1862
|
+
usage: "threa delegations update <id> [--note ...] [--claim-token t]",
|
|
1863
|
+
help: `threa delegations update <id> [flags]
|
|
1864
|
+
|
|
1865
|
+
` + `Renew the claim (15-min TTL). Reuses the stored claim token; pass --claim-token to override or recover it.
|
|
1866
|
+
|
|
1867
|
+
` + `Flags:
|
|
1868
|
+
` + ` --note text progress note (renews the TTL); omit for a pure heartbeat
|
|
1869
|
+
` + ` --claim-token t override the stored token
|
|
1870
|
+
` + ` --json force JSON output
|
|
1871
|
+
` + " --help show this help",
|
|
1872
|
+
options: {
|
|
1873
|
+
note: { type: "string" },
|
|
1874
|
+
"claim-token": { type: "string" }
|
|
1875
|
+
},
|
|
1876
|
+
run: (ctx, positionals, values) => {
|
|
1877
|
+
const id = positionals[0];
|
|
1878
|
+
if (!id)
|
|
1879
|
+
throw new UsageError("delegations update requires a <delegation-id>");
|
|
1880
|
+
return updateDelegation(ctx.client, ctx.tokenStore, ctx.config.workspaceId, {
|
|
1881
|
+
delegationId: id,
|
|
1882
|
+
statusNote: stringFlag(values, "note"),
|
|
1883
|
+
claimToken: stringFlag(values, "claim-token")
|
|
1884
|
+
});
|
|
1885
|
+
},
|
|
1886
|
+
render: renderLifecycle
|
|
1887
|
+
};
|
|
1888
|
+
var finishVerb = {
|
|
1889
|
+
name: "finish",
|
|
1890
|
+
summary: "Close the task; --outcome complete|fail (fail requires --error)",
|
|
1891
|
+
usage: "threa delegations finish <id> --outcome complete|fail [--result md|-] [--error msg] [--metadata k=v]... " + "[--claim-token t]",
|
|
1892
|
+
help: `threa delegations finish <id> --outcome complete|fail [flags]
|
|
1893
|
+
|
|
1894
|
+
` + `Complete or fail a claimed task. Reuses the stored claim token; --claim-token overrides it. On success, clears the token.
|
|
1895
|
+
|
|
1896
|
+
` + `Flags:
|
|
1897
|
+
` + ` --outcome o complete | fail (required)
|
|
1898
|
+
` + " --result md outcome (complete): markdown posted in the thread anchored on the delegation card; `-` reads stdin\n" + ` --error msg outcome (fail): why it failed
|
|
1899
|
+
` + ` --metadata k=v outcome (complete): stamp the result message; repeatable
|
|
1900
|
+
` + ` --claim-token t override the stored token
|
|
1901
|
+
` + ` --json force JSON output
|
|
1902
|
+
` + " --help show this help",
|
|
1903
|
+
options: {
|
|
1904
|
+
outcome: { type: "string" },
|
|
1905
|
+
result: { type: "string" },
|
|
1906
|
+
error: { type: "string" },
|
|
1907
|
+
metadata: { type: "string", multiple: true },
|
|
1908
|
+
"claim-token": { type: "string" }
|
|
1909
|
+
},
|
|
1910
|
+
run: async (ctx, positionals, values) => {
|
|
1911
|
+
const id = positionals[0];
|
|
1912
|
+
if (!id)
|
|
1913
|
+
throw new UsageError("delegations finish requires a <delegation-id>");
|
|
1914
|
+
const outcome = enumFlag(values, "outcome", OUTCOMES);
|
|
1915
|
+
if (!outcome)
|
|
1916
|
+
throw new UsageError("delegations finish requires --outcome complete|fail");
|
|
1917
|
+
const rawResult = stringFlag(values, "result");
|
|
1918
|
+
const resultMarkdown = rawResult === "-" ? await ctx.readStdin() : rawResult;
|
|
1919
|
+
const metadataPairs = arrayFlag(values, "metadata");
|
|
1920
|
+
const params = {
|
|
1921
|
+
delegationId: id,
|
|
1922
|
+
outcome,
|
|
1923
|
+
resultMarkdown,
|
|
1924
|
+
metadata: metadataPairs ? kvPairs(metadataPairs) : undefined,
|
|
1925
|
+
errorMessage: stringFlag(values, "error"),
|
|
1926
|
+
claimToken: stringFlag(values, "claim-token")
|
|
1927
|
+
};
|
|
1928
|
+
const argError = finishDelegationArgError(params);
|
|
1929
|
+
if (argError)
|
|
1930
|
+
throw new UsageError(argError);
|
|
1931
|
+
return finishDelegation(ctx.client, ctx.tokenStore, ctx.config.workspaceId, params);
|
|
1932
|
+
},
|
|
1933
|
+
render: renderLifecycle
|
|
1934
|
+
};
|
|
1935
|
+
var requestAccessVerb = {
|
|
1936
|
+
name: "request-access",
|
|
1937
|
+
summary: "Bot-key only; file an access request for a stream the bot cannot see",
|
|
1938
|
+
usage: "threa delegations request-access <id>",
|
|
1939
|
+
help: `threa delegations request-access <id> [flags]
|
|
1940
|
+
|
|
1941
|
+
` + `Bot-key only. File an access request for a stream the bot cannot see, so an admin can grant it.
|
|
1942
|
+
|
|
1943
|
+
` + `Flags:
|
|
1944
|
+
` + ` --json force JSON output
|
|
1945
|
+
` + " --help show this help",
|
|
1946
|
+
options: {},
|
|
1947
|
+
run: (ctx, positionals) => {
|
|
1948
|
+
const id = positionals[0];
|
|
1949
|
+
if (!id)
|
|
1950
|
+
throw new UsageError("delegations request-access requires a <delegation-id>");
|
|
1951
|
+
return requestDelegationAccess(ctx.client, id);
|
|
1952
|
+
},
|
|
1953
|
+
render: renderLifecycle
|
|
1954
|
+
};
|
|
1955
|
+
var delegationsNoun = {
|
|
1956
|
+
name: "delegations",
|
|
1957
|
+
summary: "Run the delegation lifecycle end to end",
|
|
1958
|
+
verbs: [listVerb3, getVerb2, claimVerb, releaseVerb, updateVerb, finishVerb, requestAccessVerb]
|
|
1959
|
+
};
|
|
1960
|
+
|
|
1961
|
+
// src/e2e-keys.ts
|
|
1962
|
+
import { homedir as homedir4, hostname } from "node:os";
|
|
1963
|
+
import { join as join6 } from "node:path";
|
|
1964
|
+
|
|
1965
|
+
// ../../extensions/bot-runtime-client/src/crypto.ts
|
|
1966
|
+
import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
1967
|
+
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
1968
|
+
function bytesToBase64(bytes) {
|
|
1969
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
1970
|
+
let binary = "";
|
|
1971
|
+
for (let i = 0;i < view.length; i++)
|
|
1972
|
+
binary += String.fromCharCode(view[i]);
|
|
1973
|
+
return btoa(binary);
|
|
1974
|
+
}
|
|
1975
|
+
function base64ToBytes(b64) {
|
|
1976
|
+
const binary = atob(b64);
|
|
1977
|
+
const bytes = new Uint8Array(binary.length);
|
|
1978
|
+
for (let i = 0;i < binary.length; i++)
|
|
1979
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1980
|
+
return bytes;
|
|
1981
|
+
}
|
|
1982
|
+
function utf8Encode(text) {
|
|
1983
|
+
return new TextEncoder().encode(text);
|
|
1984
|
+
}
|
|
1985
|
+
function utf8Decode(bytes) {
|
|
1986
|
+
return new TextDecoder().decode(bytes);
|
|
1987
|
+
}
|
|
1988
|
+
function concatBytes(...parts) {
|
|
1989
|
+
let total = 0;
|
|
1990
|
+
for (const p of parts)
|
|
1991
|
+
total += p.length;
|
|
1992
|
+
const out = new Uint8Array(total);
|
|
1993
|
+
let offset = 0;
|
|
1994
|
+
for (const p of parts) {
|
|
1995
|
+
out.set(p, offset);
|
|
1996
|
+
offset += p.length;
|
|
1997
|
+
}
|
|
1998
|
+
return out;
|
|
1999
|
+
}
|
|
2000
|
+
var suite = null;
|
|
2001
|
+
function getSuite() {
|
|
2002
|
+
if (!suite) {
|
|
2003
|
+
suite = new CipherSuite({
|
|
2004
|
+
kem: new DhkemX25519HkdfSha256,
|
|
2005
|
+
kdf: new HkdfSha256,
|
|
2006
|
+
aead: new Aes256Gcm
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
return suite;
|
|
2010
|
+
}
|
|
2011
|
+
async function importRecipientPrivateKey(raw) {
|
|
2012
|
+
const buf = raw instanceof Uint8Array ? raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) : raw;
|
|
2013
|
+
return getSuite().kem.deserializePrivateKey(buf);
|
|
2014
|
+
}
|
|
2015
|
+
async function exportPrivateKey(key) {
|
|
2016
|
+
return new Uint8Array(await getSuite().kem.serializePrivateKey(key));
|
|
2017
|
+
}
|
|
2018
|
+
async function hpkeOpen(params) {
|
|
2019
|
+
const buf = await getSuite().open({ recipientKey: params.recipientPrivateKey, enc: params.enc }, params.ct, params.aad);
|
|
2020
|
+
return new Uint8Array(buf);
|
|
2021
|
+
}
|
|
2022
|
+
var STREAM_ENVELOPE_VERSION = 2;
|
|
2023
|
+
var SSK_LENGTH = 32;
|
|
2024
|
+
var IV_LENGTH = 12;
|
|
2025
|
+
function assertBoundAad(fn, aad) {
|
|
2026
|
+
if (aad.length === 0) {
|
|
2027
|
+
throw new Error(`${fn}: aad must be non-empty (see buildMessageAad/buildWrapAad)`);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
async function sealMessage(input) {
|
|
2031
|
+
if (input.key.length !== SSK_LENGTH) {
|
|
2032
|
+
throw new Error(`sealMessage: SSK must be ${SSK_LENGTH} bytes, got ${input.key.length}`);
|
|
2033
|
+
}
|
|
2034
|
+
assertBoundAad("sealMessage", input.aad);
|
|
2035
|
+
const iv = new Uint8Array(IV_LENGTH);
|
|
2036
|
+
crypto.getRandomValues(iv);
|
|
2037
|
+
const plaintext = typeof input.payload === "string" ? utf8Encode(input.payload) : new Uint8Array(input.payload);
|
|
2038
|
+
const aad = new Uint8Array(input.aad);
|
|
2039
|
+
const sskKey = await crypto.subtle.importKey("raw", new Uint8Array(input.key), { name: "AES-GCM" }, false, [
|
|
2040
|
+
"encrypt"
|
|
2041
|
+
]);
|
|
2042
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, sskKey, plaintext));
|
|
2043
|
+
return {
|
|
2044
|
+
envelope: {
|
|
2045
|
+
v: STREAM_ENVELOPE_VERSION,
|
|
2046
|
+
keyGeneration: input.keyGeneration,
|
|
2047
|
+
iv: bytesToBase64(iv),
|
|
2048
|
+
aad: bytesToBase64(aad)
|
|
2049
|
+
},
|
|
2050
|
+
ciphertext
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
async function openMessage(input) {
|
|
2054
|
+
if (input.envelope.v !== STREAM_ENVELOPE_VERSION) {
|
|
2055
|
+
throw new Error(`Unsupported stream envelope version: ${input.envelope.v}`);
|
|
2056
|
+
}
|
|
2057
|
+
if (input.key.length !== SSK_LENGTH) {
|
|
2058
|
+
throw new Error(`openMessage: SSK must be ${SSK_LENGTH} bytes, got ${input.key.length}`);
|
|
2059
|
+
}
|
|
2060
|
+
const aad = base64ToBytes(input.envelope.aad);
|
|
2061
|
+
const sskKey = await crypto.subtle.importKey("raw", new Uint8Array(input.key), { name: "AES-GCM" }, false, [
|
|
2062
|
+
"decrypt"
|
|
2063
|
+
]);
|
|
2064
|
+
const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: base64ToBytes(input.envelope.iv), additionalData: aad }, sskKey, new Uint8Array(input.ciphertext)));
|
|
2065
|
+
return plaintext;
|
|
2066
|
+
}
|
|
2067
|
+
async function openMessageAsString(input) {
|
|
2068
|
+
return utf8Decode(await openMessage(input));
|
|
2069
|
+
}
|
|
2070
|
+
async function unwrapStreamKey(input) {
|
|
2071
|
+
assertBoundAad("unwrapStreamKey", input.aad);
|
|
2072
|
+
const key = await hpkeOpen({
|
|
2073
|
+
recipientPrivateKey: input.recipientPrivateKey,
|
|
2074
|
+
enc: input.enc,
|
|
2075
|
+
ct: input.ct,
|
|
2076
|
+
aad: input.aad
|
|
2077
|
+
});
|
|
2078
|
+
if (key.length !== SSK_LENGTH) {
|
|
2079
|
+
throw new Error(`unwrapStreamKey: recovered key is ${key.length} bytes, expected ${SSK_LENGTH}`);
|
|
2080
|
+
}
|
|
2081
|
+
return key;
|
|
2082
|
+
}
|
|
2083
|
+
function buildWrapAad(parts) {
|
|
2084
|
+
if (parts.streamId.length === 0 || parts.recipientKeyId.length === 0) {
|
|
2085
|
+
throw new Error("buildWrapAad: streamId and recipientKeyId must be non-empty");
|
|
2086
|
+
}
|
|
2087
|
+
if (parts.streamId.includes("|") || parts.recipientKeyId.includes("|")) {
|
|
2088
|
+
throw new Error("buildWrapAad: streamId and recipientKeyId must not contain '|'");
|
|
2089
|
+
}
|
|
2090
|
+
if (!Number.isInteger(parts.keyGeneration) || parts.keyGeneration < 0) {
|
|
2091
|
+
throw new Error("buildWrapAad: keyGeneration must be a non-negative integer");
|
|
2092
|
+
}
|
|
2093
|
+
return concatBytes(utf8Encode(parts.streamId), utf8Encode("|"), utf8Encode(String(parts.keyGeneration)), utf8Encode("|"), utf8Encode(parts.recipientKeyId));
|
|
2094
|
+
}
|
|
2095
|
+
function buildMessageAad(parts) {
|
|
2096
|
+
return concatBytes(utf8Encode(parts.streamId), utf8Encode("|"), utf8Encode(parts.messageId), utf8Encode("|"), utf8Encode(parts.senderId));
|
|
2097
|
+
}
|
|
2098
|
+
var ATTACHMENT_AAD = utf8Encode("threa-attachment-v1");
|
|
2099
|
+
var E2E_PAYLOAD_VERSION = 1;
|
|
2100
|
+
function serializeSealedPayload(contentMarkdown, extras) {
|
|
2101
|
+
const attachmentRefs = extras?.attachmentRefs;
|
|
2102
|
+
const sources = extras?.sources;
|
|
2103
|
+
const draftContentJson = extras?.draftContentJson;
|
|
2104
|
+
const hasRefs = attachmentRefs !== undefined && attachmentRefs.length > 0;
|
|
2105
|
+
const hasSources = sources !== undefined && sources.length > 0;
|
|
2106
|
+
const hasDraftBody = draftContentJson !== undefined && draftContentJson !== null;
|
|
2107
|
+
if (!hasRefs && !hasSources && !hasDraftBody)
|
|
2108
|
+
return contentMarkdown;
|
|
2109
|
+
return JSON.stringify({
|
|
2110
|
+
__e2ePayload: E2E_PAYLOAD_VERSION,
|
|
2111
|
+
contentMarkdown,
|
|
2112
|
+
attachmentRefs: attachmentRefs ?? [],
|
|
2113
|
+
...hasSources ? { sources } : {},
|
|
2114
|
+
...hasDraftBody ? { draftContentJson } : {}
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
function isAttachmentRef(value) {
|
|
2118
|
+
if (typeof value !== "object" || value === null)
|
|
2119
|
+
return false;
|
|
2120
|
+
const r = value;
|
|
2121
|
+
return typeof r.attachmentId === "string" && typeof r.key === "string" && typeof r.iv === "string" && typeof r.filename === "string" && typeof r.mimeType === "string" && typeof r.sizeBytes === "number";
|
|
2122
|
+
}
|
|
2123
|
+
function isSealedSourceItem(value) {
|
|
2124
|
+
if (typeof value !== "object" || value === null)
|
|
2125
|
+
return false;
|
|
2126
|
+
const s = value;
|
|
2127
|
+
return typeof s.title === "string" && typeof s.url === "string" && (s.type === undefined || typeof s.type === "string") && (s.snippet === undefined || typeof s.snippet === "string");
|
|
2128
|
+
}
|
|
2129
|
+
function isDocLike(value) {
|
|
2130
|
+
if (typeof value !== "object" || value === null)
|
|
2131
|
+
return false;
|
|
2132
|
+
const v = value;
|
|
2133
|
+
return v.type === "doc" && Array.isArray(v.content);
|
|
2134
|
+
}
|
|
2135
|
+
function parseSealedPayload(raw) {
|
|
2136
|
+
if (raw.startsWith("{")) {
|
|
2137
|
+
try {
|
|
2138
|
+
const parsed = JSON.parse(raw);
|
|
2139
|
+
if (parsed.__e2ePayload === E2E_PAYLOAD_VERSION && typeof parsed.contentMarkdown === "string") {
|
|
2140
|
+
const attachmentRefs = Array.isArray(parsed.attachmentRefs) ? parsed.attachmentRefs.filter(isAttachmentRef) : [];
|
|
2141
|
+
const sources = Array.isArray(parsed.sources) ? parsed.sources.filter(isSealedSourceItem) : [];
|
|
2142
|
+
const draftContentJson = isDocLike(parsed.draftContentJson) ? parsed.draftContentJson : null;
|
|
2143
|
+
return { contentMarkdown: parsed.contentMarkdown, attachmentRefs, sources, draftContentJson };
|
|
2144
|
+
}
|
|
2145
|
+
} catch {}
|
|
2146
|
+
}
|
|
2147
|
+
return { contentMarkdown: raw, attachmentRefs: [], sources: [], draftContentJson: null };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// ../../extensions/bot-runtime-client/src/user-key.ts
|
|
2151
|
+
import { argon2id } from "hash-wasm";
|
|
2152
|
+
var DEFAULT_KDF_PARAMS = {
|
|
2153
|
+
algorithm: "argon2id",
|
|
2154
|
+
m: 64 * 1024,
|
|
2155
|
+
t: 3,
|
|
2156
|
+
p: 1,
|
|
2157
|
+
version: 19
|
|
2158
|
+
};
|
|
2159
|
+
var KEK_LENGTH_BYTES = 32;
|
|
2160
|
+
var PRIVATE_BUNDLE_VERSION = 1;
|
|
2161
|
+
var IV_LENGTH2 = 12;
|
|
2162
|
+
var ARGON2_VERSION = 19;
|
|
2163
|
+
async function deriveKEK(passphrase, salt, params = DEFAULT_KDF_PARAMS) {
|
|
2164
|
+
if (params.algorithm !== "argon2id") {
|
|
2165
|
+
throw new Error(`Unsupported KDF algorithm: ${params.algorithm}`);
|
|
2166
|
+
}
|
|
2167
|
+
if (params.version !== ARGON2_VERSION) {
|
|
2168
|
+
throw new Error(`Unsupported Argon2 version: ${params.version}`);
|
|
2169
|
+
}
|
|
2170
|
+
const raw = await argon2id({
|
|
2171
|
+
password: passphrase,
|
|
2172
|
+
salt,
|
|
2173
|
+
iterations: params.t,
|
|
2174
|
+
parallelism: params.p,
|
|
2175
|
+
memorySize: params.m,
|
|
2176
|
+
hashLength: KEK_LENGTH_BYTES,
|
|
2177
|
+
outputType: "binary"
|
|
2178
|
+
});
|
|
2179
|
+
return crypto.subtle.importKey("raw", new Uint8Array(raw), { name: "AES-GCM" }, false, ["decrypt"]);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
class WrongPassphraseError extends Error {
|
|
2183
|
+
constructor() {
|
|
2184
|
+
super("Wrapped private bundle did not open with this passphrase");
|
|
2185
|
+
this.name = "WrongPassphraseError";
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
async function unwrapPrivate(bundle, kek) {
|
|
2189
|
+
if (bundle.length < 1 + IV_LENGTH2 + 1) {
|
|
2190
|
+
throw new Error("Wrapped private bundle is too short");
|
|
2191
|
+
}
|
|
2192
|
+
const version = bundle[0];
|
|
2193
|
+
if (version !== PRIVATE_BUNDLE_VERSION) {
|
|
2194
|
+
throw new Error(`Unsupported private bundle version: ${version}`);
|
|
2195
|
+
}
|
|
2196
|
+
const iv = bundle.slice(1, 1 + IV_LENGTH2);
|
|
2197
|
+
const ciphertext = bundle.slice(1 + IV_LENGTH2);
|
|
2198
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, kek, ciphertext).catch(() => {
|
|
2199
|
+
throw new WrongPassphraseError;
|
|
2200
|
+
});
|
|
2201
|
+
const privBytes = new Uint8Array(plaintext);
|
|
2202
|
+
return importRecipientPrivateKey(privBytes);
|
|
2203
|
+
}
|
|
2204
|
+
async function unlockUserKey(input) {
|
|
2205
|
+
const kek = await deriveKEK(input.passphrase, input.kdfSalt, input.kdfParams);
|
|
2206
|
+
return unwrapPrivate(input.encryptedPrivateBundle, kek);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/e2e-keys.ts
|
|
2210
|
+
function keyDirOf(choice) {
|
|
2211
|
+
return choice.dir ?? join6(homedir4(), ".threa", "e2e-keys");
|
|
2212
|
+
}
|
|
2213
|
+
function openKeyStore(choice, account) {
|
|
2214
|
+
const dir = keyDirOf(choice);
|
|
2215
|
+
return resolveKeyStore({
|
|
2216
|
+
...choice.requested === undefined ? {} : { requested: choice.requested },
|
|
2217
|
+
platform: process.platform,
|
|
2218
|
+
dir,
|
|
2219
|
+
hasExistingFileKey: new FileKeyStore({ dir }).read(account) !== undefined
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
function fetchPrincipal(client) {
|
|
2223
|
+
return client.get("/me").then((response) => response.data);
|
|
2224
|
+
}
|
|
2225
|
+
async function principalForKey(client, workspaceId) {
|
|
2226
|
+
const me = await fetchPrincipal(client);
|
|
2227
|
+
const userId = me.userId;
|
|
2228
|
+
if (!userId) {
|
|
2229
|
+
throw new Error(`threa: end-to-end keys belong to a person, but this API key acts as ${me.kind}. ` + `Use your own key, not a bot's.`);
|
|
2230
|
+
}
|
|
2231
|
+
return { account: e2eUserKeyAccount(workspaceId, userId), userId };
|
|
2232
|
+
}
|
|
2233
|
+
var NO_USER_KEY_HINT = 'threa: this stream is end-to-end encrypted, and no key is unlocked on this machine — run "threa e2e unlock"';
|
|
2234
|
+
async function resolveSealedKeyAccounts(params) {
|
|
2235
|
+
const me = await fetchPrincipal(params.client);
|
|
2236
|
+
if (me.kind !== "bot") {
|
|
2237
|
+
const userId = me.userId;
|
|
2238
|
+
if (!userId)
|
|
2239
|
+
throw new Error(`threa: GET /me named no principal id for ${me.kind}`);
|
|
2240
|
+
const account2 = e2eUserKeyAccount(params.config.workspaceId, userId);
|
|
2241
|
+
return { senderId: userId, accountFor: () => account2, missingKeyHint: () => NO_USER_KEY_HINT };
|
|
2242
|
+
}
|
|
2243
|
+
const botId = me.botId;
|
|
2244
|
+
if (!botId)
|
|
2245
|
+
throw new Error("threa: GET /me named no bot id");
|
|
2246
|
+
const scope = params.config.keyScope ?? "host";
|
|
2247
|
+
if (scope === "instance" && !params.config.instanceId) {
|
|
2248
|
+
throw new Error('threa: keyScope "instance" needs an instanceId in the config (THREA_INSTANCE_ID)');
|
|
2249
|
+
}
|
|
2250
|
+
const account = e2eKeyAccount({
|
|
2251
|
+
scope,
|
|
2252
|
+
hostname: hostname(),
|
|
2253
|
+
instanceId: params.config.instanceId ?? "",
|
|
2254
|
+
identitySeed: params.config.apiKey
|
|
2255
|
+
});
|
|
2256
|
+
return {
|
|
2257
|
+
senderId: botId,
|
|
2258
|
+
accountFor: (streamId) => account ?? e2eStreamKeyAccount(streamId),
|
|
2259
|
+
missingKeyHint: (missing, store) => `threa: this stream is end-to-end encrypted, and this bot holds no key in ${store.describe} ` + `as ${missing} — start the runtime that owns this key, or set keyScope to the one it uses ` + `(currently "${scope}")`
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
async function accountForKey(client, workspaceId) {
|
|
2263
|
+
return (await principalForKey(client, workspaceId)).account;
|
|
2264
|
+
}
|
|
2265
|
+
async function unlockE2eKey(params) {
|
|
2266
|
+
if (!params.passphrase)
|
|
2267
|
+
throw new Error("threa: no passphrase given");
|
|
2268
|
+
const account = await accountForKey(params.client, params.workspaceId);
|
|
2269
|
+
const key = (await params.client.get("/me/e2e-key")).data;
|
|
2270
|
+
const privateKey = await unlockUserKey({
|
|
2271
|
+
passphrase: params.passphrase,
|
|
2272
|
+
encryptedPrivateBundle: base64ToBytes(key.encryptedPrivateBundle),
|
|
2273
|
+
kdfSalt: base64ToBytes(key.kdfSalt),
|
|
2274
|
+
kdfParams: key.kdfParams
|
|
2275
|
+
}).catch((error) => {
|
|
2276
|
+
if (error instanceof WrongPassphraseError) {
|
|
2277
|
+
throw new Error("threa: that passphrase does not open your encryption key");
|
|
2278
|
+
}
|
|
2279
|
+
throw error;
|
|
2280
|
+
});
|
|
2281
|
+
const record = {
|
|
2282
|
+
keyId: key.keyId,
|
|
2283
|
+
publicKey: key.publicKey,
|
|
2284
|
+
privateKey: bytesToBase64(await exportPrivateKey(privateKey))
|
|
2285
|
+
};
|
|
2286
|
+
const store = openKeyStore(params.choice, account);
|
|
2287
|
+
const held = store.read(account);
|
|
2288
|
+
if (held?.keyId !== record.keyId || held.privateKey !== record.privateKey)
|
|
2289
|
+
store.write(account, record);
|
|
2290
|
+
return {
|
|
2291
|
+
keyId: record.keyId,
|
|
2292
|
+
account,
|
|
2293
|
+
store: store.kind,
|
|
2294
|
+
storeDescription: store.describe,
|
|
2295
|
+
createdAt: key.createdAt,
|
|
2296
|
+
unchanged: held?.keyId === record.keyId && held.privateKey === record.privateKey
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
async function e2eKeyStatus(params) {
|
|
2300
|
+
const account = await accountForKey(params.client, params.workspaceId);
|
|
2301
|
+
const store = openKeyStore(params.choice, account);
|
|
2302
|
+
const held = store.read(account);
|
|
2303
|
+
const serverKeyId = await params.client.get("/me/e2e-key").then((response) => response.data.keyId).catch((error) => {
|
|
2304
|
+
if (error instanceof ThreaApiError && error.status === 404)
|
|
2305
|
+
return;
|
|
2306
|
+
throw error;
|
|
2307
|
+
});
|
|
2308
|
+
return {
|
|
2309
|
+
held: held !== undefined,
|
|
2310
|
+
...held ? { keyId: held.keyId } : {},
|
|
2311
|
+
...serverKeyId === undefined ? {} : { serverKeyId },
|
|
2312
|
+
current: held !== undefined && held.keyId === serverKeyId,
|
|
2313
|
+
account,
|
|
2314
|
+
store: store.kind,
|
|
2315
|
+
storeDescription: store.describe
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
async function lockE2eKey(params) {
|
|
2319
|
+
const account = await accountForKey(params.client, params.workspaceId);
|
|
2320
|
+
const store = openKeyStore(params.choice, account);
|
|
2321
|
+
const removed = store.read(account) !== undefined;
|
|
2322
|
+
store.remove(account);
|
|
2323
|
+
return { account, store: store.kind, storeDescription: store.describe, removed };
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// src/commands/key-store.ts
|
|
2327
|
+
var KEY_STORE_OPTIONS = {
|
|
2328
|
+
"key-store": { type: "string" },
|
|
2329
|
+
"key-dir": { type: "string" }
|
|
2330
|
+
};
|
|
2331
|
+
var KEY_STORE_FLAGS = ` --key-store keychain|file where the key is kept (default: the OS keychain)
|
|
2332
|
+
` + ` --key-dir <path> directory for a file store (default: ~/.threa/e2e-keys)
|
|
2333
|
+
`;
|
|
2334
|
+
function storeChoice(values, config) {
|
|
2335
|
+
const requested = stringFlag(values, "key-store");
|
|
2336
|
+
if (requested !== undefined && !E2E_KEY_STORE_KINDS.includes(requested)) {
|
|
2337
|
+
throw new UsageError(`--key-store must be one of ${E2E_KEY_STORE_KINDS.join(", ")} — got "${requested}"`);
|
|
2338
|
+
}
|
|
2339
|
+
const dir = stringFlag(values, "key-dir") ?? config.keyDir;
|
|
2340
|
+
const store = requested ?? config.keyStore;
|
|
2341
|
+
return {
|
|
2342
|
+
...store === undefined ? {} : { requested: store },
|
|
2343
|
+
...dir === undefined ? {} : { dir }
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/commands/e2e.ts
|
|
2348
|
+
async function readPassphrase(readStdin) {
|
|
2349
|
+
const input = process.stdin;
|
|
2350
|
+
if (!input.isTTY)
|
|
2351
|
+
return (await readStdin()).replace(/\r?\n$/, "");
|
|
2352
|
+
process.stderr.write("Passphrase: ");
|
|
2353
|
+
input.setRawMode(true);
|
|
2354
|
+
input.resume();
|
|
2355
|
+
input.setEncoding("utf8");
|
|
2356
|
+
try {
|
|
2357
|
+
let typed = "";
|
|
2358
|
+
for await (const chunk of input) {
|
|
2359
|
+
for (const char of chunk) {
|
|
2360
|
+
if (char === "\r" || char === `
|
|
2361
|
+
` || char === "\x04")
|
|
2362
|
+
return typed;
|
|
2363
|
+
if (char === "\x03")
|
|
2364
|
+
throw new UsageError("cancelled");
|
|
2365
|
+
if (char === "" || char === "\b")
|
|
2366
|
+
typed = typed.slice(0, -1);
|
|
2367
|
+
else
|
|
2368
|
+
typed += char;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
return typed;
|
|
2372
|
+
} finally {
|
|
2373
|
+
input.setRawMode(false);
|
|
2374
|
+
input.pause();
|
|
2375
|
+
process.stderr.write(`
|
|
2376
|
+
`);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
var unlockVerb = {
|
|
2380
|
+
name: "unlock",
|
|
2381
|
+
summary: "Open your encryption key with your passphrase and keep it on this machine",
|
|
2382
|
+
usage: "threa e2e unlock [--key-store keychain|file] [--key-dir <path>]",
|
|
2383
|
+
help: `threa e2e unlock [flags]
|
|
2384
|
+
|
|
2385
|
+
` + "Fetch the encrypted bundle holding your identity key, open it with your passphrase, and file the key on " + "this machine so sealed streams can be read and written here. The passphrase is typed at the prompt (never " + "echoed) or piped on stdin; it is never sent anywhere, and neither is the key. Run it again after changing " + `your passphrase or rotating the key — it replaces what this machine holds.
|
|
2386
|
+
|
|
2387
|
+
` + `Flags:
|
|
2388
|
+
` + KEY_STORE_FLAGS + ` --json force JSON output
|
|
2389
|
+
` + " --help show this help",
|
|
2390
|
+
options: KEY_STORE_OPTIONS,
|
|
2391
|
+
run: async (ctx, _positionals, values) => {
|
|
2392
|
+
const choice = storeChoice(values, ctx.config);
|
|
2393
|
+
return unlockE2eKey({
|
|
2394
|
+
client: ctx.client,
|
|
2395
|
+
workspaceId: ctx.config.workspaceId,
|
|
2396
|
+
passphrase: await readPassphrase(ctx.readStdin),
|
|
2397
|
+
choice
|
|
2398
|
+
});
|
|
2399
|
+
},
|
|
2400
|
+
render: (payload) => {
|
|
2401
|
+
const p = payload;
|
|
2402
|
+
return p.unchanged ? `${p.keyId ?? "?"} was already unlocked in ${p.storeDescription ?? "?"}` : `unlocked ${p.keyId ?? "?"} into ${p.storeDescription ?? "?"}`;
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
var statusVerb = {
|
|
2406
|
+
name: "status",
|
|
2407
|
+
summary: "Show whether this machine holds your encryption key, and whether it is current",
|
|
2408
|
+
usage: "threa e2e status [--key-store keychain|file] [--key-dir <path>]",
|
|
2409
|
+
help: `threa e2e status [flags]
|
|
2410
|
+
|
|
2411
|
+
` + "Report the key this machine holds and the key the workspace has active for you. They differ after the web " + `app rotates your key, which is when sealed streams stop opening here until you unlock again.
|
|
2412
|
+
|
|
2413
|
+
` + `Flags:
|
|
2414
|
+
` + KEY_STORE_FLAGS + ` --json force JSON output
|
|
2415
|
+
` + " --help show this help",
|
|
2416
|
+
options: KEY_STORE_OPTIONS,
|
|
2417
|
+
run: (ctx, _positionals, values) => e2eKeyStatus({ client: ctx.client, workspaceId: ctx.config.workspaceId, choice: storeChoice(values, ctx.config) }),
|
|
2418
|
+
render: (payload) => {
|
|
2419
|
+
const p = payload;
|
|
2420
|
+
if (!p.held) {
|
|
2421
|
+
return p.serverKeyId ? `no key on this machine — run "threa e2e unlock" to open ${p.serverKeyId}` : "no key on this machine, and none set up in the workspace";
|
|
2422
|
+
}
|
|
2423
|
+
return p.current ? `holding ${p.keyId ?? "?"} (current)` : `holding ${p.keyId ?? "?"}, but the workspace has moved to ${p.serverKeyId ?? "no key"} — unlock again`;
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
var lockVerb = {
|
|
2427
|
+
name: "lock",
|
|
2428
|
+
summary: "Forget the encryption key this machine holds",
|
|
2429
|
+
usage: "threa e2e lock [--key-store keychain|file] [--key-dir <path>]",
|
|
2430
|
+
help: `threa e2e lock [flags]
|
|
2431
|
+
|
|
2432
|
+
` + "Remove your identity key from this machine. Sealed streams stop opening here until you unlock again. The " + `key itself is untouched everywhere else — this forgets a copy, it does not revoke anything.
|
|
2433
|
+
|
|
2434
|
+
` + `Flags:
|
|
2435
|
+
` + KEY_STORE_FLAGS + ` --json force JSON output
|
|
2436
|
+
` + " --help show this help",
|
|
2437
|
+
options: KEY_STORE_OPTIONS,
|
|
2438
|
+
run: (ctx, _positionals, values) => lockE2eKey({ client: ctx.client, workspaceId: ctx.config.workspaceId, choice: storeChoice(values, ctx.config) }),
|
|
2439
|
+
render: (payload) => {
|
|
2440
|
+
const p = payload;
|
|
2441
|
+
return p.removed ? `forgot the key in ${p.storeDescription ?? "?"}` : "no key was held on this machine";
|
|
2442
|
+
}
|
|
2443
|
+
};
|
|
2444
|
+
var e2eNoun = {
|
|
2445
|
+
name: "e2e",
|
|
2446
|
+
summary: "Unlock, inspect, and forget your end-to-end encryption key on this machine",
|
|
2447
|
+
verbs: [unlockVerb, statusVerb, lockVerb]
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
// src/commands/identity.ts
|
|
2451
|
+
var whoamiCommand = {
|
|
2452
|
+
name: "whoami",
|
|
2453
|
+
summary: "Show the authenticated principal, API version, and the bound workspace",
|
|
2454
|
+
usage: "threa whoami",
|
|
2455
|
+
help: `threa whoami
|
|
2456
|
+
|
|
2457
|
+
` + "Return the authenticated principal for the configured API key (user or bot), the resolved API version, " + "and the base URL and workspace this CLI is bound to. Use it to confirm the key works. When the config " + 'declares a principal ("principal": "bot"|"user"), the declared kind is shown too and a mismatched key ' + `refuses to run.
|
|
2458
|
+
|
|
2459
|
+
` + `Flags:
|
|
2460
|
+
` + ` --json / -o json force JSON output
|
|
2461
|
+
` + " --help show this help",
|
|
2462
|
+
options: {},
|
|
2463
|
+
run: (ctx) => whoami(ctx.client, ctx.config),
|
|
2464
|
+
render: (payload) => {
|
|
2465
|
+
const p = payload;
|
|
2466
|
+
const who = p.principal?.userId ?? p.principal?.botId ?? "?";
|
|
2467
|
+
return [
|
|
2468
|
+
`principal: ${p.principal?.kind ?? "?"} ${who}`,
|
|
2469
|
+
...p.declaredPrincipal ? [`declared: ${p.declaredPrincipal}`] : [],
|
|
2470
|
+
`workspace: ${p.workspaceId ?? "?"}`,
|
|
2471
|
+
`baseUrl: ${p.baseUrl ?? "?"}`
|
|
2472
|
+
].join(`
|
|
2473
|
+
`);
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
// src/commands/labels.ts
|
|
2478
|
+
var listVerb4 = {
|
|
2479
|
+
name: "list",
|
|
2480
|
+
summary: "List this key actor's labels and assignments",
|
|
2481
|
+
usage: "threa labels list",
|
|
2482
|
+
help: `threa labels list [flags]
|
|
2483
|
+
|
|
2484
|
+
` + "List this key actor's labels and their resource assignments. Every label is private to the actor the API " + `key acts as; you never see another actor's labels.
|
|
2485
|
+
|
|
2486
|
+
` + `Flags:
|
|
2487
|
+
` + ` --json force JSON output
|
|
2488
|
+
` + " --help show this help",
|
|
2489
|
+
options: {},
|
|
2490
|
+
run: (ctx) => listLabels(ctx.client),
|
|
2491
|
+
render: (payload) => {
|
|
2492
|
+
const p = payload;
|
|
2493
|
+
const rows = p.data?.labels ?? [];
|
|
2494
|
+
return rows.map((l) => `${l.id ?? "?"} ${l.emoji ? `${l.emoji} ` : ""}${l.name ?? ""}`.trimEnd()).join(`
|
|
2495
|
+
`) || "(no labels)";
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
var addVerb = {
|
|
2499
|
+
name: "add",
|
|
2500
|
+
summary: "Attach a label to a stream by name (found-or-created, idempotent)",
|
|
2501
|
+
usage: "threa labels add <name> <stream-ref> [--color #RRGGBB] [--emoji e] [--description d]",
|
|
2502
|
+
help: `threa labels add <name> <stream-ref> [flags]
|
|
2503
|
+
|
|
2504
|
+
` + "Attach a label to a stream, identifying the label by its name. The label is found-or-created for this key " + "actor and then assigned, so re-applying the same name is idempotent. Any appearance flag you supply " + "overwrites that field on the existing label everywhere it is used, not only at creation. <stream-ref> is a " + `stream_ id or #channel-slug.
|
|
2505
|
+
|
|
2506
|
+
` + `Flags:
|
|
2507
|
+
` + ` --color #RRGGBB set the label color
|
|
2508
|
+
` + ` --emoji e set the label emoji
|
|
2509
|
+
` + ` --description d set the label description
|
|
2510
|
+
` + ` --json force JSON output
|
|
2511
|
+
` + " --help show this help",
|
|
2512
|
+
options: {
|
|
2513
|
+
color: { type: "string" },
|
|
2514
|
+
emoji: { type: "string" },
|
|
2515
|
+
description: { type: "string" }
|
|
2516
|
+
},
|
|
2517
|
+
run: (ctx, positionals, values) => {
|
|
2518
|
+
const name = positionals[0];
|
|
2519
|
+
if (!name)
|
|
2520
|
+
throw new UsageError("labels add requires a <name>");
|
|
2521
|
+
const ref = positionals[1];
|
|
2522
|
+
if (!ref)
|
|
2523
|
+
throw new UsageError("labels add requires a <stream-ref> (a stream_ id or #channel-slug)");
|
|
2524
|
+
return applyLabel(ctx.client, ctx.resolver, {
|
|
2525
|
+
name,
|
|
2526
|
+
streamRef: ref,
|
|
2527
|
+
color: stringFlag(values, "color"),
|
|
2528
|
+
emoji: stringFlag(values, "emoji"),
|
|
2529
|
+
description: stringFlag(values, "description")
|
|
2530
|
+
});
|
|
2531
|
+
},
|
|
2532
|
+
render: (payload) => {
|
|
2533
|
+
const label = payload.data?.label;
|
|
2534
|
+
return `labeled ${label?.name ?? "?"}`;
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
var removeVerb = {
|
|
2538
|
+
name: "remove",
|
|
2539
|
+
summary: "Remove this key actor's label assignment from a stream",
|
|
2540
|
+
usage: "threa labels remove <name> <stream-ref>",
|
|
2541
|
+
help: `threa labels remove <name> <stream-ref> [flags]
|
|
2542
|
+
|
|
2543
|
+
` + "Remove this key actor's assignment of a label (identified by its name) from a stream. The label itself is " + `not deleted, only its assignment to this stream. <stream-ref> is a stream_ id or #channel-slug.
|
|
2544
|
+
|
|
2545
|
+
` + `Flags:
|
|
2546
|
+
` + ` --json force JSON output
|
|
2547
|
+
` + " --help show this help",
|
|
2548
|
+
options: {},
|
|
2549
|
+
run: (ctx, positionals) => {
|
|
2550
|
+
const name = positionals[0];
|
|
2551
|
+
if (!name)
|
|
2552
|
+
throw new UsageError("labels remove requires a <name>");
|
|
2553
|
+
const ref = positionals[1];
|
|
2554
|
+
if (!ref)
|
|
2555
|
+
throw new UsageError("labels remove requires a <stream-ref> (a stream_ id or #channel-slug)");
|
|
2556
|
+
return removeLabel(ctx.client, ctx.resolver, { name, streamRef: ref });
|
|
2557
|
+
},
|
|
2558
|
+
render: (payload) => {
|
|
2559
|
+
const p = payload;
|
|
2560
|
+
return `unlabeled ${p.name ?? "?"} from ${p.stream_id ?? "?"}`;
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
var labelsNoun = {
|
|
2564
|
+
name: "labels",
|
|
2565
|
+
summary: "List, add, and remove labels on streams",
|
|
2566
|
+
verbs: [listVerb4, addVerb, removeVerb]
|
|
2567
|
+
};
|
|
2568
|
+
|
|
2569
|
+
// src/commands/mcp.ts
|
|
2570
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2571
|
+
|
|
2572
|
+
// src/principal.ts
|
|
2573
|
+
async function assertPrincipal(client, config, { retryDelaysMs = [], sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)) } = {}) {
|
|
2574
|
+
if (config.principal === undefined)
|
|
2575
|
+
return;
|
|
2576
|
+
const data = await fetchPrincipal2(client, retryDelaysMs, sleep);
|
|
2577
|
+
if (data.kind === config.principal)
|
|
2578
|
+
return;
|
|
2579
|
+
throw new Error(`threa: config declares principal "${config.principal}" but the key belongs to ${data.kind} ${data.userId ?? data.botId ?? "?"} (INV-11)`);
|
|
2580
|
+
}
|
|
2581
|
+
async function fetchPrincipal2(client, retryDelaysMs, sleep) {
|
|
2582
|
+
for (let attempt = 0;; attempt++) {
|
|
2583
|
+
try {
|
|
2584
|
+
return (await client.get("/me")).data;
|
|
2585
|
+
} catch (error) {
|
|
2586
|
+
const delay = retryDelaysMs[attempt];
|
|
2587
|
+
if (delay === undefined || !isTransient(error))
|
|
2588
|
+
throw error;
|
|
2589
|
+
process.stderr.write(`[threa] /me failed, retrying: ${error instanceof Error ? error.message : String(error)}
|
|
2590
|
+
`);
|
|
2591
|
+
await sleep(delay);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
function isTransient(error) {
|
|
2596
|
+
if (!(error instanceof ThreaApiError))
|
|
2597
|
+
return true;
|
|
2598
|
+
return error.status === 0 || error.status === 429 || error.status >= 500;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// src/server.ts
|
|
2602
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2603
|
+
|
|
2604
|
+
// src/resolver.ts
|
|
2605
|
+
var DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
2606
|
+
|
|
2607
|
+
class RefResolver {
|
|
2608
|
+
client;
|
|
2609
|
+
ttlMs;
|
|
2610
|
+
now;
|
|
2611
|
+
channelCache = new Map;
|
|
2612
|
+
streamInfoCache = new Map;
|
|
2613
|
+
allUsersCache;
|
|
2614
|
+
allUsersInFlight;
|
|
2615
|
+
constructor(opts) {
|
|
2616
|
+
this.client = opts.client;
|
|
2617
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
2618
|
+
this.now = opts.now ?? Date.now;
|
|
2619
|
+
}
|
|
2620
|
+
async resolveStream(ref) {
|
|
2621
|
+
const trimmed = ref.trim();
|
|
2622
|
+
if (trimmed.startsWith("#"))
|
|
2623
|
+
return this.resolveChannel(trimmed.slice(1));
|
|
2624
|
+
if (trimmed.startsWith("@"))
|
|
2625
|
+
return this.resolveDmWithUser(trimmed.slice(1));
|
|
2626
|
+
return trimmed;
|
|
2627
|
+
}
|
|
2628
|
+
async resolveStreams(refs) {
|
|
2629
|
+
return Promise.all(refs.map((ref) => this.resolveStream(ref)));
|
|
2630
|
+
}
|
|
2631
|
+
async resolveUser(ref) {
|
|
2632
|
+
const trimmed = ref.trim();
|
|
2633
|
+
if (trimmed.startsWith("@"))
|
|
2634
|
+
return this.resolveUserSlug(trimmed.slice(1));
|
|
2635
|
+
return trimmed;
|
|
2636
|
+
}
|
|
2637
|
+
async allUsers() {
|
|
2638
|
+
const hit = this.allUsersCache;
|
|
2639
|
+
if (hit && hit.expires > this.now())
|
|
2640
|
+
return hit.value;
|
|
2641
|
+
if (this.allUsersInFlight)
|
|
2642
|
+
return this.allUsersInFlight;
|
|
2643
|
+
const inFlight = this.client.get(`/users${buildQuery({ limit: 200 })}`).then((resp) => {
|
|
2644
|
+
this.allUsersCache = { value: resp.data, expires: this.now() + this.ttlMs };
|
|
2645
|
+
return resp.data;
|
|
2646
|
+
}).finally(() => {
|
|
2647
|
+
this.allUsersInFlight = undefined;
|
|
2648
|
+
});
|
|
2649
|
+
this.allUsersInFlight = inFlight;
|
|
2650
|
+
return inFlight;
|
|
2651
|
+
}
|
|
2652
|
+
async streamInfo(streamId) {
|
|
2653
|
+
const hit = this.streamInfoCache.get(streamId);
|
|
2654
|
+
if (hit && hit.expires > this.now())
|
|
2655
|
+
return hit.value;
|
|
2656
|
+
let value = null;
|
|
2657
|
+
try {
|
|
2658
|
+
const resp = await this.client.get(`/streams/${encodeURIComponent(streamId)}`);
|
|
2659
|
+
value = resp.data ?? null;
|
|
2660
|
+
} catch {
|
|
2661
|
+
value = null;
|
|
2662
|
+
}
|
|
2663
|
+
this.streamInfoCache.set(streamId, { value, expires: this.now() + this.ttlMs });
|
|
2664
|
+
return value;
|
|
2665
|
+
}
|
|
2666
|
+
async resolveUserSlug(slug) {
|
|
2667
|
+
const target = slug.toLowerCase();
|
|
2668
|
+
const users = await this.allUsers();
|
|
2669
|
+
const bySlug = users.filter((u) => u.slug?.toLowerCase() === target);
|
|
2670
|
+
const matches = bySlug.length > 0 ? bySlug : users.filter((u) => u.name?.toLowerCase() === target);
|
|
2671
|
+
if (matches.length === 1)
|
|
2672
|
+
return matches[0].id;
|
|
2673
|
+
if (matches.length > 1) {
|
|
2674
|
+
throw new UnresolvedRefError(`"@${slug}" is ambiguous. Candidates: ${matches.map((u) => `${u.id} (${u.name}, @${u.slug})`).join("; ")}. Pass the usr_ id.`);
|
|
2675
|
+
}
|
|
2676
|
+
throw new UnresolvedRefError(`No user matches "@${slug}". List the workspace users (list_users tool / \`threa users list\`) to find them, then pass the usr_ id. ` + "Bots and personas are not queryable by slug on the public API; pass their bot_/persona_ id.");
|
|
2677
|
+
}
|
|
2678
|
+
async resolveChannel(slug) {
|
|
2679
|
+
const key = slug.toLowerCase();
|
|
2680
|
+
const hit = this.channelCache.get(key);
|
|
2681
|
+
if (hit && hit.expires > this.now())
|
|
2682
|
+
return hit.value;
|
|
2683
|
+
const resp = await this.client.get(`/streams${buildQuery({ type: "channel", query: slug, limit: 200 })}`);
|
|
2684
|
+
const matches = resp.data.filter((s) => {
|
|
2685
|
+
const streamSlug = s.slug?.toLowerCase();
|
|
2686
|
+
const bareName = typeof s.displayName === "string" ? s.displayName.replace(/^#/, "").toLowerCase() : undefined;
|
|
2687
|
+
return streamSlug === key || bareName === key;
|
|
2688
|
+
});
|
|
2689
|
+
if (matches.length === 1) {
|
|
2690
|
+
const id = matches[0].id;
|
|
2691
|
+
this.channelCache.set(key, { value: id, expires: this.now() + this.ttlMs });
|
|
2692
|
+
return id;
|
|
2693
|
+
}
|
|
2694
|
+
if (matches.length > 1) {
|
|
2695
|
+
throw new UnresolvedRefError(`"#${slug}" is ambiguous. Candidates: ${matches.map((s) => `${s.id} (${s.displayName ?? s.slug})`).join("; ")}. Pass the stream_ id.`);
|
|
2696
|
+
}
|
|
2697
|
+
throw new UnresolvedRefError(`No channel matches "#${slug}". List channels (list_streams tool / \`threa streams list --type channel\`) to find it, then pass the stream_ id.`);
|
|
2698
|
+
}
|
|
2699
|
+
async resolveDmWithUser(slug) {
|
|
2700
|
+
const userId = await this.resolveUserSlug(slug);
|
|
2701
|
+
throw new UnresolvedRefError(`Found user @${slug} (${userId}), but the public API does not expose which DM stream is your 1:1 with them ` + "(DM streams carry no counterpart on the wire). List your DMs (list_streams tool / `threa streams list --type dm`), " + `then read the members of each (read_stream with include_members / \`threa streams read <id> --members\`) to find the one whose members include ${userId}, and pass that stream_ id. ` + "Or target a channel with #channel-slug.");
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// ../../extensions/bot-runtime-client/src/sealed-stream-client.ts
|
|
2706
|
+
import { ulid } from "ulid";
|
|
2707
|
+
class SealedStreamApiError extends Error {
|
|
2708
|
+
status;
|
|
2709
|
+
code;
|
|
2710
|
+
constructor(message, status, code) {
|
|
2711
|
+
super(message);
|
|
2712
|
+
this.name = "SealedStreamApiError";
|
|
2713
|
+
this.status = status;
|
|
2714
|
+
this.code = code;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
function reasonOf(error) {
|
|
2718
|
+
return String(error instanceof Error ? error.message : error);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
class SealedStreamClient {
|
|
2722
|
+
opts;
|
|
2723
|
+
doFetch;
|
|
2724
|
+
roots = new Map;
|
|
2725
|
+
ssks = new Map;
|
|
2726
|
+
generations = new Map;
|
|
2727
|
+
sender;
|
|
2728
|
+
constructor(opts) {
|
|
2729
|
+
this.opts = opts;
|
|
2730
|
+
this.doFetch = opts.fetch ?? globalThis.fetch;
|
|
2731
|
+
}
|
|
2732
|
+
async readMessages(streamId, opts = {}) {
|
|
2733
|
+
const root = await this.resolveRoot(streamId);
|
|
2734
|
+
const query = new URLSearchParams;
|
|
2735
|
+
if (opts.limit !== undefined)
|
|
2736
|
+
query.set("limit", String(opts.limit));
|
|
2737
|
+
if (opts.before)
|
|
2738
|
+
query.set("before", opts.before);
|
|
2739
|
+
if (opts.after)
|
|
2740
|
+
query.set("after", opts.after);
|
|
2741
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
2742
|
+
const page = await this.request("GET", `/streams/${streamId}/messages${suffix}`);
|
|
2743
|
+
const messages = [];
|
|
2744
|
+
for (const wire of page.data) {
|
|
2745
|
+
messages.push(await this.openMessage(root, wire));
|
|
2746
|
+
}
|
|
2747
|
+
return { messages, hasMore: page.hasMore };
|
|
2748
|
+
}
|
|
2749
|
+
async sendMessage(streamId, contentMarkdown, opts = {}) {
|
|
2750
|
+
const root = await this.resolveRoot(streamId);
|
|
2751
|
+
const keyGeneration = await this.currentGeneration(root);
|
|
2752
|
+
const key = await this.streamKey(root, keyGeneration);
|
|
2753
|
+
const senderId = await this.resolveSenderId();
|
|
2754
|
+
const clientMessageId = opts.clientMessageId ?? `msg_${ulid()}`;
|
|
2755
|
+
const sealed = await sealMessage({
|
|
2756
|
+
key,
|
|
2757
|
+
keyGeneration,
|
|
2758
|
+
payload: serializeSealedPayload(contentMarkdown, { attachmentRefs: opts.attachmentRefs }),
|
|
2759
|
+
aad: buildMessageAad({ streamId: root, messageId: clientMessageId, senderId })
|
|
2760
|
+
});
|
|
2761
|
+
const created = await this.request("POST", `/streams/${streamId}/messages`, {
|
|
2762
|
+
sealed: { ciphertext: bytesToBase64(sealed.ciphertext), envelope: sealed.envelope },
|
|
2763
|
+
clientMessageId
|
|
2764
|
+
});
|
|
2765
|
+
return { messageId: created.data.id, clientMessageId };
|
|
2766
|
+
}
|
|
2767
|
+
async openSealedBody(streamId, sealed) {
|
|
2768
|
+
const root = await this.resolveRoot(streamId);
|
|
2769
|
+
return this.openBody(root, sealed);
|
|
2770
|
+
}
|
|
2771
|
+
async openBody(root, sealed) {
|
|
2772
|
+
let key;
|
|
2773
|
+
try {
|
|
2774
|
+
key = await this.streamKey(root, sealed.envelope.keyGeneration);
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
return { contentMarkdown: null, attachmentRefs: [], unreadableReason: reasonOf(error) };
|
|
2777
|
+
}
|
|
2778
|
+
try {
|
|
2779
|
+
const raw = await openMessageAsString({
|
|
2780
|
+
key,
|
|
2781
|
+
ciphertext: base64ToBytes(sealed.ciphertext),
|
|
2782
|
+
envelope: sealed.envelope
|
|
2783
|
+
});
|
|
2784
|
+
const payload = parseSealedPayload(raw);
|
|
2785
|
+
return { contentMarkdown: payload.contentMarkdown, attachmentRefs: payload.attachmentRefs ?? [] };
|
|
2786
|
+
} catch (error) {
|
|
2787
|
+
return { contentMarkdown: null, attachmentRefs: [], unreadableReason: reasonOf(error) };
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
async openMessage(root, wire) {
|
|
2791
|
+
const base = {
|
|
2792
|
+
id: wire.id,
|
|
2793
|
+
sequence: wire.sequence,
|
|
2794
|
+
authorId: wire.authorId,
|
|
2795
|
+
authorType: wire.authorType,
|
|
2796
|
+
...wire.authorDisplayName ? { authorDisplayName: wire.authorDisplayName } : {},
|
|
2797
|
+
createdAt: wire.createdAt,
|
|
2798
|
+
attachmentRefs: []
|
|
2799
|
+
};
|
|
2800
|
+
if (!wire.sealed) {
|
|
2801
|
+
return { ...base, contentMarkdown: null, unreadableReason: "Message carries no stream-key envelope" };
|
|
2802
|
+
}
|
|
2803
|
+
return { ...base, ...await this.openBody(root, wire.sealed) };
|
|
2804
|
+
}
|
|
2805
|
+
async streamKey(root, keyGeneration) {
|
|
2806
|
+
const cached = this.ssks.get(`${root}:${keyGeneration}`);
|
|
2807
|
+
if (cached)
|
|
2808
|
+
return cached;
|
|
2809
|
+
await this.loadWraps(root);
|
|
2810
|
+
const key = this.ssks.get(`${root}:${keyGeneration}`);
|
|
2811
|
+
if (!key) {
|
|
2812
|
+
throw new Error(`No key wrap for generation ${keyGeneration} of ${root} is addressed to a key this client holds`);
|
|
2813
|
+
}
|
|
2814
|
+
return key;
|
|
2815
|
+
}
|
|
2816
|
+
async currentGeneration(root) {
|
|
2817
|
+
await this.loadWraps(root);
|
|
2818
|
+
return this.generations.get(root) ?? 0;
|
|
2819
|
+
}
|
|
2820
|
+
async loadWraps(root) {
|
|
2821
|
+
const identities = await this.opts.keys.keysForStream(root);
|
|
2822
|
+
if (identities.length === 0) {
|
|
2823
|
+
throw new Error(`No end-to-end key available for ${root}`);
|
|
2824
|
+
}
|
|
2825
|
+
const wraps = await this.request("GET", `/streams/${root}/e2e/key-wraps`);
|
|
2826
|
+
this.generations.set(root, wraps.data.currentKeyGeneration);
|
|
2827
|
+
for (const wrap of wraps.data.wraps) {
|
|
2828
|
+
const identity = identities.find((candidate) => candidate.keyId === wrap.recipientKeyId);
|
|
2829
|
+
if (!identity)
|
|
2830
|
+
continue;
|
|
2831
|
+
if (this.ssks.has(`${root}:${wrap.keyGeneration}`))
|
|
2832
|
+
continue;
|
|
2833
|
+
const key = await unwrapStreamKey({
|
|
2834
|
+
enc: base64ToBytes(wrap.wrapEnc),
|
|
2835
|
+
ct: base64ToBytes(wrap.wrapCt),
|
|
2836
|
+
recipientPrivateKey: identity.privateKey,
|
|
2837
|
+
aad: buildWrapAad({
|
|
2838
|
+
streamId: root,
|
|
2839
|
+
keyGeneration: wrap.keyGeneration,
|
|
2840
|
+
recipientKeyId: wrap.recipientKeyId
|
|
2841
|
+
})
|
|
2842
|
+
});
|
|
2843
|
+
this.ssks.set(`${root}:${wrap.keyGeneration}`, key);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
async resolveRoot(streamId) {
|
|
2847
|
+
let pending = this.roots.get(streamId);
|
|
2848
|
+
if (!pending) {
|
|
2849
|
+
pending = this.request("GET", `/streams/${streamId}`).then((stream) => stream.data.rootStreamId ?? stream.data.id).catch((error) => {
|
|
2850
|
+
this.roots.delete(streamId);
|
|
2851
|
+
throw error;
|
|
2852
|
+
});
|
|
2853
|
+
this.roots.set(streamId, pending);
|
|
2854
|
+
}
|
|
2855
|
+
return pending;
|
|
2856
|
+
}
|
|
2857
|
+
async resolveSenderId() {
|
|
2858
|
+
if (this.opts.senderId)
|
|
2859
|
+
return this.opts.senderId;
|
|
2860
|
+
if (!this.sender) {
|
|
2861
|
+
this.sender = this.request("GET", "/me").then((me) => {
|
|
2862
|
+
const id = me.data.kind === "bot" ? me.data.botId : me.data.userId;
|
|
2863
|
+
if (!id)
|
|
2864
|
+
throw new Error("GET /me named no principal id");
|
|
2865
|
+
return id;
|
|
2866
|
+
}).catch((error) => {
|
|
2867
|
+
this.sender = undefined;
|
|
2868
|
+
throw error;
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
return this.sender;
|
|
2872
|
+
}
|
|
2873
|
+
async request(method, path, body) {
|
|
2874
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/api/v1/workspaces/${this.opts.workspaceId}${path}`;
|
|
2875
|
+
const response = await this.doFetch(url, {
|
|
2876
|
+
method,
|
|
2877
|
+
headers: {
|
|
2878
|
+
Authorization: `Bearer ${this.opts.apiKey}`,
|
|
2879
|
+
...body === undefined ? {} : { "Content-Type": "application/json" }
|
|
2880
|
+
},
|
|
2881
|
+
...body === undefined ? {} : { body: JSON.stringify(body) }
|
|
2882
|
+
});
|
|
2883
|
+
const text = await response.text();
|
|
2884
|
+
if (!response.ok) {
|
|
2885
|
+
let parsed = {};
|
|
2886
|
+
try {
|
|
2887
|
+
if (text.length > 0)
|
|
2888
|
+
parsed = JSON.parse(text);
|
|
2889
|
+
} catch {
|
|
2890
|
+
parsed = {};
|
|
2891
|
+
}
|
|
2892
|
+
throw new SealedStreamApiError(typeof parsed.message === "string" ? parsed.message : `${method} ${path} failed`, response.status, typeof parsed.code === "string" ? parsed.code : "UNKNOWN");
|
|
2893
|
+
}
|
|
2894
|
+
return text.length > 0 ? JSON.parse(text) : {};
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// src/sealed.ts
|
|
2899
|
+
function sealedStreams(params) {
|
|
2900
|
+
let pending;
|
|
2901
|
+
async function build() {
|
|
2902
|
+
const accounts = await resolveSealedKeyAccounts({ client: params.client, config: params.config });
|
|
2903
|
+
const imported = new Map;
|
|
2904
|
+
function identityFor(streamId) {
|
|
2905
|
+
const account = accounts.accountFor(streamId);
|
|
2906
|
+
let identity = imported.get(account);
|
|
2907
|
+
if (!identity) {
|
|
2908
|
+
const store = openKeyStore(params.choice, account);
|
|
2909
|
+
const record = store.read(account);
|
|
2910
|
+
if (!record)
|
|
2911
|
+
return Promise.reject(new Error(accounts.missingKeyHint(account, store)));
|
|
2912
|
+
identity = importRecipientPrivateKey(base64ToBytes(record.privateKey)).then((privateKey) => ({
|
|
2913
|
+
keyId: record.keyId,
|
|
2914
|
+
privateKey
|
|
2915
|
+
}));
|
|
2916
|
+
imported.set(account, identity);
|
|
2917
|
+
}
|
|
2918
|
+
return identity;
|
|
2919
|
+
}
|
|
2920
|
+
const keys = { keysForStream: async (streamId) => [await identityFor(streamId)] };
|
|
2921
|
+
return {
|
|
2922
|
+
identityFor,
|
|
2923
|
+
client: new SealedStreamClient({
|
|
2924
|
+
baseUrl: params.config.baseUrl,
|
|
2925
|
+
apiKey: params.config.apiKey,
|
|
2926
|
+
workspaceId: params.config.workspaceId,
|
|
2927
|
+
keys,
|
|
2928
|
+
senderId: accounts.senderId
|
|
2929
|
+
})
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
function sealed() {
|
|
2933
|
+
if (!pending) {
|
|
2934
|
+
pending = build().catch((error) => {
|
|
2935
|
+
pending = undefined;
|
|
2936
|
+
throw error;
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
return pending;
|
|
2940
|
+
}
|
|
2941
|
+
return {
|
|
2942
|
+
async open(streamId, body) {
|
|
2943
|
+
const { client, identityFor } = await sealed();
|
|
2944
|
+
await identityFor(streamId);
|
|
2945
|
+
return client.openSealedBody(streamId, body);
|
|
2946
|
+
},
|
|
2947
|
+
async send(streamId, contentMarkdown, opts) {
|
|
2948
|
+
const { client, identityFor } = await sealed();
|
|
2949
|
+
await identityFor(streamId);
|
|
2950
|
+
return client.sendMessage(streamId, contentMarkdown, {
|
|
2951
|
+
...opts.clientMessageId === undefined ? {} : { clientMessageId: opts.clientMessageId }
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
};
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// src/tools/attachments.ts
|
|
2958
|
+
import { z } from "zod";
|
|
2959
|
+
function registerAttachmentTools(server, client) {
|
|
2960
|
+
server.registerTool("get_attachment", {
|
|
2961
|
+
title: "Get an attachment",
|
|
2962
|
+
description: "Retrieve an attachment's metadata (filename, mime type, size, processing status) and its extracted " + "content when available: `data.extraction` carries a summary and `fullText` (the full extracted " + "text) once processing has finished — it is null while extraction is pending or when the file type " + "yields no text. Read the text here rather than downloading the file when you only need its content.",
|
|
2963
|
+
inputSchema: {
|
|
2964
|
+
attachment_id: z.string()
|
|
2965
|
+
}
|
|
2966
|
+
}, async ({ attachment_id }) => runTool(() => getAttachment(client, attachment_id)));
|
|
2967
|
+
server.registerTool("get_attachment_download_url", {
|
|
2968
|
+
title: "Get an attachment download URL",
|
|
2969
|
+
description: "Create a short-lived signed URL to download the raw bytes of an attachment. The response is " + "{ data: { url, expiresIn } } where expiresIn is the lifetime in seconds — fetch the bytes yourself promptly, the " + "URL expires. Prefer get_attachment when you only need the extracted text.",
|
|
2970
|
+
inputSchema: {
|
|
2971
|
+
attachment_id: z.string()
|
|
2972
|
+
}
|
|
2973
|
+
}, async ({ attachment_id }) => runTool(() => getAttachmentDownloadUrl(client, attachment_id)));
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// src/tools/conversations.ts
|
|
2977
|
+
import { z as z2 } from "zod";
|
|
2978
|
+
function registerConversationTools(server, client, resolver) {
|
|
2979
|
+
server.registerTool("list_conversations", {
|
|
2980
|
+
title: "List conversations",
|
|
2981
|
+
description: "List conversations (grouped runs of messages under a stream's effective root), newest activity first. " + "Filter with `stream_id` (accepts a stream_ id or `#channel-slug`; scopes to that stream's root and its " + "threads) and `status` (active, stalled, resolved). Each conversation mirrors its participantIds with a " + "participants array carrying name/slug. Page by passing the previous response's `cursor` value back as " + "`cursor`; `hasMore` tells you when to stop. limit ≤ 100 (default 50).",
|
|
2982
|
+
inputSchema: {
|
|
2983
|
+
stream_id: z2.string().optional(),
|
|
2984
|
+
status: z2.enum(CONVERSATION_STATUSES).optional(),
|
|
2985
|
+
cursor: z2.string().optional(),
|
|
2986
|
+
limit: z2.number().int().min(1).max(100).optional()
|
|
2987
|
+
}
|
|
2988
|
+
}, async ({ stream_id, status, cursor, limit }) => runTool(() => listConversations(client, resolver, { streamRef: stream_id, status, cursor, limit })));
|
|
2989
|
+
server.registerTool("read_conversation", {
|
|
2990
|
+
title: "Read a conversation with its messages",
|
|
2991
|
+
description: "Fetch a conversation and a page of its member messages in one call (concurrent requests). Returns " + "{ conversation, messages: { data, hasMore, cursor } }. The conversation mirrors its participantIds with " + "a participants array (name/slug) and message rows carry author { id, type, name, slug? }. Messages are " + "chronological and may span the " + "conversation's root stream and its threads; each carries its own `streamId`. Page the messages by " + "passing the previous response's `cursor` value back as `cursor`; `hasMore` tells you when to stop. " + "limit ≤ 100 (default 50) applies to messages. If the conversation cannot be read the whole call " + "errors — no partial data is returned.",
|
|
2992
|
+
inputSchema: {
|
|
2993
|
+
conversation_id: z2.string(),
|
|
2994
|
+
cursor: z2.string().optional(),
|
|
2995
|
+
limit: z2.number().int().min(1).max(100).optional()
|
|
2996
|
+
}
|
|
2997
|
+
}, async ({ conversation_id, cursor, limit }) => runTool(() => readConversation(client, resolver, { conversationId: conversation_id, cursor, limit })));
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// src/tools/delegations.ts
|
|
3001
|
+
import { z as z3 } from "zod";
|
|
3002
|
+
function registerDelegationTools(server, client, store, workspaceId) {
|
|
3003
|
+
server.registerTool("list_delegations", {
|
|
3004
|
+
title: "List open delegations",
|
|
3005
|
+
description: "List open delegations (the queue for `delegate_task` hand-offs). Only " + "streams this key can access appear. The lifecycle loop is: list_delegations → get_delegation → " + "claim_delegation → optional update_delegation while you work → finish_delegation or release_delegation. A completed result is " + "posted in the thread anchored on the delegation card and enters the normal message pipeline, so workspace memory can capture it. `status` is `open` (the only listable state). " + "`since`: ISO-8601 datetime; returns tasks whose availability changed after that instant. Useful for polling deltas.",
|
|
3006
|
+
inputSchema: {
|
|
3007
|
+
status: z3.literal("open").optional(),
|
|
3008
|
+
since: z3.string().optional()
|
|
3009
|
+
}
|
|
3010
|
+
}, async ({ status, since }) => runTool(() => listDelegations(client, { status, since })));
|
|
3011
|
+
server.registerTool("get_delegation", {
|
|
3012
|
+
title: "Inspect a delegation",
|
|
3013
|
+
description: "Inspect the full brief and context refs before deciding whether to claim. This never creates a claim or token.",
|
|
3014
|
+
inputSchema: { delegation_id: z3.string() }
|
|
3015
|
+
}, async ({ delegation_id }) => runTool(() => getDelegation(client, delegation_id)));
|
|
3016
|
+
server.registerTool("claim_delegation", {
|
|
3017
|
+
title: "Claim an open or historical expired delegation",
|
|
3018
|
+
description: "After inspecting with get_delegation, atomically claim an open delegation or a historical task still stored as expired. On success, returns the brief, context refs, and `claimToken` (shown once). Every later lifecycle call for this delegation needs that " + "token; this server also stashes it in a persistent state file (`~/.threa/state.json`) so you can omit " + "`claim_token` on the follow-up calls, and it survives a server restart. The claim has a 15-minute TTL: " + "call update_delegation to renew it before it lapses, or the task returns to the queue. `claimed_by_label` " + `is your human-readable identity shown on the card (e.g. "Kris's MacBook / Claude Code"). ` + "`idempotency_key` re-keys your own live claim after a crash: persist it BEFORE claiming, and a retry " + "bearing the same key hands back a fresh token and lease instead of a 409. A 409 DELEGATION_NOT_OPEN means " + "the task is not currently claimable or re-keyable; inspect its status and claim expiry. A lapsed-but-unswept claim must wait for the sweep to reopen it. After claiming, keep the lease alive with " + "update_delegation and close out with finish_delegation.",
|
|
3019
|
+
inputSchema: {
|
|
3020
|
+
delegation_id: z3.string(),
|
|
3021
|
+
claimed_by_label: z3.string().min(1).max(200),
|
|
3022
|
+
idempotency_key: z3.string().min(8).max(128).optional()
|
|
3023
|
+
}
|
|
3024
|
+
}, async ({ delegation_id, claimed_by_label, idempotency_key }) => runTool(() => claimDelegation(client, store, workspaceId, {
|
|
3025
|
+
delegationId: delegation_id,
|
|
3026
|
+
claimedByLabel: claimed_by_label,
|
|
3027
|
+
idempotencyKey: idempotency_key
|
|
3028
|
+
})));
|
|
3029
|
+
server.registerTool("release_delegation", {
|
|
3030
|
+
title: "Release a delegation claim",
|
|
3031
|
+
description: "Return a live claim to the open queue without failing it. Uses the stored token unless overridden with `claim_token`. After a successful release, only the matching stored token is cleared; a replacement token is preserved.",
|
|
3032
|
+
inputSchema: { delegation_id: z3.string(), claim_token: z3.string().optional() }
|
|
3033
|
+
}, async ({ delegation_id, claim_token }) => runTool(() => releaseDelegation(client, store, workspaceId, { delegationId: delegation_id, claimToken: claim_token })));
|
|
3034
|
+
server.registerTool("update_delegation", {
|
|
3035
|
+
title: "Update a delegation's progress",
|
|
3036
|
+
description: "Keep a claimed delegation alive and, optionally, report progress. Pass `status_note` to mark the " + "delegation running and put a free-text note on its card (each note replaces the previous one) — this " + "also renews the claim's 15-minute TTL. Omit `status_note` for a pure heartbeat: liveness only, no card " + "change, TTL renewed. Uses the claim token from claim_delegation (persisted to `~/.threa/state.json`); " + "pass `claim_token` to override the stored one. A 404 means the claim lapsed or was lost — re-claim the " + "delegation.",
|
|
3037
|
+
inputSchema: {
|
|
3038
|
+
delegation_id: z3.string(),
|
|
3039
|
+
status_note: z3.string().min(1).max(2000).optional(),
|
|
3040
|
+
claim_token: z3.string().optional()
|
|
3041
|
+
}
|
|
3042
|
+
}, async ({ delegation_id, status_note, claim_token }) => runTool(() => updateDelegation(client, store, workspaceId, {
|
|
3043
|
+
delegationId: delegation_id,
|
|
3044
|
+
statusNote: status_note,
|
|
3045
|
+
claimToken: claim_token
|
|
3046
|
+
})));
|
|
3047
|
+
server.registerTool("finish_delegation", {
|
|
3048
|
+
title: "Finish a delegation",
|
|
3049
|
+
description: 'Complete or fail a claimed delegation. `outcome: "complete"` marks success. Provide `result_markdown` ' + "to post to the thread anchored on the delegation card (authored as this key's identity, entering the normal " + "message pipeline so workspace memory can capture it) or omit it to close without a message, and `metadata` (a flat " + "string→string map) stamps the result message for later find_messages_by_metadata lookup. " + '`outcome: "fail"` marks failure and REQUIRES `error_message` (recorded on the card so the delegator ' + "knows why); it rejects result_markdown/metadata. `error_message` is only valid with outcome=fail. Uses " + "the stored claim token (pass `claim_token` to override). On success the stored token is cleared — the " + "claim is spent.",
|
|
3050
|
+
inputSchema: {
|
|
3051
|
+
delegation_id: z3.string(),
|
|
3052
|
+
outcome: z3.enum(["complete", "fail"]),
|
|
3053
|
+
result_markdown: z3.string().min(1).max(50000).optional(),
|
|
3054
|
+
metadata: z3.record(z3.string(), z3.string()).optional(),
|
|
3055
|
+
error_message: z3.string().min(1).max(2000).optional(),
|
|
3056
|
+
claim_token: z3.string().optional()
|
|
3057
|
+
}
|
|
3058
|
+
}, async ({ delegation_id, outcome, result_markdown, metadata, error_message, claim_token }) => runTool(() => finishDelegation(client, store, workspaceId, {
|
|
3059
|
+
delegationId: delegation_id,
|
|
3060
|
+
outcome,
|
|
3061
|
+
resultMarkdown: result_markdown,
|
|
3062
|
+
metadata,
|
|
3063
|
+
errorMessage: error_message,
|
|
3064
|
+
claimToken: claim_token
|
|
3065
|
+
})));
|
|
3066
|
+
server.registerTool("request_delegation_access", {
|
|
3067
|
+
title: "Request access to a delegation's stream",
|
|
3068
|
+
description: "Bot-key only. When a workspace (bot) key sees a delegation it cannot claim because the bot lacks a " + "grant on the stream, file an access request that renders as a card for a member to approve. Returns " + "already_granted (no card) when the bot already has access. A user-scoped key gets 400 " + "USER_KEY_CANNOT_REQUEST_ACCESS — a user key's access follows its user, who should join the stream " + "directly.",
|
|
3069
|
+
inputSchema: {
|
|
3070
|
+
delegation_id: z3.string()
|
|
3071
|
+
}
|
|
3072
|
+
}, async ({ delegation_id }) => runTool(() => requestDelegationAccess(client, delegation_id)));
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// src/tools/identity.ts
|
|
3076
|
+
function registerIdentityTools(server, client, config) {
|
|
3077
|
+
server.registerTool("whoami", {
|
|
3078
|
+
title: "Who am I",
|
|
3079
|
+
description: "Return the authenticated principal for the configured API key (user or bot), the resolved API " + "version info, and the base URL and workspace this server is bound to. Use it to confirm the key " + "works and to discover your identity before other calls."
|
|
3080
|
+
}, async () => runTool(() => whoami(client, config)));
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// src/tools/labels.ts
|
|
3084
|
+
import { z as z4 } from "zod";
|
|
3085
|
+
function registerLabelTools(server, client, resolver) {
|
|
3086
|
+
server.registerTool("list_labels", {
|
|
3087
|
+
title: "List labels",
|
|
3088
|
+
description: "List this key actor's labels and their resource assignments. Every label is private to the actor " + "the API key acts as; you never see another actor's labels.",
|
|
3089
|
+
inputSchema: {}
|
|
3090
|
+
}, async () => runTool(() => listLabels(client)));
|
|
3091
|
+
server.registerTool("apply_label", {
|
|
3092
|
+
title: "Apply a label to a stream",
|
|
3093
|
+
description: "Attach a label to a stream, identifying the label by its `name`. The label is found-or-created for " + "this key actor (labels are private per actor and keyed by their text), then assigned — so re-applying " + "the same name is idempotent. Any appearance field you supply (`color` as #RRGGBB, `emoji`, " + "`description`) overwrites that field on the existing label every time, not only at creation — so omit " + "them unless you intend to recolor/re-emoji the label wherever it is already used. `stream_id` accepts a " + "stream_ id or `#channel-slug`.",
|
|
3094
|
+
inputSchema: {
|
|
3095
|
+
name: z4.string().min(1).max(100),
|
|
3096
|
+
stream_id: z4.string(),
|
|
3097
|
+
color: z4.string().optional(),
|
|
3098
|
+
emoji: z4.string().optional(),
|
|
3099
|
+
description: z4.string().optional()
|
|
3100
|
+
}
|
|
3101
|
+
}, async ({ name, stream_id, color, emoji, description }) => runTool(() => applyLabel(client, resolver, { name, streamRef: stream_id, color, emoji, description })));
|
|
3102
|
+
server.registerTool("remove_label", {
|
|
3103
|
+
title: "Remove a label from a stream",
|
|
3104
|
+
description: "Remove this key actor's assignment of a label (identified by its `name`) from a stream (`stream_id` " + "accepts a stream_ id or `#channel-slug`). The label itself is not deleted, only its assignment to this stream.",
|
|
3105
|
+
inputSchema: {
|
|
3106
|
+
name: z4.string().min(1).max(100),
|
|
3107
|
+
stream_id: z4.string()
|
|
3108
|
+
}
|
|
3109
|
+
}, async ({ name, stream_id }) => runTool(() => removeLabel(client, resolver, { name, streamRef: stream_id })));
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
// src/tools/memos.ts
|
|
3113
|
+
import { z as z5 } from "zod";
|
|
3114
|
+
function registerMemoTools(server, client) {
|
|
3115
|
+
server.registerTool("get_memo", {
|
|
3116
|
+
title: "Get a memo",
|
|
3117
|
+
description: "Retrieve one memo by id, together with its source stream and the source messages it was extracted " + "from (provenance) plus any successor memo id. Use this to trace a memory back to the exact " + "conversation that produced it. Find memos in the first place with the `search` tool (what: 'memos').",
|
|
3118
|
+
inputSchema: {
|
|
3119
|
+
memo_id: z5.string()
|
|
3120
|
+
}
|
|
3121
|
+
}, async ({ memo_id }) => runTool(() => getMemo(client, memo_id)));
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// src/tools/messages.ts
|
|
3125
|
+
import { z as z6 } from "zod";
|
|
3126
|
+
function registerMessageTools(server, client, resolver, sealed) {
|
|
3127
|
+
server.registerTool("send_message", {
|
|
3128
|
+
title: "Send a message to a stream",
|
|
3129
|
+
description: "Post a markdown message to a stream. `stream_id` accepts a stream_ id or a `#channel-slug` (an " + "`@user-slug` DM ref is not resolvable — pass the DM's stream_ id). Content is markdown: mentions are `[@slug](user:usr_x)` / " + "`[@slug](persona:persona_x)` / `[#slug](channel:stream_x)`, and plain URLs unfurl into link previews. " + "Conversation control: pass `conversation_id` to append to an existing conversation (it must live under " + "the same root stream as the target, else the API returns 400 CONVERSATION_NOT_IN_ROOT), or set " + "`start_conversation: true` to open a fresh conversation; setting both is an error. Idempotency: a " + "`client_message_id` is auto-generated (`mcp-<uuid>`) when you omit one so a retried call never " + "double-posts — the effective id is returned as `clientMessageId`. `metadata` is a flat string→string " + "map (≤20 keys, no `threa.` key prefix) stamped on the message for later lookup via " + "find_messages_by_metadata. The result carries the created message plus `conversationId` when a " + "conversation directive was applied. Into an end-to-end-encrypted stream the content is sealed with " + "the key this machine holds before it leaves this process, and the result carries `sealed: true`; " + "`metadata` and the conversation args are rejected there because they would travel in the clear.",
|
|
3130
|
+
inputSchema: {
|
|
3131
|
+
stream_id: z6.string(),
|
|
3132
|
+
content: z6.string().min(1),
|
|
3133
|
+
client_message_id: z6.string().max(128).optional(),
|
|
3134
|
+
metadata: z6.record(z6.string(), z6.string()).optional(),
|
|
3135
|
+
conversation_id: z6.string().optional(),
|
|
3136
|
+
start_conversation: z6.boolean().optional()
|
|
3137
|
+
}
|
|
3138
|
+
}, async ({ stream_id, content, client_message_id, metadata, conversation_id, start_conversation }) => runTool(() => sendMessage(client, resolver, {
|
|
3139
|
+
streamRef: stream_id,
|
|
3140
|
+
content,
|
|
3141
|
+
clientMessageId: client_message_id,
|
|
3142
|
+
metadata,
|
|
3143
|
+
conversationId: conversation_id,
|
|
3144
|
+
startConversation: start_conversation,
|
|
3145
|
+
sealed
|
|
3146
|
+
})));
|
|
3147
|
+
server.registerTool("update_message", {
|
|
3148
|
+
title: "Edit a message",
|
|
3149
|
+
description: "Replace a message's content with new markdown. Only works on messages this API key sent — the API " + "rejects edits to any other author's message. Returns the updated message.",
|
|
3150
|
+
inputSchema: {
|
|
3151
|
+
message_id: z6.string(),
|
|
3152
|
+
content: z6.string().min(1)
|
|
3153
|
+
}
|
|
3154
|
+
}, async ({ message_id, content }) => runTool(() => updateMessage(client, message_id, content)));
|
|
3155
|
+
server.registerTool("delete_message", {
|
|
3156
|
+
title: "Delete a message",
|
|
3157
|
+
description: "Delete a message. Only works on messages this API key sent — the API rejects deletes of any other " + "author's message. Returns { deleted: true, message_id } on success.",
|
|
3158
|
+
inputSchema: {
|
|
3159
|
+
message_id: z6.string()
|
|
3160
|
+
}
|
|
3161
|
+
}, async ({ message_id }) => runTool(() => deleteMessage(client, message_id)));
|
|
3162
|
+
server.registerTool("find_messages_by_metadata", {
|
|
3163
|
+
title: "Find messages by metadata",
|
|
3164
|
+
description: "Find non-deleted messages whose `metadata` contains every given key/value pair (exact AND-containment, " + "not text search). Use this for dedup and lookup by external reference you stamped at send time, e.g. " + '{ "github.pr": "org/repo#42" } to check whether a message was already posted for that PR. Narrow ' + "with `stream_id` (accepts a stream_ id or `#channel-slug`). limit ≤ 100 (default 20). Message rows " + "carry author { id, type, name, slug? }.",
|
|
3165
|
+
inputSchema: {
|
|
3166
|
+
metadata: z6.record(z6.string(), z6.string()),
|
|
3167
|
+
stream_id: z6.string().optional(),
|
|
3168
|
+
limit: z6.number().int().min(1).max(100).optional()
|
|
3169
|
+
}
|
|
3170
|
+
}, async ({ metadata, stream_id, limit }) => runTool(() => findMessagesByMetadata(client, resolver, { metadata, streamRef: stream_id, limit })));
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// src/tools/search.ts
|
|
3174
|
+
import { z as z7 } from "zod";
|
|
3175
|
+
function registerSearchTools(server, client, resolver) {
|
|
3176
|
+
server.registerTool("search", {
|
|
3177
|
+
title: "Search the workspace",
|
|
3178
|
+
description: "One search across the workspace, routed by `what`. Only the filters listed for the chosen `what` are " + `accepted; passing any other filter is an error that names the offending arg.
|
|
3179
|
+
` + "- what: 'messages' — full-text search over messages in accessible streams. Filters: query (required), " + "`semantic: true` for meaning-based vector retrieval when you know the idea but not the words, " + "`exact: true` to match the query as a literal phrase, stream_ids, type (stream types), before/after " + `(ISO-8601 datetimes), limit (≤50, default 20).
|
|
3180
|
+
` + "- what: 'memos' — search GAM (the knowledge extracted from this workspace's conversations: decisions, " + "learnings, procedures, context, references). Search this BEFORE asking a human. query is meaning-based " + "and OPTIONAL — leave it empty to browse the most recent memos. Filters: query, `exact: true` (or wrap " + "the query in double quotes) to match a literal phrase, stream_ids, memo_type (message vs. conversation " + "source), knowledge_type, tags, scope (user = private to this key's user, stream, workspace), " + `before/after (ISO-8601 datetimes), limit (≤100, default 20).
|
|
3181
|
+
` + "- what: 'attachments' — search accessible attachments by filename or extracted content. query is " + "OPTIONAL — leave it empty to browse the most recent attachments. Filters: query, stream_ids, " + `content_types (chart, table, diagram, screenshot, photo, document, other), limit (≤50, default 20).
|
|
3182
|
+
` + "stream_ids maps to the API's source-stream scope; each entry accepts a stream_ id or `#channel-slug`. " + "For what='messages', result rows carry author { id, type, name, slug? }; message, attachment, and " + "conversation rows carry stream { id, name?, type? } and, for threads, rootStream. " + "Results are the search envelope { data: [...] }. For " + "exact metadata lookup use find_messages_by_metadata; for a memo's source provenance use get_memo.",
|
|
3183
|
+
inputSchema: {
|
|
3184
|
+
what: z7.enum(SEARCH_WHATS),
|
|
3185
|
+
query: z7.string().optional(),
|
|
3186
|
+
semantic: z7.boolean().optional(),
|
|
3187
|
+
exact: z7.boolean().optional(),
|
|
3188
|
+
stream_ids: z7.array(z7.string()).optional(),
|
|
3189
|
+
type: z7.array(z7.enum(STREAM_TYPES)).optional(),
|
|
3190
|
+
memo_type: z7.array(z7.enum(MEMO_TYPES)).optional(),
|
|
3191
|
+
knowledge_type: z7.array(z7.enum(KNOWLEDGE_TYPES)).optional(),
|
|
3192
|
+
tags: z7.array(z7.string()).optional(),
|
|
3193
|
+
scope: z7.enum(MEMO_SCOPES).optional(),
|
|
3194
|
+
content_types: z7.array(z7.enum(EXTRACTION_CONTENT_TYPES)).optional(),
|
|
3195
|
+
before: z7.string().optional(),
|
|
3196
|
+
after: z7.string().optional(),
|
|
3197
|
+
limit: z7.number().int().min(1).max(100).optional()
|
|
3198
|
+
}
|
|
3199
|
+
}, async (args) => runTool(() => search(client, resolver, args)));
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
// src/tools/streams.ts
|
|
3203
|
+
import { z as z8 } from "zod";
|
|
3204
|
+
function registerStreamTools(server, client, resolver, sealed) {
|
|
3205
|
+
server.registerTool("list_streams", {
|
|
3206
|
+
title: "List streams",
|
|
3207
|
+
description: "List streams this key can access, newest-ish first. Filter with `type` (one or more of scratchpad, " + "channel, dm, thread, system) and `query` (text match on name). Page by passing the previous " + "response's `cursor` value back as `after`; `hasMore` tells you when to stop. limit ≤ 200 (default 50). " + "Archived streams (and live threads under an archived root) are omitted unless `include_archived` is true.",
|
|
3208
|
+
inputSchema: {
|
|
3209
|
+
type: z8.array(z8.enum(STREAM_TYPES)).optional(),
|
|
3210
|
+
query: z8.string().optional(),
|
|
3211
|
+
after: z8.string().optional(),
|
|
3212
|
+
limit: z8.number().int().min(1).max(200).optional(),
|
|
3213
|
+
include_archived: z8.boolean().optional()
|
|
3214
|
+
}
|
|
3215
|
+
}, async ({ type, query, after, limit, include_archived }) => runTool(() => listStreams(client, { type, query, after, limit, includeArchived: include_archived })));
|
|
3216
|
+
server.registerTool("read_stream", {
|
|
3217
|
+
title: "Read a stream with its messages",
|
|
3218
|
+
description: "Fetch a stream and a page of its messages in one call (concurrent requests). `stream_id` accepts a " + "stream_ id or a `#channel-slug` (an `@user-slug` DM ref is not resolvable — pass the DM's stream_ id). " + "Returns { stream, messages: { data, hasMore } } and, when `include_members` is true, " + "{ members: { data, hasMore, cursor } }. Message rows carry author { id, type, name, slug? } and members " + "carry name/slug, so author identity needs no follow-up. Message paging is by numeric message sequence, " + "NOT a cursor: " + "`before` returns messages before that sequence (older), `after` returns messages after it (newer); " + "pass at most one, and walk pages by taking the boundary message's `sequence`. limit ≤ 100 (default " + "50) applies to messages. If the stream cannot be read the whole call errors — no partial data is " + "returned. In an end-to-end-encrypted stream each body is opened with the key this machine holds, so " + "rows read as normal content; a body sealed to a key generation this key was never wrapped to keeps a " + "null `content` and says why in `unreadableReason`, and the call errors outright when no key is held " + "here at all.",
|
|
3219
|
+
inputSchema: {
|
|
3220
|
+
stream_id: z8.string(),
|
|
3221
|
+
include_members: z8.boolean().optional(),
|
|
3222
|
+
before: z8.string().optional(),
|
|
3223
|
+
after: z8.string().optional(),
|
|
3224
|
+
limit: z8.number().int().min(1).max(100).optional()
|
|
3225
|
+
}
|
|
3226
|
+
}, async ({ stream_id, include_members, before, after, limit }) => runTool(() => readStream(client, resolver, {
|
|
3227
|
+
streamId: stream_id,
|
|
3228
|
+
includeMembers: include_members,
|
|
3229
|
+
before,
|
|
3230
|
+
after,
|
|
3231
|
+
limit,
|
|
3232
|
+
sealed
|
|
3233
|
+
})));
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// src/tools/users.ts
|
|
3237
|
+
import { z as z9 } from "zod";
|
|
3238
|
+
function registerUserTools(server, client) {
|
|
3239
|
+
server.registerTool("list_users", {
|
|
3240
|
+
title: "List workspace users",
|
|
3241
|
+
description: "List users in this workspace. Filter with `query` (text match on name or email — NOT slug; slugs resolve via the @slug ref form). Page by passing the " + "previous response's `cursor` value back as `after`; `hasMore` tells you when to stop. limit ≤ 200 " + "(default 50).",
|
|
3242
|
+
inputSchema: {
|
|
3243
|
+
query: z9.string().optional(),
|
|
3244
|
+
after: z9.string().optional(),
|
|
3245
|
+
limit: z9.number().int().min(1).max(200).optional()
|
|
3246
|
+
}
|
|
3247
|
+
}, async ({ query, after, limit }) => runTool(() => listUsers(client, { query, after, limit })));
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
// src/server.ts
|
|
3251
|
+
var INSTRUCTIONS = "Threa MCP server. Wraps the Threa public REST API for one workspace, bound once from this server's " + "config — no tool takes a workspaceId. Authentication is a single API key. Tool results are JSON text in " + "the API's envelope: single resources under `data`, lists as { data, hasMore, cursor? } (feed `cursor` back " + "as the paging arg to continue); " + "failures come back as isError results carrying { code, message, hint? }. A 404 can mean the resource " + "does not exist or that the key lacks the required scope. Identifier args accept a raw id or a " + "sigil-prefixed slug: a stream arg takes a stream_ id or `#channel-slug` (your DM with a user by `@user-slug` " + "is not resolvable — the public API hides a DM's counterpart, so pass the DM's stream_ id), and user args " + "take a usr_/bot_ id or `@user-slug` (bots and personas are not queryable by slug). An unresolved ref fails " + "before any call with code UNRESOLVED_REF. Payloads are self-descriptive: message rows carry " + "author { id, type, name, slug? }, conversation participantIds are mirrored by a participants array with " + "name/slug, and stream members carry name/slug — so you rarely need a follow-up lookup. Start with `whoami` " + "to confirm the key and " + "identity. Read tools: list_streams and read_stream (one call returns a stream plus a page of its messages " + "by numeric sequence, and its members when include_members is set), list_users, list_conversations and " + "read_conversation (a conversation plus a page of its messages), and find_messages_by_metadata (exact " + "reference lookup). Search: one `search` tool with what = messages | memos | attachments — memos is GAM " + "(the knowledge extracted from this workspace's conversations), search it before asking a human; each " + "`what` accepts only its own filters. Write tools: send_message (markdown; optional conversation resume via " + "conversation_id or a new one via start_conversation; auto client_message_id for idempotent retries), " + "update_message / delete_message (only messages this key sent), and list_labels / apply_label / remove_label " + "(labels are private to the key actor and found-or-created by name). Memory provenance: get_memo traces a " + "memo to its source messages. Attachments: get_attachment (metadata plus extracted text), " + "get_attachment_download_url (short-lived signed URL for the raw bytes). Delegations: close the loop on a " + "delegated task with list_delegations (the open queue; since filters availability changes) → get_delegation (inspect brief/context without claim secrets) → " + "claim_delegation (returns a claim token shown once and persisted to ~/.threa/state.json; 15-min TTL) → optional " + "update_delegation while working (status_note reports progress and renews the claim, no note is a pure heartbeat) → " + "finish_delegation (outcome complete or fail) or release_delegation for controlled stop. Historical expired tasks are not listed but can be inspected and claimed directly by id. " + "A completed result is posted in the thread anchored on the delegation card and enters the normal message pipeline, so workspace memory can capture it. Lifecycle tools reuse the " + "stored token; pass claim_token to override or to recover on another machine. request_delegation_access " + "is bot-key only. End-to-end-encrypted streams: read_stream and send_message open and seal bodies with the " + "key this machine holds, so they read and write like any other stream; when no key is held here they say " + "so rather than returning the placeholder the server stores.";
|
|
3252
|
+
function createThreaMcpServer(config, deps = {}) {
|
|
3253
|
+
const server = new McpServer({ name: "threa", version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
3254
|
+
const client = new ThreaApiClient({
|
|
3255
|
+
baseUrl: config.baseUrl,
|
|
3256
|
+
workspaceId: config.workspaceId,
|
|
3257
|
+
apiKey: config.apiKey
|
|
3258
|
+
});
|
|
3259
|
+
const resolver = new RefResolver({ client });
|
|
3260
|
+
const tokenStore = deps.tokenStore ?? new TokenStore;
|
|
3261
|
+
const sealed = sealedStreams({
|
|
3262
|
+
client,
|
|
3263
|
+
config,
|
|
3264
|
+
choice: {
|
|
3265
|
+
...config.keyStore === undefined ? {} : { requested: config.keyStore },
|
|
3266
|
+
...config.keyDir === undefined ? {} : { dir: config.keyDir }
|
|
3267
|
+
}
|
|
3268
|
+
});
|
|
3269
|
+
registerIdentityTools(server, client, config);
|
|
3270
|
+
registerStreamTools(server, client, resolver, sealed);
|
|
3271
|
+
registerUserTools(server, client);
|
|
3272
|
+
registerMessageTools(server, client, resolver, sealed);
|
|
3273
|
+
registerSearchTools(server, client, resolver);
|
|
3274
|
+
registerConversationTools(server, client, resolver);
|
|
3275
|
+
registerLabelTools(server, client, resolver);
|
|
3276
|
+
registerMemoTools(server, client);
|
|
3277
|
+
registerAttachmentTools(server, client);
|
|
3278
|
+
registerDelegationTools(server, client, tokenStore, config.workspaceId);
|
|
3279
|
+
return server;
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
// src/commands/mcp.ts
|
|
3283
|
+
async function serveMcp(config) {
|
|
3284
|
+
await assertPrincipal(new ThreaApiClient({ baseUrl: config.baseUrl, workspaceId: config.workspaceId, apiKey: config.apiKey }), config, { retryDelaysMs: [1000, 2000, 4000, 8000] });
|
|
3285
|
+
const server = createThreaMcpServer(config);
|
|
3286
|
+
const transport = new StdioServerTransport;
|
|
3287
|
+
await server.connect(transport);
|
|
3288
|
+
process.stderr.write(`[threa] mcp serve connected; workspace ${config.workspaceId} at ${config.baseUrl}
|
|
3289
|
+
`);
|
|
3290
|
+
}
|
|
3291
|
+
var mcpCommand = {
|
|
3292
|
+
name: "mcp",
|
|
3293
|
+
summary: "Serve the same tools over MCP (`threa mcp serve`) on stdio",
|
|
3294
|
+
usage: "threa mcp serve",
|
|
3295
|
+
help: `threa mcp serve
|
|
3296
|
+
|
|
3297
|
+
` + "Run the Threa MCP server over stdio, exposing the same workspace tools to an MCP client (e.g. Claude " + `Code). Register it with:
|
|
3298
|
+
` + ` claude mcp add threa --env THREA_API_KEY=... --env THREA_WORKSPACE_ID=... -- threa mcp serve
|
|
3299
|
+
|
|
3300
|
+
` + `Flags:
|
|
3301
|
+
` + " --help show this help",
|
|
3302
|
+
options: {},
|
|
3303
|
+
serve: true,
|
|
3304
|
+
run: (_ctx, positionals) => {
|
|
3305
|
+
const sub = positionals[0];
|
|
3306
|
+
if (sub !== "serve") {
|
|
3307
|
+
throw new UsageError(`unknown mcp subcommand "${sub ?? ""}" — the only subcommand is "serve"`);
|
|
3308
|
+
}
|
|
3309
|
+
return Promise.resolve(undefined);
|
|
3310
|
+
}
|
|
3311
|
+
};
|
|
3312
|
+
|
|
3313
|
+
// src/commands/messages.ts
|
|
3314
|
+
async function resolveContent(raw, readStdin) {
|
|
3315
|
+
const content = raw === "-" ? await readStdin() : raw;
|
|
3316
|
+
if (content.trim() === "")
|
|
3317
|
+
throw new UsageError("content is empty");
|
|
3318
|
+
return content;
|
|
3319
|
+
}
|
|
3320
|
+
var sendVerb = {
|
|
3321
|
+
name: "send",
|
|
3322
|
+
summary: "Post a markdown message to a stream (content `-` reads stdin)",
|
|
3323
|
+
usage: "threa messages send <stream-ref> <content> [--new-conversation | --conversation conv_id] [--metadata k=v]... " + "[--client-message-id id]",
|
|
3324
|
+
help: `threa messages send <stream-ref> <content> [flags]
|
|
3325
|
+
|
|
3326
|
+
` + "Post a markdown message to a stream. <stream-ref> is a stream_ id or #channel-slug. Pass content as the " + "second positional, or `-` to read it from stdin. A client message id is auto-generated (mcp-<uuid>) when " + "you omit --client-message-id, so a retried send never double-posts; the effective id is returned as " + `clientMessageId.
|
|
3327
|
+
|
|
3328
|
+
` + 'Into an end-to-end-encrypted stream the body is sealed here, with the key "threa e2e unlock" filed on this ' + "machine — the plaintext never leaves it, and without a key the send fails rather than falling back. Such a " + "stream takes no --metadata and no conversation: both would travel in the clear, so they are refused here " + `rather than dropped.
|
|
3329
|
+
|
|
3330
|
+
` + `Flags:
|
|
3331
|
+
` + ` --new-conversation open a fresh conversation (mutually exclusive with --conversation)
|
|
3332
|
+
` + ` --conversation conv_id append to an existing conversation under the same root stream
|
|
3333
|
+
` + ` --metadata k=v stamp flat string metadata; repeatable
|
|
3334
|
+
` + ` --client-message-id id set the idempotency key explicitly (<= 128 chars)
|
|
3335
|
+
` + KEY_STORE_FLAGS + ` --json force JSON output
|
|
3336
|
+
` + " --help show this help",
|
|
3337
|
+
options: {
|
|
3338
|
+
"new-conversation": { type: "boolean" },
|
|
3339
|
+
conversation: { type: "string" },
|
|
3340
|
+
metadata: { type: "string", multiple: true },
|
|
3341
|
+
"client-message-id": { type: "string" },
|
|
3342
|
+
...KEY_STORE_OPTIONS
|
|
3343
|
+
},
|
|
3344
|
+
run: async (ctx, positionals, values) => {
|
|
3345
|
+
const ref = positionals[0];
|
|
3346
|
+
if (!ref)
|
|
3347
|
+
throw new UsageError("messages send requires a <stream-ref> (a stream_ id or #channel-slug)");
|
|
3348
|
+
const rawContent = positionals[1];
|
|
3349
|
+
if (rawContent === undefined)
|
|
3350
|
+
throw new UsageError("messages send requires <content> (or `-` to read stdin)");
|
|
3351
|
+
const startConversation = boolFlag(values, "new-conversation");
|
|
3352
|
+
const conversationId = stringFlag(values, "conversation");
|
|
3353
|
+
if (sendConversationArgError({ conversationId, startConversation })) {
|
|
3354
|
+
throw new UsageError("pass either --new-conversation or --conversation conv_id, not both");
|
|
3355
|
+
}
|
|
3356
|
+
const metadataPairs = arrayFlag(values, "metadata");
|
|
3357
|
+
return sendMessage(ctx.client, ctx.resolver, {
|
|
3358
|
+
streamRef: ref,
|
|
3359
|
+
content: await resolveContent(rawContent, ctx.readStdin),
|
|
3360
|
+
clientMessageId: stringFlag(values, "client-message-id"),
|
|
3361
|
+
metadata: metadataPairs ? kvPairs(metadataPairs) : undefined,
|
|
3362
|
+
conversationId,
|
|
3363
|
+
startConversation,
|
|
3364
|
+
sealed: ctx.sealed(storeChoice(values, ctx.config))
|
|
3365
|
+
});
|
|
3366
|
+
},
|
|
3367
|
+
render: (payload) => {
|
|
3368
|
+
const p = payload;
|
|
3369
|
+
const lines = [`sent ${p.data?.id ?? "?"}${p.sealed ? " (sealed)" : ""}`];
|
|
3370
|
+
if (p.conversationId)
|
|
3371
|
+
lines.push(`conversation: ${p.conversationId}`);
|
|
3372
|
+
if (p.clientMessageId)
|
|
3373
|
+
lines.push(`clientMessageId: ${p.clientMessageId}`);
|
|
3374
|
+
return lines.join(`
|
|
3375
|
+
`);
|
|
3376
|
+
}
|
|
3377
|
+
};
|
|
3378
|
+
var editVerb = {
|
|
3379
|
+
name: "edit",
|
|
3380
|
+
summary: "Replace a message's content (only messages this key sent)",
|
|
3381
|
+
usage: "threa messages edit <message-id> <content>",
|
|
3382
|
+
help: `threa messages edit <message-id> <content> [flags]
|
|
3383
|
+
|
|
3384
|
+
` + "Replace a message's content with new markdown. Only works on messages this API key sent — the API rejects " + `edits to any other author's message.
|
|
3385
|
+
|
|
3386
|
+
` + `Flags:
|
|
3387
|
+
` + ` --json force JSON output
|
|
3388
|
+
` + " --help show this help",
|
|
3389
|
+
options: {},
|
|
3390
|
+
run: (ctx, positionals) => {
|
|
3391
|
+
const messageId = positionals[0];
|
|
3392
|
+
if (!messageId)
|
|
3393
|
+
throw new UsageError("messages edit requires a <message-id>");
|
|
3394
|
+
const content = positionals[1];
|
|
3395
|
+
if (content === undefined || content.trim() === "")
|
|
3396
|
+
throw new UsageError("messages edit requires <content>");
|
|
3397
|
+
return updateMessage(ctx.client, messageId, content);
|
|
3398
|
+
},
|
|
3399
|
+
render: (payload) => `edited ${payload.data?.id ?? "?"}`
|
|
3400
|
+
};
|
|
3401
|
+
var deleteVerb = {
|
|
3402
|
+
name: "delete",
|
|
3403
|
+
summary: "Delete a message (only messages this key sent)",
|
|
3404
|
+
usage: "threa messages delete <message-id>",
|
|
3405
|
+
help: `threa messages delete <message-id> [flags]
|
|
3406
|
+
|
|
3407
|
+
` + "Delete a message. Only works on messages this API key sent — the API rejects deletes of any other " + `author's message.
|
|
3408
|
+
|
|
3409
|
+
` + `Flags:
|
|
3410
|
+
` + ` --json force JSON output
|
|
3411
|
+
` + " --help show this help",
|
|
3412
|
+
options: {},
|
|
3413
|
+
run: (ctx, positionals) => {
|
|
3414
|
+
const messageId = positionals[0];
|
|
3415
|
+
if (!messageId)
|
|
3416
|
+
throw new UsageError("messages delete requires a <message-id>");
|
|
3417
|
+
return deleteMessage(ctx.client, messageId);
|
|
3418
|
+
},
|
|
3419
|
+
render: (payload) => `deleted ${payload.message_id ?? "?"}`
|
|
3420
|
+
};
|
|
3421
|
+
var findByMetadataVerb = {
|
|
3422
|
+
name: "find-by-metadata",
|
|
3423
|
+
summary: "Find messages whose metadata contains every given key=value pair",
|
|
3424
|
+
usage: "threa messages find-by-metadata k=v [k=v ...] [--stream ref] [--limit n]",
|
|
3425
|
+
help: `threa messages find-by-metadata k=v [k=v ...] [flags]
|
|
3426
|
+
|
|
3427
|
+
` + "Find non-deleted messages whose metadata contains every given key=value pair (exact AND-containment, not " + `text search). Use it to dedup by an external reference you stamped at send time, e.g. github.pr=org/repo#42.
|
|
3428
|
+
|
|
3429
|
+
` + `Flags:
|
|
3430
|
+
` + ` --stream ref narrow to a stream (stream_ id or #slug)
|
|
3431
|
+
` + ` --limit n max results, <= 100 (default 20)
|
|
3432
|
+
` + ` --json force JSON output
|
|
3433
|
+
` + " --help show this help",
|
|
3434
|
+
options: {
|
|
3435
|
+
stream: { type: "string" },
|
|
3436
|
+
limit: { type: "string" }
|
|
3437
|
+
},
|
|
3438
|
+
run: (ctx, positionals, values) => {
|
|
3439
|
+
if (positionals.length === 0) {
|
|
3440
|
+
throw new UsageError("messages find-by-metadata requires at least one k=v pair");
|
|
3441
|
+
}
|
|
3442
|
+
return findMessagesByMetadata(ctx.client, ctx.resolver, {
|
|
3443
|
+
metadata: kvPairs(positionals),
|
|
3444
|
+
streamRef: stringFlag(values, "stream"),
|
|
3445
|
+
limit: intFlag(values, "limit")
|
|
3446
|
+
});
|
|
3447
|
+
},
|
|
3448
|
+
render: (payload) => {
|
|
3449
|
+
const p = payload;
|
|
3450
|
+
const rows = p.data ?? [];
|
|
3451
|
+
return rows.map((m) => `${m.id ?? "?"} ${m.streamId ?? "?"} ${m.author?.name ?? ""}`.trimEnd()).join(`
|
|
3452
|
+
`) || "(no matches)";
|
|
3453
|
+
}
|
|
3454
|
+
};
|
|
3455
|
+
var messagesNoun = {
|
|
3456
|
+
name: "messages",
|
|
3457
|
+
summary: "Send, edit, delete, and look up messages",
|
|
3458
|
+
verbs: [sendVerb, editVerb, deleteVerb, findByMetadataVerb]
|
|
3459
|
+
};
|
|
3460
|
+
|
|
3461
|
+
// src/commands/memos.ts
|
|
3462
|
+
var listVerb5 = {
|
|
3463
|
+
name: "list",
|
|
3464
|
+
summary: "List the most recent memos, optionally scoped to streams",
|
|
3465
|
+
usage: "threa memos list [--stream ref]... [--limit n]",
|
|
3466
|
+
help: `threa memos list [flags]
|
|
3467
|
+
|
|
3468
|
+
` + "Browse preserved workspace memos, newest first (a query-less memo search). Filter further with " + "`threa search --what memos`.\n\n" + `Flags:
|
|
3469
|
+
` + ` --stream ref limit to a source stream (stream_ id or #slug); repeatable
|
|
3470
|
+
` + ` --limit n max results, <= 100 (default 20)
|
|
3471
|
+
` + ` --json force JSON output
|
|
3472
|
+
` + " --help show this help",
|
|
3473
|
+
options: {
|
|
3474
|
+
stream: { type: "string", multiple: true },
|
|
3475
|
+
limit: { type: "string" }
|
|
3476
|
+
},
|
|
3477
|
+
run: (ctx, _positionals, values) => search(ctx.client, ctx.resolver, {
|
|
3478
|
+
what: "memos",
|
|
3479
|
+
stream_ids: arrayFlag(values, "stream"),
|
|
3480
|
+
limit: intFlag(values, "limit")
|
|
3481
|
+
}),
|
|
3482
|
+
render: (payload) => {
|
|
3483
|
+
const rows = payload.data ?? [];
|
|
3484
|
+
return rows.map(renderSearchResult).join(`
|
|
3485
|
+
`) || "(no memos)";
|
|
3486
|
+
}
|
|
3487
|
+
};
|
|
3488
|
+
var getVerb3 = {
|
|
3489
|
+
name: "get",
|
|
3490
|
+
summary: "Get a memo by id, with its source-message provenance",
|
|
3491
|
+
usage: "threa memos get <id>",
|
|
3492
|
+
help: `threa memos get <id>
|
|
3493
|
+
|
|
3494
|
+
` + "Retrieve one memo by id, with its source stream and the source messages it was extracted from " + "(provenance). Find memos in the first place with `threa memos list` or `threa search --what memos`.\n\n" + `Flags:
|
|
3495
|
+
` + ` --json force JSON output
|
|
3496
|
+
` + " --help show this help",
|
|
3497
|
+
options: {},
|
|
3498
|
+
run: (ctx, positionals) => {
|
|
3499
|
+
const id = positionals[0];
|
|
3500
|
+
if (!id)
|
|
3501
|
+
throw new UsageError("memos get requires a <id> (a memo_ id)");
|
|
3502
|
+
return getMemo(ctx.client, id);
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
var memosNoun = {
|
|
3506
|
+
name: "memos",
|
|
3507
|
+
summary: "List memos and get one by id",
|
|
3508
|
+
verbs: [listVerb5, getVerb3]
|
|
3509
|
+
};
|
|
3510
|
+
|
|
3511
|
+
// src/commands/streams.ts
|
|
3512
|
+
function streamLabel2(s) {
|
|
3513
|
+
return s.displayName ?? s.name ?? (s.slug ? `#${s.slug}` : s.id ?? "?");
|
|
3514
|
+
}
|
|
3515
|
+
var listVerb6 = {
|
|
3516
|
+
name: "list",
|
|
3517
|
+
summary: "List streams this key can access",
|
|
3518
|
+
usage: "threa streams list [--type t]... [--query q] [--after cursor] [--limit n] [--archived]",
|
|
3519
|
+
help: `threa streams list [flags]
|
|
3520
|
+
|
|
3521
|
+
` + `List streams this key can access. Page by passing the previous response's cursor back as --after.
|
|
3522
|
+
|
|
3523
|
+
` + `Flags:
|
|
3524
|
+
` + " --type t filter by stream type (" + STREAM_TYPES.join("|") + `); repeatable
|
|
3525
|
+
` + ` --query q text match on stream name
|
|
3526
|
+
` + ` --after c pagination cursor from a previous response
|
|
3527
|
+
` + ` --limit n max results, <= 200 (default 50)
|
|
3528
|
+
` + ` --archived include archived streams and threads under archived roots
|
|
3529
|
+
` + ` --json force JSON output
|
|
3530
|
+
` + " --help show this help",
|
|
3531
|
+
options: {
|
|
3532
|
+
type: { type: "string", multiple: true },
|
|
3533
|
+
query: { type: "string" },
|
|
3534
|
+
after: { type: "string" },
|
|
3535
|
+
limit: { type: "string" },
|
|
3536
|
+
archived: { type: "boolean" }
|
|
3537
|
+
},
|
|
3538
|
+
run: (ctx, _positionals, values) => listStreams(ctx.client, {
|
|
3539
|
+
type: enumArrayFlag(values, "type", STREAM_TYPES),
|
|
3540
|
+
query: stringFlag(values, "query"),
|
|
3541
|
+
after: stringFlag(values, "after"),
|
|
3542
|
+
limit: intFlag(values, "limit"),
|
|
3543
|
+
includeArchived: boolFlag(values, "archived")
|
|
3544
|
+
}),
|
|
3545
|
+
render: (payload) => renderList(payload, (s) => `${s.id ?? "?"} ${s.type ?? "?"} ${streamLabel2(s)}`, {
|
|
3546
|
+
empty: "(no streams)",
|
|
3547
|
+
cursorFlag: "after"
|
|
3548
|
+
})
|
|
3549
|
+
};
|
|
3550
|
+
var readVerb2 = {
|
|
3551
|
+
name: "read",
|
|
3552
|
+
summary: "Read a stream with a page of its messages (accepts a stream_ id or #slug)",
|
|
3553
|
+
usage: "threa streams read <ref> [--members] [--before seq] [--after seq] [--limit n]",
|
|
3554
|
+
help: `threa streams read <ref> [flags]
|
|
3555
|
+
|
|
3556
|
+
` + `Fetch a stream and a page of its messages. <ref> is a stream_ id or a #channel-slug.
|
|
3557
|
+
` + "Message paging is by numeric sequence, not a cursor: --before returns older messages, --after newer; " + `pass at most one and walk pages by the boundary message's sequence.
|
|
3558
|
+
|
|
3559
|
+
` + 'An end-to-end-encrypted stream is read with the key this machine holds, so run "threa e2e unlock" ' + "first — without it the bodies cannot be opened here, and the command says so rather than printing the " + "placeholder the server stores. A message sealed to a key generation you were never wrapped to keeps a " + `null content and reports why, alone among the page.
|
|
3560
|
+
|
|
3561
|
+
` + `Flags:
|
|
3562
|
+
` + ` --members also fetch the stream's members
|
|
3563
|
+
` + ` --before seq messages before this sequence (older)
|
|
3564
|
+
` + ` --after seq messages after this sequence (newer)
|
|
3565
|
+
` + ` --limit n max messages, <= 100 (default 50)
|
|
3566
|
+
` + KEY_STORE_FLAGS + ` --json force JSON output
|
|
3567
|
+
` + " --help show this help",
|
|
3568
|
+
options: {
|
|
3569
|
+
members: { type: "boolean" },
|
|
3570
|
+
before: { type: "string" },
|
|
3571
|
+
after: { type: "string" },
|
|
3572
|
+
limit: { type: "string" },
|
|
3573
|
+
...KEY_STORE_OPTIONS
|
|
3574
|
+
},
|
|
3575
|
+
run: (ctx, positionals, values) => {
|
|
3576
|
+
const ref = positionals[0];
|
|
3577
|
+
if (!ref)
|
|
3578
|
+
throw new UsageError("streams read requires a <ref> (a stream_ id or #channel-slug)");
|
|
3579
|
+
return readStream(ctx.client, ctx.resolver, {
|
|
3580
|
+
streamId: ref,
|
|
3581
|
+
includeMembers: boolFlag(values, "members"),
|
|
3582
|
+
before: stringFlag(values, "before"),
|
|
3583
|
+
after: stringFlag(values, "after"),
|
|
3584
|
+
limit: intFlag(values, "limit"),
|
|
3585
|
+
sealed: ctx.sealed(storeChoice(values, ctx.config))
|
|
3586
|
+
});
|
|
3587
|
+
},
|
|
3588
|
+
render: (payload) => {
|
|
3589
|
+
const p = payload;
|
|
3590
|
+
const lines = [];
|
|
3591
|
+
if (p.stream)
|
|
3592
|
+
lines.push(`stream: ${p.stream.id ?? "?"} ${streamLabel2(p.stream)}`);
|
|
3593
|
+
const msgs = p.messages?.data ?? [];
|
|
3594
|
+
lines.push(`messages: ${msgs.length}${p.messages?.hasMore ? " (more)" : ""}`);
|
|
3595
|
+
for (const m of msgs) {
|
|
3596
|
+
const body = m.unreadableReason ? `<unreadable: ${m.unreadableReason}>` : (m.contentMarkdown ?? m.content ?? "").replace(/\s+/g, " ").trim();
|
|
3597
|
+
lines.push(` [${String(m.sequence ?? "?")}] ${m.author?.name ?? "?"}: ${body.slice(0, 120)}`);
|
|
3598
|
+
}
|
|
3599
|
+
if (p.members) {
|
|
3600
|
+
lines.push(`members: ${(p.members.data ?? []).length}`);
|
|
3601
|
+
for (const mem of p.members.data ?? [])
|
|
3602
|
+
lines.push(` ${mem.id ?? "?"} ${mem.name ?? ""}`.trimEnd());
|
|
3603
|
+
}
|
|
3604
|
+
return lines.join(`
|
|
3605
|
+
`);
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
function archiveVerb(archived) {
|
|
3609
|
+
const name = archived ? "archive" : "unarchive";
|
|
3610
|
+
const effect = archived ? "Archive a stream: it goes read-only and every thread beneath it is sealed with it." : "Reopen an archived stream. An archived ancestor keeps the subtree sealed until it is reopened too.";
|
|
3611
|
+
return {
|
|
3612
|
+
name,
|
|
3613
|
+
summary: `${archived ? "Archive" : "Unarchive"} a stream (accepts a stream_ id or #slug)`,
|
|
3614
|
+
usage: `threa streams ${name} <ref>`,
|
|
3615
|
+
help: `threa streams ${name} <ref>
|
|
3616
|
+
|
|
3617
|
+
` + `${effect}
|
|
3618
|
+
` + "<ref> is a stream_ id or a #channel-slug. Open to the stream's creator and, for a user key, the creator " + "of its root; a workspace key acts for its bot and reaches only the streams that bot opened. Repeating " + `the call is a no-op. An archived channel is not resolvable by #slug — pass its stream_ id.
|
|
3619
|
+
|
|
3620
|
+
` + `Flags:
|
|
3621
|
+
` + ` --json force JSON output
|
|
3622
|
+
` + " --help show this help",
|
|
3623
|
+
options: {},
|
|
3624
|
+
run: (ctx, positionals) => {
|
|
3625
|
+
const ref = positionals[0];
|
|
3626
|
+
if (!ref)
|
|
3627
|
+
throw new UsageError(`streams ${name} requires a <ref> (a stream_ id or #channel-slug)`);
|
|
3628
|
+
return setStreamArchived(ctx.client, ctx.resolver, { streamRef: ref, archived });
|
|
3629
|
+
},
|
|
3630
|
+
render: (payload) => {
|
|
3631
|
+
const stream = payload.data;
|
|
3632
|
+
return `${stream.id ?? "?"} ${streamLabel2(stream)} ${stream.archivedAt ? `archived ${stream.archivedAt}` : "active"}`;
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
var streamsNoun = {
|
|
3637
|
+
name: "streams",
|
|
3638
|
+
summary: "List streams, read one with its messages, archive and unarchive",
|
|
3639
|
+
verbs: [listVerb6, readVerb2, archiveVerb(true), archiveVerb(false)]
|
|
3640
|
+
};
|
|
3641
|
+
|
|
3642
|
+
// src/commands/users.ts
|
|
3643
|
+
var listVerb7 = {
|
|
3644
|
+
name: "list",
|
|
3645
|
+
summary: "List workspace users",
|
|
3646
|
+
usage: "threa users list [--query q] [--after cursor] [--limit n]",
|
|
3647
|
+
help: `threa users list [flags]
|
|
3648
|
+
|
|
3649
|
+
` + `List users in this workspace. --query matches on name or email (not slug).
|
|
3650
|
+
|
|
3651
|
+
` + `Flags:
|
|
3652
|
+
` + ` --query q text match on name or email
|
|
3653
|
+
` + ` --after c pagination cursor from a previous response
|
|
3654
|
+
` + ` --limit n max results, <= 200 (default 50)
|
|
3655
|
+
` + ` --json force JSON output
|
|
3656
|
+
` + " --help show this help",
|
|
3657
|
+
options: {
|
|
3658
|
+
query: { type: "string" },
|
|
3659
|
+
after: { type: "string" },
|
|
3660
|
+
limit: { type: "string" }
|
|
3661
|
+
},
|
|
3662
|
+
run: (ctx, _positionals, values) => listUsers(ctx.client, {
|
|
3663
|
+
query: stringFlag(values, "query"),
|
|
3664
|
+
after: stringFlag(values, "after"),
|
|
3665
|
+
limit: intFlag(values, "limit")
|
|
3666
|
+
}),
|
|
3667
|
+
render: (payload) => renderList(payload, (u) => `${u.id ?? "?"} ${u.name ?? "?"} ${u.slug ? `@${u.slug}` : ""} ${u.email ?? ""}`.trimEnd(), { empty: "(no users)", cursorFlag: "after" })
|
|
3668
|
+
};
|
|
3669
|
+
var usersNoun = {
|
|
3670
|
+
name: "users",
|
|
3671
|
+
summary: "List workspace users",
|
|
3672
|
+
verbs: [listVerb7]
|
|
3673
|
+
};
|
|
3674
|
+
|
|
3675
|
+
// src/cli.ts
|
|
3676
|
+
var FLAT_COMMANDS = [whoamiCommand, searchCommand, skillCommand, mcpCommand];
|
|
3677
|
+
var NOUNS = [
|
|
3678
|
+
messagesNoun,
|
|
3679
|
+
streamsNoun,
|
|
3680
|
+
conversationsNoun,
|
|
3681
|
+
usersNoun,
|
|
3682
|
+
memosNoun,
|
|
3683
|
+
attachmentsNoun,
|
|
3684
|
+
labelsNoun,
|
|
3685
|
+
delegationsNoun,
|
|
3686
|
+
e2eNoun
|
|
3687
|
+
];
|
|
3688
|
+
var FLAT_REGISTRY = new Map(FLAT_COMMANDS.map((c) => [c.name, c]));
|
|
3689
|
+
var NOUN_REGISTRY = new Map(NOUNS.map((n) => [n.name, n]));
|
|
3690
|
+
var SERVE_STUB = {};
|
|
3691
|
+
var TOP_ENTRIES = [
|
|
3692
|
+
{ name: whoamiCommand.name, summary: whoamiCommand.summary },
|
|
3693
|
+
...NOUNS.map((n) => ({ name: n.name, summary: n.summary, verbs: n.verbs.map((v) => v.name).join(", ") })),
|
|
3694
|
+
{ name: searchCommand.name, summary: searchCommand.summary },
|
|
3695
|
+
{ name: skillCommand.name, summary: skillCommand.summary },
|
|
3696
|
+
{ name: mcpCommand.name, summary: mcpCommand.summary }
|
|
3697
|
+
];
|
|
3698
|
+
function topHelp() {
|
|
3699
|
+
const width = Math.max(...TOP_ENTRIES.map((e) => e.name.length));
|
|
3700
|
+
const lines = TOP_ENTRIES.map((e) => {
|
|
3701
|
+
const verbs = e.verbs ? ` (${e.verbs})` : "";
|
|
3702
|
+
return ` ${e.name.padEnd(width)} ${e.summary}${verbs}`;
|
|
3703
|
+
});
|
|
3704
|
+
return [
|
|
3705
|
+
"threa — command-line access to a Threa workspace (one API key, bound via config).",
|
|
3706
|
+
"",
|
|
3707
|
+
"Usage: threa <command> [subcommand] [args] [flags]",
|
|
3708
|
+
"",
|
|
3709
|
+
"Commands:",
|
|
3710
|
+
...lines,
|
|
3711
|
+
"",
|
|
3712
|
+
"Global flags: -o json|text / --json (output mode, any position), --help (per-command help).",
|
|
3713
|
+
'Config: THREA_API_KEY and THREA_WORKSPACE_ID (env, or ~/.threa/config.json, or the file in THREA_CONFIG, which wins over env); optional THREA_BASE_URL, THREA_PRINCIPAL / "principal": "bot"|"user" (asserted against the key at startup), and "output": "json"|"text".',
|
|
3714
|
+
"Run `threa <command> --help` for a command's subcommands or flags."
|
|
3715
|
+
].join(`
|
|
3716
|
+
`);
|
|
3717
|
+
}
|
|
3718
|
+
function nounHelp(noun) {
|
|
3719
|
+
const width = Math.max(...noun.verbs.map((v) => v.name.length));
|
|
3720
|
+
const lines = noun.verbs.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`);
|
|
3721
|
+
return [
|
|
3722
|
+
`threa ${noun.name} <subcommand> [args] [flags]`,
|
|
3723
|
+
"",
|
|
3724
|
+
noun.summary,
|
|
3725
|
+
"",
|
|
3726
|
+
"Subcommands:",
|
|
3727
|
+
...lines,
|
|
3728
|
+
"",
|
|
3729
|
+
`Run \`threa ${noun.name} <subcommand> --help\` for a subcommand's flags.`
|
|
3730
|
+
].join(`
|
|
3731
|
+
`);
|
|
3732
|
+
}
|
|
3733
|
+
function leafFlags(leaf) {
|
|
3734
|
+
return {
|
|
3735
|
+
serve: leaf.serve === true,
|
|
3736
|
+
noConfig: leaf.noConfig === true
|
|
3737
|
+
};
|
|
3738
|
+
}
|
|
3739
|
+
async function executeLeaf(leaf, args, deps, flagMode) {
|
|
3740
|
+
let parsed;
|
|
3741
|
+
try {
|
|
3742
|
+
parsed = parseArgs({
|
|
3743
|
+
args,
|
|
3744
|
+
options: withGlobalOptions(leaf.options),
|
|
3745
|
+
allowPositionals: true,
|
|
3746
|
+
strict: true
|
|
3747
|
+
});
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3750
|
+
return { exitCode: 2, stdout: "", stderr: stderrJson({ code: "USAGE", message, hint: leaf.usage }) };
|
|
3751
|
+
}
|
|
3752
|
+
const values = parsed.values;
|
|
3753
|
+
const positionals = parsed.positionals;
|
|
3754
|
+
if (values.help === true) {
|
|
3755
|
+
return { exitCode: 0, stdout: `${leaf.help}
|
|
3756
|
+
`, stderr: "" };
|
|
3757
|
+
}
|
|
3758
|
+
const { serve, noConfig } = leafFlags(leaf);
|
|
3759
|
+
try {
|
|
3760
|
+
if (serve) {
|
|
3761
|
+
await leaf.run(SERVE_STUB, positionals, values);
|
|
3762
|
+
return { exitCode: 0, stdout: "", stderr: "", serve: true };
|
|
3763
|
+
}
|
|
3764
|
+
if (noConfig) {
|
|
3765
|
+
const payload2 = await leaf.run(SERVE_STUB, positionals, values);
|
|
3766
|
+
return {
|
|
3767
|
+
exitCode: 0,
|
|
3768
|
+
stdout: `${formatSuccess(leaf, payload2, { mode: flagMode ?? "text" })}
|
|
3769
|
+
`,
|
|
3770
|
+
stderr: ""
|
|
3771
|
+
};
|
|
3772
|
+
}
|
|
3773
|
+
const config = deps.config ?? loadConfig();
|
|
3774
|
+
const client = new ThreaApiClient({
|
|
3775
|
+
baseUrl: config.baseUrl,
|
|
3776
|
+
workspaceId: config.workspaceId,
|
|
3777
|
+
apiKey: config.apiKey
|
|
3778
|
+
});
|
|
3779
|
+
await assertPrincipal(client, config);
|
|
3780
|
+
const resolver = new RefResolver({ client });
|
|
3781
|
+
const readStdin = deps.readStdin ?? (() => text(process.stdin));
|
|
3782
|
+
const tokenStore = deps.tokenStore ?? new TokenStore;
|
|
3783
|
+
const sealed = (choice) => sealedStreams({ client, config, choice });
|
|
3784
|
+
const payload = await leaf.run({ client, resolver, config, tokenStore, readStdin, sealed }, positionals, values);
|
|
3785
|
+
return {
|
|
3786
|
+
exitCode: 0,
|
|
3787
|
+
stdout: `${formatSuccess(leaf, payload, { mode: flagMode ?? config.output })}
|
|
3788
|
+
`,
|
|
3789
|
+
stderr: ""
|
|
3790
|
+
};
|
|
3791
|
+
} catch (error) {
|
|
3792
|
+
if (error instanceof UsageError) {
|
|
3793
|
+
return {
|
|
3794
|
+
exitCode: 2,
|
|
3795
|
+
stdout: "",
|
|
3796
|
+
stderr: stderrJson({ code: "USAGE", message: error.message, hint: leaf.usage })
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
return { exitCode: 1, stdout: "", stderr: stderrJson(errorObject(error)) };
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
async function runNoun(noun, args, deps, flagMode) {
|
|
3803
|
+
const verbName = args[0];
|
|
3804
|
+
if (verbName === undefined) {
|
|
3805
|
+
return {
|
|
3806
|
+
exitCode: 2,
|
|
3807
|
+
stdout: "",
|
|
3808
|
+
stderr: stderrJson({
|
|
3809
|
+
code: "USAGE",
|
|
3810
|
+
message: `"${noun.name}" needs a subcommand: ${noun.verbs.map((v) => v.name).join(", ")}`,
|
|
3811
|
+
hint: nounHelp(noun)
|
|
3812
|
+
})
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
if (verbName === "--help" || verbName === "-h") {
|
|
3816
|
+
return { exitCode: 0, stdout: `${nounHelp(noun)}
|
|
3817
|
+
`, stderr: "" };
|
|
3818
|
+
}
|
|
3819
|
+
const verb = noun.verbs.find((v) => v.name === verbName);
|
|
3820
|
+
if (!verb) {
|
|
3821
|
+
return {
|
|
3822
|
+
exitCode: 2,
|
|
3823
|
+
stdout: "",
|
|
3824
|
+
stderr: stderrJson({
|
|
3825
|
+
code: "USAGE",
|
|
3826
|
+
message: `unknown ${noun.name} subcommand "${verbName}" — expected one of: ${noun.verbs.map((v) => v.name).join(", ")}`,
|
|
3827
|
+
hint: nounHelp(noun)
|
|
3828
|
+
})
|
|
3829
|
+
};
|
|
3830
|
+
}
|
|
3831
|
+
return executeLeaf(verb, args.slice(1), deps, flagMode);
|
|
3832
|
+
}
|
|
3833
|
+
async function run(rawArgv, deps = {}) {
|
|
3834
|
+
let argv;
|
|
3835
|
+
let flagMode;
|
|
3836
|
+
try {
|
|
3837
|
+
({ argv, mode: flagMode } = extractOutputMode(rawArgv));
|
|
3838
|
+
} catch (error) {
|
|
3839
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3840
|
+
return { exitCode: 2, stdout: "", stderr: stderrJson({ code: "USAGE", message }) };
|
|
3841
|
+
}
|
|
3842
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
|
|
3843
|
+
return { exitCode: 0, stdout: `${topHelp()}
|
|
3844
|
+
`, stderr: "" };
|
|
3845
|
+
}
|
|
3846
|
+
const name = argv[0];
|
|
3847
|
+
const flat = FLAT_REGISTRY.get(name);
|
|
3848
|
+
if (flat)
|
|
3849
|
+
return executeLeaf(flat, argv.slice(1), deps, flagMode);
|
|
3850
|
+
const noun = NOUN_REGISTRY.get(name);
|
|
3851
|
+
if (noun)
|
|
3852
|
+
return runNoun(noun, argv.slice(1), deps, flagMode);
|
|
3853
|
+
return {
|
|
3854
|
+
exitCode: 2,
|
|
3855
|
+
stdout: "",
|
|
3856
|
+
stderr: stderrJson({ code: "UNKNOWN_COMMAND", message: `unknown command "${name}"`, hint: topHelp() })
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
async function main() {
|
|
3860
|
+
const result = await run(process.argv.slice(2));
|
|
3861
|
+
if (result.stdout)
|
|
3862
|
+
process.stdout.write(result.stdout);
|
|
3863
|
+
if (result.stderr)
|
|
3864
|
+
process.stderr.write(result.stderr);
|
|
3865
|
+
if (result.serve) {
|
|
3866
|
+
try {
|
|
3867
|
+
await serveMcp(loadConfig());
|
|
3868
|
+
} catch (error) {
|
|
3869
|
+
process.stderr.write(stderrJson(errorObject(error)));
|
|
3870
|
+
process.exit(1);
|
|
3871
|
+
}
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
process.exit(result.exitCode);
|
|
3875
|
+
}
|
|
3876
|
+
if (__require.main == __require.module) {
|
|
3877
|
+
main().catch((error) => {
|
|
3878
|
+
process.stderr.write(stderrJson(errorObject(error)));
|
|
3879
|
+
process.exit(1);
|
|
3880
|
+
});
|
|
3881
|
+
}
|
|
3882
|
+
export {
|
|
3883
|
+
run
|
|
3884
|
+
};
|