birdclaw 0.8.2 → 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 (247) hide show
  1. package/CHANGELOG.md +33 -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 +27 -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-moderation.ts +0 -210
  100. package/src/cli.ts +0 -2336
  101. package/src/components/AccountSwitcher.tsx +0 -186
  102. package/src/components/AppNav.tsx +0 -118
  103. package/src/components/AvatarChip.tsx +0 -58
  104. package/src/components/BrandMark.tsx +0 -67
  105. package/src/components/ConversationThread.tsx +0 -131
  106. package/src/components/DmWorkspace.tsx +0 -286
  107. package/src/components/EmbeddedTweetCard.tsx +0 -55
  108. package/src/components/FeedState.tsx +0 -147
  109. package/src/components/FloatingPreview.tsx +0 -382
  110. package/src/components/InboxCard.tsx +0 -152
  111. package/src/components/LinkPreviewCard.tsx +0 -292
  112. package/src/components/MarkdownViewer.tsx +0 -777
  113. package/src/components/ProfileAnalysisStream.tsx +0 -428
  114. package/src/components/ProfilePreview.tsx +0 -129
  115. package/src/components/SavedTimelineView.tsx +0 -155
  116. package/src/components/SmartTimestamp.tsx +0 -72
  117. package/src/components/SyncNowButton.tsx +0 -140
  118. package/src/components/ThemeSlider.tsx +0 -80
  119. package/src/components/TimelineCard.tsx +0 -533
  120. package/src/components/TimelineRouteFrame.tsx +0 -186
  121. package/src/components/TweetArticleCard.tsx +0 -66
  122. package/src/components/TweetMediaGrid.tsx +0 -185
  123. package/src/components/TweetRichText.tsx +0 -159
  124. package/src/components/account-selection.ts +0 -64
  125. package/src/components/useTimelineRouteData.ts +0 -323
  126. package/src/lib/account-sync-job.ts +0 -666
  127. package/src/lib/actions-transport.ts +0 -300
  128. package/src/lib/api-client.ts +0 -419
  129. package/src/lib/archive-finder.ts +0 -185
  130. package/src/lib/archive-import.ts +0 -2764
  131. package/src/lib/authored-live.ts +0 -1072
  132. package/src/lib/avatar-cache.ts +0 -349
  133. package/src/lib/backup.ts +0 -2741
  134. package/src/lib/bird-actions.ts +0 -294
  135. package/src/lib/bird-command.ts +0 -130
  136. package/src/lib/bird.ts +0 -1140
  137. package/src/lib/blocklist.ts +0 -136
  138. package/src/lib/blocks-write.ts +0 -167
  139. package/src/lib/blocks.ts +0 -389
  140. package/src/lib/bookmark-sync-job.ts +0 -536
  141. package/src/lib/client-cache.ts +0 -109
  142. package/src/lib/config.ts +0 -224
  143. package/src/lib/conversation-surface.ts +0 -205
  144. package/src/lib/data-sources.ts +0 -219
  145. package/src/lib/db.ts +0 -1135
  146. package/src/lib/dms-live.ts +0 -933
  147. package/src/lib/effect-runtime.ts +0 -45
  148. package/src/lib/follow-graph.ts +0 -1097
  149. package/src/lib/geocoding.ts +0 -296
  150. package/src/lib/http-effect.ts +0 -222
  151. package/src/lib/identity-search-index.ts +0 -369
  152. package/src/lib/inbox.ts +0 -241
  153. package/src/lib/link-index.ts +0 -641
  154. package/src/lib/link-insights.ts +0 -860
  155. package/src/lib/link-preview-metadata.ts +0 -754
  156. package/src/lib/location.ts +0 -137
  157. package/src/lib/media-fetch.ts +0 -860
  158. package/src/lib/media-includes.ts +0 -165
  159. package/src/lib/mention-threads-live.ts +0 -917
  160. package/src/lib/mentions-export.ts +0 -147
  161. package/src/lib/mentions-live.ts +0 -1238
  162. package/src/lib/moderation-target.ts +0 -216
  163. package/src/lib/moderation-write.ts +0 -131
  164. package/src/lib/mutes-write.ts +0 -167
  165. package/src/lib/mutes.ts +0 -84
  166. package/src/lib/ndjson-stream.ts +0 -106
  167. package/src/lib/network-map.ts +0 -382
  168. package/src/lib/openai.ts +0 -117
  169. package/src/lib/period-digest.ts +0 -1552
  170. package/src/lib/present.ts +0 -118
  171. package/src/lib/profile-affiliation-hydration.ts +0 -228
  172. package/src/lib/profile-affiliations.ts +0 -262
  173. package/src/lib/profile-analysis.ts +0 -1272
  174. package/src/lib/profile-bio-entities.ts +0 -318
  175. package/src/lib/profile-history.ts +0 -164
  176. package/src/lib/profile-hydration.ts +0 -180
  177. package/src/lib/profile-replies.ts +0 -77
  178. package/src/lib/profile-resolver.ts +0 -650
  179. package/src/lib/queries.ts +0 -2381
  180. package/src/lib/research.ts +0 -652
  181. package/src/lib/search-discussion.ts +0 -1016
  182. package/src/lib/seed.ts +0 -613
  183. package/src/lib/sqlite.ts +0 -147
  184. package/src/lib/sync-cache.ts +0 -65
  185. package/src/lib/theme-transition.ts +0 -113
  186. package/src/lib/theme.tsx +0 -165
  187. package/src/lib/timeline-collections-live.ts +0 -581
  188. package/src/lib/timeline-live.ts +0 -452
  189. package/src/lib/tweet-account-edges.ts +0 -48
  190. package/src/lib/tweet-lookup.ts +0 -46
  191. package/src/lib/tweet-render.ts +0 -430
  192. package/src/lib/tweet-search-live.ts +0 -566
  193. package/src/lib/types.ts +0 -723
  194. package/src/lib/ui.ts +0 -529
  195. package/src/lib/url-expansion-store.ts +0 -110
  196. package/src/lib/url-expansion.ts +0 -343
  197. package/src/lib/url-safety.ts +0 -220
  198. package/src/lib/web-sync.ts +0 -516
  199. package/src/lib/whois.ts +0 -1079
  200. package/src/lib/x-profile.ts +0 -365
  201. package/src/lib/x-web.ts +0 -199
  202. package/src/lib/xurl-rate-limits.ts +0 -267
  203. package/src/lib/xurl.ts +0 -1677
  204. package/src/routeTree.gen.ts +0 -723
  205. package/src/router.tsx +0 -20
  206. package/src/routes/__root.tsx +0 -79
  207. package/src/routes/api/action.tsx +0 -147
  208. package/src/routes/api/avatar.tsx +0 -50
  209. package/src/routes/api/blocks.tsx +0 -35
  210. package/src/routes/api/conversation.tsx +0 -45
  211. package/src/routes/api/data-sources.tsx +0 -24
  212. package/src/routes/api/inbox.tsx +0 -43
  213. package/src/routes/api/link-insights.tsx +0 -85
  214. package/src/routes/api/link-preview.tsx +0 -47
  215. package/src/routes/api/network-map.tsx +0 -55
  216. package/src/routes/api/period-digest.tsx +0 -101
  217. package/src/routes/api/profile-analysis.tsx +0 -97
  218. package/src/routes/api/profile-hydrate.tsx +0 -54
  219. package/src/routes/api/query.tsx +0 -115
  220. package/src/routes/api/search-discussion.tsx +0 -113
  221. package/src/routes/api/status.tsx +0 -28
  222. package/src/routes/api/sync.tsx +0 -105
  223. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  224. package/src/routes/blocks.tsx +0 -377
  225. package/src/routes/bookmarks.tsx +0 -18
  226. package/src/routes/data-sources.tsx +0 -257
  227. package/src/routes/discuss.tsx +0 -419
  228. package/src/routes/dms.tsx +0 -432
  229. package/src/routes/inbox.tsx +0 -226
  230. package/src/routes/index.tsx +0 -32
  231. package/src/routes/likes.tsx +0 -18
  232. package/src/routes/links.tsx +0 -994
  233. package/src/routes/mentions.tsx +0 -32
  234. package/src/routes/network-map.tsx +0 -1035
  235. package/src/routes/profile-analyze.tsx +0 -112
  236. package/src/routes/profiles.$handle.tsx +0 -228
  237. package/src/routes/rate-limits.tsx +0 -309
  238. package/src/routes/today.tsx +0 -511
  239. package/src/styles.css +0 -257
  240. package/tsconfig.json +0 -29
  241. package/vite.config.ts +0 -29
  242. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  243. /package/{public → dist/client}/favicon.ico +0 -0
  244. /package/{public → dist/client}/logo192.png +0 -0
  245. /package/{public → dist/client}/logo512.png +0 -0
  246. /package/{public → dist/client}/manifest.json +0 -0
  247. /package/{public → dist/client}/robots.txt +0 -0
