optimal-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +175 -0
  2. package/dist/bin/optimal.d.ts +2 -0
  3. package/dist/bin/optimal.js +995 -0
  4. package/dist/lib/budget/projections.d.ts +115 -0
  5. package/dist/lib/budget/projections.js +384 -0
  6. package/dist/lib/budget/scenarios.d.ts +93 -0
  7. package/dist/lib/budget/scenarios.js +214 -0
  8. package/dist/lib/cms/publish-blog.d.ts +62 -0
  9. package/dist/lib/cms/publish-blog.js +74 -0
  10. package/dist/lib/cms/strapi-client.d.ts +123 -0
  11. package/dist/lib/cms/strapi-client.js +213 -0
  12. package/dist/lib/config.d.ts +55 -0
  13. package/dist/lib/config.js +206 -0
  14. package/dist/lib/infra/deploy.d.ts +29 -0
  15. package/dist/lib/infra/deploy.js +58 -0
  16. package/dist/lib/infra/migrate.d.ts +34 -0
  17. package/dist/lib/infra/migrate.js +103 -0
  18. package/dist/lib/kanban.d.ts +46 -0
  19. package/dist/lib/kanban.js +118 -0
  20. package/dist/lib/newsletter/distribute.d.ts +52 -0
  21. package/dist/lib/newsletter/distribute.js +193 -0
  22. package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
  23. package/dist/lib/newsletter/generate-insurance.js +36 -0
  24. package/dist/lib/newsletter/generate.d.ts +104 -0
  25. package/dist/lib/newsletter/generate.js +571 -0
  26. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  27. package/dist/lib/returnpro/anomalies.js +166 -0
  28. package/dist/lib/returnpro/audit.d.ts +32 -0
  29. package/dist/lib/returnpro/audit.js +147 -0
  30. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  31. package/dist/lib/returnpro/diagnose.js +281 -0
  32. package/dist/lib/returnpro/kpis.d.ts +32 -0
  33. package/dist/lib/returnpro/kpis.js +192 -0
  34. package/dist/lib/returnpro/templates.d.ts +48 -0
  35. package/dist/lib/returnpro/templates.js +229 -0
  36. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  37. package/dist/lib/returnpro/upload-income.js +235 -0
  38. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  39. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  40. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  41. package/dist/lib/returnpro/upload-r1.js +398 -0
  42. package/dist/lib/social/post-generator.d.ts +83 -0
  43. package/dist/lib/social/post-generator.js +333 -0
  44. package/dist/lib/social/publish.d.ts +66 -0
  45. package/dist/lib/social/publish.js +226 -0
  46. package/dist/lib/social/scraper.d.ts +67 -0
  47. package/dist/lib/social/scraper.js +361 -0
  48. package/dist/lib/supabase.d.ts +4 -0
  49. package/dist/lib/supabase.js +20 -0
  50. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  51. package/dist/lib/transactions/delete-batch.js +203 -0
  52. package/dist/lib/transactions/ingest.d.ts +43 -0
  53. package/dist/lib/transactions/ingest.js +555 -0
  54. package/dist/lib/transactions/stamp.d.ts +51 -0
  55. package/dist/lib/transactions/stamp.js +524 -0
  56. package/package.json +50 -0
