birdclaw 0.8.3 → 0.8.4

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 (284) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -8
  3. package/bin/birdclaw.mjs +4 -66
  4. package/dist/cli/birdclaw.js +28165 -0
  5. package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
  6. package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
  7. package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
  8. package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
  9. package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
  10. package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
  11. package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
  12. package/dist/client/assets/chunk-C0phanGV.js +1 -0
  13. package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
  14. package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
  15. package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
  16. package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
  17. package/dist/client/assets/discuss-DTjaOugw.js +1 -0
  18. package/dist/client/assets/dms-CGd6Eogq.js +1 -0
  19. package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
  20. package/dist/client/assets/index-CEIG7S6s.js +31 -0
  21. package/dist/client/assets/index-bpFrQ5yG.css +1 -0
  22. package/dist/client/assets/likes-cLrcAQem.js +1 -0
  23. package/dist/client/assets/links-CM9QO1LC.js +1 -0
  24. package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
  25. package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
  26. package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
  27. package/dist/client/assets/network-map-B5a3whqj.js +2 -0
  28. package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
  29. package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
  30. package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
  31. package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
  32. package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
  33. package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
  34. package/dist/client/assets/react-nBylq1U5.js +1 -0
  35. package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
  36. package/dist/client/assets/routes-q7ZaW4En.js +1 -0
  37. package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
  38. package/dist/client/assets/search-Dq5kYsxA.js +1 -0
  39. package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
  40. package/dist/client/assets/styles-B8s3tt3r.css +2 -0
  41. package/dist/client/assets/today-CeG1O7Py.js +1 -0
  42. package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
  43. package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
  44. package/dist/client/assets/users-CHbKlqUr.js +1 -0
  45. package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
  46. package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
  47. package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
  48. package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
  49. package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
  50. package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
  51. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
  52. package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
  53. package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
  54. package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
  55. package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
  56. package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
  57. package/dist/server/assets/blocks-DSgQghqP.js +17 -0
  58. package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
  59. package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
  60. package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
  61. package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
  62. package/dist/server/assets/discuss-QTo08NCN.js +338 -0
  63. package/dist/server/assets/dms-BuLj4bp8.js +368 -0
  64. package/dist/server/assets/dms-DAyQrVM2.js +240 -0
  65. package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
  66. package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
  67. package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
  68. package/dist/server/assets/likes-DBOAtsJU.js +14 -0
  69. package/dist/server/assets/links-DAhSjynu.js +240 -0
  70. package/dist/server/assets/links-DmH5lxHO.js +468 -0
  71. package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
  72. package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
  73. package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
  74. package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
  75. package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
  76. package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
  77. package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
  78. package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
  79. package/dist/server/assets/query-client-BdauHBgd.js +923 -0
  80. package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
  81. package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
  82. package/dist/server/assets/router-DqkwSPiM.js +17026 -0
  83. package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
  84. package/dist/server/assets/start-FaDOTmto.js +4 -0
  85. package/dist/server/assets/today-B5hVM57Z.js +17 -0
  86. package/dist/server/assets/today-fvGiZ7ud.js +303 -0
  87. package/dist/server/assets/ui-qbmcMSSX.js +137 -0
  88. package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
  89. package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
  90. package/dist/server/server.js +5520 -0
  91. package/package.json +26 -34
  92. package/playwright.config.ts +0 -35
  93. package/scripts/browser-perf.mjs +0 -427
  94. package/scripts/build-docs-site.mjs +0 -967
  95. package/scripts/docs-site-assets.mjs +0 -311
  96. package/scripts/run-vitest.mjs +0 -21
  97. package/scripts/sanitize-node-options.mjs +0 -23
  98. package/scripts/start-test-server.mjs +0 -42
  99. package/src/cli/command-context.ts +0 -17
  100. package/src/cli/register-analysis.ts +0 -500
  101. package/src/cli/register-compose.ts +0 -40
  102. package/src/cli/register-graph.ts +0 -132
  103. package/src/cli/register-inbox.ts +0 -41
  104. package/src/cli/register-storage.ts +0 -106
  105. package/src/cli-moderation.ts +0 -210
  106. package/src/cli.ts +0 -1616
  107. package/src/components/AccountSwitcher.tsx +0 -178
  108. package/src/components/AppNav.tsx +0 -118
  109. package/src/components/AvatarChip.tsx +0 -58
  110. package/src/components/AvatarPreload.ts +0 -149
  111. package/src/components/BrandMark.tsx +0 -67
  112. package/src/components/ConversationThread.tsx +0 -131
  113. package/src/components/DmWorkspace.tsx +0 -286
  114. package/src/components/EmbeddedTweetCard.tsx +0 -55
  115. package/src/components/FeedState.tsx +0 -147
  116. package/src/components/FloatingPreview.tsx +0 -382
  117. package/src/components/InboxCard.tsx +0 -152
  118. package/src/components/LinkPreviewCard.tsx +0 -292
  119. package/src/components/MarkdownCitations.tsx +0 -680
  120. package/src/components/MarkdownViewer.tsx +0 -111
  121. package/src/components/ProfileAnalysisClient.ts +0 -191
  122. package/src/components/ProfileAnalysisStream.tsx +0 -259
  123. package/src/components/ProfilePreview.tsx +0 -131
  124. package/src/components/SavedTimelineView.tsx +0 -155
  125. package/src/components/SmartTimestamp.tsx +0 -72
  126. package/src/components/SyncNowButton.tsx +0 -140
  127. package/src/components/ThemeSlider.tsx +0 -80
  128. package/src/components/TimelineCard.tsx +0 -533
  129. package/src/components/TimelineRouteFrame.tsx +0 -186
  130. package/src/components/TweetArticleCard.tsx +0 -66
  131. package/src/components/TweetMediaGrid.tsx +0 -185
  132. package/src/components/TweetRichText.tsx +0 -159
  133. package/src/components/account-selection.ts +0 -64
  134. package/src/components/links-controller.ts +0 -162
  135. package/src/components/links-model.ts +0 -198
  136. package/src/components/network-map-controller.ts +0 -84
  137. package/src/components/network-map-model.ts +0 -255
  138. package/src/components/useTimelineRouteData.ts +0 -193
  139. package/src/lib/account-sync-job.ts +0 -666
  140. package/src/lib/actions-transport.ts +0 -300
  141. package/src/lib/analysis-runtime.ts +0 -238
  142. package/src/lib/api-client.ts +0 -220
  143. package/src/lib/api-contracts.ts +0 -328
  144. package/src/lib/archive-finder.ts +0 -185
  145. package/src/lib/archive-import-plan.ts +0 -102
  146. package/src/lib/archive-import.ts +0 -2695
  147. package/src/lib/authored-live.ts +0 -1027
  148. package/src/lib/avatar-cache.ts +0 -349
  149. package/src/lib/backup.ts +0 -2652
  150. package/src/lib/bird-actions.ts +0 -294
  151. package/src/lib/bird-command.ts +0 -130
  152. package/src/lib/bird.ts +0 -1140
  153. package/src/lib/blocklist.ts +0 -136
  154. package/src/lib/blocks-write.ts +0 -171
  155. package/src/lib/blocks.ts +0 -387
  156. package/src/lib/bookmark-sync-job.ts +0 -536
  157. package/src/lib/config.ts +0 -224
  158. package/src/lib/conversation-surface.ts +0 -205
  159. package/src/lib/data-sources.ts +0 -219
  160. package/src/lib/database-metrics.ts +0 -88
  161. package/src/lib/database-migrations.ts +0 -34
  162. package/src/lib/database-schema.ts +0 -312
  163. package/src/lib/database-writer.ts +0 -69
  164. package/src/lib/db.ts +0 -889
  165. package/src/lib/dm-read-model.ts +0 -533
  166. package/src/lib/dms-live.ts +0 -870
  167. package/src/lib/effect-runtime.ts +0 -45
  168. package/src/lib/follow-graph.ts +0 -1087
  169. package/src/lib/geocoding.ts +0 -296
  170. package/src/lib/http-effect.ts +0 -222
  171. package/src/lib/identity-search-index.ts +0 -369
  172. package/src/lib/import-repository.ts +0 -138
  173. package/src/lib/inbox.ts +0 -242
  174. package/src/lib/link-index.ts +0 -641
  175. package/src/lib/link-insights.ts +0 -860
  176. package/src/lib/link-preview-metadata.ts +0 -754
  177. package/src/lib/live-sync-engine.ts +0 -209
  178. package/src/lib/live-transport-gateway.ts +0 -128
  179. package/src/lib/location.ts +0 -137
  180. package/src/lib/media-fetch.ts +0 -860
  181. package/src/lib/media-includes.ts +0 -165
  182. package/src/lib/mention-threads-live.ts +0 -830
  183. package/src/lib/mentions-export.ts +0 -147
  184. package/src/lib/mentions-live.ts +0 -1114
  185. package/src/lib/moderation-target.ts +0 -227
  186. package/src/lib/moderation-write.ts +0 -131
  187. package/src/lib/mutes-write.ts +0 -171
  188. package/src/lib/mutes.ts +0 -84
  189. package/src/lib/ndjson-stream.ts +0 -106
  190. package/src/lib/network-map.ts +0 -382
  191. package/src/lib/openai-response-runtime.ts +0 -251
  192. package/src/lib/openai.ts +0 -117
  193. package/src/lib/paginated-sync.ts +0 -93
  194. package/src/lib/period-digest.ts +0 -1364
  195. package/src/lib/present.ts +0 -118
  196. package/src/lib/profile-affiliation-hydration.ts +0 -228
  197. package/src/lib/profile-affiliations.ts +0 -262
  198. package/src/lib/profile-analysis.ts +0 -1198
  199. package/src/lib/profile-bio-entities.ts +0 -318
  200. package/src/lib/profile-history.ts +0 -164
  201. package/src/lib/profile-hydration.ts +0 -180
  202. package/src/lib/profile-replies.ts +0 -77
  203. package/src/lib/profile-resolver.ts +0 -650
  204. package/src/lib/queries.ts +0 -6
  205. package/src/lib/query-actions.ts +0 -437
  206. package/src/lib/query-client.tsx +0 -47
  207. package/src/lib/query-read-model-shared.ts +0 -52
  208. package/src/lib/query-read-models.ts +0 -5
  209. package/src/lib/query-resource.ts +0 -41
  210. package/src/lib/query-status.ts +0 -164
  211. package/src/lib/research.ts +0 -652
  212. package/src/lib/runtime-services.ts +0 -20
  213. package/src/lib/search-discussion.ts +0 -812
  214. package/src/lib/seed.ts +0 -613
  215. package/src/lib/server-runtime-services.ts +0 -30
  216. package/src/lib/sqlite.ts +0 -183
  217. package/src/lib/streaming-ingestion.ts +0 -240
  218. package/src/lib/sync-cache.ts +0 -70
  219. package/src/lib/sync-plan.ts +0 -175
  220. package/src/lib/theme-transition.ts +0 -113
  221. package/src/lib/theme.tsx +0 -165
  222. package/src/lib/timeline-collections-live.ts +0 -407
  223. package/src/lib/timeline-live.ts +0 -302
  224. package/src/lib/timeline-read-model.ts +0 -1191
  225. package/src/lib/tweet-account-edges.ts +0 -48
  226. package/src/lib/tweet-lookup.ts +0 -46
  227. package/src/lib/tweet-render.ts +0 -430
  228. package/src/lib/tweet-repository.ts +0 -156
  229. package/src/lib/tweet-search-live.ts +0 -462
  230. package/src/lib/types.ts +0 -723
  231. package/src/lib/ui.ts +0 -529
  232. package/src/lib/url-expansion-store.ts +0 -110
  233. package/src/lib/url-expansion.ts +0 -343
  234. package/src/lib/url-safety.ts +0 -220
  235. package/src/lib/web-sync.ts +0 -533
  236. package/src/lib/whois.ts +0 -1080
  237. package/src/lib/x-profile.ts +0 -365
  238. package/src/lib/x-web.ts +0 -199
  239. package/src/lib/xurl-rate-limits.ts +0 -267
  240. package/src/lib/xurl.ts +0 -1677
  241. package/src/routeTree.gen.ts +0 -723
  242. package/src/router.tsx +0 -20
  243. package/src/routes/__root.tsx +0 -82
  244. package/src/routes/api/action.tsx +0 -147
  245. package/src/routes/api/avatar.tsx +0 -50
  246. package/src/routes/api/blocks.tsx +0 -35
  247. package/src/routes/api/conversation.tsx +0 -45
  248. package/src/routes/api/data-sources.tsx +0 -24
  249. package/src/routes/api/inbox.tsx +0 -43
  250. package/src/routes/api/link-insights.tsx +0 -85
  251. package/src/routes/api/link-preview.tsx +0 -47
  252. package/src/routes/api/network-map.tsx +0 -55
  253. package/src/routes/api/period-digest.tsx +0 -101
  254. package/src/routes/api/profile-analysis.tsx +0 -97
  255. package/src/routes/api/profile-hydrate.tsx +0 -54
  256. package/src/routes/api/query.tsx +0 -121
  257. package/src/routes/api/search-discussion.tsx +0 -113
  258. package/src/routes/api/status.tsx +0 -30
  259. package/src/routes/api/sync.tsx +0 -108
  260. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  261. package/src/routes/blocks.tsx +0 -343
  262. package/src/routes/bookmarks.tsx +0 -18
  263. package/src/routes/data-sources.tsx +0 -249
  264. package/src/routes/discuss.tsx +0 -419
  265. package/src/routes/dms.tsx +0 -415
  266. package/src/routes/inbox.tsx +0 -232
  267. package/src/routes/index.tsx +0 -32
  268. package/src/routes/likes.tsx +0 -18
  269. package/src/routes/links.tsx +0 -631
  270. package/src/routes/mentions.tsx +0 -32
  271. package/src/routes/network-map.tsx +0 -732
  272. package/src/routes/profile-analyze.tsx +0 -112
  273. package/src/routes/profiles.$handle.tsx +0 -228
  274. package/src/routes/rate-limits.tsx +0 -305
  275. package/src/routes/today.tsx +0 -511
  276. package/src/styles.css +0 -257
  277. package/tsconfig.json +0 -29
  278. package/vite.config.ts +0 -29
  279. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  280. /package/{public → dist/client}/favicon.ico +0 -0
  281. /package/{public → dist/client}/logo192.png +0 -0
  282. /package/{public → dist/client}/logo512.png +0 -0
  283. /package/{public → dist/client}/manifest.json +0 -0
  284. /package/{public → dist/client}/robots.txt +0 -0
