flyspace-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.
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/profile.ts
4
+ import { AsyncLocalStorage } from "async_hooks";
5
+ var PROFILE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
6
+ var DEFAULT_PROFILE = "default";
7
+ var profileStore = new AsyncLocalStorage();
8
+ function runWithProfile(profile, fn) {
9
+ return profileStore.run(validateProfile(profile), fn);
10
+ }
11
+ function validateProfile(raw) {
12
+ const name = raw?.trim();
13
+ if (!name || name === DEFAULT_PROFILE) return DEFAULT_PROFILE;
14
+ if (!PROFILE_PATTERN.test(name)) {
15
+ throw new Error(
16
+ `Invalid profile name: "${name}". Use lowercase letters, digits, and hyphens (must start with a letter or digit).`
17
+ );
18
+ }
19
+ return name;
20
+ }
21
+ function getProfile() {
22
+ const bound = profileStore.getStore();
23
+ if (bound) return bound;
24
+ return validateProfile(process.env["FLYSPACE_WORKSPACE"]);
25
+ }
26
+
27
+ // src/paths.ts
28
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "fs";
29
+ import { homedir } from "os";
30
+ import { join, resolve } from "path";
31
+ var home = homedir();
32
+ var XDG_CONFIG_HOME = process.env["XDG_CONFIG_HOME"] || join(home, ".config");
33
+ var XDG_DATA_HOME = process.env["XDG_DATA_HOME"] || join(home, ".local", "share");
34
+ var paths = {
35
+ /** Profile-independent config root: `~/.config/flyspace`. */
36
+ get BASE_CONFIG_DIR() {
37
+ return join(XDG_CONFIG_HOME, "flyspace");
38
+ },
39
+ /** Profile-independent data root: `~/.local/share/flyspace`. */
40
+ get BASE_DATA_DIR() {
41
+ return join(XDG_DATA_HOME, "flyspace");
42
+ },
43
+ /** Where named profiles live: `~/.config/flyspace/profiles`. */
44
+ get PROFILES_DIR() {
45
+ return join(XDG_CONFIG_HOME, "flyspace", "profiles");
46
+ },
47
+ /** Config dir for the active profile. `default` uses the base dir. */
48
+ get CONFIG_DIR() {
49
+ const profile = getProfile();
50
+ return profile === "default" ? this.BASE_CONFIG_DIR : join(this.PROFILES_DIR, profile);
51
+ },
52
+ /** Data dir for the active profile. `default` uses the base dir. */
53
+ get DATA_DIR() {
54
+ const profile = getProfile();
55
+ return profile === "default" ? this.BASE_DATA_DIR : join(this.BASE_DATA_DIR, "profiles", profile);
56
+ },
57
+ get CONFIG_FILE() {
58
+ return join(this.CONFIG_DIR, "config.env");
59
+ },
60
+ get PROMPT_FILE() {
61
+ return join(this.CONFIG_DIR, "prompt.md");
62
+ },
63
+ get DB_FILE() {
64
+ return join(this.DATA_DIR, "flyspace.db");
65
+ },
66
+ get LOG_DIR() {
67
+ return join(this.DATA_DIR, "logs");
68
+ },
69
+ get UPLOADS_DIR() {
70
+ return join(this.DATA_DIR, "uploads");
71
+ },
72
+ /**
73
+ * Throwaway files the agent produces mid-turn (screenshots, rendered charts)
74
+ * so it has somewhere to write that isn't the shared system temp dir. Swept
75
+ * on startup by `sweepScratchDir()` — nothing here is expected to survive.
76
+ */
77
+ get SCRATCH_DIR() {
78
+ return join(this.DATA_DIR, "scratch");
79
+ },
80
+ /**
81
+ * Per-channel persistent HOME for `runtime: 'container'` channels — mounted
82
+ * into the container as the agent's home so `--resume` survives a container
83
+ * exiting (each turn is a fresh `--rm` container). Unlike SCRATCH_DIR this is
84
+ * never swept: the Claude session files under it are the resume state.
85
+ * One subdirectory per container channel; see `containerHomeFor()`.
86
+ */
87
+ get CONTAINER_HOMES_DIR() {
88
+ return join(this.DATA_DIR, "container-homes");
89
+ },
90
+ /**
91
+ * Per-channel persistent CLAUDE_CONFIG_DIR for `runtime: 'sandbox'` channels
92
+ * — a real host directory (sandbox-runtime confines the process to paths on
93
+ * disk, there is no separate container filesystem to mount into), kept out
94
+ * of the channel's own folder so it never shows up in the agent's Read/ls.
95
+ * One subdirectory per sandboxed channel; see `sandboxHomeFor()`.
96
+ */
97
+ get SANDBOX_HOMES_DIR() {
98
+ return join(this.DATA_DIR, "sandbox-homes");
99
+ },
100
+ /**
101
+ * Root for per-channel shadow git repos (bare, one per channel) that back
102
+ * the version-history feature. Kept outside the channel folder itself so a
103
+ * channel's own `cwd` never grows a `.git` — that would collide with a repo
104
+ * the user links in themselves. See `src/versions`.
105
+ */
106
+ get VERSIONS_DIR() {
107
+ return join(this.DATA_DIR, "versions");
108
+ },
109
+ get AUTOMATIONS_FILE() {
110
+ return join(this.CONFIG_DIR, "automations.yml");
111
+ },
112
+ get CHANNELS_FILE() {
113
+ return join(this.CONFIG_DIR, "channels.yml");
114
+ },
115
+ get DASHBOARD_SETTINGS_FILE() {
116
+ return join(this.CONFIG_DIR, "dashboard-settings.json");
117
+ },
118
+ PACKAGE_ROOT: resolve(import.meta.dirname, "..")
119
+ };
120
+ function ensureDirs() {
121
+ for (const dir of [
122
+ paths.CONFIG_DIR,
123
+ paths.DATA_DIR,
124
+ paths.LOG_DIR,
125
+ paths.UPLOADS_DIR,
126
+ paths.SCRATCH_DIR
127
+ ]) {
128
+ if (!existsSync(dir)) {
129
+ mkdirSync(dir, { recursive: true });
130
+ }
131
+ }
132
+ }
133
+ var SCRATCH_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
134
+ function sweepScratchDir(now = Date.now()) {
135
+ const dir = paths.SCRATCH_DIR;
136
+ if (!existsSync(dir)) return 0;
137
+ let removed = 0;
138
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
139
+ const path = join(dir, entry.name);
140
+ try {
141
+ if (now - statSync(path).mtimeMs < SCRATCH_MAX_AGE_MS) continue;
142
+ rmSync(path, { recursive: true, force: true });
143
+ removed++;
144
+ } catch {
145
+ }
146
+ }
147
+ return removed;
148
+ }
149
+
150
+ // src/store/supabase-client.ts
151
+ import { createClient } from "@supabase/supabase-js";
152
+ var client;
153
+ function getSupabaseClient() {
154
+ if (client) return client;
155
+ const url = process.env["SUPABASE_URL"];
156
+ const key = process.env["SUPABASE_SECRET_KEY"];
157
+ if (!url || !key) {
158
+ throw new Error("SUPABASE_URL / SUPABASE_SECRET_KEY not set \u2014 check .env");
159
+ }
160
+ client = createClient(url, key, { auth: { persistSession: false } });
161
+ return client;
162
+ }
163
+
164
+ // src/store/authors.ts
165
+ var AGENT_AUTHOR = "agent";
166
+ var SYNCED_BOT_AUTHOR = "Bot";
167
+ var LEGACY_BOT_AUTHOR = "Custie";
168
+ var NON_HUMAN_AUTHORS = new Set(
169
+ [AGENT_AUTHOR, SYNCED_BOT_AUTHOR, LEGACY_BOT_AUTHOR].map((a) => a.toLowerCase())
170
+ );
171
+ function isHumanAuthor(author) {
172
+ return !NON_HUMAN_AUTHORS.has(author.toLowerCase());
173
+ }
174
+
175
+ // src/dashboard/attachment-uploader.ts
176
+ import { randomUUID } from "crypto";
177
+ import { readFile, stat } from "fs/promises";
178
+ import { basename } from "path";
179
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
180
+
181
+ // src/store/r2-client.ts
182
+ import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
183
+ var client2;
184
+ function getR2Client() {
185
+ if (client2) return client2;
186
+ const accountId = process.env["CLOUDFLARE_R2_ACCOUNT_ID"];
187
+ const accessKeyId = process.env["CLOUDFLARE_R2_ACCESS_KEY_ID"];
188
+ const secretAccessKey = process.env["CLOUDFLARE_R2_SECRET_ACCESS_KEY"];
189
+ if (!accountId || !accessKeyId || !secretAccessKey) {
190
+ throw new Error("CLOUDFLARE_R2_* env vars not set \u2014 check .env");
191
+ }
192
+ client2 = new S3Client({
193
+ region: "auto",
194
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
195
+ credentials: { accessKeyId, secretAccessKey }
196
+ });
197
+ return client2;
198
+ }
199
+ function getR2Bucket() {
200
+ const bucket = process.env["CLOUDFLARE_R2_BUCKET_NAME"];
201
+ if (!bucket) throw new Error("CLOUDFLARE_R2_BUCKET_NAME not set \u2014 check .env");
202
+ return bucket;
203
+ }
204
+ async function sumR2PrefixBytes(prefix) {
205
+ let total = 0;
206
+ let token;
207
+ do {
208
+ const page = await getR2Client().send(
209
+ new ListObjectsV2Command({ Bucket: getR2Bucket(), Prefix: prefix, ContinuationToken: token })
210
+ );
211
+ for (const obj of page.Contents ?? []) total += obj.Size ?? 0;
212
+ token = page.IsTruncated ? page.NextContinuationToken : void 0;
213
+ } while (token);
214
+ return total;
215
+ }
216
+
217
+ // src/dashboard/attachment-uploader.ts
218
+ var debug = process.env["DEBUG"] === "true";
219
+ var LOCAL_IMAGE_LINK = /!\[([^\]]*)\]\((\/[^)\s]+)\)/g;
220
+ var SCRATCH_VAR_IN_IMAGE_LINK = /(!\[[^\]]*\]\()\$\{?FLYSPACE_SCRATCH_DIR\}?(?=\/)/g;
221
+ function expandScratchDirVar(body) {
222
+ return body.replace(SCRATCH_VAR_IN_IMAGE_LINK, (_, prefix) => prefix + paths.SCRATCH_DIR);
223
+ }
224
+ var MAX_FILE_BYTES = 10 * 1024 * 1024;
225
+ var SCRATCH_PREFIX = "scratch";
226
+ var MIMETYPES = {
227
+ ".png": "image/png",
228
+ ".jpg": "image/jpeg",
229
+ ".jpeg": "image/jpeg",
230
+ ".gif": "image/gif",
231
+ ".webp": "image/webp",
232
+ ".avif": "image/avif"
233
+ };
234
+ function mimetypeOf(path) {
235
+ const dot = path.lastIndexOf(".");
236
+ return dot === -1 ? void 0 : MIMETYPES[path.slice(dot).toLowerCase()];
237
+ }
238
+ function sanitizeFilename(name) {
239
+ const cleaned = name.replace(/[/\\]/g, "_").replace(/\s+/g, "-").replace(/[^\w.-]/g, "").trim();
240
+ return (cleaned || "file").slice(-100);
241
+ }
242
+ function encodeKeyForUrl(key) {
243
+ return key.split("/").map(encodeURIComponent).join("/");
244
+ }
245
+ function extractLocalImagePaths(body) {
246
+ const paths2 = [];
247
+ for (const match of body.matchAll(LOCAL_IMAGE_LINK)) {
248
+ const path = match[2];
249
+ if (path.startsWith("/api/")) continue;
250
+ if (!mimetypeOf(path)) continue;
251
+ if (!paths2.includes(path)) paths2.push(path);
252
+ }
253
+ return paths2;
254
+ }
255
+ async function uploadOne(path) {
256
+ const mimetype = mimetypeOf(path);
257
+ if (!mimetype) return null;
258
+ try {
259
+ const info = await stat(path);
260
+ if (!info.isFile()) return null;
261
+ if (info.size > MAX_FILE_BYTES) {
262
+ console.error(`[attachment-uploader] ${path} is ${info.size} bytes, over the ${MAX_FILE_BYTES} limit`);
263
+ return null;
264
+ }
265
+ const key = `${SCRATCH_PREFIX}/${getProfile()}/${randomUUID()}-${sanitizeFilename(basename(path))}`;
266
+ await getR2Client().send(
267
+ new PutObjectCommand({
268
+ Bucket: getR2Bucket(),
269
+ Key: key,
270
+ Body: await readFile(path),
271
+ ContentType: mimetype
272
+ })
273
+ );
274
+ if (debug) console.log(`[attachment-uploader] ${path} -> ${key} (${info.size} bytes)`);
275
+ return `/api/attachments/${encodeKeyForUrl(key)}`;
276
+ } catch (err) {
277
+ console.error(`[attachment-uploader] failed to upload ${path}:`, err.message);
278
+ return null;
279
+ }
280
+ }
281
+ async function uploadLocalImages(rawBody) {
282
+ const body = expandScratchDirVar(rawBody);
283
+ const localPaths = extractLocalImagePaths(body);
284
+ if (localPaths.length === 0) return body;
285
+ const urls = /* @__PURE__ */ new Map();
286
+ await Promise.all(
287
+ localPaths.map(async (path) => {
288
+ const url = await uploadOne(path);
289
+ if (url) urls.set(path, url);
290
+ })
291
+ );
292
+ if (urls.size === 0) return body;
293
+ return body.replace(LOCAL_IMAGE_LINK, (original, alt, path) => {
294
+ const url = urls.get(path);
295
+ return url ? `![${alt}](${url})` : original;
296
+ });
297
+ }
298
+
299
+ // src/store/dashboard-sync.ts
300
+ var TITLE_MAX_LENGTH = 80;
301
+ function deriveThreadTitle(text) {
302
+ const firstLine = text.split("\n").find((line) => line.trim().length > 0);
303
+ if (!firstLine) return null;
304
+ const cleaned = firstLine.replace(/^\s*(?::[a-z0-9_+-]+:\s*)+/i, "").replace(/^\s*\p{Extended_Pictographic}[️‍]*\s*/u, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/(^|\s)_([^_\n]+)_(?=\s|$|[.,:;,。、:])/g, "$1$2").replace(/`([^`]+)`/g, "$1").replace(/\s+/g, " ").trim();
305
+ if (!cleaned) return null;
306
+ return cleaned.length > TITLE_MAX_LENGTH ? `${cleaned.slice(0, TITLE_MAX_LENGTH).trim()}\u2026` : cleaned;
307
+ }
308
+ async function syncPostToDashboard(opts) {
309
+ const { channelId, ts, threadTs, text } = opts;
310
+ if (!process.env["SUPABASE_URL"] || !process.env["SUPABASE_SECRET_KEY"]) {
311
+ console.warn("[dashboard-sync] Supabase not configured \u2014 skipping dashboard sync");
312
+ return;
313
+ }
314
+ try {
315
+ const supabase = getSupabaseClient();
316
+ const dashboardChannelId = await resolveDashboardChannelId(supabase, channelId);
317
+ const threadId = threadTs ? await ensureThreadId(supabase, channelId, dashboardChannelId, threadTs) : await createOwnThread(supabase, channelId, dashboardChannelId, ts, text);
318
+ const { error } = await supabase.from("thread_replies").upsert(
319
+ {
320
+ thread_id: threadId,
321
+ // This mirror only ever runs for the bot's own posts (automation
322
+ // output, `flyspace slack post`), so the author is the agent itself —
323
+ // labelling it anything else makes the chat agent count it as a
324
+ // second human and stop replying without an explicit mention.
325
+ author: AGENT_AUTHOR,
326
+ body: await uploadLocalImages(text),
327
+ external_thread_replies_id: `${channelId}:${ts}`
328
+ },
329
+ { onConflict: "external_thread_replies_id" }
330
+ );
331
+ if (error) {
332
+ console.warn(`[dashboard-sync] failed to write reply: ${error.message}`);
333
+ }
334
+ } catch (err) {
335
+ console.warn(
336
+ `[dashboard-sync] sync failed: ${err instanceof Error ? err.message : String(err)}`
337
+ );
338
+ }
339
+ }
340
+ async function resolveDashboardChannelId(supabase, slackChannelId) {
341
+ const { data, error } = await supabase.from("channels").select("id").eq("external_channel_id", slackChannelId).maybeSingle();
342
+ if (error || !data) return slackChannelId;
343
+ return data["id"];
344
+ }
345
+ async function createOwnThread(supabase, externalChannelId, dashboardChannelId, ts, text) {
346
+ const { data, error } = await supabase.from("threads").upsert(
347
+ {
348
+ channel_id: dashboardChannelId,
349
+ title: deriveThreadTitle(text),
350
+ author: AGENT_AUTHOR,
351
+ external_thread_id: `${externalChannelId}:${ts}`
352
+ },
353
+ { onConflict: "external_thread_id" }
354
+ ).select("id").single();
355
+ if (error || !data) throw new Error(error?.message ?? "failed to create thread");
356
+ return data["id"];
357
+ }
358
+ async function ensureThreadId(supabase, externalChannelId, dashboardChannelId, parentTs) {
359
+ const key = `${externalChannelId}:${parentTs}`;
360
+ const { data: existing, error: selectError } = await supabase.from("threads").select("id").eq("external_thread_id", key).maybeSingle();
361
+ if (selectError) throw new Error(selectError.message);
362
+ if (existing) return existing["id"];
363
+ const { data: created, error: createError } = await supabase.from("threads").upsert(
364
+ // The unsynced parent could be anyone's message (often another bot's),
365
+ // so keep the generic non-human placeholder rather than claiming it.
366
+ {
367
+ channel_id: dashboardChannelId,
368
+ title: null,
369
+ author: SYNCED_BOT_AUTHOR,
370
+ external_thread_id: key
371
+ },
372
+ { onConflict: "external_thread_id" }
373
+ ).select("id").single();
374
+ if (createError || !created) {
375
+ throw new Error(createError?.message ?? "failed to create placeholder thread");
376
+ }
377
+ return created["id"];
378
+ }
379
+
380
+ // src/store/dashboard-post.ts
381
+ async function postToDashboardChannel(opts) {
382
+ const { channelId, text, threadId, title } = opts;
383
+ const supabase = getSupabaseClient();
384
+ if (!threadId && !channelId) {
385
+ throw new Error("postToDashboardChannel: channelId is required to start a new thread");
386
+ }
387
+ const parentId = threadId ?? await createThread(supabase, channelId, text, title);
388
+ const body = await uploadLocalImages(text);
389
+ const { error } = await supabase.from("thread_replies").insert({
390
+ thread_id: parentId,
391
+ author: AGENT_AUTHOR,
392
+ body
393
+ });
394
+ if (error) throw new Error(`failed to write dashboard reply: ${error.message}`);
395
+ return parentId;
396
+ }
397
+ async function createThread(supabase, channelId, text, title) {
398
+ const { data, error } = await supabase.from("threads").insert({
399
+ channel_id: channelId,
400
+ // An untitled thread is effectively invisible in the sidebar, so give a
401
+ // daily report the same first-line-derived title a mirrored post gets,
402
+ // unless the caller named the thread itself.
403
+ title: title?.trim().replace(/\\([\\`*_{}[\]()#+\-.!~>|])/g, "$1") || deriveThreadTitle(text),
404
+ author: AGENT_AUTHOR
405
+ }).select("id").single();
406
+ if (error || !data) {
407
+ throw new Error(`failed to create dashboard thread: ${error?.message ?? "no row returned"}`);
408
+ }
409
+ return data["id"];
410
+ }
411
+
412
+ export {
413
+ DEFAULT_PROFILE,
414
+ runWithProfile,
415
+ getProfile,
416
+ paths,
417
+ ensureDirs,
418
+ sweepScratchDir,
419
+ getSupabaseClient,
420
+ AGENT_AUTHOR,
421
+ SYNCED_BOT_AUTHOR,
422
+ isHumanAuthor,
423
+ getR2Client,
424
+ getR2Bucket,
425
+ sumR2PrefixBytes,
426
+ SCRATCH_PREFIX,
427
+ uploadLocalImages,
428
+ syncPostToDashboard,
429
+ resolveDashboardChannelId,
430
+ postToDashboardChannel
431
+ };
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getSupabaseClient
4
+ } from "./chunk-QZIN7MWV.js";
5
+
6
+ // src/store/chat-admin-store.ts
7
+ async function listChannels(profile) {
8
+ let query = getSupabaseClient().from("channels").select("id, name, profile, cwd");
9
+ if (profile) query = query.eq("profile", profile);
10
+ const { data, error } = await query.order("name");
11
+ if (error) throw new Error(`listChannels: ${error.message}`);
12
+ return (data ?? []).map((row) => ({
13
+ id: row["id"],
14
+ name: row["name"],
15
+ profile: row["profile"],
16
+ cwd: row["cwd"]
17
+ }));
18
+ }
19
+ async function resolveChannel(nameOrId, profile) {
20
+ const channels = await listChannels(profile);
21
+ const byId = channels.find((c) => c.id === nameOrId);
22
+ if (byId) return byId;
23
+ const wanted = nameOrId.replace(/^#/, "").toLowerCase();
24
+ const matches = channels.filter((c) => c.name.toLowerCase() === wanted);
25
+ if (matches.length === 1) return matches[0];
26
+ if (matches.length > 1) {
27
+ const detail = matches.map((c) => `${c.name} (${c.profile}) ${c.id}`).join("\n ");
28
+ throw new Error(`"${nameOrId}" matches ${matches.length} channels \u2014 pass an id:
29
+ ${detail}`);
30
+ }
31
+ throw new Error(`No channel matching "${nameOrId}". Run "flyspace chat channels".`);
32
+ }
33
+ async function listThreads(channelId, limit = 20) {
34
+ const { data, error } = await getSupabaseClient().from("threads").select("id, channel_id, title, author, updated_at, thread_replies(count)").eq("channel_id", channelId).order("updated_at", { ascending: false }).limit(limit);
35
+ if (error) throw new Error(`listThreads(${channelId}): ${error.message}`);
36
+ return (data ?? []).map(rowToThread);
37
+ }
38
+ async function getThread(threadId) {
39
+ const { data, error } = await getSupabaseClient().from("threads").select("id, channel_id, title, author, updated_at, thread_replies(count)").eq("id", threadId).maybeSingle();
40
+ if (error) throw new Error(`getThread(${threadId}): ${error.message}`);
41
+ return data ? rowToThread(data) : void 0;
42
+ }
43
+ async function firstReplyBody(threadId) {
44
+ const { data, error } = await getSupabaseClient().from("thread_replies").select("body").eq("thread_id", threadId).order("created_at").limit(1).maybeSingle();
45
+ if (error) throw new Error(`firstReplyBody(${threadId}): ${error.message}`);
46
+ return data?.body;
47
+ }
48
+ async function moveThread(threadId, toChannelId) {
49
+ const supabase = getSupabaseClient();
50
+ const { error } = await supabase.from("threads").update({ channel_id: toChannelId }).eq("id", threadId);
51
+ if (error) throw new Error(`moveThread(${threadId}): ${error.message}`);
52
+ const { error: notifyError } = await supabase.from("notifications").update({ channel_id: toChannelId }).eq("thread_id", threadId);
53
+ if (notifyError) throw new Error(`moveThread(${threadId}) notifications: ${notifyError.message}`);
54
+ }
55
+ function rowToThread(row) {
56
+ const counts = row["thread_replies"];
57
+ return {
58
+ id: row["id"],
59
+ channelId: row["channel_id"],
60
+ title: row["title"] ?? null,
61
+ author: row["author"],
62
+ replies: counts?.[0]?.count ?? 0,
63
+ updatedAt: row["updated_at"]
64
+ };
65
+ }
66
+
67
+ export {
68
+ listChannels,
69
+ resolveChannel,
70
+ listThreads,
71
+ getThread,
72
+ firstReplyBody,
73
+ moveThread
74
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { }