@nathanvale/chatline 0.0.1

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 (216) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1535 -0
  4. package/dist/bin/index.js +5121 -0
  5. package/dist/cli/commands/clean.d.ts +17 -0
  6. package/dist/cli/commands/clean.d.ts.map +1 -0
  7. package/dist/cli/commands/clean.js +142 -0
  8. package/dist/cli/commands/clean.js.map +1 -0
  9. package/dist/cli/commands/doctor.d.ts +17 -0
  10. package/dist/cli/commands/doctor.d.ts.map +1 -0
  11. package/dist/cli/commands/doctor.js +202 -0
  12. package/dist/cli/commands/doctor.js.map +1 -0
  13. package/dist/cli/commands/enrich-ai.d.ts +17 -0
  14. package/dist/cli/commands/enrich-ai.d.ts.map +1 -0
  15. package/dist/cli/commands/enrich-ai.js +371 -0
  16. package/dist/cli/commands/enrich-ai.js.map +1 -0
  17. package/dist/cli/commands/index.d.ts +16 -0
  18. package/dist/cli/commands/index.d.ts.map +1 -0
  19. package/dist/cli/commands/index.js +16 -0
  20. package/dist/cli/commands/index.js.map +1 -0
  21. package/dist/cli/commands/ingest-csv.d.ts +17 -0
  22. package/dist/cli/commands/ingest-csv.d.ts.map +1 -0
  23. package/dist/cli/commands/ingest-csv.js +138 -0
  24. package/dist/cli/commands/ingest-csv.js.map +1 -0
  25. package/dist/cli/commands/ingest-db.d.ts +17 -0
  26. package/dist/cli/commands/ingest-db.d.ts.map +1 -0
  27. package/dist/cli/commands/ingest-db.js +159 -0
  28. package/dist/cli/commands/ingest-db.js.map +1 -0
  29. package/dist/cli/commands/init.d.ts +17 -0
  30. package/dist/cli/commands/init.d.ts.map +1 -0
  31. package/dist/cli/commands/init.js +110 -0
  32. package/dist/cli/commands/init.js.map +1 -0
  33. package/dist/cli/commands/normalize-link.d.ts +16 -0
  34. package/dist/cli/commands/normalize-link.d.ts.map +1 -0
  35. package/dist/cli/commands/normalize-link.js +144 -0
  36. package/dist/cli/commands/normalize-link.js.map +1 -0
  37. package/dist/cli/commands/render-markdown.d.ts +17 -0
  38. package/dist/cli/commands/render-markdown.d.ts.map +1 -0
  39. package/dist/cli/commands/render-markdown.js +218 -0
  40. package/dist/cli/commands/render-markdown.js.map +1 -0
  41. package/dist/cli/commands/stats.d.ts +17 -0
  42. package/dist/cli/commands/stats.d.ts.map +1 -0
  43. package/dist/cli/commands/stats.js +175 -0
  44. package/dist/cli/commands/stats.js.map +1 -0
  45. package/dist/cli/commands/validate.d.ts +17 -0
  46. package/dist/cli/commands/validate.d.ts.map +1 -0
  47. package/dist/cli/commands/validate.js +152 -0
  48. package/dist/cli/commands/validate.js.map +1 -0
  49. package/dist/cli/index.d.ts +13 -0
  50. package/dist/cli/index.d.ts.map +1 -0
  51. package/dist/cli/index.js +121 -0
  52. package/dist/cli/index.js.map +1 -0
  53. package/dist/cli/types.d.ts +93 -0
  54. package/dist/cli/types.d.ts.map +1 -0
  55. package/dist/cli/types.js +7 -0
  56. package/dist/cli/types.js.map +1 -0
  57. package/dist/cli/utils.d.ts +29 -0
  58. package/dist/cli/utils.d.ts.map +1 -0
  59. package/dist/cli/utils.js +53 -0
  60. package/dist/cli/utils.js.map +1 -0
  61. package/dist/cli.d.ts +9 -0
  62. package/dist/cli.d.ts.map +1 -0
  63. package/dist/cli.js +1805 -0
  64. package/dist/config/generator.d.ts +90 -0
  65. package/dist/config/generator.d.ts.map +1 -0
  66. package/dist/config/generator.js +320 -0
  67. package/dist/config/generator.js.map +1 -0
  68. package/dist/config/loader.d.ts +107 -0
  69. package/dist/config/loader.d.ts.map +1 -0
  70. package/dist/config/loader.js +251 -0
  71. package/dist/config/loader.js.map +1 -0
  72. package/dist/config/schema.d.ts +107 -0
  73. package/dist/config/schema.d.ts.map +1 -0
  74. package/dist/config/schema.js +169 -0
  75. package/dist/config/schema.js.map +1 -0
  76. package/dist/enrich/audio-transcription.d.ts +77 -0
  77. package/dist/enrich/audio-transcription.d.ts.map +1 -0
  78. package/dist/enrich/audio-transcription.js +370 -0
  79. package/dist/enrich/audio-transcription.js.map +1 -0
  80. package/dist/enrich/checkpoint.d.ts +137 -0
  81. package/dist/enrich/checkpoint.d.ts.map +1 -0
  82. package/dist/enrich/checkpoint.js +205 -0
  83. package/dist/enrich/checkpoint.js.map +1 -0
  84. package/dist/enrich/idempotency.d.ts +90 -0
  85. package/dist/enrich/idempotency.d.ts.map +1 -0
  86. package/dist/enrich/idempotency.js +188 -0
  87. package/dist/enrich/idempotency.js.map +1 -0
  88. package/dist/enrich/image-analysis.d.ts +62 -0
  89. package/dist/enrich/image-analysis.d.ts.map +1 -0
  90. package/dist/enrich/image-analysis.js +264 -0
  91. package/dist/enrich/image-analysis.js.map +1 -0
  92. package/dist/enrich/index.d.ts +60 -0
  93. package/dist/enrich/index.d.ts.map +1 -0
  94. package/dist/enrich/index.js +74 -0
  95. package/dist/enrich/index.js.map +1 -0
  96. package/dist/enrich/link-enrichment.d.ts +37 -0
  97. package/dist/enrich/link-enrichment.d.ts.map +1 -0
  98. package/dist/enrich/link-enrichment.js +202 -0
  99. package/dist/enrich/link-enrichment.js.map +1 -0
  100. package/dist/enrich/pdf-video-handling.d.ts +49 -0
  101. package/dist/enrich/pdf-video-handling.d.ts.map +1 -0
  102. package/dist/enrich/pdf-video-handling.js +325 -0
  103. package/dist/enrich/pdf-video-handling.js.map +1 -0
  104. package/dist/enrich/progress-tracker.d.ts +120 -0
  105. package/dist/enrich/progress-tracker.d.ts.map +1 -0
  106. package/dist/enrich/progress-tracker.js +220 -0
  107. package/dist/enrich/progress-tracker.js.map +1 -0
  108. package/dist/enrich/providers/firecrawl.d.ts +18 -0
  109. package/dist/enrich/providers/firecrawl.d.ts.map +1 -0
  110. package/dist/enrich/providers/firecrawl.js +48 -0
  111. package/dist/enrich/providers/firecrawl.js.map +1 -0
  112. package/dist/enrich/providers/generic.d.ts +16 -0
  113. package/dist/enrich/providers/generic.d.ts.map +1 -0
  114. package/dist/enrich/providers/generic.js +36 -0
  115. package/dist/enrich/providers/generic.js.map +1 -0
  116. package/dist/enrich/providers/index.d.ts +14 -0
  117. package/dist/enrich/providers/index.d.ts.map +1 -0
  118. package/dist/enrich/providers/index.js +13 -0
  119. package/dist/enrich/providers/index.js.map +1 -0
  120. package/dist/enrich/providers/instagram.d.ts +16 -0
  121. package/dist/enrich/providers/instagram.d.ts.map +1 -0
  122. package/dist/enrich/providers/instagram.js +43 -0
  123. package/dist/enrich/providers/instagram.js.map +1 -0
  124. package/dist/enrich/providers/spotify.d.ts +16 -0
  125. package/dist/enrich/providers/spotify.d.ts.map +1 -0
  126. package/dist/enrich/providers/spotify.js +45 -0
  127. package/dist/enrich/providers/spotify.js.map +1 -0
  128. package/dist/enrich/providers/twitter.d.ts +16 -0
  129. package/dist/enrich/providers/twitter.d.ts.map +1 -0
  130. package/dist/enrich/providers/twitter.js +43 -0
  131. package/dist/enrich/providers/twitter.js.map +1 -0
  132. package/dist/enrich/providers/types.d.ts +47 -0
  133. package/dist/enrich/providers/types.d.ts.map +1 -0
  134. package/dist/enrich/providers/types.js +15 -0
  135. package/dist/enrich/providers/types.js.map +1 -0
  136. package/dist/enrich/providers/youtube.d.ts +16 -0
  137. package/dist/enrich/providers/youtube.d.ts.map +1 -0
  138. package/dist/enrich/providers/youtube.js +43 -0
  139. package/dist/enrich/providers/youtube.js.map +1 -0
  140. package/dist/enrich/rate-limiting.d.ts +118 -0
  141. package/dist/enrich/rate-limiting.d.ts.map +1 -0
  142. package/dist/enrich/rate-limiting.js +258 -0
  143. package/dist/enrich/rate-limiting.js.map +1 -0
  144. package/dist/index.d.ts +688 -0
  145. package/dist/index.d.ts.map +1 -0
  146. package/dist/index.js +1729 -0
  147. package/dist/index.js.map +1 -0
  148. package/dist/ingest/dedup-merge.d.ts +82 -0
  149. package/dist/ingest/dedup-merge.d.ts.map +1 -0
  150. package/dist/ingest/dedup-merge.js +262 -0
  151. package/dist/ingest/dedup-merge.js.map +1 -0
  152. package/dist/ingest/ingest-csv.d.ts +62 -0
  153. package/dist/ingest/ingest-csv.d.ts.map +1 -0
  154. package/dist/ingest/ingest-csv.js +300 -0
  155. package/dist/ingest/ingest-csv.js.map +1 -0
  156. package/dist/ingest/ingest-db.d.ts +64 -0
  157. package/dist/ingest/ingest-db.d.ts.map +1 -0
  158. package/dist/ingest/ingest-db.js +172 -0
  159. package/dist/ingest/ingest-db.js.map +1 -0
  160. package/dist/ingest/link-replies-and-tapbacks.d.ts +53 -0
  161. package/dist/ingest/link-replies-and-tapbacks.d.ts.map +1 -0
  162. package/dist/ingest/link-replies-and-tapbacks.js +381 -0
  163. package/dist/ingest/link-replies-and-tapbacks.js.map +1 -0
  164. package/dist/normalize/date-converters.d.ts +45 -0
  165. package/dist/normalize/date-converters.d.ts.map +1 -0
  166. package/dist/normalize/date-converters.js +166 -0
  167. package/dist/normalize/date-converters.js.map +1 -0
  168. package/dist/normalize/path-validator.d.ts +65 -0
  169. package/dist/normalize/path-validator.d.ts.map +1 -0
  170. package/dist/normalize/path-validator.js +221 -0
  171. package/dist/normalize/path-validator.js.map +1 -0
  172. package/dist/normalize/validate-normalized.d.ts +45 -0
  173. package/dist/normalize/validate-normalized.d.ts.map +1 -0
  174. package/dist/normalize/validate-normalized.js +144 -0
  175. package/dist/normalize/validate-normalized.js.map +1 -0
  176. package/dist/render/embeds-blockquotes.d.ts +84 -0
  177. package/dist/render/embeds-blockquotes.d.ts.map +1 -0
  178. package/dist/render/embeds-blockquotes.js +204 -0
  179. package/dist/render/embeds-blockquotes.js.map +1 -0
  180. package/dist/render/grouping.d.ts +78 -0
  181. package/dist/render/grouping.d.ts.map +1 -0
  182. package/dist/render/grouping.js +134 -0
  183. package/dist/render/grouping.js.map +1 -0
  184. package/dist/render/index.d.ts +47 -0
  185. package/dist/render/index.d.ts.map +1 -0
  186. package/dist/render/index.js +245 -0
  187. package/dist/render/index.js.map +1 -0
  188. package/dist/render/reply-rendering.d.ts +88 -0
  189. package/dist/render/reply-rendering.d.ts.map +1 -0
  190. package/dist/render/reply-rendering.js +196 -0
  191. package/dist/render/reply-rendering.js.map +1 -0
  192. package/dist/schema/message.d.ts +125 -0
  193. package/dist/schema/message.d.ts.map +1 -0
  194. package/dist/schema/message.js +331 -0
  195. package/dist/schema/message.js.map +1 -0
  196. package/dist/utils/delta-detection.d.ts +107 -0
  197. package/dist/utils/delta-detection.d.ts.map +1 -0
  198. package/dist/utils/delta-detection.js +199 -0
  199. package/dist/utils/delta-detection.js.map +1 -0
  200. package/dist/utils/enrichment-merge.d.ts +135 -0
  201. package/dist/utils/enrichment-merge.d.ts.map +1 -0
  202. package/dist/utils/enrichment-merge.js +280 -0
  203. package/dist/utils/enrichment-merge.js.map +1 -0
  204. package/dist/utils/human.d.ts +15 -0
  205. package/dist/utils/human.d.ts.map +1 -0
  206. package/dist/utils/human.js +27 -0
  207. package/dist/utils/human.js.map +1 -0
  208. package/dist/utils/incremental-state.d.ts +133 -0
  209. package/dist/utils/incremental-state.d.ts.map +1 -0
  210. package/dist/utils/incremental-state.js +237 -0
  211. package/dist/utils/incremental-state.js.map +1 -0
  212. package/dist/utils/logger.d.ts +40 -0
  213. package/dist/utils/logger.d.ts.map +1 -0
  214. package/dist/utils/logger.js +176 -0
  215. package/dist/utils/logger.js.map +1 -0
  216. package/package.json +165 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1805 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * iMessage Timeline CLI