package/src/cli.ts DELETED
@@ -1,1616 +0,0 @@
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 { registerAnalysisCommands } from "#/cli/register-analysis";
10
- import { registerComposeCommands } from "#/cli/register-compose";
11
- import { registerGraphCommands } from "#/cli/register-graph";
12
- import { registerInboxCommand } from "#/cli/register-inbox";
13
- import { registerStorageCommands } from "#/cli/register-storage";
14
- import { findArchives } from "#/lib/archive-finder";
15
- import {
16
- ARCHIVE_IMPORT_SLICES,
17
- type ArchiveImportSlice,
18
- type ImportProgressEvent,
19
- type ImportProgressSlice,
20
- type ImportWritePhase,
21
- importArchive,
22
- } from "#/lib/archive-import";
23
- import {
24
- AuthoredSyncError,
25
- syncAuthoredTweets,
26
- type AuthoredSyncMode,
27
- } from "#/lib/authored-live";
28
- import {
29
- installAccountSyncLaunchAgent,
30
- parseAccountSyncSteps,
31
- runAccountSyncJob,
32
- } from "#/lib/account-sync-job";
33
- import { maybeAutoSyncBackup, maybeAutoUpdateBackup } from "#/lib/backup";
34
- import {
35
- installBookmarkSyncLaunchAgent,
36
- runBookmarkSyncJob,
37
- } from "#/lib/bookmark-sync-job";
38
- import { runDirectMessageRequestMutationViaBird } from "#/lib/bird";
39
- import { importBlocklist } from "#/lib/blocklist";
40
- import {
41
- type ActionsTransport,
42
- ensureBirdclawDirs,
43
- resolveMentionsDataSource,
44
- setActionsTransport,
45
- } from "#/lib/config";
46
- import { closeDatabase } from "#/lib/db";
47
- import {
48
- type DirectMessagesSyncMode,
49
- syncDirectMessagesViaCachedBird,
50
- } from "#/lib/dms-live";
51
- import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
52
- import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
53
- import { syncMentionThreads } from "#/lib/mention-threads-live";
54
- import { exportMentionItems } from "#/lib/mentions-export";
55
- import {
56
- exportMentionsViaCachedAuto,
57
- exportMentionsViaCachedBird,
58
- exportMentionsViaCachedXurl,
59
- syncMentions,
60
- } from "#/lib/mentions-live";
61
- import { syncFollowGraph } from "#/lib/follow-graph";
62
- import { hydrateProfilesFromX } from "#/lib/profile-hydration";
63
- import { resolveProfilesForIds } from "#/lib/profile-resolver";
64
- import { inspectProfileReplies } from "#/lib/profile-replies";
65
- import {
66
- applyDmRequestMutationToLocalStore,
67
- getQueryEnvelope,
68
- listDmConversations,
69
- listTimelineItems,
70
- } from "#/lib/queries";
71
- import {
72
- syncTimelineCollection,
73
- type TimelineCollectionMode,
74
- } from "#/lib/timeline-collections-live";
75
- import { syncHomeTimeline } from "#/lib/timeline-live";
76
- import { expandUrlsFromTexts } from "#/lib/url-expansion";
77
- import { formatWhois, runWhois } from "#/lib/whois";
78
-
79
- const program = new Command();
80
- const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
81
- const packageVersion = JSON.parse(
82
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
83
- ) as { version?: string };
84
-
85
- function print(data: unknown, asJson: boolean) {
86
- if (asJson) {
87
- console.log(JSON.stringify(data, null, 2));
88
- return;
89
- }
90
- console.log(data);
91
- }
92
-
93
- function printError(error: string) {
94
- console.error(JSON.stringify({ error }));
95
- }
96
-
97
- function errorMessage(error: unknown) {
98
- return error instanceof Error ? error.message : String(error);
99
- }
100
-
101
- const IMPORT_SLICE_LABELS: Record<ImportProgressSlice, string> = {
102
- tweets: "tweets",
103
- noteTweets: "note tweets",
104
- directMessages: "direct messages",
105
- likes: "likes",
106
- bookmarks: "bookmarks",
107
- media: "media files",
108
- followers: "followers",
109
- following: "following",
110
- };
111
-
112
- const IMPORT_WRITE_LABELS: Record<ImportWritePhase, string> = {
113
- profiles: "profiles",
114
- tweets: "tweets",
115
- collections: "likes+bookmarks",
116
- dmMessages: "DM messages",
117
- };
118
-
119
- function logImportProgress(event: ImportProgressEvent) {
120
- switch (event.kind) {
121
- case "scanned":
122
- process.stderr.write(
123
- `Scanning archive… ${String(event.entryCount)} entries\n`,
124
- );
125
- return;
126
- case "slice-start":
127
- if (event.slice === "media") {
128
- process.stderr.write("Indexing media files…\n");
129
- return;
130
- }
131
- process.stderr.write(
132
- `Parsing ${IMPORT_SLICE_LABELS[event.slice]}… (${String(event.files)} file${event.files === 1 ? "" : "s"})\n`,
133
- );
134
- return;
135
- case "slice-file":
136
- if (event.files > 1) {
137
- process.stderr.write(
138
- ` ${IMPORT_SLICE_LABELS[event.slice]} ${String(event.processed)}/${String(event.files)}\n`,
139
- );
140
- }
141
- return;
142
- case "slice-done":
143
- process.stderr.write(
144
- ` ${IMPORT_SLICE_LABELS[event.slice]}: ${event.count.toLocaleString()}\n`,
145
- );
146
- return;
147
- case "writing":
148
- process.stderr.write("Writing to database…\n");
149
- return;
150
- case "write-start":
151
- process.stderr.write(
152
- `Writing ${IMPORT_WRITE_LABELS[event.phase]}… (${event.total.toLocaleString()})\n`,
153
- );
154
- return;
155
- case "write-progress":
156
- process.stderr.write(
157
- ` ${IMPORT_WRITE_LABELS[event.phase]} ${event.processed.toLocaleString()}/${event.total.toLocaleString()}\n`,
158
- );
159
- return;
160
- case "done":
161
- process.stderr.write("Import complete.\n");
162
- return;
163
- }
164
- }
165
-
166
- function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
167
- return items
168
- .map((item) => {
169
- const linked = item.linkedTweet
170
- ? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
171
- : ` -> ${item.expansion.finalUrl}`;
172
- const source =
173
- item.occurrence.sourceKind === "dm"
174
- ? `dm ${item.occurrence.direction ?? ""}`.trim()
175
- : "tweet";
176
- const participant = item.participant
177
- ? ` @${item.participant.handle}`
178
- : "";
179
- return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
180
- })
181
- .join("\n");
182
- }
183
-
184
- function parseNonNegativeIntegerOption(
185
- value: string | undefined,
186
- option: string,
187
- ) {
188
- if (value === undefined) {
189
- return undefined;
190
- }
191
-
192
- const trimmed = value.trim();
193
- if (!/^\d+$/.test(trimmed)) {
194
- printError(`${option} must be a non-negative integer`);
195
- process.exitCode = 1;
196
- return undefined;
197
- }
198
-
199
- const parsed = Number.parseInt(trimmed, 10);
200
- if (!Number.isSafeInteger(parsed)) {
201
- printError(`${option} must be a non-negative integer`);
202
- process.exitCode = 1;
203
- return undefined;
204
- }
205
-
206
- return parsed;
207
- }
208
-
209
- function parsePositiveIntegerOption(value: string | undefined, option: string) {
210
- const parsed = parseNonNegativeIntegerOption(value, option);
211
- if (parsed === undefined) {
212
- return undefined;
213
- }
214
- if (parsed < 1) {
215
- printError(`${option} must be at least 1`);
216
- process.exitCode = 1;
217
- return undefined;
218
- }
219
- return parsed;
220
- }
221
-
222
- function parseDmInboxOption(
223
- value: string | undefined,
224
- ): "all" | "accepted" | "requests" | undefined {
225
- const normalized = (value ?? "all").trim().toLowerCase();
226
- if (
227
- normalized === "all" ||
228
- normalized === "accepted" ||
229
- normalized === "requests"
230
- ) {
231
- return normalized;
232
- }
233
- if (normalized === "request") {
234
- return "requests";
235
- }
236
- printError("--inbox must be all, accepted, or requests");
237
- process.exitCode = 1;
238
- return undefined;
239
- }
240
-
241
- function parseDmSyncModeOption(
242
- value: string | undefined,
243
- ): DirectMessagesSyncMode | undefined {
244
- const normalized = (value ?? "bird").trim().toLowerCase();
245
- if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
246
- return normalized;
247
- }
248
- printError("--mode must be auto, bird, or xurl");
249
- process.exitCode = 1;
250
- return undefined;
251
- }
252
-
253
- function parseArchiveImportSelect(value: string | undefined) {
254
- if (value === undefined) {
255
- return undefined;
256
- }
257
-
258
- const aliases: Record<string, ArchiveImportSlice> = Object.assign(
259
- Object.create(null) as Record<string, ArchiveImportSlice>,
260
- {
261
- tweets: "tweets",
262
- likes: "likes",
263
- bookmarks: "bookmarks",
264
- directmessages: "directMessages",
265
- "direct-messages": "directMessages",
266
- dms: "directMessages",
267
- profiles: "profiles",
268
- followers: "followers",
269
- following: "following",
270
- },
271
- );
272
- const selected: ArchiveImportSlice[] = [];
273
- const seen = new Set<ArchiveImportSlice>();
274
- for (const rawItem of value.split(",")) {
275
- const item = rawItem.trim();
276
- if (!item) continue;
277
- const slice = aliases[item] ?? aliases[item.toLowerCase()];
278
- if (!slice) {
279
- printError(
280
- `--select must be a comma-separated subset of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
281
- );
282
- process.exitCode = 1;
283
- return undefined;
284
- }
285
- if (!seen.has(slice)) {
286
- seen.add(slice);
287
- selected.push(slice);
288
- }
289
- }
290
-
291
- if (selected.length === 0) {
292
- printError(
293
- `--select must include at least one of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
294
- );
295
- process.exitCode = 1;
296
- return undefined;
297
- }
298
-
299
- return selected;
300
- }
301
-
302
- function resolveActionOptions(options: { transport?: string }) {
303
- return {
304
- transport: options.transport as ActionsTransport | undefined,
305
- };
306
- }
307
-
308
- function parseActionsTransport(value: string | undefined) {
309
- const normalized = value?.trim().toLowerCase();
310
- if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
311
- return normalized;
312
- }
313
- printError("transport must be auto, bird, or xurl");
314
- process.exitCode = 1;
315
- return undefined;
316
- }
317
-
318
- async function enrichDmItems(
319
- query: Parameters<typeof listDmConversations>[0],
320
- options: {
321
- resolveProfiles?: boolean;
322
- expandUrls?: boolean;
323
- refreshProfileCache?: boolean;
324
- refreshUrlCache?: boolean;
325
- xurlFallback?: boolean;
326
- },
327
- ) {
328
- let items = listDmConversations(query);
329
- const profileResolution = options.resolveProfiles
330
- ? await resolveProfilesForIds(
331
- items.map((item) => item.participant.id),
332
- {
333
- refresh: options.refreshProfileCache,
334
- xurlFallback: options.xurlFallback ?? true,
335
- },
336
- )
337
- : undefined;
338
- if (profileResolution) {
339
- items = listDmConversations(query);
340
- }
341
-
342
- const urlExpansions = options.expandUrls
343
- ? await expandUrlsFromTexts(
344
- items.flatMap((item) => [
345
- item.lastMessagePreview,
346
- item.searchSnippet ?? "",
347
- ...(item.matches ?? []).flatMap((match) => [
348
- ...match.before.map((message) => message.text),
349
- match.message.text,
350
- ...match.after.map((message) => message.text),
351
- ]),
352
- ]),
353
- { refresh: options.refreshUrlCache },
354
- )
355
- : undefined;
356
-
357
- if (!profileResolution && !urlExpansions) {
358
- return items;
359
- }
360
-
361
- return {
362
- items,
363
- ...(profileResolution ? { profileResolution } : {}),
364
- ...(urlExpansions ? { urlExpansions } : {}),
365
- };
366
- }
367
-
368
- async function autoUpdateBeforeRead() {
369
- let result: Awaited<ReturnType<typeof maybeAutoUpdateBackup>>;
370
- try {
371
- result = await maybeAutoUpdateBackup();
372
- } catch (error) {
373
- const message = error instanceof Error ? error.message : String(error);
374
- console.error(`birdclaw backup auto-sync failed: ${message}`);
375
- return;
376
- }
377
- if (!result.ok) {
378
- console.error(`birdclaw backup auto-sync failed: ${result.error}`);
379
- }
380
- }
381
-
382
- async function autoSyncAfterWrite() {
383
- let result: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
384
- try {
385
- result = await maybeAutoSyncBackup();
386
- } catch (error) {
387
- const message = error instanceof Error ? error.message : String(error);
388
- console.error(`birdclaw backup sync failed: ${message}`);
389
- return;
390
- }
391
- if (!result.ok) {
392
- console.error(`birdclaw backup sync failed: ${result.error}`);
393
- }
394
- }
395
-
396
- program
397
- .name("birdclaw")
398
- .description("Local-first Twitter workspace")
399
- .version(packageVersion.version ?? "0.0.0")
400
- .option("--json", "Emit JSON output");
401
-
402
- program
403
- .command("init")
404
- .description("Create local birdclaw root and seed the database")
405
- .action(async () => {
406
- const paths = ensureBirdclawDirs();
407
- await getQueryEnvelope();
408
- print(
409
- {
410
- ok: true,
411
- rootDir: paths.rootDir,
412
- configPath: paths.configPath,
413
- dbPath: paths.dbPath,
414
- mediaOriginalsDir: paths.mediaOriginalsDir,
415
- mediaThumbsDir: paths.mediaThumbsDir,
416
- },
417
- program.opts().json ?? false,
418
- );
419
- });
420
-
421
- const authCommand = program
422
- .command("auth")
423
- .description("Manage live transport");
424
-
425
- authCommand
426
- .command("status")
427
- .description("Show transport status")
428
- .action(async () => {
429
- const meta = await getQueryEnvelope();
430
- print(meta.transport, program.opts().json ?? false);
431
- });
432
-
433
- authCommand
434
- .command("use <transport>")
435
- .description("Set preferred moderation action transport")
436
- .action((transport: string) => {
437
- const parsed = parseActionsTransport(transport);
438
- if (!parsed) return;
439
- print(setActionsTransport(parsed), program.opts().json ?? false);
440
- });
441
-
442
- program
443
- .command("archive find")
444
- .description("Find likely Twitter archives on disk")
445
- .action(async () => {
446
- const items = await findArchives();
447
- print(items, program.opts().json ?? false);
448
- });
449
-
450
- const importCommand = program
451
- .command("import")
452
- .description("Import local archive data");
453
-
454
- importCommand
455
- .command("archive [archivePath]")
456
- .description("Import a Twitter archive into the local SQLite store")
457
- .option(
458
- "--select <kinds>",
459
- `Import only selected archive slices: ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
460
- )
461
- .action(async (archivePath, options: { select?: string }) => {
462
- const select = parseArchiveImportSelect(options.select);
463
- if (options.select !== undefined && !select) {
464
- return;
465
- }
466
- let resolvedArchivePath = archivePath;
467
- if (!resolvedArchivePath) {
468
- const [latestArchive] = await findArchives();
469
- resolvedArchivePath = latestArchive?.path;
470
- }
471
-
472
- if (!resolvedArchivePath) {
473
- throw new Error(
474
- "No archive found. Pass a path or place one in Downloads.",
475
- );
476
- }
477
-
478
- const asJson = Boolean(program.opts().json);
479
- const result = await importArchive(resolvedArchivePath, {
480
- select,
481
- onProgress: asJson ? undefined : logImportProgress,
482
- });
483
- await autoSyncAfterWrite();
484
- print(result, asJson);
485
- });
486
-
487
- importCommand
488
- .command("hydrate-profiles")
489
- .description("Backfill archive-imported profiles from live Twitter metadata")
490
- .action(async () => {
491
- const result = await hydrateProfilesFromX();
492
- await autoSyncAfterWrite();
493
- print(result, program.opts().json ?? false);
494
- });
495
-
496
- const searchCommand = program
497
- .command("search")
498
- .description("Search local data");
499
-
500
- searchCommand
501
- .command("tweets [query]")
502
- .option("--resource <resource>", "home, mentions, or authored", "home")
503
- .option("--replied", "Only replied items")
504
- .option("--unreplied", "Only unreplied items")
505
- .option("--since <date>", "Include tweets created at or after this date")
506
- .option("--until <date>", "Include tweets created before this date")
507
- .option("--originals-only", "Exclude authored replies that start with @")
508
- .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
509
- .option(
510
- "--min-likes <n>",
511
- "Override the low-quality like threshold (default 50)",
512
- )
513
- .option("--quality-reason", "Include qualityReason on each row")
514
- .option("--liked", "Only liked tweets")
515
- .option("--bookmarked", "Only bookmarked tweets")
516
- .option("--limit <n>", "Limit results", "20")
517
- .action(async (query, options) => {
518
- const minLikes = parseNonNegativeIntegerOption(
519
- options.minLikes,
520
- "--min-likes",
521
- );
522
- if (options.minLikes !== undefined && minLikes === undefined) {
523
- return;
524
- }
525
-
526
- await autoUpdateBeforeRead();
527
- const replyFilter = options.replied
528
- ? "replied"
529
- : options.unreplied
530
- ? "unreplied"
531
- : "all";
532
- const items = listTimelineItems({
533
- resource:
534
- options.resource === "mentions"
535
- ? "mentions"
536
- : options.resource === "authored"
537
- ? "authored"
538
- : "home",
539
- search: query,
540
- replyFilter,
541
- since: options.since,
542
- until: options.until,
543
- includeReplies: !options.originalsOnly,
544
- qualityFilter: options.hideLowQuality ? "summary" : "all",
545
- lowQualityThreshold: minLikes,
546
- includeQualityReason: Boolean(options.qualityReason),
547
- likedOnly: Boolean(options.liked),
548
- bookmarkedOnly: Boolean(options.bookmarked),
549
- limit: Number(options.limit),
550
- });
551
- print(items, program.opts().json ?? false);
552
- });
553
-
554
- searchCommand
555
- .command("dms <query>")
556
- .option("--inbox <kind>", "all, accepted, or requests", "all")
557
- .option("--participant <value>")
558
- .option("--min-followers <n>", "Minimum sender follower count")
559
- .option("--max-followers <n>", "Maximum sender follower count")
560
- .option("--min-influence-score <n>", "Minimum derived influence score")
561
- .option("--max-influence-score <n>", "Maximum derived influence score")
562
- .option("--sort <mode>", "recent or followers", "recent")
563
- .option(
564
- "--context <n>",
565
- "Include N messages before and after each match",
566
- "0",
567
- )
568
- .option(
569
- "--resolve-profiles",
570
- "Resolve placeholder DM profiles through cache/bird/xurl",
571
- )
572
- .option("--expand-urls", "Expand URLs through the persistent URL cache")
573
- .option("--refresh-profile-cache", "Bypass profile lookup cache")
574
- .option("--refresh-url-cache", "Bypass URL expansion cache")
575
- .option(
576
- "--no-xurl-fallback",
577
- "Do not fall back to xurl after bird profile lookup",
578
- )
579
- .option("--replied", "Only replied threads")
580
- .option("--unreplied", "Only unreplied threads")
581
- .option("--limit <n>", "Limit results", "20")
582
- .action(async (query, options) => {
583
- await autoUpdateBeforeRead();
584
- const context = parseNonNegativeIntegerOption(options.context, "--context");
585
- if (context === undefined) {
586
- return;
587
- }
588
- const inbox = parseDmInboxOption(options.inbox);
589
- if (inbox === undefined) {
590
- return;
591
- }
592
- const replyFilter = options.replied
593
- ? "replied"
594
- : options.unreplied
595
- ? "unreplied"
596
- : "all";
597
- const dmQuery = {
598
- search: query,
599
- ...(inbox !== "all" ? { inbox } : {}),
600
- participant: options.participant,
601
- minFollowers: options.minFollowers
602
- ? Number(options.minFollowers)
603
- : undefined,
604
- maxFollowers: options.maxFollowers
605
- ? Number(options.maxFollowers)
606
- : undefined,
607
- minInfluenceScore: options.minInfluenceScore
608
- ? Number(options.minInfluenceScore)
609
- : undefined,
610
- maxInfluenceScore: options.maxInfluenceScore
611
- ? Number(options.maxInfluenceScore)
612
- : undefined,
613
- sort:
614
- options.sort === "followers" || options.sort === "influence"
615
- ? "followers"
616
- : "recent",
617
- replyFilter,
618
- context,
619
- limit: Number(options.limit),
620
- } as const;
621
- const items = await enrichDmItems(dmQuery, {
622
- resolveProfiles: Boolean(options.resolveProfiles),
623
- expandUrls: Boolean(options.expandUrls),
624
- refreshProfileCache: Boolean(options.refreshProfileCache),
625
- refreshUrlCache: Boolean(options.refreshUrlCache),
626
- xurlFallback: options.xurlFallback,
627
- });
628
- print(items, program.opts().json ?? false);
629
- });
630
-
631
- searchCommand
632
- .command("links <query>")
633
- .description("Search indexed short links, expansions, and linked tweets")
634
- .option("--account <accountIdOrHandle>", "Account id or handle")
635
- .option("--since <date>", "Include links created at or after this date")
636
- .option("--until <date>", "Include links created before this date")
637
- .option("--source <kind>", "dm or tweet")
638
- .option("--direction <direction>", "inbound or outbound")
639
- .option("--participant <value>", "DM participant handle or name")
640
- .option("--media <type>", "image, video, or gif")
641
- .option("--limit <n>", "Limit results", "20")
642
- .action(async (query, options) => {
643
- await autoUpdateBeforeRead();
644
- const items = searchLinks(query, {
645
- account: options.account,
646
- since: options.since,
647
- until: options.until,
648
- source:
649
- options.source === "tweet"
650
- ? "tweet"
651
- : options.source === "dm"
652
- ? "dm"
653
- : undefined,
654
- direction:
655
- options.direction === "inbound"
656
- ? "inbound"
657
- : options.direction === "outbound"
658
- ? "outbound"
659
- : undefined,
660
- participant: options.participant,
661
- mediaType:
662
- options.media === "image" ||
663
- options.media === "video" ||
664
- options.media === "gif"
665
- ? options.media
666
- : undefined,
667
- limit: Number(options.limit),
668
- });
669
- if (program.opts().json) {
670
- print(items, true);
671
- return;
672
- }
673
- console.log(formatLinkSearchItems(items));
674
- });
675
-
676
- const linksCommand = program
677
- .command("links")
678
- .description("Build and inspect the short-link index");
679
-
680
- linksCommand
681
- .command("backfill")
682
- .description("Backfill indexed URL occurrences and t.co expansions")
683
- .option("--all-urls", "Index all URLs, not only t.co")
684
- .option("--source <kind>", "dm or tweet")
685
- .option("--refresh-url-cache", "Re-expand URLs already in the index")
686
- .option("--limit <n>", "Limit network/cache expansions for this run")
687
- .option("--concurrency <n>", "Concurrent URL expansion workers", "12")
688
- .option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
689
- .action(async (options) => {
690
- const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
691
- if (options.limit !== undefined && limit === undefined) {
692
- return;
693
- }
694
- const concurrency = parseNonNegativeIntegerOption(
695
- options.concurrency,
696
- "--concurrency",
697
- );
698
- if (concurrency === undefined) {
699
- return;
700
- }
701
- const timeoutMs = parseNonNegativeIntegerOption(
702
- options.timeoutMs,
703
- "--timeout-ms",
704
- );
705
- if (timeoutMs === undefined) {
706
- return;
707
- }
708
- const result = await backfillLinkIndex({
709
- includeAllUrls: Boolean(options.allUrls),
710
- refresh: Boolean(options.refreshUrlCache),
711
- source:
712
- options.source === "tweet"
713
- ? "tweet"
714
- : options.source === "dm"
715
- ? "dm"
716
- : undefined,
717
- limit,
718
- concurrency,
719
- timeoutMs,
720
- });
721
- await autoSyncAfterWrite();
722
- print(result, program.opts().json ?? false);
723
- });
724
-
725
- const mediaCommand = program
726
- .command("media")
727
- .description("Manage the local media cache");
728
-
729
- mediaCommand
730
- .command("fetch")
731
- .description(
732
- "Fetch missing pbs.twimg.com image media already stored in tweets",
733
- )
734
- .option("--account <accountId>", "Account id")
735
- .option("--limit <n>", "Stop after N tweets processed")
736
- .option(
737
- "--kind <kind>",
738
- "Tweet or collection kind, e.g. home, like, bookmark",
739
- )
740
- .option("--since <isoDate>", "Only tweets created at or after this date")
741
- .option("--parallel <n>", "Concurrent fetch workers, capped at 5", "1")
742
- .option("--pacing-ms <n>", "Delay between request starts", "250")
743
- .option("--video-pacing-ms <n>", "Delay between video request starts")
744
- .option("--retry-max <n>", "Retries per file after rate limiting", "3")
745
- .option("--include-video", "Include video and animated GIF media", true)
746
- .option("--no-include-video", "Skip video and animated GIF media")
747
- .option("--max-bytes <n>", "Maximum media file size in bytes", "104857600")
748
- .option("--dry-run", "List what would be fetched without downloading")
749
- .option("--json", "Emit JSON output")
750
- .action(async (options) => {
751
- const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
752
- if (options.limit !== undefined && limit === undefined) {
753
- return;
754
- }
755
- const parallel =
756
- parsePositiveIntegerOption(options.parallel, "--parallel") ?? 1;
757
- const pacingMs =
758
- parseNonNegativeIntegerOption(options.pacingMs, "--pacing-ms") ?? 250;
759
- const retryMax =
760
- parseNonNegativeIntegerOption(options.retryMax, "--retry-max") ?? 3;
761
- const videoPacingMs =
762
- options.videoPacingMs === undefined
763
- ? undefined
764
- : parseNonNegativeIntegerOption(
765
- options.videoPacingMs,
766
- "--video-pacing-ms",
767
- );
768
- const maxBytes =
769
- parseNonNegativeIntegerOption(options.maxBytes, "--max-bytes") ??
770
- 100 * 1024 * 1024;
771
- if (process.exitCode) {
772
- return;
773
- }
774
-
775
- const result = await fetchTweetMedia({
776
- account: options.account,
777
- limit,
778
- kind: options.kind,
779
- since: options.since,
780
- parallel,
781
- pacingMs,
782
- videoPacingMs,
783
- retryMax,
784
- includeVideo: Boolean(options.includeVideo),
785
- maxBytes,
786
- dryRun: Boolean(options.dryRun),
787
- });
788
- const asJson = Boolean(program.opts().json || options.json);
789
- print(asJson ? result : formatMediaFetchResult(result), asJson);
790
- });
791
-
792
- program
793
- .command("whois <query>")
794
- .description("Identify likely people or orgs from local DMs and tweets")
795
- .option("--account <accountId>", "Account id")
796
- .option("--no-dms", "Do not search DMs")
797
- .option("--tweets", "Include local tweet search evidence")
798
- .option("--no-resolve-profiles", "Do not resolve placeholder profiles")
799
- .option("--no-expand-urls", "Do not expand URLs")
800
- .option("--refresh-profile-cache", "Bypass profile lookup cache")
801
- .option("--refresh-url-cache", "Bypass URL expansion cache")
802
- .option(
803
- "--no-xurl-fallback",
804
- "Do not fall back to xurl after bird profile lookup",
805
- )
806
- .option(
807
- "--affiliation <query>",
808
- "Require affiliation, bio, or history evidence",
809
- )
810
- .option(
811
- "--current-affiliation <query>",
812
- "Require an active affiliation badge",
813
- )
814
- .option(
815
- "--exclude-domain-only",
816
- "Drop candidates that only match domains/URLs",
817
- )
818
- .option("--context <n>", "DM messages before and after each match", "4")
819
- .option("--limit <n>", "Limit candidates", "10")
820
- .action(async (query, options) => {
821
- await autoUpdateBeforeRead();
822
- const context = parseNonNegativeIntegerOption(options.context, "--context");
823
- if (context === undefined) {
824
- return;
825
- }
826
- const result = await runWhois(query, {
827
- account: options.account,
828
- dms: options.dms,
829
- tweets: Boolean(options.tweets),
830
- resolveProfiles: options.resolveProfiles,
831
- expandUrls: options.expandUrls,
832
- refreshProfileCache: Boolean(options.refreshProfileCache),
833
- refreshUrlCache: Boolean(options.refreshUrlCache),
834
- xurlFallback: options.xurlFallback,
835
- affiliation: options.affiliation,
836
- currentAffiliation: options.currentAffiliation,
837
- excludeDomainOnly: Boolean(options.excludeDomainOnly),
838
- context,
839
- limit: Number(options.limit),
840
- });
841
- print(
842
- program.opts().json ? result : formatWhois(result),
843
- program.opts().json ?? false,
844
- );
845
- });
846
-
847
- registerAnalysisCommands({
848
- program,
849
- print,
850
- asJson: () => program.opts().json ?? false,
851
- autoSyncAfterWrite,
852
- autoUpdateBeforeRead,
853
- parseNonNegativeIntegerOption,
854
- parsePositiveIntegerOption,
855
- });
856
-
857
- const mentionsCommand = program
858
- .command("mentions")
859
- .description("Export local mention tweets for scripts and agents");
860
-
861
- mentionsCommand
862
- .command("export [query]")
863
- .description("Return mention tweets with plain-text and markdown renderings")
864
- .option("--account <accountId>", "Account id")
865
- .option("--mode <mode>", "birdclaw, auto, xurl, or bird")
866
- .option("--replied", "Only replied items")
867
- .option("--unreplied", "Only unreplied items")
868
- .option("--refresh", "Refresh the live xurl cache before returning")
869
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
870
- .option("--all", "Fetch every retrievable xurl mentions page")
871
- .option(
872
- "--max-pages <n>",
873
- "Maximum xurl mention pages to fetch (implies --all)",
874
- )
875
- .option("--limit <n>", "Limit results", "20")
876
- .action(async (query, options) => {
877
- await autoUpdateBeforeRead();
878
- const replyFilter = options.replied
879
- ? "replied"
880
- : options.unreplied
881
- ? "unreplied"
882
- : "all";
883
- const limit = Number(options.limit);
884
- const mode = resolveMentionsDataSource(options.mode);
885
- if (mode === "xurl" || mode === "bird" || mode === "auto") {
886
- const exportFn =
887
- mode === "xurl"
888
- ? exportMentionsViaCachedXurl
889
- : mode === "bird"
890
- ? exportMentionsViaCachedBird
891
- : exportMentionsViaCachedAuto;
892
- const payload = await exportFn({
893
- account: options.account,
894
- search: query,
895
- replyFilter,
896
- limit,
897
- all: Boolean(options.all) || options.maxPages !== undefined,
898
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
899
- refresh: Boolean(options.refresh),
900
- cacheTtlMs: Number(options.cacheTtl) * 1000,
901
- });
902
- await autoSyncAfterWrite();
903
- print(payload, true);
904
- return;
905
- }
906
-
907
- const items = exportMentionItems({
908
- account: options.account,
909
- search: query,
910
- replyFilter,
911
- limit,
912
- });
913
- print({ resource: "mentions", count: items.length, items }, true);
914
- });
915
-
916
- const profilesCommand = program
917
- .command("profiles")
918
- .description("Inspect live profile context for moderation and triage");
919
-
920
- profilesCommand
921
- .command("replies <query>")
922
- .description("Inspect recent authored replies for one profile")
923
- .option("--limit <n>", "Limit replies", "12")
924
- .action(async (query, options) => {
925
- const result = await inspectProfileReplies(query, {
926
- limit: Number(options.limit),
927
- });
928
- print(result, program.opts().json ?? false);
929
- });
930
-
931
- const dmsCommand = program.command("dms").description("Direct messages");
932
-
933
- const syncCommand = program
934
- .command("sync")
935
- .description("Refresh live Twitter collections into the local store");
936
-
937
- syncCommand
938
- .command("timeline")
939
- .description("Refresh live home timeline through xurl or bird")
940
- .option("--account <accountId>", "Account id")
941
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
942
- .option("--limit <n>", "Result limit", "100")
943
- .option("--max-pages <n>", "Stop after N xurl pages", "3")
944
- .option("--for-you", 'Fetch "For You" instead of chronological Following')
945
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
946
- .option("--refresh", "Bypass live-cache freshness window")
947
- .action(async (options) => {
948
- const result = await syncHomeTimeline({
949
- account: options.account,
950
- mode: options.mode,
951
- limit: Number(options.limit),
952
- maxPages: Number(options.maxPages),
953
- following: !options.forYou,
954
- refresh: Boolean(options.refresh),
955
- cacheTtlMs: Number(options.cacheTtl) * 1000,
956
- });
957
- await autoSyncAfterWrite();
958
- print(result, true);
959
- });
960
-
961
- syncCommand
962
- .command("mentions")
963
- .description("Refresh live mentions through xurl or bird")
964
- .option("--account <accountId>", "Account id")
965
- .option("--mode <mode>", "auto, bird, or xurl", "auto")
966
- .option("--limit <n>", "Result limit per page", "20")
967
- .option("--max-pages <n>", "Stop after N pages")
968
- .option("--since-id <id>", "Fetch mentions newer than this tweet id")
969
- .option("--start-time <iso>", "Fetch mentions created at or after this time")
970
- .option("--refresh", "Bypass live-cache freshness window")
971
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
972
- .action(async (options) => {
973
- try {
974
- const result = await syncMentions({
975
- account: options.account,
976
- mode: options.mode,
977
- limit: Number(options.limit),
978
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
979
- sinceId: options.sinceId,
980
- startTime: options.startTime,
981
- refresh: Boolean(options.refresh),
982
- cacheTtlMs: Number(options.cacheTtl) * 1000,
983
- });
984
- await autoSyncAfterWrite();
985
- print(result, true);
986
- if (result.partial) {
987
- process.exitCode = 5;
988
- }
989
- } catch (error) {
990
- print(
991
- {
992
- ok: false,
993
- kind: "mentions",
994
- mode: options.mode ?? "xurl",
995
- error: errorMessage(error),
996
- },
997
- true,
998
- );
999
- process.exitCode = 1;
1000
- }
1001
- });
1002
-
1003
- syncCommand
1004
- .command("authored")
1005
- .description("Refresh authenticated authored tweets through xurl")
1006
- .option("--account <accountId>", "Account id")
1007
- .option("--mode <mode>", "xurl", "xurl")
1008
- .option("--limit <n>", "X API page size", "100")
1009
- .option("--max-pages <n>", "Stop after N pages and resume later")
1010
- .option("--since-id <tweetId>", "Override the stored since_id cursor")
1011
- .option(
1012
- "--until-id <tweetId>",
1013
- "Fetch tweets older than this id without moving the cursor",
1014
- )
1015
- .action(async (options) => {
1016
- try {
1017
- const result = await syncAuthoredTweets({
1018
- account: options.account,
1019
- mode: options.mode as AuthoredSyncMode,
1020
- limit: Number(options.limit),
1021
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1022
- sinceId: options.sinceId,
1023
- untilId: options.untilId,
1024
- });
1025
- await autoSyncAfterWrite();
1026
- print(result, true);
1027
- if (result.partial) {
1028
- process.exitCode = 5;
1029
- }
1030
- } catch (error) {
1031
- print(
1032
- {
1033
- ok: false,
1034
- kind: "authored",
1035
- source: "xurl",
1036
- error: errorMessage(error),
1037
- },
1038
- true,
1039
- );
1040
- process.exitCode =
1041
- error instanceof AuthoredSyncError ? error.exitCode : 1;
1042
- }
1043
- });
1044
-
1045
- syncCommand
1046
- .command("mention-threads")
1047
- .description(
1048
- "Fetch tweet conversation context for recent mentions through bird or xurl",
1049
- )
1050
- .option("--account <accountId>", "Account id")
1051
- .option("--mode <mode>", "bird or xurl", "bird")
1052
- .option("--limit <n>", "Recent mentions to inspect", "30")
1053
- .option("--delay-ms <n>", "Delay between thread fetches", "1500")
1054
- .option("--timeout-ms <n>", "Per-thread timeout", "15000")
1055
- .option("--all", "Fetch all retrievable thread pages")
1056
- .option("--max-pages <n>", "Stop after N pages")
1057
- .action(async (options) => {
1058
- try {
1059
- const result = await syncMentionThreads({
1060
- account: options.account,
1061
- mode: options.mode,
1062
- limit: Number(options.limit),
1063
- delayMs: Number(options.delayMs),
1064
- timeoutMs: Number(options.timeoutMs),
1065
- all: Boolean(options.all),
1066
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1067
- });
1068
- await autoSyncAfterWrite();
1069
- print(result, true);
1070
- if (result.partial) {
1071
- process.exitCode = 5;
1072
- }
1073
- } catch (error) {
1074
- print(
1075
- {
1076
- ok: false,
1077
- kind: "mention-threads",
1078
- mode: options.mode ?? "bird",
1079
- error: errorMessage(error),
1080
- },
1081
- true,
1082
- );
1083
- process.exitCode = 1;
1084
- }
1085
- });
1086
-
1087
- for (const kind of ["likes", "bookmarks"] as const) {
1088
- syncCommand
1089
- .command(kind)
1090
- .description(`Refresh live ${kind} through xurl or bird`)
1091
- .option("--account <accountId>", "Account id")
1092
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1093
- .option("--limit <n>", "Per-page/result limit", "20")
1094
- .option("--all", "Fetch every retrievable page")
1095
- .option(
1096
- "--max-pages <n>",
1097
- "Stop after N pages when using --all or --early-stop",
1098
- )
1099
- .option("--early-stop", "Stop when a fetched page is already fully local")
1100
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1101
- .option("--refresh", "Bypass live-cache freshness window")
1102
- .action(async (options) => {
1103
- const result = await syncTimelineCollection({
1104
- kind,
1105
- account: options.account,
1106
- mode: options.mode as TimelineCollectionMode,
1107
- limit: Number(options.limit),
1108
- all: Boolean(options.all) || options.maxPages !== undefined,
1109
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1110
- refresh: Boolean(options.refresh),
1111
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1112
- earlyStop: Boolean(options.earlyStop),
1113
- });
1114
- await autoSyncAfterWrite();
1115
- print(result, true);
1116
- });
1117
- }
1118
-
1119
- for (const direction of ["followers", "following"] as const) {
1120
- syncCommand
1121
- .command(direction)
1122
- .description(
1123
- `Dry-run or refresh live ${direction} into the local follow graph`,
1124
- )
1125
- .option("--account <accountId>", "Account id")
1126
- .option("--mode <mode>", "auto, bird, or xurl", "auto")
1127
- .option("--limit <n>", "X API users per page", "1000")
1128
- .option("--max-pages <n>", "Stop after N pages")
1129
- .option("--max-resources <n>", "Stop after N unique users")
1130
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "86400")
1131
- .option("--refresh", "Bypass the live-cache freshness window")
1132
- .option("--allow-partial", "Acknowledge capped/incomplete snapshot")
1133
- .option("--yes", "Confirm live sync or fresh-cache merge")
1134
- .action(async (options) => {
1135
- try {
1136
- const result = await syncFollowGraph({
1137
- direction,
1138
- account: options.account,
1139
- mode: options.mode,
1140
- limit: Number(options.limit),
1141
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1142
- maxResources: options.maxResources
1143
- ? Number(options.maxResources)
1144
- : undefined,
1145
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1146
- refresh: Boolean(options.refresh),
1147
- allowPartial: Boolean(options.allowPartial),
1148
- yes: Boolean(options.yes),
1149
- });
1150
- if (!result.dryRun) {
1151
- await autoSyncAfterWrite();
1152
- }
1153
- print(result, true);
1154
- } catch (error) {
1155
- print({ ok: false, direction, error: errorMessage(error) }, true);
1156
- process.exitCode = 1;
1157
- }
1158
- });
1159
- }
1160
-
1161
- const jobsCommand = program
1162
- .command("jobs")
1163
- .description("Run and install background Birdclaw jobs");
1164
-
1165
- jobsCommand
1166
- .command("sync-account")
1167
- .description("Refresh live account timelines and append a JSONL audit entry")
1168
- .option("--account <accountId>", "Account id")
1169
- .option(
1170
- "--steps <steps>",
1171
- "Comma list: timeline,mentions,mention-threads,likes,bookmarks,dms",
1172
- )
1173
- .option("--mode <mode>", "auto, xurl, or bird for likes/bookmarks", "auto")
1174
- .option("--limit <n>", "Per-page/result limit", "100")
1175
- .option("--max-pages <n>", "Stop after N pages", "3")
1176
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1177
- .option("--refresh", "Bypass live-cache freshness window")
1178
- .option(
1179
- "--allow-bird-account",
1180
- "Assert bird cookies match --account for Bird-backed steps",
1181
- )
1182
- .option("--log <path>", "Audit JSONL path")
1183
- .action(async (options) => {
1184
- const result = await runAccountSyncJob({
1185
- account: options.account,
1186
- steps: parseAccountSyncSteps(options.steps),
1187
- mode: options.mode as TimelineCollectionMode,
1188
- limit: Number(options.limit),
1189
- maxPages: Number(options.maxPages),
1190
- refresh: Boolean(options.refresh),
1191
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1192
- allowBirdAccount: Boolean(options.allowBirdAccount),
1193
- logPath: options.log,
1194
- });
1195
- print(result, true);
1196
- if (!result.ok) {
1197
- process.exitCode = 1;
1198
- }
1199
- });
1200
-
1201
- jobsCommand
1202
- .command("install-account-launchd")
1203
- .description("Install a LaunchAgent that runs account sync")
1204
- .option("--label <label>", "LaunchAgent label")
1205
- .option("--interval-seconds <seconds>", "Launch interval", "1800")
1206
- .option("--program <path>", "birdclaw executable or command", "birdclaw")
1207
- .option("--account <accountId>", "Account id")
1208
- .option(
1209
- "--steps <steps>",
1210
- "Comma list: timeline,mentions,mention-threads,likes,bookmarks,dms",
1211
- )
1212
- .option("--mode <mode>", "auto, xurl, or bird for likes/bookmarks", "auto")
1213
- .option("--limit <n>", "Per-page/result limit", "100")
1214
- .option("--max-pages <n>", "Stop after N pages", "3")
1215
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1216
- .option("--no-refresh", "Allow live-cache reuse")
1217
- .option(
1218
- "--allow-bird-account",
1219
- "Assert bird cookies match --account for Bird-backed steps",
1220
- )
1221
- .option("--log <path>", "Audit JSONL path")
1222
- .option("--env-path <path>", "Shell env file to source before running")
1223
- .option("--env-file <path>", "Deprecated alias for --env-path")
1224
- .option("--stdout <path>", "launchd stdout path")
1225
- .option("--stderr <path>", "launchd stderr path")
1226
- .option("--launch-agents-dir <path>", "LaunchAgents directory")
1227
- .option("--no-load", "Write plist without loading it")
1228
- .action(async (options) => {
1229
- const result = await installAccountSyncLaunchAgent({
1230
- label: options.label,
1231
- intervalSeconds: Number(options.intervalSeconds),
1232
- program: options.program,
1233
- account: options.account,
1234
- steps: parseAccountSyncSteps(options.steps),
1235
- mode: options.mode as TimelineCollectionMode,
1236
- limit: Number(options.limit),
1237
- maxPages: Number(options.maxPages),
1238
- refresh: options.refresh,
1239
- allowBirdAccount: Boolean(options.allowBirdAccount),
1240
- cacheTtlSeconds: Number(options.cacheTtl),
1241
- logPath: options.log,
1242
- envFile: options.envPath ?? options.envFile,
1243
- stdoutPath: options.stdout,
1244
- stderrPath: options.stderr,
1245
- launchAgentsDir: options.launchAgentsDir,
1246
- load: options.load,
1247
- });
1248
- print(result, true);
1249
- });
1250
-
1251
- jobsCommand
1252
- .command("sync-bookmarks")
1253
- .description("Refresh live bookmarks and append a JSONL audit entry")
1254
- .option("--account <accountId>", "Account id")
1255
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1256
- .option("--limit <n>", "Per-page/result limit", "100")
1257
- .option("--all", "Fetch every retrievable page")
1258
- .option("--max-pages <n>", "Stop after N pages", "5")
1259
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1260
- .option("--refresh", "Bypass live-cache freshness window")
1261
- .option("--log <path>", "Audit JSONL path")
1262
- .action(async (options) => {
1263
- const result = await runBookmarkSyncJob({
1264
- account: options.account,
1265
- mode: options.mode as TimelineCollectionMode,
1266
- limit: Number(options.limit),
1267
- all: Boolean(options.all) || options.maxPages !== undefined,
1268
- maxPages: options.all ? undefined : Number(options.maxPages),
1269
- refresh: Boolean(options.refresh),
1270
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1271
- logPath: options.log,
1272
- });
1273
- print(result, true);
1274
- if (!result.ok) {
1275
- process.exitCode = 1;
1276
- }
1277
- });
1278
-
1279
- jobsCommand
1280
- .command("install-bookmarks-launchd")
1281
- .description("Install a LaunchAgent that runs bookmark sync every 3 hours")
1282
- .option("--label <label>", "LaunchAgent label")
1283
- .option("--interval-seconds <seconds>", "Launch interval", "10800")
1284
- .option("--program <path>", "birdclaw executable or command", "birdclaw")
1285
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1286
- .option("--limit <n>", "Per-page/result limit", "100")
1287
- .option("--all", "Fetch every retrievable page")
1288
- .option("--max-pages <n>", "Stop after N pages", "5")
1289
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1290
- .option("--no-refresh", "Allow live-cache reuse")
1291
- .option("--log <path>", "Audit JSONL path")
1292
- .option("--env-path <path>", "Shell env file to source before running")
1293
- .option("--env-file <path>", "Deprecated alias for --env-path")
1294
- .option("--stdout <path>", "launchd stdout path")
1295
- .option("--stderr <path>", "launchd stderr path")
1296
- .option("--launch-agents-dir <path>", "LaunchAgents directory")
1297
- .option("--no-load", "Write plist without loading it")
1298
- .action(async (options) => {
1299
- const result = await installBookmarkSyncLaunchAgent({
1300
- label: options.label,
1301
- intervalSeconds: Number(options.intervalSeconds),
1302
- program: options.program,
1303
- mode: options.mode as TimelineCollectionMode,
1304
- limit: Number(options.limit),
1305
- all: Boolean(options.all) || options.maxPages !== undefined,
1306
- maxPages: options.all ? undefined : Number(options.maxPages),
1307
- refresh: options.refresh,
1308
- cacheTtlSeconds: Number(options.cacheTtl),
1309
- logPath: options.log,
1310
- envFile: options.envPath ?? options.envFile,
1311
- stdoutPath: options.stdout,
1312
- stderrPath: options.stderr,
1313
- launchAgentsDir: options.launchAgentsDir,
1314
- load: options.load,
1315
- });
1316
- print(result, true);
1317
- });
1318
-
1319
- dmsCommand
1320
- .command("list")
1321
- .option("--account <accountId>", "Account id")
1322
- .option("--mode <mode>", "auto, bird, or xurl", "bird")
1323
- .option("--refresh", "Refresh live DMs before listing")
1324
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1325
- .option("--inbox <kind>", "all, accepted, or requests", "all")
1326
- .option("--max-pages <n>", "Additional accepted/request pages to sync", "0")
1327
- .option("--all-pages", "Fetch all accepted/request pages while syncing")
1328
- .option("--page-delay-ms <n>", "Delay between live DM page requests", "0")
1329
- .option("--participant <value>")
1330
- .option("--min-followers <n>", "Minimum sender follower count")
1331
- .option("--max-followers <n>", "Maximum sender follower count")
1332
- .option("--min-influence-score <n>", "Minimum derived influence score")
1333
- .option("--max-influence-score <n>", "Maximum derived influence score")
1334
- .option("--sort <mode>", "recent or followers", "recent")
1335
- .option(
1336
- "--resolve-profiles",
1337
- "Resolve placeholder DM profiles through cache/bird/xurl",
1338
- )
1339
- .option("--expand-urls", "Expand URLs through the persistent URL cache")
1340
- .option("--refresh-profile-cache", "Bypass profile lookup cache")
1341
- .option("--refresh-url-cache", "Bypass URL expansion cache")
1342
- .option(
1343
- "--no-xurl-fallback",
1344
- "Do not fall back to xurl after bird profile lookup",
1345
- )
1346
- .option("--replied", "Only replied threads")
1347
- .option("--unreplied", "Only unreplied threads")
1348
- .option("--limit <n>", "Limit results", "20")
1349
- .action(async (options) => {
1350
- const replyFilter = options.replied
1351
- ? "replied"
1352
- : options.unreplied
1353
- ? "unreplied"
1354
- : "all";
1355
- const inbox = parseDmInboxOption(options.inbox);
1356
- const mode = parseDmSyncModeOption(options.mode);
1357
- const maxPages = parseNonNegativeIntegerOption(
1358
- options.maxPages,
1359
- "--max-pages",
1360
- );
1361
- const pageDelayMs = parseNonNegativeIntegerOption(
1362
- options.pageDelayMs,
1363
- "--page-delay-ms",
1364
- );
1365
- if (
1366
- inbox === undefined ||
1367
- mode === undefined ||
1368
- maxPages === undefined ||
1369
- pageDelayMs === undefined
1370
- ) {
1371
- return;
1372
- }
1373
- if (options.refresh) {
1374
- await syncDirectMessagesViaCachedBird({
1375
- account: options.account,
1376
- mode,
1377
- limit: Number(options.limit),
1378
- ...(inbox !== "all" ? { inbox } : {}),
1379
- ...(maxPages > 0 ? { maxPages } : {}),
1380
- ...(options.allPages ? { allPages: true } : {}),
1381
- ...(pageDelayMs > 0 ? { pageDelayMs } : {}),
1382
- refresh: true,
1383
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1384
- });
1385
- await autoSyncAfterWrite();
1386
- } else {
1387
- await autoUpdateBeforeRead();
1388
- }
1389
- const items = await enrichDmItems(
1390
- {
1391
- account: options.account,
1392
- ...(inbox !== "all" ? { inbox } : {}),
1393
- participant: options.participant,
1394
- minFollowers: options.minFollowers
1395
- ? Number(options.minFollowers)
1396
- : undefined,
1397
- maxFollowers: options.maxFollowers
1398
- ? Number(options.maxFollowers)
1399
- : undefined,
1400
- minInfluenceScore: options.minInfluenceScore
1401
- ? Number(options.minInfluenceScore)
1402
- : undefined,
1403
- maxInfluenceScore: options.maxInfluenceScore
1404
- ? Number(options.maxInfluenceScore)
1405
- : undefined,
1406
- sort:
1407
- options.sort === "followers" || options.sort === "influence"
1408
- ? "followers"
1409
- : "recent",
1410
- replyFilter,
1411
- limit: Number(options.limit),
1412
- },
1413
- {
1414
- resolveProfiles: Boolean(options.resolveProfiles),
1415
- expandUrls: Boolean(options.expandUrls),
1416
- refreshProfileCache: Boolean(options.refreshProfileCache),
1417
- refreshUrlCache: Boolean(options.refreshUrlCache),
1418
- xurlFallback: options.xurlFallback,
1419
- },
1420
- );
1421
- print(items, program.opts().json ?? false);
1422
- });
1423
-
1424
- dmsCommand
1425
- .command("sync")
1426
- .description("Refresh live direct messages into the local store")
1427
- .option("--account <accountId>", "Account id")
1428
- .option("--mode <mode>", "auto, bird, or xurl", "bird")
1429
- .option("--limit <n>", "Limit messages", "20")
1430
- .option("--inbox <kind>", "all, accepted, or requests", "all")
1431
- .option("--max-pages <n>", "Additional accepted/request pages to sync", "0")
1432
- .option("--all-pages", "Fetch all accepted/request pages")
1433
- .option("--page-delay-ms <n>", "Delay between live DM page requests", "0")
1434
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1435
- .option("--refresh", "Bypass live-cache freshness window")
1436
- .action(async (options) => {
1437
- const inbox = parseDmInboxOption(options.inbox);
1438
- const mode = parseDmSyncModeOption(options.mode);
1439
- const maxPages = parseNonNegativeIntegerOption(
1440
- options.maxPages,
1441
- "--max-pages",
1442
- );
1443
- const pageDelayMs = parseNonNegativeIntegerOption(
1444
- options.pageDelayMs,
1445
- "--page-delay-ms",
1446
- );
1447
- if (
1448
- inbox === undefined ||
1449
- mode === undefined ||
1450
- maxPages === undefined ||
1451
- pageDelayMs === undefined
1452
- ) {
1453
- return;
1454
- }
1455
- const result = await syncDirectMessagesViaCachedBird({
1456
- account: options.account,
1457
- mode,
1458
- limit: Number(options.limit),
1459
- ...(inbox !== "all" ? { inbox } : {}),
1460
- ...(maxPages > 0 ? { maxPages } : {}),
1461
- ...(options.allPages ? { allPages: true } : {}),
1462
- ...(pageDelayMs > 0 ? { pageDelayMs } : {}),
1463
- refresh: Boolean(options.refresh),
1464
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1465
- });
1466
- await autoSyncAfterWrite();
1467
- print(result, true);
1468
- });
1469
-
1470
- for (const action of ["accept", "reject", "block"] as const) {
1471
- const command = dmsCommand
1472
- .command(`${action} <conversationId>`)
1473
- .description(`${action} a live DM message request through bird`);
1474
- if (action === "block") {
1475
- command
1476
- .option("--max-pages <n>", "Additional timeline pages to search", "3")
1477
- .option("--all-pages", "Search all accepted/request timeline pages");
1478
- }
1479
- command.action(async (conversationId, options) => {
1480
- const maxPages =
1481
- action === "block"
1482
- ? parseNonNegativeIntegerOption(options.maxPages, "--max-pages")
1483
- : undefined;
1484
- if (action === "block" && maxPages === undefined) {
1485
- return;
1486
- }
1487
- const result = await runDirectMessageRequestMutationViaBird({
1488
- action,
1489
- conversationId,
1490
- ...(action === "block" && maxPages !== undefined ? { maxPages } : {}),
1491
- ...(action === "block" && options.allPages ? { allPages: true } : {}),
1492
- });
1493
- if (result.success) {
1494
- await applyDmRequestMutationToLocalStore(conversationId, action);
1495
- } else {
1496
- process.exitCode = 1;
1497
- }
1498
- await autoSyncAfterWrite();
1499
- print(result, true);
1500
- });
1501
- }
1502
-
1503
- registerModerationCommands({
1504
- program,
1505
- print,
1506
- asJson: () => program.opts().json ?? false,
1507
- importBlocklist,
1508
- resolveActionOptions,
1509
- });
1510
-
1511
- const commandContext = {
1512
- program,
1513
- print,
1514
- asJson: () => program.opts().json ?? false,
1515
- autoSyncAfterWrite,
1516
- autoUpdateBeforeRead,
1517
- parseNonNegativeIntegerOption,
1518
- parsePositiveIntegerOption,
1519
- };
1520
-
1521
- registerComposeCommands(commandContext);
1522
- registerInboxCommand(commandContext);
1523
-
1524
- registerGraphCommands(commandContext);
1525
-
1526
- registerStorageCommands(commandContext);
1527
-
1528
- program
1529
- .command("serve")
1530
- .description("Run the local web app")
1531
- .action(async () => {
1532
- await autoUpdateBeforeRead();
1533
- const child = spawn(
1534
- process.execPath,
1535
- [
1536
- "node_modules/vite/bin/vite.js",
1537
- "dev",
1538
- "--host",
1539
- "127.0.0.1",
1540
- "--port",
1541
- "3000",
1542
- ],
1543
- {
1544
- cwd: packageRoot,
1545
- env: { ...process.env, BIRDCLAW_LOCAL_WEB: "1" },
1546
- stdio: "inherit",
1547
- detached: process.platform !== "win32",
1548
- },
1549
- );
1550
- const forwardedSignals = [
1551
- "SIGINT",
1552
- "SIGTERM",
1553
- "SIGHUP",
1554
- "SIGQUIT",
1555
- ] as const;
1556
- const forwardSignal = (signal: NodeJS.Signals) => {
1557
- if (child.exitCode === null && child.signalCode === null) {
1558
- signalChild(signal);
1559
- }
1560
- };
1561
- const signalChild = (signal: NodeJS.Signals) => {
1562
- if (child.pid === undefined) {
1563
- return;
1564
- }
1565
- const targetPid = process.platform === "win32" ? child.pid : -child.pid;
1566
- try {
1567
- process.kill(targetPid, signal);
1568
- } catch (error) {
1569
- if (
1570
- !(
1571
- typeof error === "object" &&
1572
- error !== null &&
1573
- "code" in error &&
1574
- error.code === "ESRCH"
1575
- )
1576
- ) {
1577
- throw error;
1578
- }
1579
- }
1580
- };
1581
- const removeSignalHandlers = () => {
1582
- for (const signal of forwardedSignals) {
1583
- process.removeListener(signal, forwardSignal);
1584
- }
1585
- };
1586
- for (const signal of forwardedSignals) {
1587
- process.on(signal, forwardSignal);
1588
- }
1589
- child.on("exit", (code, signal) => {
1590
- removeSignalHandlers();
1591
- if (signal) {
1592
- process.kill(process.pid, signal);
1593
- return;
1594
- }
1595
- process.exit(code ?? 0);
1596
- });
1597
- });
1598
-
1599
- export async function runCli(argv = process.argv) {
1600
- try {
1601
- await program.parseAsync(argv);
1602
- } finally {
1603
- await closeDatabase();
1604
- }
1605
- }
1606
-
1607
- /* v8 ignore next 5 */
1608
- if (process.argv[1]) {
1609
- const entryUrl = pathToFileURL(process.argv[1]).href;
1610
- if (import.meta.url === entryUrl) {
1611
- void runCli().catch((error) => {
1612
- console.error(error instanceof Error ? error.message : String(error));
1613
- process.exitCode = 1;
1614
- });
1615
- }
1616
- }