package/src/cli.ts DELETED
@@ -1,2336 +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 { findArchives } from "#/lib/archive-finder";
10
- import {
11
- ARCHIVE_IMPORT_SLICES,
12
- type ArchiveImportSlice,
13
- type ImportProgressEvent,
14
- type ImportProgressSlice,
15
- type ImportWritePhase,
16
- importArchive,
17
- } from "#/lib/archive-import";
18
- import {
19
- AuthoredSyncError,
20
- syncAuthoredTweets,
21
- type AuthoredSyncMode,
22
- } from "#/lib/authored-live";
23
- import {
24
- installAccountSyncLaunchAgent,
25
- parseAccountSyncSteps,
26
- runAccountSyncJob,
27
- } from "#/lib/account-sync-job";
28
- import {
29
- exportBackup,
30
- importBackup,
31
- maybeAutoSyncBackup,
32
- maybeAutoUpdateBackup,
33
- syncBackup,
34
- validateBackup,
35
- } from "#/lib/backup";
36
- import {
37
- installBookmarkSyncLaunchAgent,
38
- runBookmarkSyncJob,
39
- } from "#/lib/bookmark-sync-job";
40
- import { runDirectMessageRequestMutationViaBird } from "#/lib/bird";
41
- import { importBlocklist } from "#/lib/blocklist";
42
- import {
43
- type ActionsTransport,
44
- ensureBirdclawDirs,
45
- getBirdclawPaths,
46
- resolveMentionsDataSource,
47
- setActionsTransport,
48
- } from "#/lib/config";
49
- import { closeDatabase } from "#/lib/db";
50
- import {
51
- type DirectMessagesSyncMode,
52
- syncDirectMessagesViaCachedBird,
53
- } from "#/lib/dms-live";
54
- import { listInboxItems, scoreInbox } from "#/lib/inbox";
55
- import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
56
- import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
57
- import { syncMentionThreads } from "#/lib/mention-threads-live";
58
- import { exportMentionItems } from "#/lib/mentions-export";
59
- import {
60
- exportMentionsViaCachedAuto,
61
- exportMentionsViaCachedBird,
62
- exportMentionsViaCachedXurl,
63
- syncMentions,
64
- } from "#/lib/mentions-live";
65
- import {
66
- normalizeDigestLanguage,
67
- streamPeriodDigest,
68
- type PeriodDigestOptions,
69
- type PeriodDigestPreset,
70
- } from "#/lib/period-digest";
71
- import {
72
- streamProfileAnalysis,
73
- type ProfileAnalysisOptions,
74
- } from "#/lib/profile-analysis";
75
- import {
76
- getFollowGraphSummary,
77
- listFollowEvents,
78
- listMutuals,
79
- listNonMutualFollowing,
80
- listTopFollowers,
81
- listUnfollowedSince,
82
- syncFollowGraph,
83
- } from "#/lib/follow-graph";
84
- import { hydrateProfilesFromX } from "#/lib/profile-hydration";
85
- import { resolveProfilesForIds } from "#/lib/profile-resolver";
86
- import { inspectProfileReplies } from "#/lib/profile-replies";
87
- import { runResearchMode } from "#/lib/research";
88
- import {
89
- streamSearchDiscussion,
90
- type SearchDiscussionOptions,
91
- type SearchDiscussionSource,
92
- } from "#/lib/search-discussion";
93
- import {
94
- applyDmRequestMutationToLocalStore,
95
- createDmReply,
96
- createPost,
97
- createTweetReply,
98
- getQueryEnvelope,
99
- listDmConversations,
100
- listTimelineItems,
101
- } from "#/lib/queries";
102
- import {
103
- syncTimelineCollection,
104
- type TimelineCollectionMode,
105
- } from "#/lib/timeline-collections-live";
106
- import { syncHomeTimeline } from "#/lib/timeline-live";
107
- import { expandUrlsFromTexts } from "#/lib/url-expansion";
108
- import { formatWhois, runWhois } from "#/lib/whois";
109
-
110
- const program = new Command();
111
- const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
112
- const packageVersion = JSON.parse(
113
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
114
- ) as { version?: string };
115
-
116
- function print(data: unknown, asJson: boolean) {
117
- if (asJson) {
118
- console.log(JSON.stringify(data, null, 2));
119
- return;
120
- }
121
- console.log(data);
122
- }
123
-
124
- function printError(error: string) {
125
- console.error(JSON.stringify({ error }));
126
- }
127
-
128
- function errorMessage(error: unknown) {
129
- return error instanceof Error ? error.message : String(error);
130
- }
131
-
132
- const IMPORT_SLICE_LABELS: Record<ImportProgressSlice, string> = {
133
- tweets: "tweets",
134
- noteTweets: "note tweets",
135
- directMessages: "direct messages",
136
- likes: "likes",
137
- bookmarks: "bookmarks",
138
- media: "media files",
139
- followers: "followers",
140
- following: "following",
141
- };
142
-
143
- const IMPORT_WRITE_LABELS: Record<ImportWritePhase, string> = {
144
- profiles: "profiles",
145
- tweets: "tweets",
146
- collections: "likes+bookmarks",
147
- dmMessages: "DM messages",
148
- };
149
-
150
- function logImportProgress(event: ImportProgressEvent) {
151
- switch (event.kind) {
152
- case "scanned":
153
- process.stderr.write(
154
- `Scanning archive… ${String(event.entryCount)} entries\n`,
155
- );
156
- return;
157
- case "slice-start":
158
- if (event.slice === "media") {
159
- process.stderr.write("Indexing media files…\n");
160
- return;
161
- }
162
- process.stderr.write(
163
- `Parsing ${IMPORT_SLICE_LABELS[event.slice]}… (${String(event.files)} file${event.files === 1 ? "" : "s"})\n`,
164
- );
165
- return;
166
- case "slice-file":
167
- if (event.files > 1) {
168
- process.stderr.write(
169
- ` ${IMPORT_SLICE_LABELS[event.slice]} ${String(event.processed)}/${String(event.files)}\n`,
170
- );
171
- }
172
- return;
173
- case "slice-done":
174
- process.stderr.write(
175
- ` ${IMPORT_SLICE_LABELS[event.slice]}: ${event.count.toLocaleString()}\n`,
176
- );
177
- return;
178
- case "writing":
179
- process.stderr.write("Writing to database…\n");
180
- return;
181
- case "write-start":
182
- process.stderr.write(
183
- `Writing ${IMPORT_WRITE_LABELS[event.phase]}… (${event.total.toLocaleString()})\n`,
184
- );
185
- return;
186
- case "write-progress":
187
- process.stderr.write(
188
- ` ${IMPORT_WRITE_LABELS[event.phase]} ${event.processed.toLocaleString()}/${event.total.toLocaleString()}\n`,
189
- );
190
- return;
191
- case "done":
192
- process.stderr.write("Import complete.\n");
193
- return;
194
- }
195
- }
196
-
197
- function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
198
- return items
199
- .map((item) => {
200
- const linked = item.linkedTweet
201
- ? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
202
- : ` -> ${item.expansion.finalUrl}`;
203
- const source =
204
- item.occurrence.sourceKind === "dm"
205
- ? `dm ${item.occurrence.direction ?? ""}`.trim()
206
- : "tweet";
207
- const participant = item.participant
208
- ? ` @${item.participant.handle}`
209
- : "";
210
- return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
211
- })
212
- .join("\n");
213
- }
214
-
215
- function parseNonNegativeIntegerOption(
216
- value: string | undefined,
217
- option: string,
218
- ) {
219
- if (value === undefined) {
220
- return undefined;
221
- }
222
-
223
- const trimmed = value.trim();
224
- if (!/^\d+$/.test(trimmed)) {
225
- printError(`${option} must be a non-negative integer`);
226
- process.exitCode = 1;
227
- return undefined;
228
- }
229
-
230
- const parsed = Number.parseInt(trimmed, 10);
231
- if (!Number.isSafeInteger(parsed)) {
232
- printError(`${option} must be a non-negative integer`);
233
- process.exitCode = 1;
234
- return undefined;
235
- }
236
-
237
- return parsed;
238
- }
239
-
240
- function parsePositiveIntegerOption(value: string | undefined, option: string) {
241
- const parsed = parseNonNegativeIntegerOption(value, option);
242
- if (parsed === undefined) {
243
- return undefined;
244
- }
245
- if (parsed < 1) {
246
- printError(`${option} must be at least 1`);
247
- process.exitCode = 1;
248
- return undefined;
249
- }
250
- return parsed;
251
- }
252
-
253
- function parseDmInboxOption(
254
- value: string | undefined,
255
- ): "all" | "accepted" | "requests" | undefined {
256
- const normalized = (value ?? "all").trim().toLowerCase();
257
- if (
258
- normalized === "all" ||
259
- normalized === "accepted" ||
260
- normalized === "requests"
261
- ) {
262
- return normalized;
263
- }
264
- if (normalized === "request") {
265
- return "requests";
266
- }
267
- printError("--inbox must be all, accepted, or requests");
268
- process.exitCode = 1;
269
- return undefined;
270
- }
271
-
272
- function parseDmSyncModeOption(
273
- value: string | undefined,
274
- ): DirectMessagesSyncMode | undefined {
275
- const normalized = (value ?? "bird").trim().toLowerCase();
276
- if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
277
- return normalized;
278
- }
279
- printError("--mode must be auto, bird, or xurl");
280
- process.exitCode = 1;
281
- return undefined;
282
- }
283
-
284
- function parseDigestLiveModeOption(
285
- value: string | undefined,
286
- ): PeriodDigestOptions["liveSyncMode"] {
287
- const normalized = (value ?? "xurl").trim().toLowerCase();
288
- if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
289
- return normalized;
290
- }
291
- printError("--live-mode must be auto, bird, or xurl");
292
- process.exitCode = 1;
293
- return undefined;
294
- }
295
-
296
- function parseArchiveImportSelect(value: string | undefined) {
297
- if (value === undefined) {
298
- return undefined;
299
- }
300
-
301
- const aliases: Record<string, ArchiveImportSlice> = Object.assign(
302
- Object.create(null) as Record<string, ArchiveImportSlice>,
303
- {
304
- tweets: "tweets",
305
- likes: "likes",
306
- bookmarks: "bookmarks",
307
- directmessages: "directMessages",
308
- "direct-messages": "directMessages",
309
- dms: "directMessages",
310
- profiles: "profiles",
311
- followers: "followers",
312
- following: "following",
313
- },
314
- );
315
- const selected: ArchiveImportSlice[] = [];
316
- const seen = new Set<ArchiveImportSlice>();
317
- for (const rawItem of value.split(",")) {
318
- const item = rawItem.trim();
319
- if (!item) continue;
320
- const slice = aliases[item] ?? aliases[item.toLowerCase()];
321
- if (!slice) {
322
- printError(
323
- `--select must be a comma-separated subset of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
324
- );
325
- process.exitCode = 1;
326
- return undefined;
327
- }
328
- if (!seen.has(slice)) {
329
- seen.add(slice);
330
- selected.push(slice);
331
- }
332
- }
333
-
334
- if (selected.length === 0) {
335
- printError(
336
- `--select must include at least one of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
337
- );
338
- process.exitCode = 1;
339
- return undefined;
340
- }
341
-
342
- return selected;
343
- }
344
-
345
- function resolveActionOptions(options: { transport?: string }) {
346
- return {
347
- transport: options.transport as ActionsTransport | undefined,
348
- };
349
- }
350
-
351
- function parseActionsTransport(value: string | undefined) {
352
- const normalized = value?.trim().toLowerCase();
353
- if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
354
- return normalized;
355
- }
356
- printError("transport must be auto, bird, or xurl");
357
- process.exitCode = 1;
358
- return undefined;
359
- }
360
-
361
- function parseDigestPeriod(value: string | undefined): PeriodDigestPreset {
362
- const normalized = value?.trim().toLowerCase();
363
- if (normalized === "yesterday") return "yesterday";
364
- if (normalized === "24h" || normalized === "day") return "24h";
365
- if (normalized === "week" || normalized === "7d") return "week";
366
- return "today";
367
- }
368
-
369
- function buildDigestOptions(
370
- period: string | undefined,
371
- options: {
372
- account?: string;
373
- includeDms?: boolean;
374
- model?: string;
375
- language?: string;
376
- refresh?: boolean;
377
- since?: string;
378
- until?: string;
379
- maxTweets?: string;
380
- maxLinks?: string;
381
- liveSync?: boolean;
382
- liveMode?: string;
383
- },
384
- ): PeriodDigestOptions | null {
385
- const maxTweets = parseNonNegativeIntegerOption(
386
- options.maxTweets,
387
- "--max-tweets",
388
- );
389
- if (options.maxTweets !== undefined && maxTweets === undefined) {
390
- return null;
391
- }
392
- const maxLinks = parseNonNegativeIntegerOption(
393
- options.maxLinks,
394
- "--max-links",
395
- );
396
- if (options.maxLinks !== undefined && maxLinks === undefined) {
397
- return null;
398
- }
399
- const liveSyncMode = parseDigestLiveModeOption(options.liveMode);
400
- if (liveSyncMode === undefined) {
401
- return null;
402
- }
403
- let language: string | undefined;
404
- try {
405
- language = normalizeDigestLanguage(options.language);
406
- } catch (error) {
407
- printError(error instanceof Error ? error.message : String(error));
408
- process.exitCode = 1;
409
- return null;
410
- }
411
- return {
412
- period: parseDigestPeriod(period),
413
- since: options.since,
414
- until: options.until,
415
- account: options.account,
416
- includeDms: Boolean(options.includeDms),
417
- refresh: Boolean(options.refresh),
418
- model: options.model,
419
- language,
420
- maxTweets,
421
- maxLinks,
422
- liveSync: options.liveSync !== false,
423
- liveSyncMode,
424
- };
425
- }
426
-
427
- function runDigestCli(options: PeriodDigestOptions) {
428
- const asJson = Boolean(program.opts().json);
429
- return streamPeriodDigest(options, {
430
- onDelta: asJson
431
- ? undefined
432
- : (delta) => {
433
- process.stdout.write(delta);
434
- },
435
- }).then((result) => {
436
- if (asJson) {
437
- print(result, true);
438
- return;
439
- }
440
- if (!result.markdown.endsWith("\n")) {
441
- process.stdout.write("\n");
442
- }
443
- });
444
- }
445
-
446
- function parseSearchDiscussionSource(
447
- value: string | undefined,
448
- ): SearchDiscussionSource | undefined {
449
- const normalized = (value ?? "all").trim().toLowerCase();
450
- if (
451
- normalized === "all" ||
452
- normalized === "home" ||
453
- normalized === "mentions" ||
454
- normalized === "authored" ||
455
- normalized === "search" ||
456
- normalized === "likes" ||
457
- normalized === "bookmarks"
458
- ) {
459
- return normalized;
460
- }
461
- printError(
462
- "--source must be all, search, home, mentions, authored, likes, or bookmarks",
463
- );
464
- process.exitCode = 1;
465
- return undefined;
466
- }
467
-
468
- function parseTweetSearchMode(value: string | undefined) {
469
- const normalized = (value ?? "auto").trim().toLowerCase();
470
- if (
471
- normalized === "auto" ||
472
- normalized === "bird" ||
473
- normalized === "xurl" ||
474
- normalized === "local"
475
- ) {
476
- return normalized;
477
- }
478
- printError("--mode must be auto, bird, xurl, or local");
479
- process.exitCode = 1;
480
- return undefined;
481
- }
482
-
483
- function buildSearchDiscussionOptions(
484
- query: string,
485
- options: {
486
- account?: string;
487
- source?: string;
488
- includeDms?: boolean;
489
- since?: string;
490
- until?: string;
491
- question?: string;
492
- originalsOnly?: boolean;
493
- hideLowQuality?: boolean;
494
- mode?: string;
495
- model?: string;
496
- refresh?: boolean;
497
- limit?: string;
498
- maxPages?: string;
499
- },
500
- ): SearchDiscussionOptions | null {
501
- const source = parseSearchDiscussionSource(options.source);
502
- if (!source) return null;
503
- const mode = parseTweetSearchMode(options.mode);
504
- if (!mode) return null;
505
- const limit = parsePositiveIntegerOption(options.limit, "--limit");
506
- if (options.limit !== undefined && limit === undefined) {
507
- return null;
508
- }
509
- const maxPages = parsePositiveIntegerOption(options.maxPages, "--max-pages");
510
- if (options.maxPages !== undefined && maxPages === undefined) {
511
- return null;
512
- }
513
- return {
514
- query,
515
- account: options.account,
516
- source,
517
- includeDms: Boolean(options.includeDms),
518
- since: options.since,
519
- until: options.until,
520
- question: options.question,
521
- originalsOnly: Boolean(options.originalsOnly),
522
- hideLowQuality: Boolean(options.hideLowQuality),
523
- mode,
524
- model: options.model,
525
- refresh: Boolean(options.refresh),
526
- limit,
527
- maxPages,
528
- };
529
- }
530
-
531
- function runSearchDiscussionCli(options: SearchDiscussionOptions) {
532
- const asJson = Boolean(program.opts().json);
533
- return streamSearchDiscussion(options, {
534
- onDelta: asJson
535
- ? undefined
536
- : (delta) => {
537
- process.stdout.write(delta);
538
- },
539
- }).then((result) => {
540
- if (asJson) {
541
- print(result, true);
542
- return;
543
- }
544
- if (!result.markdown.endsWith("\n")) {
545
- process.stdout.write("\n");
546
- }
547
- });
548
- }
549
-
550
- function buildProfileAnalysisOptions(
551
- handle: string,
552
- options: {
553
- account?: string;
554
- model?: string;
555
- refresh?: boolean;
556
- maxTweets?: string;
557
- maxPages?: string;
558
- maxConversations?: string;
559
- maxConversationPages?: string;
560
- conversationDelayMs?: string;
561
- rateLimitRetryMs?: string;
562
- rateLimitRetries?: string;
563
- },
564
- ): ProfileAnalysisOptions | null {
565
- const maxTweets = parsePositiveIntegerOption(
566
- options.maxTweets,
567
- "--max-tweets",
568
- );
569
- if (options.maxTweets !== undefined && maxTweets === undefined) {
570
- return null;
571
- }
572
- const maxPages = parsePositiveIntegerOption(options.maxPages, "--max-pages");
573
- if (options.maxPages !== undefined && maxPages === undefined) {
574
- return null;
575
- }
576
- const maxConversations = parsePositiveIntegerOption(
577
- options.maxConversations,
578
- "--max-conversations",
579
- );
580
- if (
581
- options.maxConversations !== undefined &&
582
- maxConversations === undefined
583
- ) {
584
- return null;
585
- }
586
- const maxConversationPages = parsePositiveIntegerOption(
587
- options.maxConversationPages,
588
- "--max-conversation-pages",
589
- );
590
- if (
591
- options.maxConversationPages !== undefined &&
592
- maxConversationPages === undefined
593
- ) {
594
- return null;
595
- }
596
- const conversationDelayMs = parseNonNegativeIntegerOption(
597
- options.conversationDelayMs,
598
- "--conversation-delay-ms",
599
- );
600
- if (
601
- options.conversationDelayMs !== undefined &&
602
- conversationDelayMs === undefined
603
- ) {
604
- return null;
605
- }
606
- const rateLimitRetryMs = parseNonNegativeIntegerOption(
607
- options.rateLimitRetryMs,
608
- "--rate-limit-retry-ms",
609
- );
610
- if (
611
- options.rateLimitRetryMs !== undefined &&
612
- rateLimitRetryMs === undefined
613
- ) {
614
- return null;
615
- }
616
- const rateLimitMaxRetries = parseNonNegativeIntegerOption(
617
- options.rateLimitRetries,
618
- "--rate-limit-retries",
619
- );
620
- if (
621
- options.rateLimitRetries !== undefined &&
622
- rateLimitMaxRetries === undefined
623
- ) {
624
- return null;
625
- }
626
- return {
627
- handle,
628
- account: options.account,
629
- model: options.model,
630
- refresh: Boolean(options.refresh),
631
- maxTweets,
632
- maxPages,
633
- maxConversations,
634
- maxConversationPages,
635
- conversationDelayMs,
636
- rateLimitRetryMs,
637
- rateLimitMaxRetries,
638
- };
639
- }
640
-
641
- function runProfileAnalysisCli(options: ProfileAnalysisOptions) {
642
- const asJson = Boolean(program.opts().json);
643
- return streamProfileAnalysis(options, {
644
- onDelta: asJson
645
- ? undefined
646
- : (delta) => {
647
- process.stdout.write(delta);
648
- },
649
- onEvent: asJson
650
- ? undefined
651
- : (event) => {
652
- if (event.type === "status") {
653
- process.stderr.write(
654
- event.detail
655
- ? `${event.label}: ${event.detail}\n`
656
- : `${event.label}\n`,
657
- );
658
- }
659
- },
660
- }).then((result) => {
661
- if (asJson) {
662
- print(result, true);
663
- return;
664
- }
665
- if (!result.markdown.endsWith("\n")) {
666
- process.stdout.write("\n");
667
- }
668
- });
669
- }
670
-
671
- async function enrichDmItems(
672
- query: Parameters<typeof listDmConversations>[0],
673
- options: {
674
- resolveProfiles?: boolean;
675
- expandUrls?: boolean;
676
- refreshProfileCache?: boolean;
677
- refreshUrlCache?: boolean;
678
- xurlFallback?: boolean;
679
- },
680
- ) {
681
- let items = listDmConversations(query);
682
- const profileResolution = options.resolveProfiles
683
- ? await resolveProfilesForIds(
684
- items.map((item) => item.participant.id),
685
- {
686
- refresh: options.refreshProfileCache,
687
- xurlFallback: options.xurlFallback ?? true,
688
- },
689
- )
690
- : undefined;
691
- if (profileResolution) {
692
- items = listDmConversations(query);
693
- }
694
-
695
- const urlExpansions = options.expandUrls
696
- ? await expandUrlsFromTexts(
697
- items.flatMap((item) => [
698
- item.lastMessagePreview,
699
- item.searchSnippet ?? "",
700
- ...(item.matches ?? []).flatMap((match) => [
701
- ...match.before.map((message) => message.text),
702
- match.message.text,
703
- ...match.after.map((message) => message.text),
704
- ]),
705
- ]),
706
- { refresh: options.refreshUrlCache },
707
- )
708
- : undefined;
709
-
710
- if (!profileResolution && !urlExpansions) {
711
- return items;
712
- }
713
-
714
- return {
715
- items,
716
- ...(profileResolution ? { profileResolution } : {}),
717
- ...(urlExpansions ? { urlExpansions } : {}),
718
- };
719
- }
720
-
721
- async function autoUpdateBeforeRead() {
722
- let result: Awaited<ReturnType<typeof maybeAutoUpdateBackup>>;
723
- try {
724
- result = await maybeAutoUpdateBackup();
725
- } catch (error) {
726
- const message = error instanceof Error ? error.message : String(error);
727
- console.error(`birdclaw backup auto-sync failed: ${message}`);
728
- return;
729
- }
730
- if (!result.ok) {
731
- console.error(`birdclaw backup auto-sync failed: ${result.error}`);
732
- }
733
- }
734
-
735
- async function autoSyncAfterWrite() {
736
- let result: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
737
- try {
738
- result = await maybeAutoSyncBackup();
739
- } catch (error) {
740
- const message = error instanceof Error ? error.message : String(error);
741
- console.error(`birdclaw backup sync failed: ${message}`);
742
- return;
743
- }
744
- if (!result.ok) {
745
- console.error(`birdclaw backup sync failed: ${result.error}`);
746
- }
747
- }
748
-
749
- program
750
- .name("birdclaw")
751
- .description("Local-first Twitter workspace")
752
- .version(packageVersion.version ?? "0.0.0")
753
- .option("--json", "Emit JSON output");
754
-
755
- program
756
- .command("init")
757
- .description("Create local birdclaw root and seed the database")
758
- .action(async () => {
759
- const paths = ensureBirdclawDirs();
760
- await getQueryEnvelope();
761
- print(
762
- {
763
- ok: true,
764
- rootDir: paths.rootDir,
765
- configPath: paths.configPath,
766
- dbPath: paths.dbPath,
767
- mediaOriginalsDir: paths.mediaOriginalsDir,
768
- mediaThumbsDir: paths.mediaThumbsDir,
769
- },
770
- program.opts().json ?? false,
771
- );
772
- });
773
-
774
- const authCommand = program
775
- .command("auth")
776
- .description("Manage live transport");
777
-
778
- authCommand
779
- .command("status")
780
- .description("Show transport status")
781
- .action(async () => {
782
- const meta = await getQueryEnvelope();
783
- print(meta.transport, program.opts().json ?? false);
784
- });
785
-
786
- authCommand
787
- .command("use <transport>")
788
- .description("Set preferred moderation action transport")
789
- .action((transport: string) => {
790
- const parsed = parseActionsTransport(transport);
791
- if (!parsed) return;
792
- print(setActionsTransport(parsed), program.opts().json ?? false);
793
- });
794
-
795
- program
796
- .command("archive find")
797
- .description("Find likely Twitter archives on disk")
798
- .action(async () => {
799
- const items = await findArchives();
800
- print(items, program.opts().json ?? false);
801
- });
802
-
803
- const importCommand = program
804
- .command("import")
805
- .description("Import local archive data");
806
-
807
- importCommand
808
- .command("archive [archivePath]")
809
- .description("Import a Twitter archive into the local SQLite store")
810
- .option(
811
- "--select <kinds>",
812
- `Import only selected archive slices: ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
813
- )
814
- .action(async (archivePath, options: { select?: string }) => {
815
- const select = parseArchiveImportSelect(options.select);
816
- if (options.select !== undefined && !select) {
817
- return;
818
- }
819
- let resolvedArchivePath = archivePath;
820
- if (!resolvedArchivePath) {
821
- const [latestArchive] = await findArchives();
822
- resolvedArchivePath = latestArchive?.path;
823
- }
824
-
825
- if (!resolvedArchivePath) {
826
- throw new Error(
827
- "No archive found. Pass a path or place one in Downloads.",
828
- );
829
- }
830
-
831
- const asJson = Boolean(program.opts().json);
832
- const result = await importArchive(resolvedArchivePath, {
833
- select,
834
- onProgress: asJson ? undefined : logImportProgress,
835
- });
836
- await autoSyncAfterWrite();
837
- print(result, asJson);
838
- });
839
-
840
- importCommand
841
- .command("hydrate-profiles")
842
- .description("Backfill archive-imported profiles from live Twitter metadata")
843
- .action(async () => {
844
- const result = await hydrateProfilesFromX();
845
- await autoSyncAfterWrite();
846
- print(result, program.opts().json ?? false);
847
- });
848
-
849
- const searchCommand = program
850
- .command("search")
851
- .description("Search local data");
852
-
853
- searchCommand
854
- .command("tweets [query]")
855
- .option("--resource <resource>", "home, mentions, or authored", "home")
856
- .option("--replied", "Only replied items")
857
- .option("--unreplied", "Only unreplied items")
858
- .option("--since <date>", "Include tweets created at or after this date")
859
- .option("--until <date>", "Include tweets created before this date")
860
- .option("--originals-only", "Exclude authored replies that start with @")
861
- .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
862
- .option(
863
- "--min-likes <n>",
864
- "Override the low-quality like threshold (default 50)",
865
- )
866
- .option("--quality-reason", "Include qualityReason on each row")
867
- .option("--liked", "Only liked tweets")
868
- .option("--bookmarked", "Only bookmarked tweets")
869
- .option("--limit <n>", "Limit results", "20")
870
- .action(async (query, options) => {
871
- const minLikes = parseNonNegativeIntegerOption(
872
- options.minLikes,
873
- "--min-likes",
874
- );
875
- if (options.minLikes !== undefined && minLikes === undefined) {
876
- return;
877
- }
878
-
879
- await autoUpdateBeforeRead();
880
- const replyFilter = options.replied
881
- ? "replied"
882
- : options.unreplied
883
- ? "unreplied"
884
- : "all";
885
- const items = listTimelineItems({
886
- resource:
887
- options.resource === "mentions"
888
- ? "mentions"
889
- : options.resource === "authored"
890
- ? "authored"
891
- : "home",
892
- search: query,
893
- replyFilter,
894
- since: options.since,
895
- until: options.until,
896
- includeReplies: !options.originalsOnly,
897
- qualityFilter: options.hideLowQuality ? "summary" : "all",
898
- lowQualityThreshold: minLikes,
899
- includeQualityReason: Boolean(options.qualityReason),
900
- likedOnly: Boolean(options.liked),
901
- bookmarkedOnly: Boolean(options.bookmarked),
902
- limit: Number(options.limit),
903
- });
904
- print(items, program.opts().json ?? false);
905
- });
906
-
907
- searchCommand
908
- .command("dms <query>")
909
- .option("--inbox <kind>", "all, accepted, or requests", "all")
910
- .option("--participant <value>")
911
- .option("--min-followers <n>", "Minimum sender follower count")
912
- .option("--max-followers <n>", "Maximum sender follower count")
913
- .option("--min-influence-score <n>", "Minimum derived influence score")
914
- .option("--max-influence-score <n>", "Maximum derived influence score")
915
- .option("--sort <mode>", "recent or followers", "recent")
916
- .option(
917
- "--context <n>",
918
- "Include N messages before and after each match",
919
- "0",
920
- )
921
- .option(
922
- "--resolve-profiles",
923
- "Resolve placeholder DM profiles through cache/bird/xurl",
924
- )
925
- .option("--expand-urls", "Expand URLs through the persistent URL cache")
926
- .option("--refresh-profile-cache", "Bypass profile lookup cache")
927
- .option("--refresh-url-cache", "Bypass URL expansion cache")
928
- .option(
929
- "--no-xurl-fallback",
930
- "Do not fall back to xurl after bird profile lookup",
931
- )
932
- .option("--replied", "Only replied threads")
933
- .option("--unreplied", "Only unreplied threads")
934
- .option("--limit <n>", "Limit results", "20")
935
- .action(async (query, options) => {
936
- await autoUpdateBeforeRead();
937
- const context = parseNonNegativeIntegerOption(options.context, "--context");
938
- if (context === undefined) {
939
- return;
940
- }
941
- const inbox = parseDmInboxOption(options.inbox);
942
- if (inbox === undefined) {
943
- return;
944
- }
945
- const replyFilter = options.replied
946
- ? "replied"
947
- : options.unreplied
948
- ? "unreplied"
949
- : "all";
950
- const dmQuery = {
951
- search: query,
952
- ...(inbox !== "all" ? { inbox } : {}),
953
- participant: options.participant,
954
- minFollowers: options.minFollowers
955
- ? Number(options.minFollowers)
956
- : undefined,
957
- maxFollowers: options.maxFollowers
958
- ? Number(options.maxFollowers)
959
- : undefined,
960
- minInfluenceScore: options.minInfluenceScore
961
- ? Number(options.minInfluenceScore)
962
- : undefined,
963
- maxInfluenceScore: options.maxInfluenceScore
964
- ? Number(options.maxInfluenceScore)
965
- : undefined,
966
- sort:
967
- options.sort === "followers" || options.sort === "influence"
968
- ? "followers"
969
- : "recent",
970
- replyFilter,
971
- context,
972
- limit: Number(options.limit),
973
- } as const;
974
- const items = await enrichDmItems(dmQuery, {
975
- resolveProfiles: Boolean(options.resolveProfiles),
976
- expandUrls: Boolean(options.expandUrls),
977
- refreshProfileCache: Boolean(options.refreshProfileCache),
978
- refreshUrlCache: Boolean(options.refreshUrlCache),
979
- xurlFallback: options.xurlFallback,
980
- });
981
- print(items, program.opts().json ?? false);
982
- });
983
-
984
- searchCommand
985
- .command("links <query>")
986
- .description("Search indexed short links, expansions, and linked tweets")
987
- .option("--account <accountIdOrHandle>", "Account id or handle")
988
- .option("--since <date>", "Include links created at or after this date")
989
- .option("--until <date>", "Include links created before this date")
990
- .option("--source <kind>", "dm or tweet")
991
- .option("--direction <direction>", "inbound or outbound")
992
- .option("--participant <value>", "DM participant handle or name")
993
- .option("--media <type>", "image, video, or gif")
994
- .option("--limit <n>", "Limit results", "20")
995
- .action(async (query, options) => {
996
- await autoUpdateBeforeRead();
997
- const items = searchLinks(query, {
998
- account: options.account,
999
- since: options.since,
1000
- until: options.until,
1001
- source:
1002
- options.source === "tweet"
1003
- ? "tweet"
1004
- : options.source === "dm"
1005
- ? "dm"
1006
- : undefined,
1007
- direction:
1008
- options.direction === "inbound"
1009
- ? "inbound"
1010
- : options.direction === "outbound"
1011
- ? "outbound"
1012
- : undefined,
1013
- participant: options.participant,
1014
- mediaType:
1015
- options.media === "image" ||
1016
- options.media === "video" ||
1017
- options.media === "gif"
1018
- ? options.media
1019
- : undefined,
1020
- limit: Number(options.limit),
1021
- });
1022
- if (program.opts().json) {
1023
- print(items, true);
1024
- return;
1025
- }
1026
- console.log(formatLinkSearchItems(items));
1027
- });
1028
-
1029
- const linksCommand = program
1030
- .command("links")
1031
- .description("Build and inspect the short-link index");
1032
-
1033
- linksCommand
1034
- .command("backfill")
1035
- .description("Backfill indexed URL occurrences and t.co expansions")
1036
- .option("--all-urls", "Index all URLs, not only t.co")
1037
- .option("--source <kind>", "dm or tweet")
1038
- .option("--refresh-url-cache", "Re-expand URLs already in the index")
1039
- .option("--limit <n>", "Limit network/cache expansions for this run")
1040
- .option("--concurrency <n>", "Concurrent URL expansion workers", "12")
1041
- .option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
1042
- .action(async (options) => {
1043
- const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
1044
- if (options.limit !== undefined && limit === undefined) {
1045
- return;
1046
- }
1047
- const concurrency = parseNonNegativeIntegerOption(
1048
- options.concurrency,
1049
- "--concurrency",
1050
- );
1051
- if (concurrency === undefined) {
1052
- return;
1053
- }
1054
- const timeoutMs = parseNonNegativeIntegerOption(
1055
- options.timeoutMs,
1056
- "--timeout-ms",
1057
- );
1058
- if (timeoutMs === undefined) {
1059
- return;
1060
- }
1061
- const result = await backfillLinkIndex({
1062
- includeAllUrls: Boolean(options.allUrls),
1063
- refresh: Boolean(options.refreshUrlCache),
1064
- source:
1065
- options.source === "tweet"
1066
- ? "tweet"
1067
- : options.source === "dm"
1068
- ? "dm"
1069
- : undefined,
1070
- limit,
1071
- concurrency,
1072
- timeoutMs,
1073
- });
1074
- await autoSyncAfterWrite();
1075
- print(result, program.opts().json ?? false);
1076
- });
1077
-
1078
- const mediaCommand = program
1079
- .command("media")
1080
- .description("Manage the local media cache");
1081
-
1082
- mediaCommand
1083
- .command("fetch")
1084
- .description(
1085
- "Fetch missing pbs.twimg.com image media already stored in tweets",
1086
- )
1087
- .option("--account <accountId>", "Account id")
1088
- .option("--limit <n>", "Stop after N tweets processed")
1089
- .option(
1090
- "--kind <kind>",
1091
- "Tweet or collection kind, e.g. home, like, bookmark",
1092
- )
1093
- .option("--since <isoDate>", "Only tweets created at or after this date")
1094
- .option("--parallel <n>", "Concurrent fetch workers, capped at 5", "1")
1095
- .option("--pacing-ms <n>", "Delay between request starts", "250")
1096
- .option("--video-pacing-ms <n>", "Delay between video request starts")
1097
- .option("--retry-max <n>", "Retries per file after rate limiting", "3")
1098
- .option("--include-video", "Include video and animated GIF media", true)
1099
- .option("--no-include-video", "Skip video and animated GIF media")
1100
- .option("--max-bytes <n>", "Maximum media file size in bytes", "104857600")
1101
- .option("--dry-run", "List what would be fetched without downloading")
1102
- .option("--json", "Emit JSON output")
1103
- .action(async (options) => {
1104
- const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
1105
- if (options.limit !== undefined && limit === undefined) {
1106
- return;
1107
- }
1108
- const parallel =
1109
- parsePositiveIntegerOption(options.parallel, "--parallel") ?? 1;
1110
- const pacingMs =
1111
- parseNonNegativeIntegerOption(options.pacingMs, "--pacing-ms") ?? 250;
1112
- const retryMax =
1113
- parseNonNegativeIntegerOption(options.retryMax, "--retry-max") ?? 3;
1114
- const videoPacingMs =
1115
- options.videoPacingMs === undefined
1116
- ? undefined
1117
- : parseNonNegativeIntegerOption(
1118
- options.videoPacingMs,
1119
- "--video-pacing-ms",
1120
- );
1121
- const maxBytes =
1122
- parseNonNegativeIntegerOption(options.maxBytes, "--max-bytes") ??
1123
- 100 * 1024 * 1024;
1124
- if (process.exitCode) {
1125
- return;
1126
- }
1127
-
1128
- const result = await fetchTweetMedia({
1129
- account: options.account,
1130
- limit,
1131
- kind: options.kind,
1132
- since: options.since,
1133
- parallel,
1134
- pacingMs,
1135
- videoPacingMs,
1136
- retryMax,
1137
- includeVideo: Boolean(options.includeVideo),
1138
- maxBytes,
1139
- dryRun: Boolean(options.dryRun),
1140
- });
1141
- const asJson = Boolean(program.opts().json || options.json);
1142
- print(asJson ? result : formatMediaFetchResult(result), asJson);
1143
- });
1144
-
1145
- program
1146
- .command("whois <query>")
1147
- .description("Identify likely people or orgs from local DMs and tweets")
1148
- .option("--account <accountId>", "Account id")
1149
- .option("--no-dms", "Do not search DMs")
1150
- .option("--tweets", "Include local tweet search evidence")
1151
- .option("--no-resolve-profiles", "Do not resolve placeholder profiles")
1152
- .option("--no-expand-urls", "Do not expand URLs")
1153
- .option("--refresh-profile-cache", "Bypass profile lookup cache")
1154
- .option("--refresh-url-cache", "Bypass URL expansion cache")
1155
- .option(
1156
- "--no-xurl-fallback",
1157
- "Do not fall back to xurl after bird profile lookup",
1158
- )
1159
- .option(
1160
- "--affiliation <query>",
1161
- "Require affiliation, bio, or history evidence",
1162
- )
1163
- .option(
1164
- "--current-affiliation <query>",
1165
- "Require an active affiliation badge",
1166
- )
1167
- .option(
1168
- "--exclude-domain-only",
1169
- "Drop candidates that only match domains/URLs",
1170
- )
1171
- .option("--context <n>", "DM messages before and after each match", "4")
1172
- .option("--limit <n>", "Limit candidates", "10")
1173
- .action(async (query, options) => {
1174
- await autoUpdateBeforeRead();
1175
- const context = parseNonNegativeIntegerOption(options.context, "--context");
1176
- if (context === undefined) {
1177
- return;
1178
- }
1179
- const result = await runWhois(query, {
1180
- account: options.account,
1181
- dms: options.dms,
1182
- tweets: Boolean(options.tweets),
1183
- resolveProfiles: options.resolveProfiles,
1184
- expandUrls: options.expandUrls,
1185
- refreshProfileCache: Boolean(options.refreshProfileCache),
1186
- refreshUrlCache: Boolean(options.refreshUrlCache),
1187
- xurlFallback: options.xurlFallback,
1188
- affiliation: options.affiliation,
1189
- currentAffiliation: options.currentAffiliation,
1190
- excludeDomainOnly: Boolean(options.excludeDomainOnly),
1191
- context,
1192
- limit: Number(options.limit),
1193
- });
1194
- print(
1195
- program.opts().json ? result : formatWhois(result),
1196
- program.opts().json ?? false,
1197
- );
1198
- });
1199
-
1200
- program
1201
- .command("research [query]")
1202
- .description("Build a markdown research brief from bookmarked threads")
1203
- .option("--account <accountId>", "Account id")
1204
- .option("--limit <n>", "Seed bookmark limit", "20")
1205
- .option("--thread-depth <n>", "Maximum ancestor walk depth", "10")
1206
- .option("--out <path>", "Write the markdown brief to a file")
1207
- .action(async (query, options) => {
1208
- await autoUpdateBeforeRead();
1209
- const report = await runResearchMode({
1210
- account: options.account,
1211
- query,
1212
- limit: Number(options.limit),
1213
- maxThreadDepth: Number(options.threadDepth),
1214
- outPath: options.out,
1215
- });
1216
- print(
1217
- program.opts().json ? report : report.markdown,
1218
- program.opts().json ?? false,
1219
- );
1220
- });
1221
-
1222
- program
1223
- .command("discuss <query>")
1224
- .description("Search live/local tweets and summarize the results with AI")
1225
- .option("--account <accountId>", "Account id")
1226
- .option(
1227
- "--source <source>",
1228
- "all, search, home, mentions, authored, likes, or bookmarks",
1229
- "search",
1230
- )
1231
- .option("--mode <mode>", "auto, bird, xurl, or local", "xurl")
1232
- .option("--include-dms", "Include private DM search matches")
1233
- .option("--since <isoDate>", "Include matches created at or after this date")
1234
- .option("--until <isoDate>", "Include matches created before this date")
1235
- .option("--question <prompt>", "Discussion question or angle")
1236
- .option("--originals-only", "Exclude authored replies that start with @")
1237
- .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
1238
- .option("--model <model>", "OpenAI model id")
1239
- .option("--refresh", "Bypass the local discussion cache")
1240
- .option("--limit <n>", "Maximum tweet context", "20000")
1241
- .option("--max-pages <n>", "Maximum live search pages", "200")
1242
- .action(async (query, options) => {
1243
- await autoUpdateBeforeRead();
1244
- const discussionOptions = buildSearchDiscussionOptions(query, options);
1245
- if (!discussionOptions) return;
1246
- await runSearchDiscussionCli(discussionOptions);
1247
- });
1248
-
1249
- program
1250
- .command("profile-analyze <handle>")
1251
- .alias("profile-analyse")
1252
- .description("Backfill a profile with xurl and summarize it with AI")
1253
- .option("--account <accountId>", "Account id")
1254
- .option("--model <model>", "OpenAI model id")
1255
- .option("--refresh", "Bypass profile fetch and analysis caches")
1256
- .option("--max-tweets <n>", "Maximum profile tweets", "10000")
1257
- .option("--max-pages <n>", "Maximum profile timeline pages", "100")
1258
- .option("--max-conversations <n>", "Maximum conversations to backfill", "80")
1259
- .option("--max-conversation-pages <n>", "Maximum pages per conversation", "3")
1260
- .option(
1261
- "--conversation-delay-ms <n>",
1262
- "Delay between conversation search calls",
1263
- )
1264
- .option(
1265
- "--rate-limit-retry-ms <n>",
1266
- "Delay before retrying conversation 429s",
1267
- )
1268
- .option("--rate-limit-retries <n>", "Conversation 429 retry count")
1269
- .action(async (handle, options) => {
1270
- await autoUpdateBeforeRead();
1271
- const analysisOptions = buildProfileAnalysisOptions(handle, options);
1272
- if (!analysisOptions) return;
1273
- await runProfileAnalysisCli(analysisOptions);
1274
- });
1275
-
1276
- program
1277
- .command("today")
1278
- .description("Stream an AI digest of what happened today")
1279
- .option("--account <accountId>", "Account id")
1280
- .option("--include-dms", "Include private DM context")
1281
- .option("--model <model>", "OpenAI model id")
1282
- .option(
1283
- "--language <tag>",
1284
- "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
1285
- )
1286
- .option("--refresh", "Bypass the local digest cache")
1287
- .option("--max-tweets <n>", "Maximum tweet context", "5000")
1288
- .option("--max-links <n>", "Maximum linked articles", "12")
1289
- .option("--no-live-sync", "Use only the local database")
1290
- .option(
1291
- "--live-mode <mode>",
1292
- "Live timeline mode: xurl, bird, or auto",
1293
- "xurl",
1294
- )
1295
- .action(async (options) => {
1296
- await autoUpdateBeforeRead();
1297
- const digestOptions = buildDigestOptions("today", options);
1298
- if (!digestOptions) return;
1299
- await runDigestCli(digestOptions);
1300
- });
1301
-
1302
- program
1303
- .command("digest [period]")
1304
- .description("Stream an AI digest for today, 24h, yesterday, or week")
1305
- .option("--account <accountId>", "Account id")
1306
- .option("--include-dms", "Include private DM context")
1307
- .option("--since <isoDate>", "Start of explicit window")
1308
- .option("--until <isoDate>", "End of explicit window")
1309
- .option("--model <model>", "OpenAI model id")
1310
- .option(
1311
- "--language <tag>",
1312
- "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
1313
- )
1314
- .option("--refresh", "Bypass the local digest cache")
1315
- .option("--max-tweets <n>", "Maximum tweet context", "5000")
1316
- .option("--max-links <n>", "Maximum linked articles", "12")
1317
- .option("--no-live-sync", "Use only the local database")
1318
- .option(
1319
- "--live-mode <mode>",
1320
- "Live timeline mode: xurl, bird, or auto",
1321
- "xurl",
1322
- )
1323
- .action(async (period, options) => {
1324
- await autoUpdateBeforeRead();
1325
- const digestOptions = buildDigestOptions(period, options);
1326
- if (!digestOptions) return;
1327
- await runDigestCli(digestOptions);
1328
- });
1329
-
1330
- const mentionsCommand = program
1331
- .command("mentions")
1332
- .description("Export local mention tweets for scripts and agents");
1333
-
1334
- mentionsCommand
1335
- .command("export [query]")
1336
- .description("Return mention tweets with plain-text and markdown renderings")
1337
- .option("--account <accountId>", "Account id")
1338
- .option("--mode <mode>", "birdclaw, auto, xurl, or bird")
1339
- .option("--replied", "Only replied items")
1340
- .option("--unreplied", "Only unreplied items")
1341
- .option("--refresh", "Refresh the live xurl cache before returning")
1342
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1343
- .option("--all", "Fetch every retrievable xurl mentions page")
1344
- .option(
1345
- "--max-pages <n>",
1346
- "Maximum xurl mention pages to fetch (implies --all)",
1347
- )
1348
- .option("--limit <n>", "Limit results", "20")
1349
- .action(async (query, options) => {
1350
- await autoUpdateBeforeRead();
1351
- const replyFilter = options.replied
1352
- ? "replied"
1353
- : options.unreplied
1354
- ? "unreplied"
1355
- : "all";
1356
- const limit = Number(options.limit);
1357
- const mode = resolveMentionsDataSource(options.mode);
1358
- if (mode === "xurl" || mode === "bird" || mode === "auto") {
1359
- const exportFn =
1360
- mode === "xurl"
1361
- ? exportMentionsViaCachedXurl
1362
- : mode === "bird"
1363
- ? exportMentionsViaCachedBird
1364
- : exportMentionsViaCachedAuto;
1365
- const payload = await exportFn({
1366
- account: options.account,
1367
- search: query,
1368
- replyFilter,
1369
- limit,
1370
- all: Boolean(options.all) || options.maxPages !== undefined,
1371
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1372
- refresh: Boolean(options.refresh),
1373
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1374
- });
1375
- await autoSyncAfterWrite();
1376
- print(payload, true);
1377
- return;
1378
- }
1379
-
1380
- const items = exportMentionItems({
1381
- account: options.account,
1382
- search: query,
1383
- replyFilter,
1384
- limit,
1385
- });
1386
- print({ resource: "mentions", count: items.length, items }, true);
1387
- });
1388
-
1389
- const profilesCommand = program
1390
- .command("profiles")
1391
- .description("Inspect live profile context for moderation and triage");
1392
-
1393
- profilesCommand
1394
- .command("replies <query>")
1395
- .description("Inspect recent authored replies for one profile")
1396
- .option("--limit <n>", "Limit replies", "12")
1397
- .action(async (query, options) => {
1398
- const result = await inspectProfileReplies(query, {
1399
- limit: Number(options.limit),
1400
- });
1401
- print(result, program.opts().json ?? false);
1402
- });
1403
-
1404
- const dmsCommand = program.command("dms").description("Direct messages");
1405
-
1406
- const syncCommand = program
1407
- .command("sync")
1408
- .description("Refresh live Twitter collections into the local store");
1409
-
1410
- syncCommand
1411
- .command("timeline")
1412
- .description("Refresh live home timeline through xurl or bird")
1413
- .option("--account <accountId>", "Account id")
1414
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1415
- .option("--limit <n>", "Result limit", "100")
1416
- .option("--max-pages <n>", "Stop after N xurl pages", "3")
1417
- .option("--for-you", 'Fetch "For You" instead of chronological Following')
1418
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1419
- .option("--refresh", "Bypass live-cache freshness window")
1420
- .action(async (options) => {
1421
- const result = await syncHomeTimeline({
1422
- account: options.account,
1423
- mode: options.mode,
1424
- limit: Number(options.limit),
1425
- maxPages: Number(options.maxPages),
1426
- following: !options.forYou,
1427
- refresh: Boolean(options.refresh),
1428
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1429
- });
1430
- await autoSyncAfterWrite();
1431
- print(result, true);
1432
- });
1433
-
1434
- syncCommand
1435
- .command("mentions")
1436
- .description("Refresh live mentions through xurl or bird")
1437
- .option("--account <accountId>", "Account id")
1438
- .option("--mode <mode>", "auto, bird, or xurl", "auto")
1439
- .option("--limit <n>", "Result limit per page", "20")
1440
- .option("--max-pages <n>", "Stop after N pages")
1441
- .option("--since-id <id>", "Fetch mentions newer than this tweet id")
1442
- .option("--start-time <iso>", "Fetch mentions created at or after this time")
1443
- .option("--refresh", "Bypass live-cache freshness window")
1444
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1445
- .action(async (options) => {
1446
- try {
1447
- const result = await syncMentions({
1448
- account: options.account,
1449
- mode: options.mode,
1450
- limit: Number(options.limit),
1451
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1452
- sinceId: options.sinceId,
1453
- startTime: options.startTime,
1454
- refresh: Boolean(options.refresh),
1455
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1456
- });
1457
- await autoSyncAfterWrite();
1458
- print(result, true);
1459
- if (result.partial) {
1460
- process.exitCode = 5;
1461
- }
1462
- } catch (error) {
1463
- print(
1464
- {
1465
- ok: false,
1466
- kind: "mentions",
1467
- mode: options.mode ?? "xurl",
1468
- error: errorMessage(error),
1469
- },
1470
- true,
1471
- );
1472
- process.exitCode = 1;
1473
- }
1474
- });
1475
-
1476
- syncCommand
1477
- .command("authored")
1478
- .description("Refresh authenticated authored tweets through xurl")
1479
- .option("--account <accountId>", "Account id")
1480
- .option("--mode <mode>", "xurl", "xurl")
1481
- .option("--limit <n>", "X API page size", "100")
1482
- .option("--max-pages <n>", "Stop after N pages and resume later")
1483
- .option("--since-id <tweetId>", "Override the stored since_id cursor")
1484
- .option(
1485
- "--until-id <tweetId>",
1486
- "Fetch tweets older than this id without moving the cursor",
1487
- )
1488
- .action(async (options) => {
1489
- try {
1490
- const result = await syncAuthoredTweets({
1491
- account: options.account,
1492
- mode: options.mode as AuthoredSyncMode,
1493
- limit: Number(options.limit),
1494
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1495
- sinceId: options.sinceId,
1496
- untilId: options.untilId,
1497
- });
1498
- await autoSyncAfterWrite();
1499
- print(result, true);
1500
- if (result.partial) {
1501
- process.exitCode = 5;
1502
- }
1503
- } catch (error) {
1504
- print(
1505
- {
1506
- ok: false,
1507
- kind: "authored",
1508
- source: "xurl",
1509
- error: errorMessage(error),
1510
- },
1511
- true,
1512
- );
1513
- process.exitCode =
1514
- error instanceof AuthoredSyncError ? error.exitCode : 1;
1515
- }
1516
- });
1517
-
1518
- syncCommand
1519
- .command("mention-threads")
1520
- .description(
1521
- "Fetch tweet conversation context for recent mentions through bird or xurl",
1522
- )
1523
- .option("--account <accountId>", "Account id")
1524
- .option("--mode <mode>", "bird or xurl", "bird")
1525
- .option("--limit <n>", "Recent mentions to inspect", "30")
1526
- .option("--delay-ms <n>", "Delay between thread fetches", "1500")
1527
- .option("--timeout-ms <n>", "Per-thread timeout", "15000")
1528
- .option("--all", "Fetch all retrievable thread pages")
1529
- .option("--max-pages <n>", "Stop after N pages")
1530
- .action(async (options) => {
1531
- try {
1532
- const result = await syncMentionThreads({
1533
- account: options.account,
1534
- mode: options.mode,
1535
- limit: Number(options.limit),
1536
- delayMs: Number(options.delayMs),
1537
- timeoutMs: Number(options.timeoutMs),
1538
- all: Boolean(options.all),
1539
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1540
- });
1541
- await autoSyncAfterWrite();
1542
- print(result, true);
1543
- if (result.partial) {
1544
- process.exitCode = 5;
1545
- }
1546
- } catch (error) {
1547
- print(
1548
- {
1549
- ok: false,
1550
- kind: "mention-threads",
1551
- mode: options.mode ?? "bird",
1552
- error: errorMessage(error),
1553
- },
1554
- true,
1555
- );
1556
- process.exitCode = 1;
1557
- }
1558
- });
1559
-
1560
- for (const kind of ["likes", "bookmarks"] as const) {
1561
- syncCommand
1562
- .command(kind)
1563
- .description(`Refresh live ${kind} through xurl or bird`)
1564
- .option("--account <accountId>", "Account id")
1565
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1566
- .option("--limit <n>", "Per-page/result limit", "20")
1567
- .option("--all", "Fetch every retrievable page")
1568
- .option(
1569
- "--max-pages <n>",
1570
- "Stop after N pages when using --all or --early-stop",
1571
- )
1572
- .option("--early-stop", "Stop when a fetched page is already fully local")
1573
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1574
- .option("--refresh", "Bypass live-cache freshness window")
1575
- .action(async (options) => {
1576
- const result = await syncTimelineCollection({
1577
- kind,
1578
- account: options.account,
1579
- mode: options.mode as TimelineCollectionMode,
1580
- limit: Number(options.limit),
1581
- all: Boolean(options.all) || options.maxPages !== undefined,
1582
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1583
- refresh: Boolean(options.refresh),
1584
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1585
- earlyStop: Boolean(options.earlyStop),
1586
- });
1587
- await autoSyncAfterWrite();
1588
- print(result, true);
1589
- });
1590
- }
1591
-
1592
- for (const direction of ["followers", "following"] as const) {
1593
- syncCommand
1594
- .command(direction)
1595
- .description(
1596
- `Dry-run or refresh live ${direction} into the local follow graph`,
1597
- )
1598
- .option("--account <accountId>", "Account id")
1599
- .option("--mode <mode>", "auto, bird, or xurl", "auto")
1600
- .option("--limit <n>", "X API users per page", "1000")
1601
- .option("--max-pages <n>", "Stop after N pages")
1602
- .option("--max-resources <n>", "Stop after N unique users")
1603
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "86400")
1604
- .option("--refresh", "Bypass the live-cache freshness window")
1605
- .option("--allow-partial", "Acknowledge capped/incomplete snapshot")
1606
- .option("--yes", "Confirm live sync or fresh-cache merge")
1607
- .action(async (options) => {
1608
- try {
1609
- const result = await syncFollowGraph({
1610
- direction,
1611
- account: options.account,
1612
- mode: options.mode,
1613
- limit: Number(options.limit),
1614
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1615
- maxResources: options.maxResources
1616
- ? Number(options.maxResources)
1617
- : undefined,
1618
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1619
- refresh: Boolean(options.refresh),
1620
- allowPartial: Boolean(options.allowPartial),
1621
- yes: Boolean(options.yes),
1622
- });
1623
- if (!result.dryRun) {
1624
- await autoSyncAfterWrite();
1625
- }
1626
- print(result, true);
1627
- } catch (error) {
1628
- print({ ok: false, direction, error: errorMessage(error) }, true);
1629
- process.exitCode = 1;
1630
- }
1631
- });
1632
- }
1633
-
1634
- const jobsCommand = program
1635
- .command("jobs")
1636
- .description("Run and install background Birdclaw jobs");
1637
-
1638
- jobsCommand
1639
- .command("sync-account")
1640
- .description("Refresh live account timelines and append a JSONL audit entry")
1641
- .option("--account <accountId>", "Account id")
1642
- .option(
1643
- "--steps <steps>",
1644
- "Comma list: timeline,mentions,mention-threads,likes,bookmarks,dms",
1645
- )
1646
- .option("--mode <mode>", "auto, xurl, or bird for likes/bookmarks", "auto")
1647
- .option("--limit <n>", "Per-page/result limit", "100")
1648
- .option("--max-pages <n>", "Stop after N pages", "3")
1649
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1650
- .option("--refresh", "Bypass live-cache freshness window")
1651
- .option(
1652
- "--allow-bird-account",
1653
- "Assert bird cookies match --account for Bird-backed steps",
1654
- )
1655
- .option("--log <path>", "Audit JSONL path")
1656
- .action(async (options) => {
1657
- const result = await runAccountSyncJob({
1658
- account: options.account,
1659
- steps: parseAccountSyncSteps(options.steps),
1660
- mode: options.mode as TimelineCollectionMode,
1661
- limit: Number(options.limit),
1662
- maxPages: Number(options.maxPages),
1663
- refresh: Boolean(options.refresh),
1664
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1665
- allowBirdAccount: Boolean(options.allowBirdAccount),
1666
- logPath: options.log,
1667
- });
1668
- print(result, true);
1669
- if (!result.ok) {
1670
- process.exitCode = 1;
1671
- }
1672
- });
1673
-
1674
- jobsCommand
1675
- .command("install-account-launchd")
1676
- .description("Install a LaunchAgent that runs account sync")
1677
- .option("--label <label>", "LaunchAgent label")
1678
- .option("--interval-seconds <seconds>", "Launch interval", "1800")
1679
- .option("--program <path>", "birdclaw executable or command", "birdclaw")
1680
- .option("--account <accountId>", "Account id")
1681
- .option(
1682
- "--steps <steps>",
1683
- "Comma list: timeline,mentions,mention-threads,likes,bookmarks,dms",
1684
- )
1685
- .option("--mode <mode>", "auto, xurl, or bird for likes/bookmarks", "auto")
1686
- .option("--limit <n>", "Per-page/result limit", "100")
1687
- .option("--max-pages <n>", "Stop after N pages", "3")
1688
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1689
- .option("--no-refresh", "Allow live-cache reuse")
1690
- .option(
1691
- "--allow-bird-account",
1692
- "Assert bird cookies match --account for Bird-backed steps",
1693
- )
1694
- .option("--log <path>", "Audit JSONL path")
1695
- .option("--env-path <path>", "Shell env file to source before running")
1696
- .option("--env-file <path>", "Deprecated alias for --env-path")
1697
- .option("--stdout <path>", "launchd stdout path")
1698
- .option("--stderr <path>", "launchd stderr path")
1699
- .option("--launch-agents-dir <path>", "LaunchAgents directory")
1700
- .option("--no-load", "Write plist without loading it")
1701
- .action(async (options) => {
1702
- const result = await installAccountSyncLaunchAgent({
1703
- label: options.label,
1704
- intervalSeconds: Number(options.intervalSeconds),
1705
- program: options.program,
1706
- account: options.account,
1707
- steps: parseAccountSyncSteps(options.steps),
1708
- mode: options.mode as TimelineCollectionMode,
1709
- limit: Number(options.limit),
1710
- maxPages: Number(options.maxPages),
1711
- refresh: options.refresh,
1712
- allowBirdAccount: Boolean(options.allowBirdAccount),
1713
- cacheTtlSeconds: Number(options.cacheTtl),
1714
- logPath: options.log,
1715
- envFile: options.envPath ?? options.envFile,
1716
- stdoutPath: options.stdout,
1717
- stderrPath: options.stderr,
1718
- launchAgentsDir: options.launchAgentsDir,
1719
- load: options.load,
1720
- });
1721
- print(result, true);
1722
- });
1723
-
1724
- jobsCommand
1725
- .command("sync-bookmarks")
1726
- .description("Refresh live bookmarks and append a JSONL audit entry")
1727
- .option("--account <accountId>", "Account id")
1728
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1729
- .option("--limit <n>", "Per-page/result limit", "100")
1730
- .option("--all", "Fetch every retrievable page")
1731
- .option("--max-pages <n>", "Stop after N pages", "5")
1732
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1733
- .option("--refresh", "Bypass live-cache freshness window")
1734
- .option("--log <path>", "Audit JSONL path")
1735
- .action(async (options) => {
1736
- const result = await runBookmarkSyncJob({
1737
- account: options.account,
1738
- mode: options.mode as TimelineCollectionMode,
1739
- limit: Number(options.limit),
1740
- all: Boolean(options.all) || options.maxPages !== undefined,
1741
- maxPages: options.all ? undefined : Number(options.maxPages),
1742
- refresh: Boolean(options.refresh),
1743
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1744
- logPath: options.log,
1745
- });
1746
- print(result, true);
1747
- if (!result.ok) {
1748
- process.exitCode = 1;
1749
- }
1750
- });
1751
-
1752
- jobsCommand
1753
- .command("install-bookmarks-launchd")
1754
- .description("Install a LaunchAgent that runs bookmark sync every 3 hours")
1755
- .option("--label <label>", "LaunchAgent label")
1756
- .option("--interval-seconds <seconds>", "Launch interval", "10800")
1757
- .option("--program <path>", "birdclaw executable or command", "birdclaw")
1758
- .option("--mode <mode>", "auto, xurl, or bird", "auto")
1759
- .option("--limit <n>", "Per-page/result limit", "100")
1760
- .option("--all", "Fetch every retrievable page")
1761
- .option("--max-pages <n>", "Stop after N pages", "5")
1762
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1763
- .option("--no-refresh", "Allow live-cache reuse")
1764
- .option("--log <path>", "Audit JSONL path")
1765
- .option("--env-path <path>", "Shell env file to source before running")
1766
- .option("--env-file <path>", "Deprecated alias for --env-path")
1767
- .option("--stdout <path>", "launchd stdout path")
1768
- .option("--stderr <path>", "launchd stderr path")
1769
- .option("--launch-agents-dir <path>", "LaunchAgents directory")
1770
- .option("--no-load", "Write plist without loading it")
1771
- .action(async (options) => {
1772
- const result = await installBookmarkSyncLaunchAgent({
1773
- label: options.label,
1774
- intervalSeconds: Number(options.intervalSeconds),
1775
- program: options.program,
1776
- mode: options.mode as TimelineCollectionMode,
1777
- limit: Number(options.limit),
1778
- all: Boolean(options.all) || options.maxPages !== undefined,
1779
- maxPages: options.all ? undefined : Number(options.maxPages),
1780
- refresh: options.refresh,
1781
- cacheTtlSeconds: Number(options.cacheTtl),
1782
- logPath: options.log,
1783
- envFile: options.envPath ?? options.envFile,
1784
- stdoutPath: options.stdout,
1785
- stderrPath: options.stderr,
1786
- launchAgentsDir: options.launchAgentsDir,
1787
- load: options.load,
1788
- });
1789
- print(result, true);
1790
- });
1791
-
1792
- dmsCommand
1793
- .command("list")
1794
- .option("--account <accountId>", "Account id")
1795
- .option("--mode <mode>", "auto, bird, or xurl", "bird")
1796
- .option("--refresh", "Refresh live DMs before listing")
1797
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1798
- .option("--inbox <kind>", "all, accepted, or requests", "all")
1799
- .option("--max-pages <n>", "Additional accepted/request pages to sync", "0")
1800
- .option("--all-pages", "Fetch all accepted/request pages while syncing")
1801
- .option("--page-delay-ms <n>", "Delay between live DM page requests", "0")
1802
- .option("--participant <value>")
1803
- .option("--min-followers <n>", "Minimum sender follower count")
1804
- .option("--max-followers <n>", "Maximum sender follower count")
1805
- .option("--min-influence-score <n>", "Minimum derived influence score")
1806
- .option("--max-influence-score <n>", "Maximum derived influence score")
1807
- .option("--sort <mode>", "recent or followers", "recent")
1808
- .option(
1809
- "--resolve-profiles",
1810
- "Resolve placeholder DM profiles through cache/bird/xurl",
1811
- )
1812
- .option("--expand-urls", "Expand URLs through the persistent URL cache")
1813
- .option("--refresh-profile-cache", "Bypass profile lookup cache")
1814
- .option("--refresh-url-cache", "Bypass URL expansion cache")
1815
- .option(
1816
- "--no-xurl-fallback",
1817
- "Do not fall back to xurl after bird profile lookup",
1818
- )
1819
- .option("--replied", "Only replied threads")
1820
- .option("--unreplied", "Only unreplied threads")
1821
- .option("--limit <n>", "Limit results", "20")
1822
- .action(async (options) => {
1823
- const replyFilter = options.replied
1824
- ? "replied"
1825
- : options.unreplied
1826
- ? "unreplied"
1827
- : "all";
1828
- const inbox = parseDmInboxOption(options.inbox);
1829
- const mode = parseDmSyncModeOption(options.mode);
1830
- const maxPages = parseNonNegativeIntegerOption(
1831
- options.maxPages,
1832
- "--max-pages",
1833
- );
1834
- const pageDelayMs = parseNonNegativeIntegerOption(
1835
- options.pageDelayMs,
1836
- "--page-delay-ms",
1837
- );
1838
- if (
1839
- inbox === undefined ||
1840
- mode === undefined ||
1841
- maxPages === undefined ||
1842
- pageDelayMs === undefined
1843
- ) {
1844
- return;
1845
- }
1846
- if (options.refresh) {
1847
- await syncDirectMessagesViaCachedBird({
1848
- account: options.account,
1849
- mode,
1850
- limit: Number(options.limit),
1851
- ...(inbox !== "all" ? { inbox } : {}),
1852
- ...(maxPages > 0 ? { maxPages } : {}),
1853
- ...(options.allPages ? { allPages: true } : {}),
1854
- ...(pageDelayMs > 0 ? { pageDelayMs } : {}),
1855
- refresh: true,
1856
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1857
- });
1858
- await autoSyncAfterWrite();
1859
- } else {
1860
- await autoUpdateBeforeRead();
1861
- }
1862
- const items = await enrichDmItems(
1863
- {
1864
- account: options.account,
1865
- ...(inbox !== "all" ? { inbox } : {}),
1866
- participant: options.participant,
1867
- minFollowers: options.minFollowers
1868
- ? Number(options.minFollowers)
1869
- : undefined,
1870
- maxFollowers: options.maxFollowers
1871
- ? Number(options.maxFollowers)
1872
- : undefined,
1873
- minInfluenceScore: options.minInfluenceScore
1874
- ? Number(options.minInfluenceScore)
1875
- : undefined,
1876
- maxInfluenceScore: options.maxInfluenceScore
1877
- ? Number(options.maxInfluenceScore)
1878
- : undefined,
1879
- sort:
1880
- options.sort === "followers" || options.sort === "influence"
1881
- ? "followers"
1882
- : "recent",
1883
- replyFilter,
1884
- limit: Number(options.limit),
1885
- },
1886
- {
1887
- resolveProfiles: Boolean(options.resolveProfiles),
1888
- expandUrls: Boolean(options.expandUrls),
1889
- refreshProfileCache: Boolean(options.refreshProfileCache),
1890
- refreshUrlCache: Boolean(options.refreshUrlCache),
1891
- xurlFallback: options.xurlFallback,
1892
- },
1893
- );
1894
- print(items, program.opts().json ?? false);
1895
- });
1896
-
1897
- dmsCommand
1898
- .command("sync")
1899
- .description("Refresh live direct messages into the local store")
1900
- .option("--account <accountId>", "Account id")
1901
- .option("--mode <mode>", "auto, bird, or xurl", "bird")
1902
- .option("--limit <n>", "Limit messages", "20")
1903
- .option("--inbox <kind>", "all, accepted, or requests", "all")
1904
- .option("--max-pages <n>", "Additional accepted/request pages to sync", "0")
1905
- .option("--all-pages", "Fetch all accepted/request pages")
1906
- .option("--page-delay-ms <n>", "Delay between live DM page requests", "0")
1907
- .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1908
- .option("--refresh", "Bypass live-cache freshness window")
1909
- .action(async (options) => {
1910
- const inbox = parseDmInboxOption(options.inbox);
1911
- const mode = parseDmSyncModeOption(options.mode);
1912
- const maxPages = parseNonNegativeIntegerOption(
1913
- options.maxPages,
1914
- "--max-pages",
1915
- );
1916
- const pageDelayMs = parseNonNegativeIntegerOption(
1917
- options.pageDelayMs,
1918
- "--page-delay-ms",
1919
- );
1920
- if (
1921
- inbox === undefined ||
1922
- mode === undefined ||
1923
- maxPages === undefined ||
1924
- pageDelayMs === undefined
1925
- ) {
1926
- return;
1927
- }
1928
- const result = await syncDirectMessagesViaCachedBird({
1929
- account: options.account,
1930
- mode,
1931
- limit: Number(options.limit),
1932
- ...(inbox !== "all" ? { inbox } : {}),
1933
- ...(maxPages > 0 ? { maxPages } : {}),
1934
- ...(options.allPages ? { allPages: true } : {}),
1935
- ...(pageDelayMs > 0 ? { pageDelayMs } : {}),
1936
- refresh: Boolean(options.refresh),
1937
- cacheTtlMs: Number(options.cacheTtl) * 1000,
1938
- });
1939
- await autoSyncAfterWrite();
1940
- print(result, true);
1941
- });
1942
-
1943
- for (const action of ["accept", "reject", "block"] as const) {
1944
- const command = dmsCommand
1945
- .command(`${action} <conversationId>`)
1946
- .description(`${action} a live DM message request through bird`);
1947
- if (action === "block") {
1948
- command
1949
- .option("--max-pages <n>", "Additional timeline pages to search", "3")
1950
- .option("--all-pages", "Search all accepted/request timeline pages");
1951
- }
1952
- command.action(async (conversationId, options) => {
1953
- const maxPages =
1954
- action === "block"
1955
- ? parseNonNegativeIntegerOption(options.maxPages, "--max-pages")
1956
- : undefined;
1957
- if (action === "block" && maxPages === undefined) {
1958
- return;
1959
- }
1960
- const result = await runDirectMessageRequestMutationViaBird({
1961
- action,
1962
- conversationId,
1963
- ...(action === "block" && maxPages !== undefined ? { maxPages } : {}),
1964
- ...(action === "block" && options.allPages ? { allPages: true } : {}),
1965
- });
1966
- if (result.success) {
1967
- applyDmRequestMutationToLocalStore(conversationId, action);
1968
- } else {
1969
- process.exitCode = 1;
1970
- }
1971
- await autoSyncAfterWrite();
1972
- print(result, true);
1973
- });
1974
- }
1975
-
1976
- registerModerationCommands({
1977
- program,
1978
- print,
1979
- asJson: () => program.opts().json ?? false,
1980
- importBlocklist,
1981
- resolveActionOptions,
1982
- });
1983
-
1984
- const composeCommand = program
1985
- .command("compose")
1986
- .description("Create local/xurl actions");
1987
-
1988
- composeCommand
1989
- .command("post <text>")
1990
- .option("--account <accountId>", "Account id", "acct_primary")
1991
- .action(async (text, options) => {
1992
- const result = await createPost(options.account, text);
1993
- await autoSyncAfterWrite();
1994
- print(result, program.opts().json ?? false);
1995
- });
1996
-
1997
- composeCommand
1998
- .command("reply <tweetId> <text>")
1999
- .option("--account <accountId>", "Account id", "acct_primary")
2000
- .action(async (tweetId, text, options) => {
2001
- const result = await createTweetReply(options.account, tweetId, text);
2002
- await autoSyncAfterWrite();
2003
- print(result, program.opts().json ?? false);
2004
- });
2005
-
2006
- composeCommand
2007
- .command("dm <conversationId> <text>")
2008
- .description("Reply inside an existing DM conversation")
2009
- .action(async (conversationId, text) => {
2010
- const result = await createDmReply(conversationId, text);
2011
- await autoSyncAfterWrite();
2012
- print(result, program.opts().json ?? false);
2013
- });
2014
-
2015
- program
2016
- .command("inbox")
2017
- .option("--kind <kind>", "mixed, mentions, or dms", "mixed")
2018
- .option("--min-score <n>", "Minimum rank", "0")
2019
- .option("--hide-low-signal", "Hide low-signal items")
2020
- .option("--score", "Score top items with OpenAI before listing")
2021
- .option("--limit <n>", "Limit results", "20")
2022
- .action(async (options) => {
2023
- await autoUpdateBeforeRead();
2024
- const kind =
2025
- options.kind === "mentions" || options.kind === "dms"
2026
- ? options.kind
2027
- : "mixed";
2028
- if (options.score) {
2029
- await scoreInbox({
2030
- kind,
2031
- limit: Number(options.limit),
2032
- });
2033
- await autoSyncAfterWrite();
2034
- }
2035
- print(
2036
- listInboxItems({
2037
- kind,
2038
- minScore: Number(options.minScore),
2039
- hideLowSignal: Boolean(options.hideLowSignal),
2040
- limit: Number(options.limit),
2041
- }),
2042
- program.opts().json ?? false,
2043
- );
2044
- });
2045
-
2046
- const graphCommand = program
2047
- .command("graph")
2048
- .description("Query the local cache-only follow graph");
2049
-
2050
- graphCommand
2051
- .command("summary")
2052
- .description("Summarize cached followers, following, mutuals, and snapshots")
2053
- .option("--account <accountId>", "Account id")
2054
- .action(async (options) => {
2055
- await autoUpdateBeforeRead();
2056
- print(getFollowGraphSummary({ account: options.account }), true);
2057
- });
2058
-
2059
- graphCommand
2060
- .command("top-followers")
2061
- .description("List current followers sorted by their follower count")
2062
- .option("--account <accountId>", "Account id")
2063
- .option("--limit <n>", "Limit results", "20")
2064
- .action(async (options) => {
2065
- await autoUpdateBeforeRead();
2066
- print(
2067
- listTopFollowers({
2068
- account: options.account,
2069
- limit: Number(options.limit),
2070
- }),
2071
- true,
2072
- );
2073
- });
2074
-
2075
- graphCommand
2076
- .command("unfollowed")
2077
- .description("List cached ended follow edges since a date")
2078
- .requiredOption("--date <date>", "YYYY-MM-DD or ISO timestamp")
2079
- .option("--account <accountId>", "Account id")
2080
- .option("--direction <direction>", "followers or following", "followers")
2081
- .option("--limit <n>", "Limit results", "100")
2082
- .action(async (options) => {
2083
- await autoUpdateBeforeRead();
2084
- print(
2085
- listUnfollowedSince({
2086
- account: options.account,
2087
- date: options.date,
2088
- direction:
2089
- options.direction === "following" ? "following" : "followers",
2090
- limit: Number(options.limit),
2091
- }),
2092
- true,
2093
- );
2094
- });
2095
-
2096
- graphCommand
2097
- .command("events")
2098
- .description("List cached append-only follow graph events")
2099
- .option("--account <accountId>", "Account id")
2100
- .option("--direction <direction>", "followers or following")
2101
- .option("--kind <kind>", "started or ended")
2102
- .option("--since <date>", "YYYY-MM-DD or ISO timestamp")
2103
- .option("--until <date>", "YYYY-MM-DD or ISO timestamp")
2104
- .option("--limit <n>", "Limit results", "100")
2105
- .action(async (options) => {
2106
- await autoUpdateBeforeRead();
2107
- print(
2108
- listFollowEvents({
2109
- account: options.account,
2110
- direction:
2111
- options.direction === "followers" || options.direction === "following"
2112
- ? options.direction
2113
- : undefined,
2114
- kind:
2115
- options.kind === "started" || options.kind === "ended"
2116
- ? options.kind
2117
- : undefined,
2118
- since: options.since,
2119
- until: options.until,
2120
- limit: Number(options.limit),
2121
- }),
2122
- true,
2123
- );
2124
- });
2125
-
2126
- graphCommand
2127
- .command("non-mutual-following")
2128
- .description("List current following who are not current followers")
2129
- .option("--account <accountId>", "Account id")
2130
- .option("--sort <mode>", "followers or handle", "followers")
2131
- .option("--limit <n>", "Limit results", "100")
2132
- .action(async (options) => {
2133
- await autoUpdateBeforeRead();
2134
- print(
2135
- listNonMutualFollowing({
2136
- account: options.account,
2137
- sort: options.sort === "handle" ? "handle" : "followers",
2138
- limit: Number(options.limit),
2139
- }),
2140
- true,
2141
- );
2142
- });
2143
-
2144
- graphCommand
2145
- .command("mutuals")
2146
- .description("List profiles that are both followers and following")
2147
- .option("--account <accountId>", "Account id")
2148
- .option("--limit <n>", "Limit results", "100")
2149
- .action(async (options) => {
2150
- await autoUpdateBeforeRead();
2151
- print(
2152
- listMutuals({
2153
- account: options.account,
2154
- limit: Number(options.limit),
2155
- }),
2156
- true,
2157
- );
2158
- });
2159
-
2160
- program
2161
- .command("db stats")
2162
- .description("Show local storage and dataset stats")
2163
- .action(async () => {
2164
- await autoUpdateBeforeRead();
2165
- const meta = await getQueryEnvelope();
2166
- const paths = getBirdclawPaths();
2167
- print(
2168
- {
2169
- paths,
2170
- stats: meta.stats,
2171
- transport: meta.transport,
2172
- },
2173
- program.opts().json ?? false,
2174
- );
2175
- });
2176
-
2177
- const backupCommand = program
2178
- .command("backup")
2179
- .description("Export, import, and validate Git-friendly text backups");
2180
-
2181
- backupCommand
2182
- .command("export")
2183
- .description("Export canonical JSONL backup shards")
2184
- .requiredOption("--repo <path>", "Backup repository/path")
2185
- .option("--commit", "Create a git commit in the backup repo")
2186
- .option("--push", "Push the backup repo after committing")
2187
- .option(
2188
- "--message <message>",
2189
- "Git commit message",
2190
- "archive: update birdclaw backup",
2191
- )
2192
- .option("--no-validate", "Skip post-export validation")
2193
- .action(async (options) => {
2194
- const result = await exportBackup({
2195
- repoPath: options.repo,
2196
- commit: Boolean(options.commit) || Boolean(options.push),
2197
- push: Boolean(options.push),
2198
- message: options.message,
2199
- validate: options.validate,
2200
- });
2201
- print(result, true);
2202
- });
2203
-
2204
- backupCommand
2205
- .command("import <repo>")
2206
- .description("Merge a canonical JSONL backup into the local SQLite store")
2207
- .option("--no-validate", "Skip backup validation before import")
2208
- .option("--replace", "Replace local portable tables instead of merging")
2209
- .action(async (repo, options) => {
2210
- const result = await importBackup({
2211
- repoPath: repo,
2212
- validate: options.validate,
2213
- mode: options.replace ? "replace" : "merge",
2214
- });
2215
- print(result, true);
2216
- });
2217
-
2218
- backupCommand
2219
- .command("sync")
2220
- .description("Pull, merge-import, export, commit, and push a backup repo")
2221
- .requiredOption("--repo <path>", "Backup repository/path")
2222
- .option("--remote <url>", "Git remote to clone/configure")
2223
- .option(
2224
- "--message <message>",
2225
- "Git commit message",
2226
- "archive: sync birdclaw backup",
2227
- )
2228
- .action(async (options) => {
2229
- const result = await syncBackup({
2230
- repoPath: options.repo,
2231
- remote: options.remote,
2232
- message: options.message,
2233
- });
2234
- print(result, true);
2235
- });
2236
-
2237
- backupCommand
2238
- .command("validate <repo>")
2239
- .description("Validate backup manifest, shard hashes, and JSONL rows")
2240
- .action(async (repo) => {
2241
- const result = await validateBackup(repo);
2242
- print(result, true);
2243
- if (!result.ok) {
2244
- process.exitCode = 1;
2245
- }
2246
- });
2247
-
2248
- program
2249
- .command("serve")
2250
- .description("Run the local web app")
2251
- .action(async () => {
2252
- await autoUpdateBeforeRead();
2253
- const child = spawn(
2254
- process.execPath,
2255
- [
2256
- "node_modules/vite/bin/vite.js",
2257
- "dev",
2258
- "--host",
2259
- "127.0.0.1",
2260
- "--port",
2261
- "3000",
2262
- ],
2263
- {
2264
- cwd: packageRoot,
2265
- env: { ...process.env, BIRDCLAW_LOCAL_WEB: "1" },
2266
- stdio: "inherit",
2267
- detached: process.platform !== "win32",
2268
- },
2269
- );
2270
- const forwardedSignals = [
2271
- "SIGINT",
2272
- "SIGTERM",
2273
- "SIGHUP",
2274
- "SIGQUIT",
2275
- ] as const;
2276
- const forwardSignal = (signal: NodeJS.Signals) => {
2277
- if (child.exitCode === null && child.signalCode === null) {
2278
- signalChild(signal);
2279
- }
2280
- };
2281
- const signalChild = (signal: NodeJS.Signals) => {
2282
- if (child.pid === undefined) {
2283
- return;
2284
- }
2285
- const targetPid = process.platform === "win32" ? child.pid : -child.pid;
2286
- try {
2287
- process.kill(targetPid, signal);
2288
- } catch (error) {
2289
- if (
2290
- !(
2291
- typeof error === "object" &&
2292
- error !== null &&
2293
- "code" in error &&
2294
- error.code === "ESRCH"
2295
- )
2296
- ) {
2297
- throw error;
2298
- }
2299
- }
2300
- };
2301
- const removeSignalHandlers = () => {
2302
- for (const signal of forwardedSignals) {
2303
- process.removeListener(signal, forwardSignal);
2304
- }
2305
- };
2306
- for (const signal of forwardedSignals) {
2307
- process.on(signal, forwardSignal);
2308
- }
2309
- child.on("exit", (code, signal) => {
2310
- removeSignalHandlers();
2311
- if (signal) {
2312
- process.kill(process.pid, signal);
2313
- return;
2314
- }
2315
- process.exit(code ?? 0);
2316
- });
2317
- });
2318
-
2319
- export async function runCli(argv = process.argv) {
2320
- try {
2321
- await program.parseAsync(argv);
2322
- } finally {
2323
- await closeDatabase();
2324
- }
2325
- }
2326
-
2327
- /* v8 ignore next 5 */
2328
- if (process.argv[1]) {
2329
- const entryUrl = pathToFileURL(process.argv[1]).href;
2330
- if (import.meta.url === entryUrl) {
2331
- void runCli().catch((error) => {
2332
- console.error(error instanceof Error ? error.message : String(error));
2333
- process.exitCode = 1;
2334
- });
2335
- }
2336
- }