4
+ *
5
+ * Main entry point for the CLI using commander.js.
6
+ * Implements CLI--T01: Setup Commander.js Structure
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { dirname, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { Command } from 'commander';
12
+ import { humanError, humanInfo, humanWarn, setHumanLoggingEnabled, } from '#utils/human';
13
+ import { createLogger, setCorrelationId, setLogLevel } from '#utils/logger';
14
+ // Get package.json for version
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const packageJsonPath = resolve(__dirname, '../package.json');
18
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
19
+ // ============================================================================
20
+ // CLI--T01-AC02: Create main program with version, description, global options
21
+ // ============================================================================
22
+ const program = new Command();
23
+ const cliLogger = createLogger('cli');
24
+ function applyLogLevel(verbose, quiet) {
25
+ const level = quiet ? 'error' : verbose ? 'debug' : 'info';
26
+ setLogLevel(level);
27
+ }
28
+ // Exported for potential test usage
29
+ function logEvent(event, meta) {
30
+ cliLogger.info(event, meta);
31
+ }
32
+ program
33
+ .name('imessage-timeline')
34
+ .version(packageJson.version)
35
+ .description('Extract, transform, and analyze iMessage conversations with AI-powered enrichment and timeline rendering');
36
+ // ============================================================================
37
+ // CLI--T01-AC03: Global options: --verbose, --quiet, --config <path>
38
+ // ============================================================================
39
+ program
40
+ .option('-v, --verbose', 'enable verbose logging', false)
41
+ .option('-q, --quiet', 'suppress non-error output', false)
42
+ .option('-c, --config <path>', 'path to config file', 'imessage-config.json')
43
+ .option('--json', 'emit structured JSON log events only (machine-readable)', false);
44
+ // Toggle human logging before each command action
45
+ program.hook('preAction', () => {
46
+ const opts = program.opts();
47
+ const envJson = String(process.env.IMESSAGE_JSON || '').toLowerCase();
48
+ const envJsonOnly = ['1', 'true', 'yes', 'y', 'json', 'json-only'].includes(envJson);
49
+ const jsonOnly = Boolean(opts.json) || envJsonOnly;
50
+ setHumanLoggingEnabled(!jsonOnly);
51
+ });
52
+ // ============================================================================
53
+ // CLI--T01-AC05: Top-level error handler with user-friendly messages
54
+ // ============================================================================
55
+ program.configureOutput({
56
+ outputError: (str, write) => {
57
+ const errorMsg = str
58
+ .replace(/^error: /, '❌ Error: ')
59
+ .replace(/^Error: /, '❌ Error: ');
60
+ write(errorMsg);
61
+ cliLogger.error('Commander output error', { raw: str });
62
+ },
63
+ });
64
+ // Custom error handler for better error messages
65
+ program.exitOverride((err) => {
66
+ // Allow help and version to exit cleanly
67
+ if (err.code === 'commander.help' || err.code === 'commander.helpDisplayed') {
68
+ process.exit(0);
69
+ }
70
+ if (err.code === 'commander.version') {
71
+ process.exit(0);
72
+ }
73
+ // Handle missing required options
74
+ if (err.code === 'commander.missingArgument') {
75
+ humanError(`❌ Error: ${err.message}`);
76
+ humanError(`\nRun 'imessage-timeline ${program.args[0] || ''} --help' for usage information`);
77
+ process.exit(1);
78
+ }
79
+ // Handle unknown commands
80
+ if (err.code === 'commander.unknownCommand') {
81
+ humanError(`❌ Error: ${err.message}`);
82
+ humanError(`\nRun 'imessage-timeline --help' to see available commands`);
83
+ process.exit(1);
84
+ }
85
+ // Generic error handler
86
+ humanError(`❌ Error: ${err.message}`);
87
+ cliLogger.error('CLI exit override error', {
88
+ code: err.code,
89
+ message: err.message,
90
+ });
91
+ process.exit(err.exitCode || 1);
92
+ });
93
+ // ============================================================================
94
+ // Placeholder Commands (to be implemented in CLI--T02, CLI--T03, CLI--T04, CLI--T05)
95
+ // ============================================================================
96
+ // ============================================================================
97
+ // Ingest commands (CLI--T02)
98
+ // ============================================================================
99
+ program
100
+ .command('ingest-csv')
101
+ .description('Import messages from iMazing CSV export')
102
+ .requiredOption('-i, --input <path>', 'path to CSV file')
103
+ .option('-o, --output <path>', 'output JSON file path', './messages.csv.ingested.json')
104
+ .option('-a, --attachments <dir...>', 'attachment root directories')
105
+ .action(async (options) => {
106
+ const { input, output, attachments } = options;
107
+ const verbose = program.opts().verbose;
108
+ applyLogLevel(verbose, program.opts().quiet);
109
+ setCorrelationId(`ingest-csv:${Date.now().toString(36)}`);
110
+ logEvent('ingest-start', {
111
+ command: 'ingest-csv',
112
+ phase: 'start',
113
+ options: { input, output, attachmentsCount: attachments?.length },
114
+ });
115
+ try {
116
+ // CLI-T02-AC04: Input file validation with clear error messages
117
+ const fs = await import('node:fs');
118
+ if (!fs.existsSync(input)) {
119
+ humanError(`❌ Input CSV file not found: ${input}`);
120
+ humanError('\nPlease check:');
121
+ humanError(' • File path is correct');
122
+ humanError(' • File exists and is readable');
123
+ process.exit(1);
124
+ }
125
+ // CLI-T02-AC03: Attachment root validation (check directories exist)
126
+ const attachmentRoots = [];
127
+ if (attachments && attachments.length > 0) {
128
+ for (const dir of attachments) {
129
+ if (!fs.existsSync(dir)) {
130
+ humanError(`❌ Attachment directory not found: ${dir}`);
131
+ process.exit(1);
132
+ }
133
+ if (!fs.statSync(dir).isDirectory()) {
134
+ humanError(`❌ Not a directory: ${dir}`);
135
+ process.exit(1);
136
+ }
137
+ attachmentRoots.push(dir);
138
+ }
139
+ }
140
+ else {
141
+ // Default: ~/Library/Messages/Attachments
142
+ const os = await import('node:os');
143
+ const path = await import('node:path');
144
+ const defaultRoot = path.join(os.homedir(), 'Library', 'Messages', 'Attachments');
145
+ if (fs.existsSync(defaultRoot)) {
146
+ attachmentRoots.push(defaultRoot);
147
+ if (verbose) {
148
+ cliLogger.info('Using default attachment root', { defaultRoot });
149
+ }
150
+ }
151
+ }
152
+ // CLI-T02-AC01: ingest-csv command with all options from usage guide
153
+ const { ingestCSV, createExportEnvelope, validateMessages } = await import('./ingest/ingest-csv.js');
154
+ if (verbose) {
155
+ cliLogger.info('Reading CSV ingest', { input, attachmentRoots });
156
+ }
157
+ const messages = ingestCSV(input, { attachmentRoots });
158
+ // CLI-T02-AC05: Progress output: ✓ Parsed 2,847 messages from CSV
159
+ humanInfo(`✓ Parsed ${messages.length.toLocaleString()} messages from CSV`);
160
+ // Validate messages before writing
161
+ const validation = validateMessages(messages);
162
+ if (!validation.valid) {
163
+ humanError(`❌ ${validation.errors.length} messages failed validation`);
164
+ if (verbose) {
165
+ validation.errors.slice(0, 5).forEach((err) => {
166
+ humanError(` Message ${err.index}:`, err.issues);
167
+ });
168
+ }
169
+ process.exit(1);
170
+ }
171
+ // Write export envelope
172
+ const envelope = createExportEnvelope(messages);
173
+ fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
174
+ humanInfo(`✓ Wrote ${messages.length.toLocaleString()} messages to ${output}`);
175
+ humanInfo('\n📊 Summary:');
176
+ const textCount = messages.filter((m) => m.messageKind === 'text').length;
177
+ const mediaCount = messages.filter((m) => m.messageKind === 'media').length;
178
+ const notifCount = messages.filter((m) => m.messageKind === 'notification').length;
179
+ humanInfo(` Text: ${textCount}`);
180
+ humanInfo(` Media: ${mediaCount}`);
181
+ humanInfo(` Notifications: ${notifCount}`);
182
+ logEvent('ingest-summary', {
183
+ command: 'ingest-csv',
184
+ phase: 'summary',
185
+ metrics: {
186
+ total: messages.length,
187
+ text: textCount,
188
+ media: mediaCount,
189
+ notifications: notifCount,
190
+ },
191
+ options: { output },
192
+ exitCode: 0,
193
+ });
194
+ process.exit(0);
195
+ }
196
+ catch (error) {
197
+ humanError('❌ Failed to ingest CSV:', error instanceof Error ? error.message : String(error));
198
+ if (program.opts().verbose && error instanceof Error) {
199
+ humanError(error.stack);
200
+ }
201
+ const errorMeta = {
202
+ type: error instanceof Error ? error.name : 'Unknown',
203
+ message: error instanceof Error ? error.message : String(error),
204
+ ...(error instanceof Error && error.stack
205
+ ? { stack: error.stack }
206
+ : {}),
207
+ };
208
+ logEvent('ingest-error', {
209
+ command: 'ingest-csv',
210
+ phase: 'error',
211
+ error: errorMeta,
212
+ exitCode: 2,
213
+ });
214
+ process.exit(2);
215
+ }
216
+ });
217
+ program
218
+ .command('ingest-db')
219
+ .description('Import messages from macOS Messages.app database export (JSON)')
220
+ .requiredOption('-i, --input <path>', 'path to JSON file with DB messages')
221
+ .option('-o, --output <path>', 'output JSON file path', './messages.db.ingested.json')
222
+ .option('-a, --attachments <dir...>', 'attachment root directories')
223
+ .option('--contact <handle>', 'filter to specific contact handle')
224
+ .action(async (options) => {
225
+ const { input, output, attachments, contact } = options;
226
+ const verbose = program.opts().verbose;
227
+ applyLogLevel(verbose, program.opts().quiet);
228
+ logEvent('ingest-start', {
229
+ command: 'ingest-db',
230
+ phase: 'start',
231
+ options: {
232
+ input,
233
+ output,
234
+ attachmentsCount: attachments?.length,
235
+ contact,
236
+ },
237
+ });
238
+ try {
239
+ // CLI-T02-AC04: Input file validation with clear error messages
240
+ const fs = await import('node:fs');
241
+ if (!fs.existsSync(input)) {
242
+ humanError(`❌ Input JSON file not found: ${input}`);
243
+ humanError('\nPlease check:');
244
+ humanError(' • File path is correct');
245
+ humanError(' • File exists and is readable');
246
+ process.exit(1);
247
+ }
248
+ // CLI-T02-AC03: Attachment root validation (check directories exist)
249
+ const attachmentRoots = [];
250
+ if (attachments && attachments.length > 0) {
251
+ for (const dir of attachments) {
252
+ if (!fs.existsSync(dir)) {
253
+ humanError(`❌ Attachment directory not found: ${dir}`);
254
+ process.exit(1);
255
+ }
256
+ if (!fs.statSync(dir).isDirectory()) {
257
+ humanError(`❌ Not a directory: ${dir}`);
258
+ process.exit(1);
259
+ }
260
+ attachmentRoots.push(dir);
261
+ }
262
+ }
263
+ else {
264
+ // Default: ~/Library/Messages/Attachments
265
+ const os = await import('node:os');
266
+ const path = await import('node:path');
267
+ const defaultRoot = path.join(os.homedir(), 'Library', 'Messages', 'Attachments');
268
+ if (fs.existsSync(defaultRoot)) {
269
+ attachmentRoots.push(defaultRoot);
270
+ if (verbose) {
271
+ humanInfo(`Using default attachment root: ${defaultRoot}`);
272
+ }
273
+ }
274
+ }
275
+ // CLI-T02-AC02: ingest-db command with database path and contact filtering
276
+ const { splitDBMessage } = await import('./ingest/ingest-db.js');
277
+ const { createExportEnvelope, validateMessages } = await import('./ingest/ingest-csv.js');
278
+ if (verbose) {
279
+ cliLogger.info('Reading DB export', {
280
+ input,
281
+ attachmentRoots,
282
+ contact,
283
+ });
284
+ }
285
+ // Read and parse DB export JSON
286
+ const content = fs.readFileSync(input, 'utf-8');
287
+ const dbMessages = JSON.parse(content);
288
+ if (!Array.isArray(dbMessages)) {
289
+ humanError(`❌ Expected JSON array of DB messages, got: ${typeof dbMessages}`);
290
+ process.exit(1);
291
+ }
292
+ // Filter by contact if specified
293
+ let filteredMessages = dbMessages;
294
+ if (contact) {
295
+ filteredMessages = dbMessages.filter((m) => m.handle === contact);
296
+ humanInfo(`✓ Filtered to ${filteredMessages.length} messages from ${contact}`);
297
+ }
298
+ // Split DB messages into Message objects
299
+ const messages = [];
300
+ filteredMessages.forEach((dbMsg, index) => {
301
+ const split = splitDBMessage(dbMsg, index + 1, { attachmentRoots });
302
+ messages.push(...split);
303
+ });
304
+ // CLI-T02-AC05: Progress output
305
+ humanInfo(`✓ Parsed ${messages.length.toLocaleString()} messages from DB export`);
306
+ // Validate messages before writing
307
+ const validation = validateMessages(messages);
308
+ if (!validation.valid) {
309
+ humanError(`❌ ${validation.errors.length} messages failed validation`);
310
+ if (verbose) {
311
+ validation.errors.slice(0, 5).forEach((err) => {
312
+ humanError(` Message ${err.index}:`, err.issues);
313
+ });
314
+ }
315
+ process.exit(1);
316
+ }
317
+ // Write export envelope
318
+ const envelope = createExportEnvelope(messages);
319
+ fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
320
+ humanInfo(`✓ Wrote ${messages.length.toLocaleString()} messages to ${output}`);
321
+ humanInfo('\n📊 Summary:');
322
+ const dbText = messages.filter((m) => m.messageKind === 'text').length;
323
+ const dbMedia = messages.filter((m) => m.messageKind === 'media').length;
324
+ humanInfo(` Text: ${dbText}`);
325
+ humanInfo(` Media: ${dbMedia}`);
326
+ logEvent('ingest-summary', {
327
+ command: 'ingest-db',
328
+ phase: 'summary',
329
+ metrics: { total: messages.length, text: dbText, media: dbMedia },
330
+ options: { output, contact },
331
+ exitCode: 0,
332
+ });
333
+ process.exit(0);
334
+ }
335
+ catch (error) {
336
+ humanError('❌ Failed to ingest DB export:', error instanceof Error ? error.message : String(error));
337
+ if (program.opts().verbose && error instanceof Error) {
338
+ humanError(error.stack);
339
+ }
340
+ const errorMeta = {
341
+ type: error instanceof Error ? error.name : 'Unknown',
342
+ message: error instanceof Error ? error.message : String(error),
343
+ ...(error instanceof Error && error.stack
344
+ ? { stack: error.stack }
345
+ : {}),
346
+ };
347
+ logEvent('ingest-error', {
348
+ command: 'ingest-db',
349
+ phase: 'error',
350
+ error: errorMeta,
351
+ options: { output, contact },
352
+ exitCode: 2,
353
+ });
354
+ process.exit(2);
355
+ }
356
+ });
357
+ // Normalize command (CLI--T03-AC01)
358
+ program
359
+ .command('normalize-link')
360
+ .description('Deduplicate and link messages from multiple sources')
361
+ .requiredOption('-i, --input <files...>', 'input JSON files (CSV, DB, or both)')
362
+ .option('-o, --output <path>', 'output JSON file path', './messages.normalized.json')
363
+ .option('-m, --merge-mode <mode>', 'merge mode: exact|content|all (default: all)', 'all')
364
+ .action(async (options) => {
365
+ const { input, output, mergeMode } = options;
366
+ const verbose = program.opts().verbose;
367
+ applyLogLevel(verbose, program.opts().quiet);
368
+ try {
369
+ // Validate inputs
370
+ const fs = await import('node:fs');
371
+ const inputFiles = Array.isArray(input) ? input : [input];
372
+ for (const file of inputFiles) {
373
+ if (!fs.existsSync(file)) {
374
+ humanError(`❌ Input file not found: ${file}`);
375
+ process.exit(1);
376
+ }
377
+ }
378
+ // CLI-T03-AC01: Validate merge mode
379
+ if (!['exact', 'content', 'all'].includes(mergeMode)) {
380
+ humanError(`❌ Invalid merge mode: ${mergeMode}`);
381
+ humanError('Valid modes: exact (GUID only), content (text matching), all (both)');
382
+ process.exit(1);
383
+ }
384
+ if (verbose) {
385
+ cliLogger.info('Start normalize-link', {
386
+ files: inputFiles.length,
387
+ mergeMode,
388
+ });
389
+ }
390
+ // Load input files
391
+ const allMessages = [];
392
+ for (const file of inputFiles) {
393
+ const content = fs.readFileSync(file, 'utf-8');
394
+ const data = JSON.parse(content);
395
+ const messages = Array.isArray(data) ? data : data.messages || [];
396
+ allMessages.push(...messages);
397
+ humanInfo(`✓ Loaded ${messages.length} messages from ${file}`);
398
+ }
399
+ // Import normalize pipeline
400
+ const { linkRepliesToParents } = await import('./ingest/link-replies-and-tapbacks.js');
401
+ const { dedupAndMerge } = await import('./ingest/dedup-merge.js');
402
+ const { validateNormalizedMessages } = await import('./normalize/validate-normalized.js');
403
+ if (verbose) {
404
+ cliLogger.info('Total messages before linking', {
405
+ count: allMessages.length,
406
+ });
407
+ }
408
+ // Step 1: Link replies and tapbacks
409
+ const linkedResult = linkRepliesToParents(allMessages, {
410
+ trackAmbiguous: true,
411
+ });
412
+ const linkedMessages = Array.isArray(linkedResult)
413
+ ? linkedResult
414
+ : linkedResult.messages;
415
+ if (verbose &&
416
+ !Array.isArray(linkedResult) &&
417
+ linkedResult.ambiguousLinks) {
418
+ cliLogger.warn('Ambiguous reply/tapback links detected', {
419
+ count: linkedResult.ambiguousLinks.length,
420
+ });
421
+ }
422
+ // Step 2: Deduplicate and merge (if multiple sources)
423
+ let normalizedMessages = linkedMessages;
424
+ if (inputFiles.length > 1) {
425
+ // Split messages by source for dedup-merge
426
+ const csvMessages = linkedMessages.filter((m) => m.exportVersion?.includes('csv') || !m.rowid);
427
+ const dbMessages = linkedMessages.filter((m) => m.rowid !== undefined);
428
+ const mergeResult = dedupAndMerge(csvMessages, dbMessages);
429
+ normalizedMessages = mergeResult.messages;
430
+ if (verbose) {
431
+ cliLogger.info('Merge statistics', {
432
+ input: mergeResult.stats.csvCount + mergeResult.stats.dbCount,
433
+ output: mergeResult.stats.outputCount,
434
+ exactMatches: mergeResult.stats.exactMatches,
435
+ contentMatches: mergeResult.stats.contentMatches,
436
+ noMatches: mergeResult.stats.noMatches,
437
+ });
438
+ }
439
+ }
440
+ // Step 3: Validate normalized messages
441
+ const validatedMessages = validateNormalizedMessages(normalizedMessages);
442
+ // Write output envelope
443
+ const { createExportEnvelope } = await import('./ingest/ingest-csv.js');
444
+ const envelope = createExportEnvelope(validatedMessages);
445
+ envelope.source = 'merged';
446
+ fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
447
+ humanInfo(`\n✅ Normalized ${validatedMessages.length.toLocaleString()} messages`);
448
+ humanInfo(`✓ Wrote to ${output}`);
449
+ humanInfo('\n📊 Final Summary:');
450
+ const nText = validatedMessages.filter((m) => m.messageKind === 'text').length;
451
+ const nMedia = validatedMessages.filter((m) => m.messageKind === 'media').length;
452
+ const nTapbacks = validatedMessages.filter((m) => m.messageKind === 'tapback').length;
453
+ const nNotifs = validatedMessages.filter((m) => m.messageKind === 'notification').length;
454
+ humanInfo(` Text: ${nText}`);
455
+ humanInfo(` Media: ${nMedia}`);
456
+ humanInfo(` Tapbacks: ${nTapbacks}`);
457
+ humanInfo(` Notifications: ${nNotifs}`);
458
+ logEvent('normalize-summary', {
459
+ command: 'normalize-link',
460
+ phase: 'summary',
461
+ metrics: {
462
+ total: validatedMessages.length,
463
+ text: nText,
464
+ media: nMedia,
465
+ tapbacks: nTapbacks,
466
+ notifications: nNotifs,
467
+ },
468
+ options: { output },
469
+ exitCode: 0,
470
+ });
471
+ process.exit(0);
472
+ }
473
+ catch (error) {
474
+ humanError('❌ Failed to normalize-link:', error instanceof Error ? error.message : String(error));
475
+ if (program.opts().verbose && error instanceof Error) {
476
+ humanError(error.stack);
477
+ }
478
+ const errorMeta = {
479
+ type: error instanceof Error ? error.name : 'Unknown',
480
+ message: error instanceof Error ? error.message : String(error),
481
+ ...(error instanceof Error && error.stack
482
+ ? { stack: error.stack }
483
+ : {}),
484
+ };
485
+ logEvent('normalize-error', {
486
+ command: 'normalize-link',
487
+ phase: 'error',
488
+ error: errorMeta,
489
+ exitCode: 2,
490
+ });
491
+ process.exit(2);
492
+ }
493
+ });
494
+ // Enrich command (CLI--T03-AC02, AC03, AC04, AC05)
495
+ program
496
+ .command('enrich-ai')
497
+ .description('Add AI-powered enrichment to media messages')
498
+ .requiredOption('-i, --input <path>', 'input normalized JSON file')
499
+ .option('-o, --output <path>', 'output JSON file path', './messages.enriched.json')
500
+ .option('-c, --checkpoint-dir <path>', 'checkpoint directory', './.checkpoints')
501
+ .option('--resume', 'resume from last checkpoint', false)
502
+ .option('--incremental', 'only enrich messages new since last enrichment run', false)
503
+ .option('--state-file <path>', 'path to incremental state file (auto-detects .imessage-state.json by default)')
504
+ .option('--reset-state', 'clear incremental state and enrich all messages', false)
505
+ .option('--force-refresh', 'force re-enrichment even if already done', false)
506
+ .option('--rate-limit <ms>', 'delay between API calls (milliseconds)', '1000')
507
+ .option('--max-retries <n>', 'max retries on API errors', '3')
508
+ .option('--checkpoint-interval <n>', 'write checkpoint every N items', '100')
509
+ .option('--enable-vision', 'enable image analysis with Gemini Vision', true)
510
+ .option('--enable-audio', 'enable audio transcription with Gemini Audio', true)
511
+ .option('--enable-links', 'enable link enrichment with Firecrawl', true)
512
+ .action(async (options) => {
513
+ const { input, output, checkpointDir, resume, incremental, stateFile: userProvidedStateFile, resetState, forceRefresh: _forceRefresh, rateLimitMs, maxRetries, checkpointInterval, enableVision, enableAudio, enableLinks, } = options;
514
+ const verbose = program.opts().verbose;
515
+ applyLogLevel(verbose, program.opts().quiet);
516
+ logEvent('enrich-start', {
517
+ command: 'enrich',
518
+ phase: 'start',
519
+ options: {
520
+ input,
521
+ output,
522
+ checkpointDir,
523
+ resume,
524
+ incremental,
525
+ stateFile: userProvidedStateFile,
526
+ resetState,
527
+ rateLimitMs,
528
+ maxRetries,
529
+ checkpointInterval,
530
+ enableVision,
531
+ enableAudio,
532
+ enableLinks,
533
+ },
534
+ });
535
+ try {
536
+ // Validate inputs
537
+ const fs = await import('node:fs');
538
+ if (!fs.existsSync(input)) {
539
+ humanError(`❌ Input file not found: ${input}`);
540
+ process.exit(1);
541
+ }
542
+ // Parse rate limit and retry options
543
+ const rateLimitDelay = Number.parseInt(rateLimitMs, 10);
544
+ const maxRetriesNum = Number.parseInt(maxRetries, 10);
545
+ const checkpointIntervalNum = Number.parseInt(checkpointInterval, 10);
546
+ if (Number.isNaN(rateLimitDelay) || rateLimitDelay < 0) {
547
+ humanError('❌ --rate-limit must be a non-negative number (milliseconds)');
548
+ process.exit(1);
549
+ }
550
+ if (Number.isNaN(maxRetriesNum) || maxRetriesNum < 0) {
551
+ humanError('❌ --max-retries must be a non-negative number');
552
+ process.exit(1);
553
+ }
554
+ if (Number.isNaN(checkpointIntervalNum) || checkpointIntervalNum < 1) {
555
+ humanError('❌ --checkpoint-interval must be a positive number');
556
+ process.exit(1);
557
+ }
558
+ if (verbose) {
559
+ cliLogger.info('Enrich config', {
560
+ input,
561
+ output,
562
+ checkpointDir,
563
+ rateLimitDelay,
564
+ maxRetries: maxRetriesNum,
565
+ checkpointInterval: checkpointIntervalNum,
566
+ enableVision,
567
+ enableAudio,
568
+ enableLinks,
569
+ incremental,
570
+ });
571
+ }
572
+ // Create checkpoint directory if needed
573
+ if (!fs.existsSync(checkpointDir)) {
574
+ await import('node:fs/promises').then((fsp) => fsp.mkdir(checkpointDir, { recursive: true }));
575
+ }
576
+ // Load normalized messages
577
+ const content = fs.readFileSync(input, 'utf-8');
578
+ const data = JSON.parse(content);
579
+ const messages = Array.isArray(data) ? data : data.messages || [];
580
+ humanInfo(`✓ Loaded ${messages.length.toLocaleString()} messages`);
581
+ // Import enrichment modules
582
+ const { loadCheckpoint, computeConfigHash, saveCheckpoint, createCheckpoint, } = await import('./enrich/checkpoint.js');
583
+ // Import actual enrichment functions
584
+ const { analyzeImage } = await import('./enrich/image-analysis.js');
585
+ const { analyzeAudio } = await import('./enrich/audio-transcription.js');
586
+ const { enrichLinkContext } = await import('./enrich/link-enrichment.js');
587
+ const { createRateLimiter } = await import('./enrich/rate-limiting.js');
588
+ // Create rate limiter with circuit breaker
589
+ const rateLimiter = createRateLimiter({
590
+ rateLimitDelay,
591
+ maxRetries: maxRetriesNum,
592
+ circuitBreakerThreshold: 5,
593
+ circuitBreakerResetMs: 60000,
594
+ });
595
+ // Compute config hash for checkpoint verification (AC05: Config consistency)
596
+ const enrichConfig = {
597
+ enableVisionAnalysis: enableVision,
598
+ enableLinkAnalysis: enableLinks,
599
+ enableAudioTranscription: enableAudio,
600
+ rateLimitDelay,
601
+ maxRetries: maxRetriesNum,
602
+ };
603
+ const configHash = computeConfigHash(enrichConfig);
604
+ const checkpointPath = `${checkpointDir}/enrich-checkpoint-${configHash}.json`;
605
+ // INCREMENTAL--T04-AC02/AC03: Handle incremental state file
606
+ const stateFilePath = userProvidedStateFile || '.imessage-state.json';
607
+ const stateFileExists = fs.existsSync(stateFilePath);
608
+ // INCREMENTAL--T04-AC04: Handle --reset-state flag
609
+ if (resetState && stateFileExists) {
610
+ fs.unlinkSync(stateFilePath);
611
+ if (verbose) {
612
+ humanInfo(`🗑️ Reset incremental state file: ${stateFilePath}`);
613
+ }
614
+ }
615
+ // Import incremental state module for AC02, AC03, AC04, AC05
616
+ const { loadIncrementalState, detectNewMessages } = await import('./utils/incremental-state.js');
617
+ // INCREMENTAL--T04-AC02: Auto-detect state file and load previous state
618
+ let previousState = null;
619
+ let newMessageGuids = [];
620
+ let newMessageCount = messages.length;
621
+ if (incremental && stateFileExists && !resetState) {
622
+ previousState = await loadIncrementalState(stateFilePath);
623
+ if (previousState) {
624
+ // Detect new messages using GUID comparison
625
+ const currentGuids = new Set(messages
626
+ .map((m) => m.guid)
627
+ .filter((g) => Boolean(g)));
628
+ newMessageGuids = detectNewMessages(currentGuids, previousState);
629
+ newMessageCount = newMessageGuids.length;
630
+ if (verbose) {
631
+ humanInfo(`♻️ Incremental mode: detected ${newMessageCount.toLocaleString()} new messages`);
632
+ humanInfo(` Total messages: ${messages.length.toLocaleString()}`);
633
+ }
634
+ }
635
+ }
636
+ else if (incremental && !stateFileExists && !resetState) {
637
+ if (verbose) {
638
+ humanInfo(`♻️ Incremental mode enabled but no state file found: ${stateFilePath}`);
639
+ humanInfo(` Enriching all ${messages.length.toLocaleString()} messages`);
640
+ }
641
+ }
642
+ // AC05: Load checkpoint and verify config hash
643
+ let startIndex = 0;
644
+ if (resume) {
645
+ const checkpoint = await loadCheckpoint(checkpointPath);
646
+ if (checkpoint) {
647
+ if (checkpoint.configHash !== configHash) {
648
+ humanError('❌ Config has changed since last checkpoint');
649
+ humanError('Use --force-refresh to re-enrich or delete checkpoint file');
650
+ process.exit(1);
651
+ }
652
+ startIndex = checkpoint.lastProcessedIndex + 1;
653
+ cliLogger.info('Resuming from checkpoint', {
654
+ startIndex,
655
+ alreadyProcessed: checkpoint.totalProcessed,
656
+ failedItems: checkpoint.totalFailed,
657
+ });
658
+ }
659
+ else if (resume) {
660
+ humanWarn('⚠️ No checkpoint found, starting from beginning');
661
+ }
662
+ }
663
+ // AC02: Enrich messages with checkpoint support
664
+ const enrichedMessages = [];
665
+ let totalProcessed = 0;
666
+ let totalFailed = 0;
667
+ const failedItems = [];
668
+ // INCREMENTAL--T04-AC05: Show progress with new message count
669
+ const progressMsg = incremental && newMessageCount < messages.length
670
+ ? `Enriching ${newMessageCount.toLocaleString()} new messages (${messages.length.toLocaleString()} total)`
671
+ : `Processing ${messages.length.toLocaleString()} messages`;
672
+ humanInfo(`\n🚀 Starting enrichment: ${progressMsg}`);
673
+ // Build enrichment configs
674
+ const geminiApiKey = process.env.GEMINI_API_KEY || '';
675
+ const firecrawlApiKey = process.env.FIRECRAWL_API_KEY;
676
+ const imageConfig = {
677
+ enableVisionAnalysis: enableVision ?? true,
678
+ geminiApiKey,
679
+ geminiModel: 'gemini-1.5-pro',
680
+ imageCacheDir: '/tmp/image-cache',
681
+ };
682
+ const audioConfig = {
683
+ enableAudioTranscription: enableAudio ?? true,
684
+ geminiApiKey,
685
+ geminiModel: 'gemini-1.5-pro',
686
+ rateLimitDelay,
687
+ maxRetries: maxRetriesNum,
688
+ };
689
+ const linkConfig = {
690
+ enableLinkAnalysis: enableLinks ?? true,
691
+ ...(firecrawlApiKey ? { firecrawlApiKey } : {}),
692
+ rateLimitDelay,
693
+ maxRetries: maxRetriesNum,
694
+ };
695
+ // Build set of new message GUIDs for incremental filtering
696
+ const newGuidSet = new Set(newMessageGuids);
697
+ for (let i = startIndex; i < messages.length; i++) {
698
+ const message = messages[i];
699
+ try {
700
+ // INCREMENTAL--T04: Skip already-enriched messages in incremental mode
701
+ const shouldEnrich = !incremental || !previousState || newGuidSet.has(message.guid || '');
702
+ let enrichedMessage = message;
703
+ if (shouldEnrich) {
704
+ // Check circuit breaker before making API calls
705
+ if (rateLimiter.isCircuitOpen()) {
706
+ // Circuit is open - skip enrichment but don't fail
707
+ if (verbose) {
708
+ humanWarn(`⚠️ Circuit breaker open - skipping enrichment for message ${i}`);
709
+ }
710
+ }
711
+ else {
712
+ // Apply rate limiting delay between API calls
713
+ const rateLimitDelayMs = rateLimiter.shouldRateLimit();
714
+ if (rateLimitDelayMs > 0) {
715
+ await new Promise((resolve) => setTimeout(resolve, rateLimitDelayMs));
716
+ }
717
+ rateLimiter.recordCall();
718
+ // Enrich based on message type and config
719
+ if (enableVision &&
720
+ message.messageKind === 'media' &&
721
+ message.media?.mediaKind === 'image') {
722
+ enrichedMessage = await analyzeImage(enrichedMessage, imageConfig);
723
+ rateLimiter.recordSuccess();
724
+ }
725
+ else if (enableAudio &&
726
+ message.messageKind === 'media' &&
727
+ message.media?.mediaKind === 'audio') {
728
+ enrichedMessage = await analyzeAudio(enrichedMessage, audioConfig);
729
+ rateLimiter.recordSuccess();
730
+ }
731
+ else if (enableLinks &&
732
+ message.messageKind === 'text' &&
733
+ message.text) {
734
+ enrichedMessage = await enrichLinkContext(enrichedMessage, linkConfig);
735
+ rateLimiter.recordSuccess();
736
+ }
737
+ }
738
+ }
739
+ enrichedMessages.push(enrichedMessage);
740
+ totalProcessed++;
741
+ // AC01: Write checkpoint at intervals
742
+ if ((i + 1) % checkpointIntervalNum === 0) {
743
+ const checkpoint = createCheckpoint({
744
+ lastProcessedIndex: i,
745
+ totalProcessed,
746
+ totalFailed,
747
+ stats: {
748
+ processedCount: totalProcessed,
749
+ failedCount: totalFailed,
750
+ enrichmentsByKind: {},
751
+ },
752
+ failedItems,
753
+ configHash,
754
+ });
755
+ await saveCheckpoint(checkpoint, checkpointPath);
756
+ if (verbose) {
757
+ cliLogger.info('Checkpoint written', { index: i + 1 });
758
+ }
759
+ }
760
+ }
761
+ catch (error) {
762
+ totalFailed++;
763
+ const errorMessage = error instanceof Error ? error.message : String(error);
764
+ failedItems.push({
765
+ index: i,
766
+ guid: message.guid || 'unknown',
767
+ kind: message.messageKind || 'unknown',
768
+ error: errorMessage,
769
+ });
770
+ if (verbose) {
771
+ humanWarn(`⚠️ Failed to enrich message ${i}: ${errorMessage}`);
772
+ logEvent('enrich-item-failed', {
773
+ command: 'enrich',
774
+ phase: 'warning',
775
+ context: {
776
+ index: i,
777
+ guid: message.guid || 'unknown',
778
+ kind: message.messageKind || 'unknown',
779
+ },
780
+ error: {
781
+ type: error instanceof Error ? error.name : 'Unknown',
782
+ message: errorMessage,
783
+ ...(error instanceof Error && error.stack
784
+ ? { stack: error.stack }
785
+ : {}),
786
+ },
787
+ });
788
+ }
789
+ }
790
+ }
791
+ // Write final checkpoint
792
+ const finalCheckpoint = createCheckpoint({
793
+ lastProcessedIndex: messages.length - 1,
794
+ totalProcessed,
795
+ totalFailed,
796
+ stats: {
797
+ processedCount: totalProcessed,
798
+ failedCount: totalFailed,
799
+ enrichmentsByKind: {},
800
+ },
801
+ failedItems,
802
+ configHash,
803
+ });
804
+ await saveCheckpoint(finalCheckpoint, checkpointPath);
805
+ // Write output
806
+ const { createExportEnvelope } = await import('./ingest/ingest-csv.js');
807
+ const envelope = createExportEnvelope(enrichedMessages);
808
+ envelope.source = 'merged';
809
+ fs.writeFileSync(output, JSON.stringify(envelope, null, 2), 'utf-8');
810
+ humanInfo('\n✅ Enrichment complete');
811
+ humanInfo(`✓ Processed: ${totalProcessed.toLocaleString()} messages`);
812
+ if (totalFailed > 0) {
813
+ humanInfo(`⚠️ Failed: ${totalFailed.toLocaleString()} messages`);
814
+ }
815
+ humanInfo(`✓ Wrote to ${output}`);
816
+ logEvent('enrich-summary', {
817
+ command: 'enrich',
818
+ phase: 'summary',
819
+ metrics: { processed: totalProcessed, failed: totalFailed },
820
+ options: { output, checkpointInterval: checkpointIntervalNum },
821
+ context: { checkpointPath },
822
+ exitCode: 0,
823
+ });
824
+ process.exit(0);
825
+ }
826
+ catch (error) {
827
+ humanError('❌ Failed to enrich:', error instanceof Error ? error.message : String(error));
828
+ if (program.opts().verbose && error instanceof Error) {
829
+ humanError(error.stack);
830
+ }
831
+ const errorMeta = {
832
+ type: error instanceof Error ? error.name : 'Unknown',
833
+ message: error instanceof Error ? error.message : String(error),
834
+ ...(error instanceof Error && error.stack
835
+ ? { stack: error.stack }
836
+ : {}),
837
+ };
838
+ logEvent('enrich-error', {
839
+ command: 'enrich',
840
+ phase: 'error',
841
+ error: errorMeta,
842
+ exitCode: 2,
843
+ });
844
+ process.exit(2);
845
+ }
846
+ });
847
+ // ============================================================================
848
+ // Render command (CLI--T04: Implement Render Command)
849
+ // ============================================================================
850
+ program
851
+ .command('render-markdown')
852
+ .description('Generate Obsidian-compatible markdown timeline files')
853
+ .requiredOption('-i, --input <path>', 'input enriched JSON file')
854
+ .option('-o, --output <dir>', 'output directory for markdown files', './timeline')
855
+ .option('--start-date <date>', 'render messages from this date (YYYY-MM-DD)')
856
+ .option('--end-date <date>', 'render messages until this date (YYYY-MM-DD)')
857
+ .option('--group-by-time', 'group messages by time-of-day (Morning/Afternoon/Evening)', true)
858
+ .option('--nested-replies', 'render replies as nested blockquotes', true)
859
+ .option('--max-nesting-depth <n>', 'maximum nesting depth for replies (default 10)', '10')
860
+ .action(async (options) => {
861
+ const { input, output, startDate, endDate, groupByTime, nestedReplies, maxNestingDepth, } = options;
862
+ const verbose = program.opts().verbose;
863
+ applyLogLevel(verbose, program.opts().quiet);
864
+ logEvent('render-start', {
865
+ command: 'render-markdown',
866
+ phase: 'start',
867
+ options: {
868
+ input,
869
+ output,
870
+ startDate,
871
+ endDate,
872
+ groupByTime,
873
+ nestedReplies,
874
+ maxNestingDepth,
875
+ },
876
+ });
877
+ try {
878
+ // CLI-T04-AC01: Date filtering validation
879
+ const fs = await import('node:fs');
880
+ if (!fs.existsSync(input)) {
881
+ humanError(`❌ Input file not found: ${input}`);
882
+ process.exit(1);
883
+ }
884
+ let startDateObj = null;
885
+ let endDateObj = null;
886
+ if (startDate) {
887
+ const start = new Date(startDate);
888
+ if (Number.isNaN(start.getTime())) {
889
+ humanError(`❌ Invalid start date: ${startDate} (use YYYY-MM-DD format)`);
890
+ process.exit(1);
891
+ }
892
+ startDateObj = start;
893
+ }
894
+ if (endDate) {
895
+ const end = new Date(endDate);
896
+ if (Number.isNaN(end.getTime())) {
897
+ humanError(`❌ Invalid end date: ${endDate} (use YYYY-MM-DD format)`);
898
+ process.exit(1);
899
+ }
900
+ // Set to end of day
901
+ end.setHours(23, 59, 59, 999);
902
+ endDateObj = end;
903
+ }
904
+ // CLI-T04-AC03: Validate max nesting depth
905
+ const maxNestingDepthNum = Number.parseInt(maxNestingDepth, 10);
906
+ if (Number.isNaN(maxNestingDepthNum) || maxNestingDepthNum < 1) {
907
+ humanError('❌ --max-nesting-depth must be a positive number');
908
+ process.exit(1);
909
+ }
910
+ if (verbose) {
911
+ humanInfo(`📄 Input: ${input}`);
912
+ humanInfo(`📁 Output directory: ${output}`);
913
+ if (startDateObj) {
914
+ humanInfo(`📅 Start date: ${startDate}`);
915
+ }
916
+ if (endDateObj) {
917
+ humanInfo(`📅 End date: ${endDate}`);
918
+ }
919
+ humanInfo(`⏱️ Group by time: ${groupByTime}`);
920
+ humanInfo(`⬅️ Nested replies: ${nestedReplies}`);
921
+ humanInfo(`📊 Max nesting depth: ${maxNestingDepthNum}`);
922
+ }
923
+ // Load input messages
924
+ const content = fs.readFileSync(input, 'utf-8');
925
+ const data = JSON.parse(content);
926
+ let messages = Array.isArray(data) ? data : data.messages || [];
927
+ if (verbose) {
928
+ humanInfo(`✓ Loaded ${messages.length.toLocaleString()} messages`);
929
+ }
930
+ // Filter by date range if specified
931
+ if (startDateObj || endDateObj) {
932
+ const filtered = messages.filter((msg) => {
933
+ const msgDate = new Date(msg.date);
934
+ if (startDateObj && msgDate < startDateObj)
935
+ return false;
936
+ if (endDateObj && msgDate > endDateObj)
937
+ return false;
938
+ return true;
939
+ });
940
+ humanInfo(`📊 Filtered to ${filtered.length.toLocaleString()} messages in date range`);
941
+ logEvent('render-filtered', {
942
+ command: 'render-markdown',
943
+ phase: 'progress',
944
+ metrics: { filtered: filtered.length, original: messages.length },
945
+ options: {
946
+ startDate: startDateObj ? startDateObj.toISOString() : undefined,
947
+ endDate: endDateObj ? endDateObj.toISOString() : undefined,
948
+ },
949
+ });
950
+ messages = filtered;
951
+ }
952
+ // Import render functions
953
+ const { renderMessages } = await import('./render/index.js');
954
+ // Render messages to markdown
955
+ const rendered = renderMessages(messages);
956
+ if (rendered.size === 0) {
957
+ humanWarn('⚠️ No messages to render');
958
+ process.exit(0);
959
+ }
960
+ // CLI-T04-AC04: Create output directory if doesn't exist
961
+ const path = await import('node:path');
962
+ const outputDir = path.resolve(output || './timeline');
963
+ if (!fs.existsSync(outputDir)) {
964
+ fs.mkdirSync(outputDir, { recursive: true });
965
+ if (verbose) {
966
+ cliLogger.info('Created output directory', { outputDir });
967
+ }
968
+ }
969
+ // Write markdown files
970
+ let filesWritten = 0;
971
+ const dates = Array.from(rendered.keys()).sort();
972
+ for (const date of dates) {
973
+ const markdown = rendered.get(date);
974
+ if (!markdown)
975
+ continue;
976
+ const filename = `${date}.md`;
977
+ const filepath = path.join(outputDir, filename);
978
+ fs.writeFileSync(filepath, markdown, 'utf-8');
979
+ filesWritten++;
980
+ if (verbose) {
981
+ humanInfo(`✓ Wrote ${filename}`);
982
+ logEvent('render-file-written', {
983
+ command: 'render-markdown',
984
+ phase: 'progress',
985
+ metrics: { filesWritten },
986
+ context: { filename, filepath },
987
+ });
988
+ }
989
+ }
990
+ // CLI-T04-AC05: Summary output
991
+ humanInfo(`\n✅ Rendered ${filesWritten} markdown file(s)`);
992
+ humanInfo(`✓ Wrote ${filesWritten.toLocaleString()} markdown file${filesWritten === 1 ? '' : 's'} to ${outputDir}`);
993
+ // Message summary
994
+ const textMessages = messages.filter((m) => m.messageKind === 'text').length;
995
+ const mediaMessages = messages.filter((m) => m.messageKind === 'media').length;
996
+ const tapbacks = messages.filter((m) => m.messageKind === 'tapback').length;
997
+ humanInfo('\n📊 Message Summary:');
998
+ humanInfo(` Total: ${messages.length.toLocaleString()}`);
999
+ humanInfo(` Text: ${textMessages.toLocaleString()}`);
1000
+ humanInfo(` Media: ${mediaMessages.toLocaleString()}`);
1001
+ humanInfo(` Tapbacks: ${tapbacks.toLocaleString()}`);
1002
+ logEvent('render-summary', {
1003
+ command: 'render-markdown',
1004
+ phase: 'summary',
1005
+ metrics: {
1006
+ filesWritten,
1007
+ totalMessages: messages.length,
1008
+ textMessages,
1009
+ mediaMessages,
1010
+ tapbacks,
1011
+ },
1012
+ options: {
1013
+ outputDir,
1014
+ groupByTime,
1015
+ nestedReplies,
1016
+ maxNestingDepth: maxNestingDepthNum,
1017
+ },
1018
+ exitCode: 0,
1019
+ });
1020
+ process.exit(0);
1021
+ }
1022
+ catch (error) {
1023
+ humanError('❌ Failed to render markdown:', error instanceof Error ? error.message : String(error));
1024
+ if (program.opts().verbose && error instanceof Error) {
1025
+ humanError(error.stack);
1026
+ }
1027
+ const errorMeta = {
1028
+ type: error instanceof Error ? error.name : 'Unknown',
1029
+ message: error instanceof Error ? error.message : String(error),
1030
+ ...(error instanceof Error && error.stack
1031
+ ? { stack: error.stack }
1032
+ : {}),
1033
+ };
1034
+ logEvent('render-error', {
1035
+ command: 'render-markdown',
1036
+ phase: 'error',
1037
+ error: errorMeta,
1038
+ options: { output, groupByTime, nestedReplies, maxNestingDepth },
1039
+ exitCode: 2,
1040
+ });
1041
+ process.exit(2);
1042
+ }
1043
+ });
1044
+ // ============================================================================
1045
+ // Helper commands (CLI--T05)
1046
+ // ============================================================================
1047
+ // CLI--T05-AC01: validate command to check JSON against schema
1048
+ program
1049
+ .command('validate')
1050
+ .description('Validate JSON file against message schema')
1051
+ .requiredOption('-i, --input <path>', 'path to JSON file to validate')
1052
+ .option('-q, --quiet', 'suppress detailed error messages', false)
1053
+ .action(async (options) => {
1054
+ const { input, quiet } = options;
1055
+ const _verbose = program.opts().verbose;
1056
+ applyLogLevel(_verbose, program.opts().quiet);
1057
+ try {
1058
+ const fs = await import('node:fs');
1059
+ // Validate input file exists
1060
+ if (!fs.existsSync(input)) {
1061
+ humanError(`❌ Input file not found: ${input}`);
1062
+ process.exit(1);
1063
+ }
1064
+ // Load and parse JSON
1065
+ const content = fs.readFileSync(input, 'utf-8');
1066
+ let data;
1067
+ try {
1068
+ data = JSON.parse(content);
1069
+ }
1070
+ catch (e) {
1071
+ humanError(`❌ Invalid JSON: ${input}`);
1072
+ humanError(` ${e instanceof Error ? e.message : String(e)}`);
1073
+ process.exit(1);
1074
+ }
1075
+ // Validate schema
1076
+ const { MessageSchema } = await import('./schema/message.js');
1077
+ const messages = Array.isArray(data)
1078
+ ? data
1079
+ : data.messages || [];
1080
+ let validCount = 0;
1081
+ const errors = [];
1082
+ for (let i = 0; i < messages.length; i++) {
1083
+ const result = MessageSchema.safeParse(messages[i]);
1084
+ if (result.success) {
1085
+ validCount++;
1086
+ }
1087
+ else {
1088
+ result.error.errors.forEach((err) => {
1089
+ errors.push({
1090
+ index: i,
1091
+ path: err.path.join('.'),
1092
+ message: err.message,
1093
+ });
1094
+ });
1095
+ }
1096
+ }
1097
+ // Output results
1098
+ if (!quiet) {
1099
+ humanInfo('📊 Validation Results:');
1100
+ humanInfo(` Valid: ${validCount}/${messages.length}`);
1101
+ }
1102
+ logEvent('validate-results', {
1103
+ command: 'validate',
1104
+ phase: 'progress',
1105
+ metrics: {
1106
+ valid: validCount,
1107
+ total: messages.length,
1108
+ errors: errors.length,
1109
+ },
1110
+ options: { quiet },
1111
+ });
1112
+ if (errors.length === 0) {
1113
+ humanInfo(`✅ All ${messages.length} messages are valid`);
1114
+ logEvent('validate-summary', {
1115
+ command: 'validate',
1116
+ phase: 'summary',
1117
+ metrics: { valid: validCount, total: messages.length },
1118
+ exitCode: 0,
1119
+ });
1120
+ process.exit(0);
1121
+ }
1122
+ else {
1123
+ humanError(`❌ ${errors.length} validation error(s) found`);
1124
+ logEvent('validate-error-summary', {
1125
+ command: 'validate',
1126
+ phase: 'error',
1127
+ metrics: {
1128
+ valid: validCount,
1129
+ total: messages.length,
1130
+ errors: errors.length,
1131
+ },
1132
+ exitCode: 1,
1133
+ });
1134
+ if (!quiet) {
1135
+ const grouped = new Map();
1136
+ errors.forEach((err) => {
1137
+ if (!grouped.has(err.index))
1138
+ grouped.set(err.index, []);
1139
+ grouped
1140
+ .get(err.index)
1141
+ .push({ path: err.path, message: err.message });
1142
+ });
1143
+ let shown = 0;
1144
+ grouped.forEach((errs, index) => {
1145
+ if (shown < 10) {
1146
+ humanError(`\n Message ${index}:`);
1147
+ errs.forEach((err) => {
1148
+ humanError(` ${err.path || 'root'}: ${err.message}`);
1149
+ shown++;
1150
+ if (shown >= 10)
1151
+ return;
1152
+ });
1153
+ }
1154
+ });
1155
+ if (shown < errors.length) {
1156
+ humanError(`\n ... and ${errors.length - shown} more errors`);
1157
+ }
1158
+ }
1159
+ process.exit(1);
1160
+ }
1161
+ }
1162
+ catch (error) {
1163
+ humanError('❌ Validation failed:', error instanceof Error ? error.message : String(error));
1164
+ if (program.opts().verbose && error instanceof Error) {
1165
+ humanError(error.stack);
1166
+ }
1167
+ const errorMeta = {
1168
+ type: error instanceof Error ? error.name : 'Unknown',
1169
+ message: error instanceof Error ? error.message : String(error),
1170
+ ...(error instanceof Error && error.stack
1171
+ ? { stack: error.stack }
1172
+ : {}),
1173
+ };
1174
+ logEvent('validate-runtime-error', {
1175
+ command: 'validate',
1176
+ phase: 'error',
1177
+ error: errorMeta,
1178
+ exitCode: 2,
1179
+ });
1180
+ process.exit(2);
1181
+ }
1182
+ });
1183
+ // CLI--T05-AC02: stats command to show message counts by type
1184
+ program
1185
+ .command('stats')
1186
+ .description('Show statistics for message file')
1187
+ .requiredOption('-i, --input <path>', 'path to message JSON file')
1188
+ .option('-v, --verbose', 'show detailed statistics', false)
1189
+ .action(async (options) => {
1190
+ const { input } = options;
1191
+ const verbose = program.opts().verbose || options.verbose;
1192
+ applyLogLevel(verbose, program.opts().quiet);
1193
+ logEvent('stats-start', {
1194
+ command: 'stats',
1195
+ phase: 'start',
1196
+ options: { input },
1197
+ });
1198
+ try {
1199
+ const fs = await import('node:fs');
1200
+ // Validate input file exists
1201
+ if (!fs.existsSync(input)) {
1202
+ humanError(`❌ Input file not found: ${input}`);
1203
+ process.exit(1);
1204
+ }
1205
+ // Load and parse JSON
1206
+ const content = fs.readFileSync(input, 'utf-8');
1207
+ let data;
1208
+ try {
1209
+ data = JSON.parse(content);
1210
+ }
1211
+ catch {
1212
+ humanError(`❌ Invalid JSON: ${input}`);
1213
+ process.exit(1);
1214
+ }
1215
+ // Extract messages
1216
+ const messages = Array.isArray(data)
1217
+ ? data
1218
+ : data.messages || [];
1219
+ // Count by messageKind
1220
+ const stats = {
1221
+ total: messages.length,
1222
+ text: 0,
1223
+ media: 0,
1224
+ tapback: 0,
1225
+ notification: 0,
1226
+ withMedia: 0,
1227
+ withEnrichment: 0,
1228
+ dateRange: { min: null, max: null },
1229
+ };
1230
+ const senders = new Set();
1231
+ let totalEnrichments = 0;
1232
+ messages.forEach((msg) => {
1233
+ if (msg.messageKind === 'text')
1234
+ stats.text++;
1235
+ if (msg.messageKind === 'media')
1236
+ stats.media++;
1237
+ if (msg.messageKind === 'tapback')
1238
+ stats.tapback++;
1239
+ if (msg.messageKind === 'notification')
1240
+ stats.notification++;
1241
+ if (msg.media) {
1242
+ stats.withMedia++;
1243
+ if (msg.media.enrichment && Array.isArray(msg.media.enrichment)) {
1244
+ stats.withEnrichment++;
1245
+ totalEnrichments += msg.media.enrichment.length;
1246
+ }
1247
+ }
1248
+ const sender = msg.handle ?? (msg.isFromMe ? 'Me' : 'Unknown');
1249
+ senders.add(sender);
1250
+ if (msg.date) {
1251
+ if (!stats.dateRange.min || msg.date < stats.dateRange.min) {
1252
+ stats.dateRange.min = msg.date;
1253
+ }
1254
+ if (!stats.dateRange.max || msg.date > stats.dateRange.max) {
1255
+ stats.dateRange.max = msg.date;
1256
+ }
1257
+ }
1258
+ });
1259
+ // Output summary
1260
+ humanInfo('📊 Message Statistics');
1261
+ humanInfo(`\n Total messages: ${stats.total.toLocaleString()}`);
1262
+ humanInfo('\n Message Types:');
1263
+ humanInfo(` Text: ${stats.text}`);
1264
+ humanInfo(` Media: ${stats.media}`);
1265
+ humanInfo(` Tapbacks: ${stats.tapback}`);
1266
+ humanInfo(` Notifications: ${stats.notification}`);
1267
+ humanInfo('\n Enrichment:');
1268
+ humanInfo(` Messages with media: ${stats.withMedia}`);
1269
+ humanInfo(` Messages with enrichment: ${stats.withEnrichment}`);
1270
+ if (stats.withEnrichment > 0) {
1271
+ humanInfo(` Total enrichments: ${totalEnrichments}`);
1272
+ humanInfo(` Avg enrichments per message: ${(totalEnrichments / stats.withEnrichment).toFixed(2)}`);
1273
+ }
1274
+ humanInfo('\n Date Range:');
1275
+ if (stats.dateRange.min && stats.dateRange.max) {
1276
+ humanInfo(` From: ${stats.dateRange.min}`);
1277
+ humanInfo(` To: ${stats.dateRange.max}`);
1278
+ const minDate = new Date(stats.dateRange.min);
1279
+ const maxDate = new Date(stats.dateRange.max);
1280
+ const days = Math.floor((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24));
1281
+ humanInfo(` Duration: ${days + 1} days`);
1282
+ }
1283
+ else {
1284
+ humanInfo(' None');
1285
+ }
1286
+ if (verbose) {
1287
+ humanInfo(`\n Participants: ${senders.size}`);
1288
+ if (senders.size > 0 && senders.size <= 20) {
1289
+ Array.from(senders)
1290
+ .sort()
1291
+ .forEach((sender) => {
1292
+ const count = messages.filter((m) => {
1293
+ const msgSender = m.handle ?? (m.isFromMe ? 'Me' : 'Unknown');
1294
+ return msgSender === sender;
1295
+ }).length;
1296
+ humanInfo(` ${sender}: ${count}`);
1297
+ });
1298
+ }
1299
+ }
1300
+ logEvent('stats-summary', {
1301
+ command: 'stats',
1302
+ phase: 'summary',
1303
+ metrics: {
1304
+ total: stats.total,
1305
+ text: stats.text,
1306
+ media: stats.media,
1307
+ tapback: stats.tapback,
1308
+ notification: stats.notification,
1309
+ withMedia: stats.withMedia,
1310
+ withEnrichment: stats.withEnrichment,
1311
+ participants: senders.size,
1312
+ },
1313
+ options: { input, verbose },
1314
+ exitCode: 0,
1315
+ });
1316
+ process.exit(0);
1317
+ }
1318
+ catch (error) {
1319
+ humanError('❌ Stats failed:', error instanceof Error ? error.message : String(error));
1320
+ if (program.opts().verbose && error instanceof Error) {
1321
+ humanError(error.stack);
1322
+ }
1323
+ const errorMeta = {
1324
+ type: error instanceof Error ? error.name : 'Unknown',
1325
+ message: error instanceof Error ? error.message : String(error),
1326
+ ...(error instanceof Error && error.stack
1327
+ ? { stack: error.stack }
1328
+ : {}),
1329
+ };
1330
+ logEvent('stats-error', {
1331
+ command: 'stats',
1332
+ phase: 'error',
1333
+ error: errorMeta,
1334
+ options: { input, verbose },
1335
+ exitCode: 2,
1336
+ });
1337
+ process.exit(2);
1338
+ }
1339
+ });
1340
+ // CLI--T05-AC03: clean command to remove checkpoints and temp files
1341
+ program
1342
+ .command('clean')
1343
+ .description('Remove temporary files and checkpoints')
1344
+ .option('-c, --checkpoint-dir <path>', 'checkpoint directory to clean', './.checkpoints')
1345
+ .option('-f, --force', 'remove without confirmation', false)
1346
+ .option('--all', 'also remove backup files (.backup, .old)', false)
1347
+ .action(async (options) => {
1348
+ const { checkpointDir, force, all } = options;
1349
+ const verbose = program.opts().verbose;
1350
+ applyLogLevel(verbose, program.opts().quiet);
1351
+ try {
1352
+ const fs = await import('node:fs');
1353
+ const path = await import('node:path');
1354
+ // Check if checkpoint dir exists
1355
+ if (!fs.existsSync(checkpointDir)) {
1356
+ if (verbose) {
1357
+ humanInfo(`ℹ️ Checkpoint directory not found: ${checkpointDir}`);
1358
+ }
1359
+ logEvent('clean-missing-dir', {
1360
+ command: 'clean',
1361
+ phase: 'progress',
1362
+ options: { checkpointDir },
1363
+ });
1364
+ process.exit(0);
1365
+ }
1366
+ if (!fs.statSync(checkpointDir).isDirectory()) {
1367
+ humanError(`❌ Not a directory: ${checkpointDir}`);
1368
+ process.exit(1);
1369
+ }
1370
+ // List files to be removed
1371
+ const files = fs.readdirSync(checkpointDir);
1372
+ const toRemove = files.filter((f) => f.startsWith('enrich-checkpoint-') ||
1373
+ (all && (f.endsWith('.backup') || f.endsWith('.old'))));
1374
+ if (toRemove.length === 0) {
1375
+ humanInfo('ℹ️ No checkpoint files to clean');
1376
+ logEvent('clean-none', {
1377
+ command: 'clean',
1378
+ phase: 'summary',
1379
+ metrics: { removed: 0 },
1380
+ options: { checkpointDir },
1381
+ exitCode: 0,
1382
+ });
1383
+ process.exit(0);
1384
+ }
1385
+ // Show what will be removed
1386
+ humanInfo('♻️ Files to be removed:');
1387
+ toRemove.forEach((f) => {
1388
+ const filepath = path.join(checkpointDir, f);
1389
+ const size = fs.statSync(filepath).size;
1390
+ humanInfo(` ${f} (${(size / 1024).toFixed(1)} KB)`);
1391
+ });
1392
+ logEvent('clean-list', {
1393
+ command: 'clean',
1394
+ phase: 'progress',
1395
+ metrics: { count: toRemove.length },
1396
+ options: { checkpointDir, all },
1397
+ });
1398
+ if (!force) {
1399
+ humanInfo('\nRun with --force to remove these files');
1400
+ logEvent('clean-requires-force', {
1401
+ command: 'clean',
1402
+ phase: 'progress',
1403
+ options: { checkpointDir, all },
1404
+ });
1405
+ process.exit(0);
1406
+ }
1407
+ // Remove files
1408
+ let removed = 0;
1409
+ toRemove.forEach((f) => {
1410
+ const filepath = path.join(checkpointDir, f);
1411
+ try {
1412
+ fs.unlinkSync(filepath);
1413
+ removed++;
1414
+ if (verbose) {
1415
+ humanInfo(`✓ Removed ${f}`);
1416
+ logEvent('clean-file-removed', {
1417
+ command: 'clean',
1418
+ phase: 'progress',
1419
+ context: { file: f },
1420
+ });
1421
+ }
1422
+ }
1423
+ catch {
1424
+ humanWarn(`⚠️ Failed to remove ${f}`);
1425
+ logEvent('clean-file-remove-failed', {
1426
+ command: 'clean',
1427
+ phase: 'warning',
1428
+ context: { file: f },
1429
+ });
1430
+ }
1431
+ });
1432
+ humanInfo(`✅ Cleaned ${removed} checkpoint file(s)`);
1433
+ logEvent('clean-summary', {
1434
+ command: 'clean',
1435
+ phase: 'summary',
1436
+ metrics: { removed },
1437
+ options: { checkpointDir, all },
1438
+ exitCode: 0,
1439
+ });
1440
+ process.exit(0);
1441
+ }
1442
+ catch (error) {
1443
+ humanError('❌ Clean failed:', error instanceof Error ? error.message : String(error));
1444
+ if (program.opts().verbose && error instanceof Error) {
1445
+ humanError(error.stack);
1446
+ }
1447
+ const errorMeta = {
1448
+ type: error instanceof Error ? error.name : 'Unknown',
1449
+ message: error instanceof Error ? error.message : String(error),
1450
+ ...(error instanceof Error && error.stack
1451
+ ? { stack: error.stack }
1452
+ : {}),
1453
+ };
1454
+ logEvent('clean-error', {
1455
+ command: 'clean',
1456
+ phase: 'error',
1457
+ error: errorMeta,
1458
+ options: { checkpointDir, all },
1459
+ exitCode: 2,
1460
+ });
1461
+ process.exit(2);
1462
+ }
1463
+ });
1464
+ // CLI--T05-AC04: doctor command to diagnose common issues
1465
+ program
1466
+ .command('doctor')
1467
+ .description('Diagnose common configuration issues')
1468
+ .option('-v, --verbose', 'show detailed diagnostics', false)
1469
+ .action(async (options) => {
1470
+ const verbose = program.opts().verbose || options.verbose;
1471
+ applyLogLevel(verbose, program.opts().quiet);
1472
+ try {
1473
+ const fs = await import('node:fs');
1474
+ const path = await import('node:path');
1475
+ const os = await import('node:os');
1476
+ humanInfo('🔍 iMessage Timeline Diagnostics\n');
1477
+ logEvent('doctor-start', { command: 'doctor', phase: 'start' });
1478
+ const checks = [];
1479
+ // Check 1: Node version
1480
+ const nodeVersion = process.version;
1481
+ const nodeMajor = Number.parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
1482
+ const nodeOk = nodeMajor >= 18;
1483
+ checks.push({
1484
+ name: 'Node.js version',
1485
+ pass: nodeOk,
1486
+ message: `${nodeVersion} ${nodeOk ? '✓' : '(requires ≥18)'}`,
1487
+ });
1488
+ // Check 2: Current directory
1489
+ const cwd = process.cwd();
1490
+ const packageJsonExists = fs.existsSync(path.join(cwd, 'package.json'));
1491
+ checks.push({
1492
+ name: 'package.json',
1493
+ pass: packageJsonExists,
1494
+ message: packageJsonExists
1495
+ ? `Found in ${cwd}`
1496
+ : 'Not found in current directory',
1497
+ });
1498
+ // Check 3: Config file
1499
+ const configFormats = [
1500
+ 'imessage-config.yaml',
1501
+ 'imessage-config.yml',
1502
+ 'imessage-config.json',
1503
+ ];
1504
+ const foundConfig = configFormats.find((f) => fs.existsSync(path.join(cwd, f)));
1505
+ checks.push({
1506
+ name: 'Config file',
1507
+ pass: Boolean(foundConfig),
1508
+ message: foundConfig
1509
+ ? `Found: ${foundConfig}`
1510
+ : 'Not found (run: imessage-timeline init)',
1511
+ });
1512
+ // Check 4: API Keys
1513
+ const geminiKey = process.env.GEMINI_API_KEY;
1514
+ const firecrawlKey = process.env.FIRECRAWL_API_KEY;
1515
+ checks.push({
1516
+ name: 'GEMINI_API_KEY',
1517
+ pass: Boolean(geminiKey),
1518
+ message: geminiKey
1519
+ ? 'Set'
1520
+ : 'Not set (required for image/audio enrichment)',
1521
+ });
1522
+ checks.push({
1523
+ name: 'FIRECRAWL_API_KEY',
1524
+ pass: Boolean(firecrawlKey),
1525
+ message: firecrawlKey
1526
+ ? 'Set'
1527
+ : 'Not set (optional, improves link enrichment - get from firecrawl.dev)',
1528
+ });
1529
+ // Check 5: Default attachment directory
1530
+ const defaultAttachDir = path.join(os.homedir(), 'Library', 'Messages', 'Attachments');
1531
+ const attachDirExists = fs.existsSync(defaultAttachDir);
1532
+ checks.push({
1533
+ name: 'Messages attachments',
1534
+ pass: attachDirExists,
1535
+ message: attachDirExists
1536
+ ? `Found: ${defaultAttachDir}`
1537
+ : `Not found: ${defaultAttachDir}`,
1538
+ });
1539
+ // Check 6: Output directory permission
1540
+ const canWrite = (() => {
1541
+ try {
1542
+ const testFile = path.join(cwd, '.test-write');
1543
+ fs.writeFileSync(testFile, 'test');
1544
+ fs.unlinkSync(testFile);
1545
+ return true;
1546
+ }
1547
+ catch {
1548
+ return false;
1549
+ }
1550
+ })();
1551
+ checks.push({
1552
+ name: 'Write permission',
1553
+ pass: canWrite,
1554
+ message: canWrite
1555
+ ? `Can write to ${cwd}`
1556
+ : `Cannot write to ${cwd} (check permissions)`,
1557
+ });
1558
+ // Print results
1559
+ let passCount = 0;
1560
+ checks.forEach((check) => {
1561
+ const icon = check.pass ? '✅' : '⚠️ ';
1562
+ humanInfo(`${icon} ${check.name.padEnd(25)} ${check.message}`);
1563
+ if (check.pass)
1564
+ passCount++;
1565
+ logEvent('doctor-check', {
1566
+ command: 'doctor',
1567
+ phase: 'progress',
1568
+ context: { name: check.name },
1569
+ metrics: { pass: check.pass },
1570
+ message: check.message,
1571
+ });
1572
+ });
1573
+ humanInfo(`\n📊 Summary: ${passCount}/${checks.length} checks passed`);
1574
+ logEvent('doctor-summary', {
1575
+ command: 'doctor',
1576
+ phase: 'summary',
1577
+ metrics: { passed: passCount, total: checks.length },
1578
+ });
1579
+ // Recommendations
1580
+ const failures = checks.filter((c) => !c.pass);
1581
+ if (failures.length > 0) {
1582
+ humanInfo('\n💡 Recommendations:');
1583
+ failures.forEach((check) => {
1584
+ if (check.name === 'Config file') {
1585
+ humanInfo(' • Run: imessage-timeline init');
1586
+ }
1587
+ else if (check.name === 'GEMINI_API_KEY') {
1588
+ humanInfo(' • Get API key from: https://ai.google.dev/tutorials/setup');
1589
+ humanInfo(' • Set: export GEMINI_API_KEY=your_key');
1590
+ }
1591
+ else if (check.name === 'FIRECRAWL_API_KEY') {
1592
+ humanInfo(' • (Optional) Get from: https://www.firecrawl.dev');
1593
+ }
1594
+ logEvent('doctor-recommendation', {
1595
+ command: 'doctor',
1596
+ phase: 'progress',
1597
+ context: { name: check.name },
1598
+ message: check.message,
1599
+ });
1600
+ });
1601
+ }
1602
+ if (verbose) {
1603
+ humanInfo('\n📝 Environment:');
1604
+ humanInfo(` Platform: ${os.platform()}`);
1605
+ humanInfo(` Arch: ${os.arch()}`);
1606
+ humanInfo(` Home: ${os.homedir()}`);
1607
+ humanInfo(` CWD: ${cwd}`);
1608
+ logEvent('doctor-environment', {
1609
+ command: 'doctor',
1610
+ phase: 'progress',
1611
+ context: {
1612
+ platform: os.platform(),
1613
+ arch: os.arch(),
1614
+ home: os.homedir(),
1615
+ cwd,
1616
+ },
1617
+ });
1618
+ }
1619
+ logEvent('doctor-exit', {
1620
+ command: 'doctor',
1621
+ phase: 'summary',
1622
+ metrics: { failures: failures.length },
1623
+ exitCode: failures.length > 0 ? 1 : 0,
1624
+ });
1625
+ process.exit(failures.length > 0 ? 1 : 0);
1626
+ }
1627
+ catch (error) {
1628
+ humanError('❌ Doctor failed:', error instanceof Error ? error.message : String(error));
1629
+ if (program.opts().verbose && error instanceof Error) {
1630
+ humanError(error.stack);
1631
+ }
1632
+ const errorMeta = {
1633
+ type: error instanceof Error ? error.name : 'Unknown',
1634
+ message: error instanceof Error ? error.message : String(error),
1635
+ ...(error instanceof Error && error.stack
1636
+ ? { stack: error.stack }
1637
+ : {}),
1638
+ };
1639
+ logEvent('doctor-error', {
1640
+ command: 'doctor',
1641
+ phase: 'error',
1642
+ error: errorMeta,
1643
+ exitCode: 2,
1644
+ });
1645
+ process.exit(2);
1646
+ }
1647
+ });
1648
+ // ============================================================================
1649
+ // Config generation command (CONFIG--T03)
1650
+ // ============================================================================
1651
+ program
1652
+ .command('init')
1653
+ .description('Generate starter configuration file')
1654
+ .option('-f, --format <type>', 'config file format (json|yaml)', 'yaml')
1655
+ .option('--force', 'overwrite existing config file without prompting', false)
1656
+ .option('-o, --output <path>', 'output file path (default: auto-detected from format)')
1657
+ .action(async (options) => {
1658
+ const { format, force, output } = options;
1659
+ // Validate format
1660
+ if (format !== 'json' && format !== 'yaml') {
1661
+ humanError(`❌ Invalid format: ${format}`);
1662
+ humanError('Supported formats: json, yaml');
1663
+ process.exit(1);
1664
+ }
1665
+ try {
1666
+ // Lazy import to avoid circular dependencies
1667
+ const { generateConfigFile, getDefaultConfigPath, configFileExists } = await import('./config/generator.js');
1668
+ // Determine output path
1669
+ const filePath = output || getDefaultConfigPath(format);
1670
+ // CONFIG-T03-AC04: Check for existing file and prompt if needed
1671
+ const exists = await configFileExists(filePath);
1672
+ if (exists && !force) {
1673
+ humanError(`❌ Config file already exists: ${filePath}`);
1674
+ humanError('\nOptions:');
1675
+ humanError(' • Use --force to overwrite');
1676
+ humanError(' • Use --output to specify different path');
1677
+ humanError(' • Manually remove the existing file');
1678
+ process.exit(1);
1679
+ }
1680
+ // CONFIG-T03-AC01, AC02, AC03: Generate config file
1681
+ const result = await generateConfigFile({
1682
+ filePath,
1683
+ format,
1684
+ force,
1685
+ });
1686
+ if (result.success) {
1687
+ humanInfo(result.message);
1688
+ humanInfo('\n📝 Next steps:');
1689
+ humanInfo(` 1. Edit ${filePath} to add your API keys`);
1690
+ humanInfo(' 2. Set GEMINI_API_KEY environment variable');
1691
+ humanInfo(' 3. (Optional) Set FIRECRAWL_API_KEY for enhanced link scraping');
1692
+ humanInfo('\n💡 See inline comments in the config file for details');
1693
+ logEvent('init-summary', {
1694
+ command: 'init',
1695
+ phase: 'summary',
1696
+ options: { format, filePath, force },
1697
+ message: result.message,
1698
+ exitCode: 0,
1699
+ });
1700
+ process.exit(0);
1701
+ }
1702
+ else {
1703
+ humanError(`❌ ${result.message}`);
1704
+ logEvent('init-error', {
1705
+ command: 'init',
1706
+ phase: 'error',
1707
+ options: { format, filePath, force },
1708
+ message: result.message,
1709
+ exitCode: 1,
1710
+ });
1711
+ process.exit(1);
1712
+ }
1713
+ }
1714
+ catch (error) {
1715
+ humanError('❌ Failed to generate config:', error instanceof Error ? error.message : String(error));
1716
+ if (program.opts().verbose && error instanceof Error) {
1717
+ humanError(error.stack);
1718
+ }
1719
+ const errorMeta = {
1720
+ type: error instanceof Error ? error.name : 'Unknown',
1721
+ message: error instanceof Error ? error.message : String(error),
1722
+ ...(error instanceof Error && error.stack
1723
+ ? { stack: error.stack }
1724
+ : {}),
1725
+ };
1726
+ logEvent('init-runtime-error', {
1727
+ command: 'init',
1728
+ phase: 'error',
1729
+ error: errorMeta,
1730
+ options: { format, output, force },
1731
+ exitCode: 2,
1732
+ });
1733
+ process.exit(2);
1734
+ }
1735
+ });
1736
+ // ============================================================================
1737
+ // CLI--T01-AC04: Proper exit codes (0=success, 1=validation, 2=runtime)
1738
+ // ============================================================================
1739
+ // Global error handler for uncaught errors (guard to prevent duplicate listeners)
1740
+ const globalAny = globalThis;
1741
+ const listenersFlag = '__IMESSAGE_CLI_LISTENERS_ATTACHED__';
1742
+ if (!globalAny[listenersFlag]) {
1743
+ globalAny[listenersFlag] = true;
1744
+ process.on('uncaughtException', (err) => {
1745
+ humanError('❌ Fatal Error:', err.message);
1746
+ if (program.opts().verbose) {
1747
+ humanError(err.stack);
1748
+ }
1749
+ logEvent('fatal-error', {
1750
+ command: 'global',
1751
+ phase: 'error',
1752
+ error: {
1753
+ type: err.name,
1754
+ message: err.message,
1755
+ ...(err.stack ? { stack: err.stack } : {}),
1756
+ },
1757
+ exitCode: 2,
1758
+ });
1759
+ process.exit(2); // Runtime error
1760
+ });
1761
+ process.on('unhandledRejection', (reason) => {
1762
+ humanError('❌ Unhandled Promise Rejection:', reason);
1763
+ logEvent('unhandled-rejection', {
1764
+ command: 'global',
1765
+ phase: 'error',
1766
+ error: { message: String(reason) },
1767
+ exitCode: 2,
1768
+ });
1769
+ process.exit(2); // Runtime error
1770
+ });
1771
+ }
1772
+ // ============================================================================
1773
+ // Main Execution
1774
+ // ============================================================================
1775
+ async function main() {
1776
+ try {
1777
+ await program.parseAsync(process.argv);
1778
+ }
1779
+ catch (error) {
1780
+ if (error instanceof Error) {
1781
+ humanError(`❌ Error: ${error.message}`);
1782
+ if (program.opts().verbose) {
1783
+ humanError(error.stack);
1784
+ }
1785
+ }
1786
+ else {
1787
+ humanError('❌ Unknown error:', error);
1788
+ }
1789
+ logEvent('cli-runtime-error', {
1790
+ command: 'global',
1791
+ phase: 'error',
1792
+ error: {
1793
+ type: error instanceof Error ? error.name : 'Unknown',
1794
+ message: error instanceof Error ? error.message : String(error),
1795
+ ...(error instanceof Error && error.stack
1796
+ ? { stack: error.stack }
1797
+ : {}),
1798
+ },
1799
+ exitCode: 1,
1800
+ });
1801
+ process.exit(1);
1802
+ }
1803
+ }
1804
+ void main();
1805
+ //# sourceMappingURL=cli.js.map