birdclaw 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,210 @@
1
+ import type { Command } from "commander";
2
+ import {
3
+ addBlock,
4
+ listBlocks,
5
+ recordBlock,
6
+ removeBlock,
7
+ syncBlocks,
8
+ } from "#/lib/blocks";
9
+ import { addMute, listMutes, recordMute, removeMute } from "#/lib/mutes";
10
+
11
+ interface RegisterModerationCommandsParams {
12
+ program: Command;
13
+ print: (data: unknown, asJson: boolean) => void;
14
+ asJson: () => boolean;
15
+ importBlocklist: (accountId: string, filePath: string) => Promise<unknown>;
16
+ resolveActionOptions: (options: { transport?: string }) => {
17
+ transport: "auto" | "bird" | "xurl" | undefined;
18
+ };
19
+ }
20
+
21
+ export function registerModerationCommands({
22
+ program,
23
+ print,
24
+ asJson,
25
+ importBlocklist,
26
+ resolveActionOptions,
27
+ }: RegisterModerationCommandsParams) {
28
+ const blocksCommand = program
29
+ .command("blocks")
30
+ .description("Maintain the local blocklist");
31
+
32
+ blocksCommand
33
+ .command("list")
34
+ .option("--account <accountId>", "Account id")
35
+ .option("--search <query>", "Filter blocked profiles")
36
+ .option("--limit <n>", "Limit results", "50")
37
+ .action((options) => {
38
+ const items = listBlocks({
39
+ account: options.account,
40
+ search: options.search,
41
+ limit: Number(options.limit),
42
+ });
43
+ print(items, asJson());
44
+ });
45
+
46
+ blocksCommand
47
+ .command("add <query>")
48
+ .option("--account <accountId>", "Account id", "acct_primary")
49
+ .option("--transport <mode>", "auto, bird, or xurl")
50
+ .action(async (query, options) => {
51
+ const result = await addBlock(
52
+ options.account,
53
+ query,
54
+ resolveActionOptions(options),
55
+ );
56
+ print(result, asJson());
57
+ });
58
+
59
+ blocksCommand
60
+ .command("remove <query>")
61
+ .option("--account <accountId>", "Account id", "acct_primary")
62
+ .option("--transport <mode>", "auto, bird, or xurl")
63
+ .action(async (query, options) => {
64
+ const result = await removeBlock(
65
+ options.account,
66
+ query,
67
+ resolveActionOptions(options),
68
+ );
69
+ print(result, asJson());
70
+ });
71
+
72
+ blocksCommand
73
+ .command("sync")
74
+ .option("--account <accountId>", "Account id", "acct_primary")
75
+ .action(async (options) => {
76
+ const result = await syncBlocks(options.account);
77
+ print(result, asJson());
78
+ });
79
+
80
+ blocksCommand
81
+ .command("import <path>")
82
+ .description("Import a newline-delimited blocklist file")
83
+ .option("--account <accountId>", "Account id", "acct_primary")
84
+ .action(async (filePath, options) => {
85
+ const result = await importBlocklist(options.account, filePath);
86
+ print(result, asJson());
87
+ });
88
+
89
+ blocksCommand
90
+ .command("record <query>")
91
+ .description(
92
+ "Record a known-good remote block locally without another live write",
93
+ )
94
+ .option("--account <accountId>", "Account id", "acct_primary")
95
+ .action(async (query, options) => {
96
+ const result = await recordBlock(options.account, query);
97
+ print(result, asJson());
98
+ });
99
+
100
+ const mutesCommand = program
101
+ .command("mutes")
102
+ .description("Maintain the local mute list");
103
+
104
+ mutesCommand
105
+ .command("list")
106
+ .option("--account <accountId>", "Account id")
107
+ .option("--search <query>", "Filter muted profiles")
108
+ .option("--limit <n>", "Limit results", "50")
109
+ .action((options) => {
110
+ const items = listMutes({
111
+ account: options.account,
112
+ search: options.search,
113
+ limit: Number(options.limit),
114
+ });
115
+ print(items, asJson());
116
+ });
117
+
118
+ mutesCommand
119
+ .command("add <query>")
120
+ .option("--account <accountId>", "Account id", "acct_primary")
121
+ .option("--transport <mode>", "auto, bird, or xurl")
122
+ .action(async (query, options) => {
123
+ const result = await addMute(
124
+ options.account,
125
+ query,
126
+ resolveActionOptions(options),
127
+ );
128
+ print(result, asJson());
129
+ });
130
+
131
+ mutesCommand
132
+ .command("remove <query>")
133
+ .option("--account <accountId>", "Account id", "acct_primary")
134
+ .option("--transport <mode>", "auto, bird, or xurl")
135
+ .action(async (query, options) => {
136
+ const result = await removeMute(
137
+ options.account,
138
+ query,
139
+ resolveActionOptions(options),
140
+ );
141
+ print(result, asJson());
142
+ });
143
+
144
+ mutesCommand
145
+ .command("record <query>")
146
+ .description(
147
+ "Record a known-good remote mute locally without another live write",
148
+ )
149
+ .option("--account <accountId>", "Account id", "acct_primary")
150
+ .action(async (query, options) => {
151
+ const result = await recordMute(options.account, query);
152
+ print(result, asJson());
153
+ });
154
+
155
+ program
156
+ .command("ban <query>")
157
+ .description("Alias for blocks add")
158
+ .option("--account <accountId>", "Account id", "acct_primary")
159
+ .option("--transport <mode>", "auto, bird, or xurl")
160
+ .action(async (query, options) => {
161
+ const result = await addBlock(
162
+ options.account,
163
+ query,
164
+ resolveActionOptions(options),
165
+ );
166
+ print(result, asJson());
167
+ });
168
+
169
+ program
170
+ .command("unban <query>")
171
+ .description("Alias for blocks remove")
172
+ .option("--account <accountId>", "Account id", "acct_primary")
173
+ .option("--transport <mode>", "auto, bird, or xurl")
174
+ .action(async (query, options) => {
175
+ const result = await removeBlock(
176
+ options.account,
177
+ query,
178
+ resolveActionOptions(options),
179
+ );
180
+ print(result, asJson());
181
+ });
182
+
183
+ program
184
+ .command("mute <query>")
185
+ .description("Mute a user for one account")
186
+ .option("--account <accountId>", "Account id", "acct_primary")
187
+ .option("--transport <mode>", "auto, bird, or xurl")
188
+ .action(async (query, options) => {
189
+ const result = await addMute(
190
+ options.account,
191
+ query,
192
+ resolveActionOptions(options),
193
+ );
194
+ print(result, asJson());
195
+ });
196
+
197
+ program
198
+ .command("unmute <query>")
199
+ .description("Unmute a user for one account")
200
+ .option("--account <accountId>", "Account id", "acct_primary")
201
+ .option("--transport <mode>", "auto, bird, or xurl")
202
+ .action(async (query, options) => {
203
+ const result = await removeMute(
204
+ options.account,
205
+ query,
206
+ resolveActionOptions(options),
207
+ );
208
+ print(result, asJson());
209
+ });
210
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import process from "node:process";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+ import { Command } from "commander";
8
+ import { registerModerationCommands } from "#/cli-moderation";
9
+ import { findArchives } from "#/lib/archive-finder";
10
+ import { importArchive } from "#/lib/archive-import";
11
+ import { importBlocklist } from "#/lib/blocklist";
12
+ import {
13
+ type ActionsTransport,
14
+ ensureBirdclawDirs,
15
+ getBirdclawPaths,
16
+ resolveMentionsDataSource,
17
+ } from "#/lib/config";
18
+ import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
19
+ import { listInboxItems, scoreInbox } from "#/lib/inbox";
20
+ import { exportMentionItems } from "#/lib/mentions-export";
21
+ import {
22
+ exportMentionsViaCachedBird,
23
+ exportMentionsViaCachedXurl,
24
+ } from "#/lib/mentions-live";
25
+ import { hydrateProfilesFromX } from "#/lib/profile-hydration";
26
+ import { inspectProfileReplies } from "#/lib/profile-replies";
27
+ import {
28
+ createDmReply,
29
+ createPost,
30
+ createTweetReply,
31
+ getQueryEnvelope,
32
+ listDmConversations,
33
+ listTimelineItems,
34
+ } from "#/lib/queries";
35
+
36
+ const program = new Command();
37
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
38
+ const packageVersion = JSON.parse(
39
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
40
+ ) as { version?: string };
41
+
42
+ function print(data: unknown, asJson: boolean) {
43
+ if (asJson) {
44
+ console.log(JSON.stringify(data, null, 2));
45
+ return;
46
+ }
47
+ console.log(data);
48
+ }
49
+
50
+ function resolveActionOptions(options: { transport?: string }) {
51
+ return {
52
+ transport: options.transport as ActionsTransport | undefined,
53
+ };
54
+ }
55
+
56
+ program
57
+ .name("birdclaw")
58
+ .description("Local-first X workspace")
59
+ .version(packageVersion.version ?? "0.0.0")
60
+ .option("--json", "Emit JSON output");
61
+
62
+ program
63
+ .command("init")
64
+ .description("Create local birdclaw root and seed the database")
65
+ .action(async () => {
66
+ const paths = ensureBirdclawDirs();
67
+ await getQueryEnvelope();
68
+ print(
69
+ {
70
+ ok: true,
71
+ rootDir: paths.rootDir,
72
+ configPath: paths.configPath,
73
+ dbPath: paths.dbPath,
74
+ mediaOriginalsDir: paths.mediaOriginalsDir,
75
+ mediaThumbsDir: paths.mediaThumbsDir,
76
+ },
77
+ program.opts().json ?? false,
78
+ );
79
+ });
80
+
81
+ program
82
+ .command("auth status")
83
+ .description("Show transport status")
84
+ .action(async () => {
85
+ const meta = await getQueryEnvelope();
86
+ print(meta.transport, program.opts().json ?? false);
87
+ });
88
+
89
+ program
90
+ .command("archive find")
91
+ .description("Find likely X/Twitter archives on disk")
92
+ .action(async () => {
93
+ const items = await findArchives();
94
+ print(items, program.opts().json ?? false);
95
+ });
96
+
97
+ const importCommand = program
98
+ .command("import")
99
+ .description("Import local archive data");
100
+
101
+ importCommand
102
+ .command("archive [archivePath]")
103
+ .description("Import an X/Twitter archive into the local SQLite store")
104
+ .action(async (archivePath) => {
105
+ let resolvedArchivePath = archivePath;
106
+ if (!resolvedArchivePath) {
107
+ const [latestArchive] = await findArchives();
108
+ resolvedArchivePath = latestArchive?.path;
109
+ }
110
+
111
+ if (!resolvedArchivePath) {
112
+ throw new Error(
113
+ "No archive found. Pass a path or place one in Downloads.",
114
+ );
115
+ }
116
+
117
+ const result = await importArchive(resolvedArchivePath);
118
+ print(result, program.opts().json ?? false);
119
+ });
120
+
121
+ importCommand
122
+ .command("hydrate-profiles")
123
+ .description("Backfill archive-imported profiles from live X metadata")
124
+ .action(async () => {
125
+ const result = await hydrateProfilesFromX();
126
+ print(result, program.opts().json ?? false);
127
+ });
128
+
129
+ const searchCommand = program
130
+ .command("search")
131
+ .description("Search local data");
132
+
133
+ searchCommand
134
+ .command("tweets <query>")
135
+ .option("--resource <resource>", "home or mentions", "home")
136
+ .option("--replied", "Only replied items")
137
+ .option("--unreplied", "Only unreplied items")
138
+ .option("--limit <n>", "Limit results", "20")
139
+ .action((query, options) => {
140
+ const replyFilter = options.replied
141
+ ? "replied"
142
+ : options.unreplied
143
+ ? "unreplied"
144
+ : "all";
145
+ const items = listTimelineItems({
146
+ resource: options.resource === "mentions" ? "mentions" : "home",
147
+ search: query,
148
+ replyFilter,
149
+ limit: Number(options.limit),
150
+ });
151
+ print(items, program.opts().json ?? false);
152
+ });
153
+
154
+ searchCommand
155
+ .command("dms <query>")
156
+ .option("--participant <value>")
157
+ .option("--min-followers <n>", "Minimum sender follower count")
158
+ .option("--max-followers <n>", "Maximum sender follower count")
159
+ .option("--min-influence-score <n>", "Minimum derived influence score")
160
+ .option("--max-influence-score <n>", "Maximum derived influence score")
161
+ .option("--sort <mode>", "recent or influence", "recent")
162
+ .option("--replied", "Only replied threads")
163
+ .option("--unreplied", "Only unreplied threads")
164
+ .option("--limit <n>", "Limit results", "20")
165
+ .action((query, options) => {
166
+ const replyFilter = options.replied
167
+ ? "replied"
168
+ : options.unreplied
169
+ ? "unreplied"
170
+ : "all";
171
+ const items = listDmConversations({
172
+ search: query,
173
+ participant: options.participant,
174
+ minFollowers: options.minFollowers
175
+ ? Number(options.minFollowers)
176
+ : undefined,
177
+ maxFollowers: options.maxFollowers
178
+ ? Number(options.maxFollowers)
179
+ : undefined,
180
+ minInfluenceScore: options.minInfluenceScore
181
+ ? Number(options.minInfluenceScore)
182
+ : undefined,
183
+ maxInfluenceScore: options.maxInfluenceScore
184
+ ? Number(options.maxInfluenceScore)
185
+ : undefined,
186
+ sort: options.sort === "influence" ? "influence" : "recent",
187
+ replyFilter,
188
+ limit: Number(options.limit),
189
+ });
190
+ print(items, program.opts().json ?? false);
191
+ });
192
+
193
+ const mentionsCommand = program
194
+ .command("mentions")
195
+ .description("Export local mention tweets for scripts and agents");
196
+
197
+ mentionsCommand
198
+ .command("export [query]")
199
+ .description("Return mention tweets with plain-text and markdown renderings")
200
+ .option("--account <accountId>", "Account id")
201
+ .option("--mode <mode>", "birdclaw, xurl, or bird")
202
+ .option("--replied", "Only replied items")
203
+ .option("--unreplied", "Only unreplied items")
204
+ .option("--refresh", "Refresh the live xurl cache before returning")
205
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
206
+ .option("--all", "Fetch every retrievable xurl mentions page")
207
+ .option(
208
+ "--max-pages <n>",
209
+ "Maximum xurl mention pages to fetch (implies --all)",
210
+ )
211
+ .option("--limit <n>", "Limit results", "20")
212
+ .action(async (query, options) => {
213
+ const replyFilter = options.replied
214
+ ? "replied"
215
+ : options.unreplied
216
+ ? "unreplied"
217
+ : "all";
218
+ const limit = Number(options.limit);
219
+ const mode = resolveMentionsDataSource(options.mode);
220
+ if (mode === "xurl") {
221
+ const payload = await exportMentionsViaCachedXurl({
222
+ account: options.account,
223
+ search: query,
224
+ replyFilter,
225
+ limit,
226
+ all: Boolean(options.all) || options.maxPages !== undefined,
227
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
228
+ refresh: Boolean(options.refresh),
229
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
230
+ });
231
+ print(payload, true);
232
+ return;
233
+ }
234
+ if (mode === "bird") {
235
+ const payload = await exportMentionsViaCachedBird({
236
+ account: options.account,
237
+ search: query,
238
+ replyFilter,
239
+ limit,
240
+ all: Boolean(options.all) || options.maxPages !== undefined,
241
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
242
+ refresh: Boolean(options.refresh),
243
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
244
+ });
245
+ print(payload, true);
246
+ return;
247
+ }
248
+
249
+ const items = exportMentionItems({
250
+ account: options.account,
251
+ search: query,
252
+ replyFilter,
253
+ limit,
254
+ });
255
+ print({ resource: "mentions", count: items.length, items }, true);
256
+ });
257
+
258
+ const profilesCommand = program
259
+ .command("profiles")
260
+ .description("Inspect live profile context for moderation and triage");
261
+
262
+ profilesCommand
263
+ .command("replies <query>")
264
+ .description("Inspect recent authored replies for one profile")
265
+ .option("--limit <n>", "Limit replies", "12")
266
+ .action(async (query, options) => {
267
+ const result = await inspectProfileReplies(query, {
268
+ limit: Number(options.limit),
269
+ });
270
+ print(result, program.opts().json ?? false);
271
+ });
272
+
273
+ const dmsCommand = program.command("dms").description("Direct messages");
274
+
275
+ dmsCommand
276
+ .command("list")
277
+ .option("--account <accountId>", "Account id")
278
+ .option("--refresh", "Refresh live DMs through bird before listing")
279
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
280
+ .option("--participant <value>")
281
+ .option("--min-followers <n>", "Minimum sender follower count")
282
+ .option("--max-followers <n>", "Maximum sender follower count")
283
+ .option("--min-influence-score <n>", "Minimum derived influence score")
284
+ .option("--max-influence-score <n>", "Maximum derived influence score")
285
+ .option("--sort <mode>", "recent or influence", "recent")
286
+ .option("--replied", "Only replied threads")
287
+ .option("--unreplied", "Only unreplied threads")
288
+ .option("--limit <n>", "Limit results", "20")
289
+ .action(async (options) => {
290
+ const replyFilter = options.replied
291
+ ? "replied"
292
+ : options.unreplied
293
+ ? "unreplied"
294
+ : "all";
295
+ if (options.refresh) {
296
+ await syncDirectMessagesViaCachedBird({
297
+ account: options.account,
298
+ limit: Number(options.limit),
299
+ refresh: true,
300
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
301
+ });
302
+ }
303
+ const items = listDmConversations({
304
+ account: options.account,
305
+ participant: options.participant,
306
+ minFollowers: options.minFollowers
307
+ ? Number(options.minFollowers)
308
+ : undefined,
309
+ maxFollowers: options.maxFollowers
310
+ ? Number(options.maxFollowers)
311
+ : undefined,
312
+ minInfluenceScore: options.minInfluenceScore
313
+ ? Number(options.minInfluenceScore)
314
+ : undefined,
315
+ maxInfluenceScore: options.maxInfluenceScore
316
+ ? Number(options.maxInfluenceScore)
317
+ : undefined,
318
+ sort: options.sort === "influence" ? "influence" : "recent",
319
+ replyFilter,
320
+ limit: Number(options.limit),
321
+ });
322
+ print(items, program.opts().json ?? false);
323
+ });
324
+
325
+ dmsCommand
326
+ .command("sync")
327
+ .description("Refresh live direct messages through bird into the local store")
328
+ .option("--account <accountId>", "Account id")
329
+ .option("--limit <n>", "Limit messages", "20")
330
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
331
+ .option("--refresh", "Bypass live-cache freshness window")
332
+ .action(async (options) => {
333
+ const result = await syncDirectMessagesViaCachedBird({
334
+ account: options.account,
335
+ limit: Number(options.limit),
336
+ refresh: Boolean(options.refresh),
337
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
338
+ });
339
+ print(result, true);
340
+ });
341
+
342
+ registerModerationCommands({
343
+ program,
344
+ print,
345
+ asJson: () => program.opts().json ?? false,
346
+ importBlocklist,
347
+ resolveActionOptions,
348
+ });
349
+
350
+ const composeCommand = program
351
+ .command("compose")
352
+ .description("Create local/xurl actions");
353
+
354
+ composeCommand
355
+ .command("post <text>")
356
+ .option("--account <accountId>", "Account id", "acct_primary")
357
+ .action(async (text, options) => {
358
+ const result = await createPost(options.account, text);
359
+ print(result, program.opts().json ?? false);
360
+ });
361
+
362
+ composeCommand
363
+ .command("reply <tweetId> <text>")
364
+ .option("--account <accountId>", "Account id", "acct_primary")
365
+ .action(async (tweetId, text, options) => {
366
+ const result = await createTweetReply(options.account, tweetId, text);
367
+ print(result, program.opts().json ?? false);
368
+ });
369
+
370
+ composeCommand
371
+ .command("dm <conversationId> <text>")
372
+ .description("Reply inside an existing DM conversation")
373
+ .action(async (conversationId, text) => {
374
+ const result = await createDmReply(conversationId, text);
375
+ print(result, program.opts().json ?? false);
376
+ });
377
+
378
+ program
379
+ .command("inbox")
380
+ .option("--kind <kind>", "mixed, mentions, or dms", "mixed")
381
+ .option("--min-score <n>", "Minimum rank", "0")
382
+ .option("--hide-low-signal", "Hide low-signal items")
383
+ .option("--score", "Score top items with OpenAI before listing")
384
+ .option("--limit <n>", "Limit results", "20")
385
+ .action(async (options) => {
386
+ const kind =
387
+ options.kind === "mentions" || options.kind === "dms"
388
+ ? options.kind
389
+ : "mixed";
390
+ if (options.score) {
391
+ await scoreInbox({
392
+ kind,
393
+ limit: Number(options.limit),
394
+ });
395
+ }
396
+ print(
397
+ listInboxItems({
398
+ kind,
399
+ minScore: Number(options.minScore),
400
+ hideLowSignal: Boolean(options.hideLowSignal),
401
+ limit: Number(options.limit),
402
+ }),
403
+ program.opts().json ?? false,
404
+ );
405
+ });
406
+
407
+ program
408
+ .command("db stats")
409
+ .description("Show local storage and dataset stats")
410
+ .action(async () => {
411
+ const meta = await getQueryEnvelope();
412
+ const paths = getBirdclawPaths();
413
+ print(
414
+ {
415
+ paths,
416
+ stats: meta.stats,
417
+ transport: meta.transport,
418
+ },
419
+ program.opts().json ?? false,
420
+ );
421
+ });
422
+
423
+ program
424
+ .command("serve")
425
+ .description("Run the local web app")
426
+ .action(() => {
427
+ const child = spawn(
428
+ process.execPath,
429
+ ["node_modules/vite/bin/vite.js", "dev", "--port", "3000"],
430
+ {
431
+ cwd: packageRoot,
432
+ stdio: "inherit",
433
+ },
434
+ );
435
+ child.on("exit", (code) => {
436
+ process.exit(code ?? 0);
437
+ });
438
+ });
439
+
440
+ export async function runCli(argv = process.argv) {
441
+ await program.parseAsync(argv);
442
+ }
443
+
444
+ /* v8 ignore next 5 */
445
+ if (process.argv[1]) {
446
+ const entryUrl = pathToFileURL(process.argv[1]).href;
447
+ if (import.meta.url === entryUrl) {
448
+ void runCli();
449
+ }
450
+ }
@@ -0,0 +1,49 @@
1
+ import { Link, useRouterState } from "@tanstack/react-router";
2
+ import {
3
+ brandMarkClass,
4
+ cx,
5
+ eyebrowClass,
6
+ navClass,
7
+ navLinkActiveClass,
8
+ navLinkClass,
9
+ navLinksClass,
10
+ } from "#/lib/ui";
11
+ import { ThemeSlider } from "./ThemeSlider";
12
+
13
+ const links = [
14
+ { to: "/inbox", label: "Inbox" },
15
+ { to: "/", label: "Home" },
16
+ { to: "/mentions", label: "Mentions" },
17
+ { to: "/dms", label: "DMs" },
18
+ { to: "/blocks", label: "Blocks" },
19
+ ] as const;
20
+
21
+ export function AppNav() {
22
+ const pathname = useRouterState({
23
+ select: (state) => state.location.pathname,
24
+ });
25
+
26
+ return (
27
+ <nav className={navClass}>
28
+ <div>
29
+ <p className={eyebrowClass}>birdclaw</p>
30
+ <h1 className={brandMarkClass}>Quiet signal for X.</h1>
31
+ </div>
32
+ <div className={navLinksClass}>
33
+ {links.map((link) => {
34
+ const active = pathname === link.to;
35
+ return (
36
+ <Link
37
+ key={link.to}
38
+ to={link.to}
39
+ className={cx(navLinkClass, active && navLinkActiveClass)}
40
+ >
41
+ {link.label}
42
+ </Link>
43
+ );
44
+ })}
45
+ <ThemeSlider />
46
+ </div>
47
+ </nav>
48
+ );
49
+ }