@@ -0,0 +1,995 @@
1
+ #!/usr/bin/env tsx
2
+ import { Command } from 'commander';
3
+ import 'dotenv/config';
4
+ import { getBoard, createTask, updateTask, logActivity, } from '../lib/kanban.js';
5
+ import { runAuditComparison } from '../lib/returnpro/audit.js';
6
+ import { exportKpis, formatKpiTable, formatKpiCsv } from '../lib/returnpro/kpis.js';
7
+ import { deploy, healthCheck, listApps } from '../lib/infra/deploy.js';
8
+ import { fetchWesImports, parseSummaryFromJson, initializeProjections, applyUniformAdjustment, calculateTotals, exportToCSV, formatProjectionTable, } from '../lib/budget/projections.js';
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { generateNewsletter } from '../lib/newsletter/generate.js';
11
+ import { scrapeCompanies, formatCsv } from '../lib/social/scraper.js';
12
+ import { ingestTransactions } from '../lib/transactions/ingest.js';
13
+ import { stampTransactions } from '../lib/transactions/stamp.js';
14
+ import { processR1Upload } from '../lib/returnpro/upload-r1.js';
15
+ import { processNetSuiteUpload } from '../lib/returnpro/upload-netsuite.js';
16
+ import { uploadIncomeStatements } from '../lib/returnpro/upload-income.js';
17
+ import { detectRateAnomalies } from '../lib/returnpro/anomalies.js';
18
+ import { diagnoseMonths } from '../lib/returnpro/diagnose.js';
19
+ import { generateNetSuiteTemplate } from '../lib/returnpro/templates.js';
20
+ import { distributeNewsletter, checkDistributionStatus } from '../lib/newsletter/distribute.js';
21
+ import { generateSocialPosts } from '../lib/social/post-generator.js';
22
+ import { publishSocialPosts, getPublishQueue, retryFailed } from '../lib/social/publish.js';
23
+ import { publishBlog, listBlogDrafts } from '../lib/cms/publish-blog.js';
24
+ import { migrateDb, listPendingMigrations, createMigration } from '../lib/infra/migrate.js';
25
+ import { saveScenario, listScenarios, compareScenarios, deleteScenario } from '../lib/budget/scenarios.js';
26
+ import { deleteBatch, previewBatch } from '../lib/transactions/delete-batch.js';
27
+ import { pushConfig, pullConfig, listConfigs, diffConfig, syncConfig } from '../lib/config.js';
28
+ const program = new Command()
29
+ .name('optimal')
30
+ .description('Optimal CLI — unified skills for financial analytics, content, and infra')
31
+ .version('0.1.0');
32
+ // Board commands
33
+ const board = program.command('board').description('Kanban board operations');
34
+ board
35
+ .command('view')
36
+ .description('Display the kanban board')
37
+ .option('-p, --project <slug>', 'Project slug', 'optimal-cli-refactor')
38
+ .option('-s, --status <status>', 'Filter by status')
39
+ .action(async (opts) => {
40
+ let tasks = await getBoard(opts.project);
41
+ if (opts.status)
42
+ tasks = tasks.filter(t => t.status === opts.status);
43
+ const grouped = new Map();
44
+ for (const t of tasks) {
45
+ const list = grouped.get(t.status) ?? [];
46
+ list.push(t);
47
+ grouped.set(t.status, list);
48
+ }
49
+ const order = ['in_progress', 'blocked', 'ready', 'backlog', 'review', 'done'];
50
+ console.log('| Status | P | Title | Agent | Skill |');
51
+ console.log('|--------|---|-------|-------|-------|');
52
+ for (const status of order) {
53
+ const list = grouped.get(status) ?? [];
54
+ for (const t of list) {
55
+ console.log(`| ${t.status} | ${t.priority} | ${t.title} | ${t.assigned_agent ?? '—'} | ${t.skill_ref ?? '—'} |`);
56
+ }
57
+ }
58
+ console.log(`\nTotal: ${tasks.length} tasks`);
59
+ });
60
+ board
61
+ .command('create')
62
+ .description('Create a new task')
63
+ .requiredOption('-t, --title <title>', 'Task title')
64
+ .option('-p, --project <slug>', 'Project slug', 'optimal-cli-refactor')
65
+ .option('-d, --description <desc>', 'Task description')
66
+ .option('--priority <n>', 'Priority 1-4', '3')
67
+ .option('--skill <ref>', 'Skill reference')
68
+ .option('--labels <labels>', 'Comma-separated labels')
69
+ .action(async (opts) => {
70
+ const task = await createTask({
71
+ project_slug: opts.project,
72
+ title: opts.title,
73
+ description: opts.description,
74
+ priority: parseInt(opts.priority),
75
+ skill_ref: opts.skill,
76
+ labels: opts.labels?.split(',').map((l) => l.trim()),
77
+ });
78
+ console.log(`Created task: ${task.id} — "${task.title}" (priority ${task.priority}, status ${task.status})`);
79
+ });
80
+ board
81
+ .command('update')
82
+ .description('Update a task')
83
+ .requiredOption('--id <taskId>', 'Task UUID')
84
+ .option('-s, --status <status>', 'New status')
85
+ .option('-a, --agent <name>', 'Assign to agent')
86
+ .option('--priority <n>', 'New priority')
87
+ .option('-m, --message <msg>', 'Log message')
88
+ .action(async (opts) => {
89
+ const updates = {};
90
+ if (opts.status)
91
+ updates.status = opts.status;
92
+ if (opts.agent)
93
+ updates.assigned_agent = opts.agent;
94
+ if (opts.priority)
95
+ updates.priority = parseInt(opts.priority);
96
+ const task = await updateTask(opts.id, updates);
97
+ if (opts.message) {
98
+ await logActivity(opts.id, {
99
+ agent: opts.agent ?? 'cli',
100
+ action: 'status_change',
101
+ message: opts.message,
102
+ });
103
+ }
104
+ console.log(`Updated task ${task.id}: status → ${task.status}, agent → ${task.assigned_agent ?? '—'}`);
105
+ });
106
+ // Audit financials command
107
+ program
108
+ .command('audit-financials')
109
+ .description('Compare staged financials against confirmed income statements')
110
+ .option('--months <csv>', 'Comma-separated YYYY-MM months to audit (default: all)')
111
+ .option('--tolerance <n>', 'Dollar tolerance for match detection', '1.00')
112
+ .action(async (opts) => {
113
+ const months = opts.months
114
+ ? opts.months.split(',').map((m) => m.trim())
115
+ : undefined;
116
+ const tolerance = parseFloat(opts.tolerance);
117
+ console.log('Fetching financial data...');
118
+ const result = await runAuditComparison(months, tolerance);
119
+ console.log(`\nStaging rows: ${result.totalStagingRows} | Confirmed rows: ${result.totalConfirmedRows}`);
120
+ console.log(`Tolerance: $${tolerance.toFixed(2)}\n`);
121
+ // Header
122
+ console.log('| Month | Confirmed | Staged | Match | SignFlip | Mismatch | C-Only | S-Only | Accuracy |');
123
+ console.log('|---------|-----------|--------|-------|---------|----------|--------|--------|----------|');
124
+ let flagged = false;
125
+ for (const s of result.summaries) {
126
+ const acc = s.accuracy !== null ? `${s.accuracy}%` : 'N/A';
127
+ const warn = s.accuracy !== null && s.accuracy < 100 ? ' *' : '';
128
+ if (warn)
129
+ flagged = true;
130
+ console.log(`| ${s.month} | ${String(s.confirmedAccounts).padStart(9)} | ${String(s.stagedAccounts).padStart(6)} | ${String(s.exactMatch).padStart(5)} | ${String(s.signFlipMatch).padStart(7)} | ${String(s.mismatch).padStart(8)} | ${String(s.confirmedOnly).padStart(6)} | ${String(s.stagingOnly).padStart(6)} | ${(acc + warn).padStart(8)} |`);
131
+ }
132
+ if (flagged) {
133
+ console.log('\n* Months below 100% accuracy — investigate mismatches');
134
+ }
135
+ // Totals row
136
+ if (result.summaries.length > 1) {
137
+ const totals = result.summaries.reduce((acc, s) => ({
138
+ confirmed: acc.confirmed + s.confirmedAccounts,
139
+ staged: acc.staged + s.stagedAccounts,
140
+ exact: acc.exact + s.exactMatch,
141
+ flip: acc.flip + s.signFlipMatch,
142
+ mismatch: acc.mismatch + s.mismatch,
143
+ cOnly: acc.cOnly + s.confirmedOnly,
144
+ sOnly: acc.sOnly + s.stagingOnly,
145
+ }), { confirmed: 0, staged: 0, exact: 0, flip: 0, mismatch: 0, cOnly: 0, sOnly: 0 });
146
+ const totalOverlap = totals.exact + totals.flip + totals.mismatch;
147
+ const totalAcc = totalOverlap > 0
148
+ ? Math.round(((totals.exact + totals.flip) / totalOverlap) * 1000) / 10
149
+ : null;
150
+ console.log(`| TOTAL | ${String(totals.confirmed).padStart(9)} | ${String(totals.staged).padStart(6)} | ${String(totals.exact).padStart(5)} | ${String(totals.flip).padStart(7)} | ${String(totals.mismatch).padStart(8)} | ${String(totals.cOnly).padStart(6)} | ${String(totals.sOnly).padStart(6)} | ${(totalAcc !== null ? `${totalAcc}%` : 'N/A').padStart(8)} |`);
151
+ }
152
+ });
153
+ // Export KPIs command
154
+ program
155
+ .command('export-kpis')
156
+ .description('Export KPI totals by program/client from ReturnPro financial data')
157
+ .option('--months <csv>', 'Comma-separated YYYY-MM months (default: 3 most recent)')
158
+ .option('--programs <csv>', 'Comma-separated program name substrings to filter')
159
+ .option('--format <fmt>', 'Output format: table or csv', 'table')
160
+ .action(async (opts) => {
161
+ const months = opts.months
162
+ ? opts.months.split(',').map((m) => m.trim())
163
+ : undefined;
164
+ const programs = opts.programs
165
+ ? opts.programs.split(',').map((p) => p.trim())
166
+ : undefined;
167
+ const format = opts.format;
168
+ if (format !== 'table' && format !== 'csv') {
169
+ console.error(`Invalid format "${format}". Use "table" or "csv".`);
170
+ process.exit(1);
171
+ }
172
+ console.error('Fetching KPI data...');
173
+ const rows = await exportKpis({ months, programs });
174
+ console.error(`Fetched ${rows.length} KPI rows`);
175
+ if (format === 'csv') {
176
+ console.log(formatKpiCsv(rows));
177
+ }
178
+ else {
179
+ console.log(formatKpiTable(rows));
180
+ }
181
+ });
182
+ // Deploy command
183
+ program
184
+ .command('deploy')
185
+ .description('Deploy an app to Vercel (preview or production)')
186
+ .argument('<app>', `App to deploy (${listApps().join(', ')})`)
187
+ .option('--prod', 'Deploy to production', false)
188
+ .action(async (app, opts) => {
189
+ console.log(`Deploying ${app}${opts.prod ? ' (production)' : ' (preview)'}...`);
190
+ try {
191
+ const url = await deploy(app, opts.prod);
192
+ console.log(`Deployed: ${url}`);
193
+ }
194
+ catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ console.error(`Deploy failed: ${msg}`);
197
+ process.exit(1);
198
+ }
199
+ });
200
+ // Health check command
201
+ program
202
+ .command('health-check')
203
+ .description('Run health check across all Optimal services')
204
+ .action(async () => {
205
+ try {
206
+ const output = await healthCheck();
207
+ console.log(output);
208
+ }
209
+ catch (err) {
210
+ const msg = err instanceof Error ? err.message : String(err);
211
+ console.error(`Health check failed: ${msg}`);
212
+ process.exit(1);
213
+ }
214
+ });
215
+ // Budget projection commands
216
+ async function loadProjectionData(opts) {
217
+ if (opts.file) {
218
+ const raw = readFileSync(opts.file, 'utf-8');
219
+ return parseSummaryFromJson(raw);
220
+ }
221
+ const fy = opts.fiscalYear ? parseInt(opts.fiscalYear) : 2025;
222
+ return fetchWesImports({ fiscalYear: fy, userId: opts.userId });
223
+ }
224
+ function resolveAdjustmentType(raw) {
225
+ if (raw === 'flat')
226
+ return 'flat';
227
+ return 'percentage';
228
+ }
229
+ program
230
+ .command('project-budget')
231
+ .description('Run FY26 budget projections with adjustments on FY25 checked-in units')
232
+ .option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
233
+ .option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
234
+ .option('--format <fmt>', 'Output format: table or csv', 'table')
235
+ .option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
236
+ .option('--user-id <uuid>', 'Supabase user UUID to filter by')
237
+ .option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
238
+ .action(async (opts) => {
239
+ const format = opts.format;
240
+ if (format !== 'table' && format !== 'csv') {
241
+ console.error(`Invalid format "${format}". Use "table" or "csv".`);
242
+ process.exit(1);
243
+ }
244
+ console.error('Loading projection data...');
245
+ const summary = await loadProjectionData(opts);
246
+ console.error(`Loaded ${summary.length} programs`);
247
+ let projections = initializeProjections(summary);
248
+ const adjType = resolveAdjustmentType(opts.adjustmentType);
249
+ const adjValue = parseFloat(opts.adjustmentValue);
250
+ if (adjValue !== 0) {
251
+ projections = applyUniformAdjustment(projections, adjType, adjValue);
252
+ console.error(`Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`);
253
+ }
254
+ const totals = calculateTotals(projections);
255
+ console.error(`Totals: ${totals.totalActual} actual -> ${totals.totalProjected} projected (${totals.percentageChange >= 0 ? '+' : ''}${totals.percentageChange.toFixed(1)}%)`);
256
+ if (format === 'csv') {
257
+ console.log(exportToCSV(projections));
258
+ }
259
+ else {
260
+ console.log(formatProjectionTable(projections));
261
+ }
262
+ });
263
+ program
264
+ .command('export-budget')
265
+ .description('Export FY26 budget projections as CSV')
266
+ .option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
267
+ .option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
268
+ .option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
269
+ .option('--user-id <uuid>', 'Supabase user UUID to filter by')
270
+ .option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
271
+ .action(async (opts) => {
272
+ console.error('Loading projection data...');
273
+ const summary = await loadProjectionData(opts);
274
+ console.error(`Loaded ${summary.length} programs`);
275
+ let projections = initializeProjections(summary);
276
+ const adjType = resolveAdjustmentType(opts.adjustmentType);
277
+ const adjValue = parseFloat(opts.adjustmentValue);
278
+ if (adjValue !== 0) {
279
+ projections = applyUniformAdjustment(projections, adjType, adjValue);
280
+ console.error(`Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`);
281
+ }
282
+ console.log(exportToCSV(projections));
283
+ });
284
+ // Newsletter generation command
285
+ program
286
+ .command('generate-newsletter')
287
+ .description('Generate a branded newsletter with AI content and push to Strapi CMS')
288
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
289
+ .option('--date <date>', 'Edition date as YYYY-MM-DD (default: today)')
290
+ .option('--excel <path>', 'Path to Excel file with property listings (CRE-11TRUST only)')
291
+ .option('--dry-run', 'Generate content but do NOT push to Strapi', false)
292
+ .action(async (opts) => {
293
+ try {
294
+ const result = await generateNewsletter({
295
+ brand: opts.brand,
296
+ date: opts.date,
297
+ excelPath: opts.excel,
298
+ dryRun: opts.dryRun,
299
+ });
300
+ if (result.strapiDocumentId) {
301
+ console.log(`\nStrapi documentId: ${result.strapiDocumentId}`);
302
+ }
303
+ }
304
+ catch (err) {
305
+ const msg = err instanceof Error ? err.message : String(err);
306
+ console.error(`Newsletter generation failed: ${msg}`);
307
+ process.exit(1);
308
+ }
309
+ });
310
+ // Scrape Meta Ad Library command
311
+ program
312
+ .command('scrape-ads')
313
+ .description('Scrape Meta Ad Library for competitor ad intelligence')
314
+ .requiredOption('--companies <csv-or-file>', 'Comma-separated company names or path to a text file (one per line)')
315
+ .option('--output <path>', 'Save CSV results to file (default: stdout)')
316
+ .option('--batch-size <n>', 'Companies per batch', '6')
317
+ .action(async (opts) => {
318
+ // Parse companies: file path or comma-separated list
319
+ let companies;
320
+ if (existsSync(opts.companies)) {
321
+ const raw = readFileSync(opts.companies, 'utf-8');
322
+ companies = raw
323
+ .split('\n')
324
+ .map((l) => l.trim())
325
+ .filter((l) => l.length > 0 && !l.startsWith('#'));
326
+ }
327
+ else {
328
+ companies = opts.companies
329
+ .split(',')
330
+ .map((c) => c.trim())
331
+ .filter((c) => c.length > 0);
332
+ }
333
+ if (companies.length === 0) {
334
+ console.error('No companies specified');
335
+ process.exit(1);
336
+ }
337
+ const batchSize = parseInt(opts.batchSize);
338
+ if (isNaN(batchSize) || batchSize < 1) {
339
+ console.error('Invalid batch size');
340
+ process.exit(1);
341
+ }
342
+ try {
343
+ const result = await scrapeCompanies({
344
+ companies,
345
+ outputPath: opts.output,
346
+ batchSize,
347
+ });
348
+ // If no output file, write CSV to stdout
349
+ if (!opts.output) {
350
+ process.stdout.write(formatCsv(result.ads));
351
+ }
352
+ }
353
+ catch (err) {
354
+ const msg = err instanceof Error ? err.message : String(err);
355
+ console.error(`Scrape failed: ${msg}`);
356
+ process.exit(1);
357
+ }
358
+ });
359
+ // Ingest transactions command
360
+ program
361
+ .command('ingest-transactions')
362
+ .description('Parse & deduplicate bank CSV files into the transactions table')
363
+ .requiredOption('--file <path>', 'Path to the CSV file')
364
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
365
+ .action(async (opts) => {
366
+ if (!existsSync(opts.file)) {
367
+ console.error(`File not found: ${opts.file}`);
368
+ process.exit(1);
369
+ }
370
+ console.log(`Ingesting transactions from: ${opts.file}`);
371
+ try {
372
+ const result = await ingestTransactions(opts.file, opts.userId);
373
+ console.log(`\nFormat detected: ${result.format}`);
374
+ console.log(`Inserted: ${result.inserted} | Skipped (duplicates): ${result.skipped} | Failed: ${result.failed}`);
375
+ if (result.errors.length > 0) {
376
+ console.log(`\nWarnings/Errors (${result.errors.length}):`);
377
+ for (const err of result.errors.slice(0, 20)) {
378
+ console.log(` - ${err}`);
379
+ }
380
+ if (result.errors.length > 20) {
381
+ console.log(` ... and ${result.errors.length - 20} more`);
382
+ }
383
+ }
384
+ }
385
+ catch (err) {
386
+ const msg = err instanceof Error ? err.message : String(err);
387
+ console.error(`Ingest failed: ${msg}`);
388
+ process.exit(1);
389
+ }
390
+ });
391
+ // Stamp transactions command
392
+ program
393
+ .command('stamp-transactions')
394
+ .description('Auto-categorize unclassified transactions using rule-based matching')
395
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
396
+ .option('--dry-run', 'Preview matches without writing to database', false)
397
+ .action(async (opts) => {
398
+ console.log(`Stamping transactions for user: ${opts.userId}${opts.dryRun ? ' (DRY RUN)' : ''}`);
399
+ try {
400
+ const result = await stampTransactions(opts.userId, { dryRun: opts.dryRun });
401
+ console.log(`\nTotal unclassified: ${result.total}`);
402
+ console.log(`Stamped: ${result.stamped} | Unmatched: ${result.unmatched}`);
403
+ console.log(`By match type: PATTERN=${result.byMatchType.PATTERN}, LEARNED=${result.byMatchType.LEARNED}, EXACT=${result.byMatchType.EXACT}, FUZZY=${result.byMatchType.FUZZY}, CATEGORY_INFER=${result.byMatchType.CATEGORY_INFER}`);
404
+ if (result.dryRun) {
405
+ console.log('\n(Dry run — no database changes made)');
406
+ }
407
+ }
408
+ catch (err) {
409
+ const msg = err instanceof Error ? err.message : String(err);
410
+ console.error(`Stamp failed: ${msg}`);
411
+ process.exit(1);
412
+ }
413
+ });
414
+ // ── Upload R1 data ──────────────────────────────────────────────────
415
+ program
416
+ .command('upload-r1')
417
+ .description('Upload R1 XLSX file to ReturnPro staging')
418
+ .requiredOption('--file <path>', 'Path to R1 XLSX file')
419
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
420
+ .requiredOption('--month <YYYY-MM>', 'Month in YYYY-MM format')
421
+ .action(async (opts) => {
422
+ if (!existsSync(opts.file)) {
423
+ console.error(`File not found: ${opts.file}`);
424
+ process.exit(1);
425
+ }
426
+ try {
427
+ const result = await processR1Upload(opts.file, opts.userId, opts.month);
428
+ console.log(`R1 upload complete: ${result.rowsInserted} rows inserted, ${result.rowsSkipped} skipped (${result.programGroupsFound} program groups)`);
429
+ if (result.warnings.length > 0) {
430
+ console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`);
431
+ }
432
+ }
433
+ catch (err) {
434
+ console.error(`R1 upload failed: ${err instanceof Error ? err.message : String(err)}`);
435
+ process.exit(1);
436
+ }
437
+ });
438
+ // ── Upload NetSuite data ────────────────────────────────────────────
439
+ program
440
+ .command('upload-netsuite')
441
+ .description('Upload NetSuite CSV/XLSX to ReturnPro staging')
442
+ .requiredOption('--file <path>', 'Path to NetSuite file (CSV, XLSX, or XLSM)')
443
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
444
+ .action(async (opts) => {
445
+ if (!existsSync(opts.file)) {
446
+ console.error(`File not found: ${opts.file}`);
447
+ process.exit(1);
448
+ }
449
+ try {
450
+ const result = await processNetSuiteUpload(opts.file, opts.userId);
451
+ console.log(`NetSuite upload: ${result.inserted} rows inserted (months: ${result.monthsCovered.join(', ')})`);
452
+ if (result.warnings.length > 0) {
453
+ console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`);
454
+ }
455
+ }
456
+ catch (err) {
457
+ console.error(`NetSuite upload failed: ${err instanceof Error ? err.message : String(err)}`);
458
+ process.exit(1);
459
+ }
460
+ });
461
+ // ── Upload income statements ────────────────────────────────────────
462
+ program
463
+ .command('upload-income-statements')
464
+ .description('Upload confirmed income statement CSV to ReturnPro')
465
+ .requiredOption('--file <path>', 'Path to income statement CSV')
466
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
467
+ .action(async (opts) => {
468
+ if (!existsSync(opts.file)) {
469
+ console.error(`File not found: ${opts.file}`);
470
+ process.exit(1);
471
+ }
472
+ try {
473
+ const result = await uploadIncomeStatements(opts.file, opts.userId);
474
+ console.log(`Income statements: ${result.upserted} rows upserted, ${result.skipped} skipped (period: ${result.period})`);
475
+ }
476
+ catch (err) {
477
+ console.error(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
478
+ process.exit(1);
479
+ }
480
+ });
481
+ // ── Rate anomalies ──────────────────────────────────────────────────
482
+ program
483
+ .command('rate-anomalies')
484
+ .description('Detect rate anomalies via z-score analysis on ReturnPro data')
485
+ .option('--from <YYYY-MM>', 'Start month')
486
+ .option('--to <YYYY-MM>', 'End month')
487
+ .option('--threshold <n>', 'Z-score threshold', '2.0')
488
+ .action(async (opts) => {
489
+ try {
490
+ const months = opts.from && opts.to
491
+ ? (() => {
492
+ const result = [];
493
+ const [fy, fm] = opts.from.split('-').map(Number);
494
+ const [ty, tm] = opts.to.split('-').map(Number);
495
+ let y = fy, m = fm;
496
+ while (y < ty || (y === ty && m <= tm)) {
497
+ result.push(`${y}-${String(m).padStart(2, '0')}`);
498
+ m++;
499
+ if (m > 12) {
500
+ m = 1;
501
+ y++;
502
+ }
503
+ }
504
+ return result;
505
+ })()
506
+ : undefined;
507
+ const result = await detectRateAnomalies({
508
+ months,
509
+ threshold: parseFloat(opts.threshold),
510
+ });
511
+ console.log(`Found ${result.anomalies.length} anomalies (threshold: ${opts.threshold}σ)`);
512
+ for (const a of result.anomalies.slice(0, 30)) {
513
+ console.log(` ${a.month} | ${a.program_code ?? a.master_program} | z=${a.zscore.toFixed(2)} | rate=${a.rate_per_unit}`);
514
+ }
515
+ if (result.anomalies.length > 30)
516
+ console.log(` ... and ${result.anomalies.length - 30} more`);
517
+ }
518
+ catch (err) {
519
+ console.error(`Anomaly detection failed: ${err instanceof Error ? err.message : String(err)}`);
520
+ process.exit(1);
521
+ }
522
+ });
523
+ // ── Diagnose months ─────────────────────────────────────────────────
524
+ program
525
+ .command('diagnose-months')
526
+ .description('Run diagnostic checks on staging data for specified months')
527
+ .option('--months <csv>', 'Comma-separated YYYY-MM months (default: all)')
528
+ .action(async (opts) => {
529
+ const months = opts.months?.split(',').map(m => m.trim());
530
+ try {
531
+ const result = await diagnoseMonths(months ? { months } : undefined);
532
+ console.log(`Analysed months: ${result.monthsAnalysed.join(', ')}`);
533
+ console.log(`Total staging rows: ${result.totalRows} (median: ${result.medianRowCount}/month)\n`);
534
+ for (const issue of result.issues) {
535
+ console.log(` ✗ [${issue.kind}] ${issue.month ?? 'global'}: ${issue.message}`);
536
+ }
537
+ if (result.issues.length === 0) {
538
+ console.log(' ✓ No issues found');
539
+ }
540
+ console.log(`\nSummary: ${result.summary.totalIssues} issues found`);
541
+ }
542
+ catch (err) {
543
+ console.error(`Diagnosis failed: ${err instanceof Error ? err.message : String(err)}`);
544
+ process.exit(1);
545
+ }
546
+ });
547
+ // ── Generate NetSuite template ──────────────────────────────────────
548
+ program
549
+ .command('generate-netsuite-template')
550
+ .description('Generate a blank NetSuite XLSX upload template')
551
+ .option('--output <path>', 'Output file path', 'netsuite-template.xlsx')
552
+ .action(async (opts) => {
553
+ try {
554
+ const result = await generateNetSuiteTemplate(opts.output);
555
+ console.log(`Template saved: ${result.outputPath} (${result.accountCount} accounts)`);
556
+ }
557
+ catch (err) {
558
+ console.error(`Template generation failed: ${err instanceof Error ? err.message : String(err)}`);
559
+ process.exit(1);
560
+ }
561
+ });
562
+ // ── Distribute newsletter ───────────────────────────────────────────
563
+ program
564
+ .command('distribute-newsletter')
565
+ .description('Trigger newsletter distribution via n8n webhook')
566
+ .requiredOption('--document-id <id>', 'Strapi newsletter documentId')
567
+ .option('--channel <ch>', 'Distribution channel: email or all', 'all')
568
+ .action(async (opts) => {
569
+ try {
570
+ const result = await distributeNewsletter(opts.documentId, {
571
+ channel: opts.channel,
572
+ });
573
+ if (result.success) {
574
+ console.log(`Distribution triggered for ${opts.documentId} (channel: ${opts.channel})`);
575
+ }
576
+ else {
577
+ console.error(`Distribution failed: ${result.error}`);
578
+ process.exit(1);
579
+ }
580
+ }
581
+ catch (err) {
582
+ console.error(`Distribution failed: ${err instanceof Error ? err.message : String(err)}`);
583
+ process.exit(1);
584
+ }
585
+ });
586
+ // ── Check distribution status ───────────────────────────────────────
587
+ program
588
+ .command('distribution-status')
589
+ .description('Check delivery status of a newsletter')
590
+ .requiredOption('--document-id <id>', 'Strapi newsletter documentId')
591
+ .action(async (opts) => {
592
+ const status = await checkDistributionStatus(opts.documentId);
593
+ console.log(`Status: ${status.delivery_status}`);
594
+ if (status.delivered_at)
595
+ console.log(`Delivered: ${status.delivered_at}`);
596
+ if (status.recipients_count)
597
+ console.log(`Recipients: ${status.recipients_count}`);
598
+ if (status.ghl_campaign_id)
599
+ console.log(`GHL Campaign: ${status.ghl_campaign_id}`);
600
+ });
601
+ // ── Generate social posts ───────────────────────────────────────────
602
+ program
603
+ .command('generate-social-posts')
604
+ .description('Generate AI-powered social media ad posts and push to Strapi')
605
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
606
+ .option('--count <n>', 'Number of posts to generate', '9')
607
+ .option('--week-of <date>', 'Week start date YYYY-MM-DD (default: next Monday)')
608
+ .option('--dry-run', 'Generate without pushing to Strapi', false)
609
+ .action(async (opts) => {
610
+ try {
611
+ const result = await generateSocialPosts({
612
+ brand: opts.brand,
613
+ count: parseInt(opts.count),
614
+ weekOf: opts.weekOf,
615
+ dryRun: opts.dryRun,
616
+ });
617
+ console.log(`Created ${result.postsCreated} posts for ${result.brand}`);
618
+ for (const p of result.posts) {
619
+ console.log(` ${p.scheduled_date} | ${p.platform} | ${p.headline}`);
620
+ }
621
+ if (result.errors.length > 0) {
622
+ console.log(`\nErrors: ${result.errors.join(', ')}`);
623
+ }
624
+ }
625
+ catch (err) {
626
+ console.error(`Post generation failed: ${err instanceof Error ? err.message : String(err)}`);
627
+ process.exit(1);
628
+ }
629
+ });
630
+ // ── Publish social posts ────────────────────────────────────────────
631
+ program
632
+ .command('publish-social-posts')
633
+ .description('Publish pending social posts to platforms via n8n')
634
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
635
+ .option('--limit <n>', 'Max posts to publish')
636
+ .option('--dry-run', 'Preview without publishing', false)
637
+ .option('--retry', 'Retry previously failed posts', false)
638
+ .action(async (opts) => {
639
+ try {
640
+ let result;
641
+ if (opts.retry) {
642
+ result = await retryFailed(opts.brand);
643
+ }
644
+ else {
645
+ result = await publishSocialPosts({
646
+ brand: opts.brand,
647
+ limit: opts.limit ? parseInt(opts.limit) : undefined,
648
+ dryRun: opts.dryRun,
649
+ });
650
+ }
651
+ console.log(`Published: ${result.published} | Failed: ${result.failed} | Skipped: ${result.skipped}`);
652
+ for (const d of result.details) {
653
+ console.log(` ${d.status} | ${d.headline}${d.error ? ` — ${d.error}` : ''}`);
654
+ }
655
+ }
656
+ catch (err) {
657
+ console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`);
658
+ process.exit(1);
659
+ }
660
+ });
661
+ // ── Social post queue ───────────────────────────────────────────────
662
+ program
663
+ .command('social-queue')
664
+ .description('View pending social posts ready for publishing')
665
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
666
+ .action(async (opts) => {
667
+ const queue = await getPublishQueue(opts.brand);
668
+ if (queue.length === 0) {
669
+ console.log('No posts in queue');
670
+ return;
671
+ }
672
+ console.log('| Date | Platform | Headline |');
673
+ console.log('|------|----------|----------|');
674
+ for (const p of queue) {
675
+ console.log(`| ${p.scheduled_date} | ${p.platform} | ${p.headline} |`);
676
+ }
677
+ console.log(`\n${queue.length} posts queued`);
678
+ });
679
+ // ── Publish blog ────────────────────────────────────────────────────
680
+ program
681
+ .command('publish-blog')
682
+ .description('Publish a Strapi blog post and optionally deploy portfolio site')
683
+ .requiredOption('--slug <slug>', 'Blog post slug')
684
+ .option('--deploy', 'Deploy portfolio site after publishing', false)
685
+ .action(async (opts) => {
686
+ try {
687
+ const result = await publishBlog({ slug: opts.slug, deployAfter: opts.deploy });
688
+ console.log(`Published: ${result.slug} (${result.documentId})`);
689
+ if (result.deployUrl)
690
+ console.log(`Deployed: ${result.deployUrl}`);
691
+ }
692
+ catch (err) {
693
+ console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`);
694
+ process.exit(1);
695
+ }
696
+ });
697
+ // ── Blog drafts ─────────────────────────────────────────────────────
698
+ program
699
+ .command('blog-drafts')
700
+ .description('List unpublished blog post drafts')
701
+ .option('--site <site>', 'Filter by site (portfolio, insurance)')
702
+ .action(async (opts) => {
703
+ const drafts = await listBlogDrafts(opts.site);
704
+ if (drafts.length === 0) {
705
+ console.log('No drafts found');
706
+ return;
707
+ }
708
+ console.log('| Created | Site | Title | Slug |');
709
+ console.log('|---------|------|-------|------|');
710
+ for (const d of drafts) {
711
+ console.log(`| ${d.createdAt.slice(0, 10)} | ${d.site} | ${d.title} | ${d.slug} |`);
712
+ }
713
+ });
714
+ // ── Database migration ──────────────────────────────────────────────
715
+ const migrate = program.command('migrate').description('Supabase database migration operations');
716
+ migrate
717
+ .command('push')
718
+ .description('Run supabase db push --linked on a target project')
719
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
720
+ .option('--dry-run', 'Preview without applying', false)
721
+ .action(async (opts) => {
722
+ const target = opts.target;
723
+ if (target !== 'returnpro' && target !== 'optimalos') {
724
+ console.error('Target must be "returnpro" or "optimalos"');
725
+ process.exit(1);
726
+ }
727
+ console.log(`Migrating ${target}${opts.dryRun ? ' (dry run)' : ''}...`);
728
+ const result = await migrateDb({ target, dryRun: opts.dryRun });
729
+ if (result.success) {
730
+ console.log(result.output);
731
+ }
732
+ else {
733
+ console.error(`Migration failed:\n${result.errors}`);
734
+ process.exit(1);
735
+ }
736
+ });
737
+ migrate
738
+ .command('pending')
739
+ .description('List pending migration files')
740
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
741
+ .action(async (opts) => {
742
+ const files = await listPendingMigrations(opts.target);
743
+ if (files.length === 0) {
744
+ console.log('No migration files found');
745
+ return;
746
+ }
747
+ for (const f of files)
748
+ console.log(` ${f}`);
749
+ console.log(`\n${files.length} migration files`);
750
+ });
751
+ migrate
752
+ .command('create')
753
+ .description('Create a new empty migration file')
754
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
755
+ .requiredOption('--name <name>', 'Migration name')
756
+ .action(async (opts) => {
757
+ const path = await createMigration(opts.target, opts.name);
758
+ console.log(`Created: ${path}`);
759
+ });
760
+ // ── Budget scenarios ────────────────────────────────────────────────
761
+ const scenario = program.command('scenario').description('Budget scenario management');
762
+ scenario
763
+ .command('save')
764
+ .description('Save current projections as a named scenario')
765
+ .requiredOption('--name <name>', 'Scenario name')
766
+ .requiredOption('--adjustment-type <type>', 'Adjustment type: percentage or flat')
767
+ .requiredOption('--adjustment-value <n>', 'Adjustment value')
768
+ .option('--description <desc>', 'Description')
769
+ .option('--fiscal-year <fy>', 'Fiscal year', '2025')
770
+ .option('--user-id <uuid>', 'User UUID')
771
+ .action(async (opts) => {
772
+ try {
773
+ const path = await saveScenario({
774
+ name: opts.name,
775
+ adjustmentType: opts.adjustmentType,
776
+ adjustmentValue: parseFloat(opts.adjustmentValue),
777
+ fiscalYear: parseInt(opts.fiscalYear),
778
+ userId: opts.userId,
779
+ description: opts.description,
780
+ });
781
+ console.log(`Scenario saved: ${path}`);
782
+ }
783
+ catch (err) {
784
+ console.error(`Save failed: ${err instanceof Error ? err.message : String(err)}`);
785
+ process.exit(1);
786
+ }
787
+ });
788
+ scenario
789
+ .command('list')
790
+ .description('List all saved budget scenarios')
791
+ .action(async () => {
792
+ const scenarios = await listScenarios();
793
+ if (scenarios.length === 0) {
794
+ console.log('No scenarios saved');
795
+ return;
796
+ }
797
+ console.log('| Name | Adjustment | Projected | Change | Created |');
798
+ console.log('|------|------------|-----------|--------|---------|');
799
+ for (const s of scenarios) {
800
+ const adj = s.adjustmentType === 'percentage' ? `${s.adjustmentValue}%` : `+${s.adjustmentValue}`;
801
+ console.log(`| ${s.name} | ${adj} | ${s.totalProjected.toLocaleString()} | ${s.percentageChange.toFixed(1)}% | ${s.createdAt.slice(0, 10)} |`);
802
+ }
803
+ });
804
+ scenario
805
+ .command('compare')
806
+ .description('Compare two or more scenarios side by side')
807
+ .requiredOption('--names <csv>', 'Comma-separated scenario names')
808
+ .action(async (opts) => {
809
+ const names = opts.names.split(',').map(n => n.trim());
810
+ if (names.length < 2) {
811
+ console.error('Need at least 2 scenario names to compare');
812
+ process.exit(1);
813
+ }
814
+ try {
815
+ const result = await compareScenarios(names);
816
+ // Print header
817
+ const header = ['Program', 'Actual', ...result.scenarioNames].join(' | ');
818
+ console.log(`| ${header} |`);
819
+ console.log(`|${result.scenarioNames.map(() => '---').concat(['---', '---']).join('|')}|`);
820
+ for (const p of result.programs.slice(0, 50)) {
821
+ const vals = result.scenarioNames.map(n => String(p.projectedByScenario[n] ?? 0));
822
+ console.log(`| ${p.programCode} | ${p.actual} | ${vals.join(' | ')} |`);
823
+ }
824
+ // Totals
825
+ console.log('\nTotals:');
826
+ for (const name of result.scenarioNames) {
827
+ const t = result.totalsByScenario[name];
828
+ console.log(` ${name}: ${t.totalProjected.toLocaleString()} (${t.percentageChange >= 0 ? '+' : ''}${t.percentageChange.toFixed(1)}%)`);
829
+ }
830
+ }
831
+ catch (err) {
832
+ console.error(`Compare failed: ${err instanceof Error ? err.message : String(err)}`);
833
+ process.exit(1);
834
+ }
835
+ });
836
+ scenario
837
+ .command('delete')
838
+ .description('Delete a saved scenario')
839
+ .requiredOption('--name <name>', 'Scenario name')
840
+ .action(async (opts) => {
841
+ try {
842
+ await deleteScenario(opts.name);
843
+ console.log(`Deleted scenario: ${opts.name}`);
844
+ }
845
+ catch (err) {
846
+ console.error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
847
+ process.exit(1);
848
+ }
849
+ });
850
+ // ── Delete batch ────────────────────────────────────────────────────
851
+ program
852
+ .command('delete-batch')
853
+ .description('Batch delete transactions or staging rows (safe: dry-run by default)')
854
+ .requiredOption('--table <t>', 'Table: transactions or stg_financials_raw')
855
+ .option('--user-id <uuid>', 'User UUID filter')
856
+ .option('--date-from <date>', 'Start date YYYY-MM-DD')
857
+ .option('--date-to <date>', 'End date YYYY-MM-DD')
858
+ .option('--source <src>', 'Source filter')
859
+ .option('--category <cat>', 'Category filter (transactions)')
860
+ .option('--account-code <code>', 'Account code filter (staging)')
861
+ .option('--month <YYYY-MM>', 'Month filter (staging)')
862
+ .option('--execute', 'Actually delete (default is dry-run preview)', false)
863
+ .action(async (opts) => {
864
+ const table = opts.table;
865
+ const filters = {
866
+ dateFrom: opts.dateFrom,
867
+ dateTo: opts.dateTo,
868
+ source: opts.source,
869
+ category: opts.category,
870
+ accountCode: opts.accountCode,
871
+ month: opts.month,
872
+ };
873
+ const dryRun = !opts.execute;
874
+ if (dryRun) {
875
+ const preview = await previewBatch({ table, userId: opts.userId, filters });
876
+ console.log(`Preview: ${preview.matchCount} rows would be deleted from ${table}`);
877
+ if (Object.keys(preview.groupedCounts).length > 0) {
878
+ console.log('\nGrouped:');
879
+ for (const [key, count] of Object.entries(preview.groupedCounts)) {
880
+ console.log(` ${key}: ${count}`);
881
+ }
882
+ }
883
+ if (preview.sample.length > 0) {
884
+ console.log(`\nSample (first ${preview.sample.length}):`);
885
+ for (const row of preview.sample) {
886
+ console.log(` ${JSON.stringify(row)}`);
887
+ }
888
+ }
889
+ console.log('\nUse --execute to actually delete');
890
+ }
891
+ else {
892
+ const result = await deleteBatch({ table, userId: opts.userId, filters, dryRun: false });
893
+ console.log(`Deleted ${result.deletedCount} rows from ${table}`);
894
+ }
895
+ });
896
+ // ── Config commands ─────────────────────────────────────────────────
897
+ const config = program.command('config').description('OpenClaw config sync operations');
898
+ config
899
+ .command('push')
900
+ .description('Push local openclaw.json to cloud storage')
901
+ .requiredOption('--agent <name>', 'Agent name (e.g., oracle, opal, kimklaw)')
902
+ .action(async (opts) => {
903
+ try {
904
+ const result = await pushConfig(opts.agent);
905
+ console.log(`✓ Config pushed for ${opts.agent}`);
906
+ console.log(` ID: ${result.id}`);
907
+ console.log(` Version: ${result.version}`);
908
+ }
909
+ catch (err) {
910
+ console.error(`Push failed: ${err instanceof Error ? err.message : String(err)}`);
911
+ process.exit(1);
912
+ }
913
+ });
914
+ config
915
+ .command('pull')
916
+ .description('Pull config from cloud and save to local openclaw.json')
917
+ .requiredOption('--agent <name>', 'Agent name to pull config for')
918
+ .action(async (opts) => {
919
+ try {
920
+ const result = await pullConfig(opts.agent);
921
+ console.log(`✓ Config pulled for ${result.agent_name}`);
922
+ console.log(` Version: ${result.version}`);
923
+ console.log(` Updated: ${result.updated_at}`);
924
+ console.log(`\nSaved to: ~/.openclaw/openclaw.json`);
925
+ }
926
+ catch (err) {
927
+ console.error(`Pull failed: ${err instanceof Error ? err.message : String(err)}`);
928
+ process.exit(1);
929
+ }
930
+ });
931
+ config
932
+ .command('list')
933
+ .description('List all saved agent configs in cloud')
934
+ .action(async () => {
935
+ try {
936
+ const configs = await listConfigs();
937
+ if (configs.length === 0) {
938
+ console.log('No configs found in cloud storage');
939
+ return;
940
+ }
941
+ console.log('| Agent | Version | Updated |');
942
+ console.log('|-------|---------|---------|');
943
+ for (const c of configs) {
944
+ console.log(`| ${c.agent_name} | ${c.version.slice(0, 19)} | ${c.updated_at.slice(0, 19)} |`);
945
+ }
946
+ console.log(`\nTotal: ${configs.length} configs`);
947
+ }
948
+ catch (err) {
949
+ console.error(`List failed: ${err instanceof Error ? err.message : String(err)}`);
950
+ process.exit(1);
951
+ }
952
+ });
953
+ config
954
+ .command('diff')
955
+ .description('Compare local config with cloud version')
956
+ .requiredOption('--agent <name>', 'Agent name to compare')
957
+ .action(async (opts) => {
958
+ try {
959
+ const { local, cloud, differences } = await diffConfig(opts.agent);
960
+ if (differences.length === 0) {
961
+ console.log('✓ Local and cloud configs are in sync');
962
+ return;
963
+ }
964
+ console.log('Differences found:');
965
+ for (const d of differences) {
966
+ console.log(` • ${d}`);
967
+ }
968
+ if (local?.meta?.lastTouchedAt) {
969
+ console.log(`\nLocal updated: ${local.meta.lastTouchedAt}`);
970
+ }
971
+ if (cloud?.updated_at) {
972
+ console.log(`Cloud updated: ${cloud.updated_at}`);
973
+ }
974
+ }
975
+ catch (err) {
976
+ console.error(`Diff failed: ${err instanceof Error ? err.message : String(err)}`);
977
+ process.exit(1);
978
+ }
979
+ });
980
+ config
981
+ .command('sync')
982
+ .description('Two-way sync: push if local is newer, pull if cloud is newer')
983
+ .requiredOption('--agent <name>', 'Agent name to sync')
984
+ .action(async (opts) => {
985
+ try {
986
+ const result = await syncConfig(opts.agent);
987
+ console.log(`✓ ${result.action}`);
988
+ console.log(` ${result.message}`);
989
+ }
990
+ catch (err) {
991
+ console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
992
+ process.exit(1);
993
+ }
994
+ });
995
+ program.parseAsync();