optimal-cli 0.1.0 → 1.0.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 (107) hide show
  1. package/agents/.gitkeep +0 -0
  2. package/agents/content-ops.md +227 -0
  3. package/agents/financial-ops.md +184 -0
  4. package/agents/infra-ops.md +206 -0
  5. package/agents/profiles.json +5 -0
  6. package/bin/optimal.ts +1731 -0
  7. package/docs/CLI-REFERENCE.md +361 -0
  8. package/lib/assets/index.ts +225 -0
  9. package/lib/assets.ts +124 -0
  10. package/lib/auth/index.ts +189 -0
  11. package/lib/board/index.ts +309 -0
  12. package/lib/board/types.ts +124 -0
  13. package/lib/bot/claim.ts +43 -0
  14. package/lib/bot/coordinator.ts +254 -0
  15. package/lib/bot/heartbeat.ts +37 -0
  16. package/lib/bot/index.ts +9 -0
  17. package/lib/bot/protocol.ts +99 -0
  18. package/lib/bot/reporter.ts +42 -0
  19. package/lib/bot/skills.ts +81 -0
  20. package/lib/budget/projections.ts +561 -0
  21. package/lib/budget/scenarios.ts +312 -0
  22. package/lib/cms/publish-blog.ts +129 -0
  23. package/lib/cms/strapi-client.ts +302 -0
  24. package/lib/config/registry.ts +228 -0
  25. package/lib/config/schema.ts +58 -0
  26. package/lib/config.ts +247 -0
  27. package/lib/errors.ts +129 -0
  28. package/lib/format.ts +120 -0
  29. package/lib/infra/.gitkeep +0 -0
  30. package/lib/infra/deploy.ts +70 -0
  31. package/lib/infra/migrate.ts +141 -0
  32. package/lib/newsletter/.gitkeep +0 -0
  33. package/lib/newsletter/distribute.ts +256 -0
  34. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  35. package/lib/newsletter/generate.ts +735 -0
  36. package/lib/returnpro/.gitkeep +0 -0
  37. package/lib/returnpro/anomalies.ts +258 -0
  38. package/lib/returnpro/audit.ts +194 -0
  39. package/lib/returnpro/diagnose.ts +400 -0
  40. package/lib/returnpro/kpis.ts +255 -0
  41. package/lib/returnpro/templates.ts +323 -0
  42. package/lib/returnpro/upload-income.ts +311 -0
  43. package/lib/returnpro/upload-netsuite.ts +696 -0
  44. package/lib/returnpro/upload-r1.ts +563 -0
  45. package/lib/returnpro/validate.ts +154 -0
  46. package/lib/social/meta.ts +228 -0
  47. package/lib/social/post-generator.ts +468 -0
  48. package/lib/social/publish.ts +301 -0
  49. package/lib/social/scraper.ts +503 -0
  50. package/lib/supabase.ts +25 -0
  51. package/lib/transactions/delete-batch.ts +258 -0
  52. package/lib/transactions/ingest.ts +659 -0
  53. package/lib/transactions/stamp.ts +654 -0
  54. package/package.json +15 -25
  55. package/dist/bin/optimal.d.ts +0 -2
  56. package/dist/bin/optimal.js +0 -995
  57. package/dist/lib/budget/projections.d.ts +0 -115
  58. package/dist/lib/budget/projections.js +0 -384
  59. package/dist/lib/budget/scenarios.d.ts +0 -93
  60. package/dist/lib/budget/scenarios.js +0 -214
  61. package/dist/lib/cms/publish-blog.d.ts +0 -62
  62. package/dist/lib/cms/publish-blog.js +0 -74
  63. package/dist/lib/cms/strapi-client.d.ts +0 -123
  64. package/dist/lib/cms/strapi-client.js +0 -213
  65. package/dist/lib/config.d.ts +0 -55
  66. package/dist/lib/config.js +0 -206
  67. package/dist/lib/infra/deploy.d.ts +0 -29
  68. package/dist/lib/infra/deploy.js +0 -58
  69. package/dist/lib/infra/migrate.d.ts +0 -34
  70. package/dist/lib/infra/migrate.js +0 -103
  71. package/dist/lib/kanban.d.ts +0 -46
  72. package/dist/lib/kanban.js +0 -118
  73. package/dist/lib/newsletter/distribute.d.ts +0 -52
  74. package/dist/lib/newsletter/distribute.js +0 -193
  75. package/dist/lib/newsletter/generate-insurance.js +0 -36
  76. package/dist/lib/newsletter/generate.d.ts +0 -104
  77. package/dist/lib/newsletter/generate.js +0 -571
  78. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  79. package/dist/lib/returnpro/anomalies.js +0 -166
  80. package/dist/lib/returnpro/audit.d.ts +0 -32
  81. package/dist/lib/returnpro/audit.js +0 -147
  82. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  83. package/dist/lib/returnpro/diagnose.js +0 -281
  84. package/dist/lib/returnpro/kpis.d.ts +0 -32
  85. package/dist/lib/returnpro/kpis.js +0 -192
  86. package/dist/lib/returnpro/templates.d.ts +0 -48
  87. package/dist/lib/returnpro/templates.js +0 -229
  88. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  89. package/dist/lib/returnpro/upload-income.js +0 -235
  90. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  91. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  92. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  93. package/dist/lib/returnpro/upload-r1.js +0 -398
  94. package/dist/lib/social/post-generator.d.ts +0 -83
  95. package/dist/lib/social/post-generator.js +0 -333
  96. package/dist/lib/social/publish.d.ts +0 -66
  97. package/dist/lib/social/publish.js +0 -226
  98. package/dist/lib/social/scraper.d.ts +0 -67
  99. package/dist/lib/social/scraper.js +0 -361
  100. package/dist/lib/supabase.d.ts +0 -4
  101. package/dist/lib/supabase.js +0 -20
  102. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  103. package/dist/lib/transactions/delete-batch.js +0 -203
  104. package/dist/lib/transactions/ingest.d.ts +0 -43
  105. package/dist/lib/transactions/ingest.js +0 -555
  106. package/dist/lib/transactions/stamp.d.ts +0 -51
  107. package/dist/lib/transactions/stamp.js +0 -524
package/bin/optimal.ts ADDED
@@ -0,0 +1,1731 @@
1
+ #!/usr/bin/env tsx
2
+ import { Command } from 'commander'
3
+ import 'dotenv/config'
4
+ import {
5
+ createProject, getProjectBySlug, listProjects, updateProject,
6
+ createMilestone, listMilestones,
7
+ createLabel, listLabels,
8
+ createTask, updateTask, getTask, listTasks, claimTask, completeTask,
9
+ addComment, listComments,
10
+ logActivity, listActivity,
11
+ formatBoardTable, getNextClaimable,
12
+ type Task, type TaskStatus,
13
+ } from '../lib/board/index.js'
14
+ import { runAuditComparison } from '../lib/returnpro/audit.js'
15
+ import { exportKpis, formatKpiTable, formatKpiCsv } from '../lib/returnpro/kpis.js'
16
+ import { deploy, healthCheck, listApps } from '../lib/infra/deploy.js'
17
+ import {
18
+ fetchWesImports,
19
+ parseSummaryFromJson,
20
+ initializeProjections,
21
+ applyUniformAdjustment,
22
+ calculateTotals,
23
+ exportToCSV,
24
+ formatProjectionTable,
25
+ } from '../lib/budget/projections.js'
26
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs'
27
+ import { generateNewsletter } from '../lib/newsletter/generate.js'
28
+ import { scrapeCompanies, formatCsv } from '../lib/social/scraper.js'
29
+ import { ingestTransactions } from '../lib/transactions/ingest.js'
30
+ import { stampTransactions } from '../lib/transactions/stamp.js'
31
+ import { processR1Upload } from '../lib/returnpro/upload-r1.js'
32
+ import { processNetSuiteUpload } from '../lib/returnpro/upload-netsuite.js'
33
+ import { uploadIncomeStatements } from '../lib/returnpro/upload-income.js'
34
+ import { detectRateAnomalies } from '../lib/returnpro/anomalies.js'
35
+ import { diagnoseMonths } from '../lib/returnpro/diagnose.js'
36
+ import { generateNetSuiteTemplate } from '../lib/returnpro/templates.js'
37
+ import { distributeNewsletter, checkDistributionStatus } from '../lib/newsletter/distribute.js'
38
+ import { generateSocialPosts } from '../lib/social/post-generator.js'
39
+ import { publishSocialPosts, getPublishQueue, retryFailed } from '../lib/social/publish.js'
40
+ import { publishBlog, createBlogPost, listBlogDrafts } from '../lib/cms/publish-blog.js'
41
+ import { publishIgPhoto, getMetaConfigForBrand } from '../lib/social/meta.js'
42
+ import { strapiGet, strapiPut, type StrapiPage } from '../lib/cms/strapi-client.js'
43
+ import { migrateDb, listPendingMigrations, createMigration } from '../lib/infra/migrate.js'
44
+ import { saveScenario, loadScenario, listScenarios, compareScenarios, deleteScenario } from '../lib/budget/scenarios.js'
45
+ import { deleteBatch, previewBatch } from '../lib/transactions/delete-batch.js'
46
+ import { assertOptimalConfigV1, type OptimalConfigV1 } from '../lib/config/schema.js'
47
+ import {
48
+ appendHistory,
49
+ getHistoryPath,
50
+ getLocalConfigPath,
51
+ hashConfig,
52
+ pullRegistryProfile,
53
+ pushRegistryProfile,
54
+ readLocalConfig,
55
+ writeLocalConfig,
56
+ } from '../lib/config/registry.js'
57
+ import {
58
+ sendHeartbeat, getActiveAgents,
59
+ claimNextTask, releaseTask,
60
+ reportProgress, reportCompletion, reportBlocked,
61
+ runCoordinatorLoop, getCoordinatorStatus, assignTask, rebalance,
62
+ } from '../lib/bot/index.js'
63
+ import {
64
+ colorize, table as fmtTable, statusBadge, priorityBadge,
65
+ success, error as fmtError, warn as fmtWarn, info as fmtInfo,
66
+ } from '../lib/format.js'
67
+ import {
68
+ listAssets, createAsset, updateAsset, getAsset, deleteAsset,
69
+ trackAssetUsage, listAssetUsage, formatAssetTable,
70
+ type AssetType, type AssetStatus,
71
+ } from '../lib/assets/index.js'
72
+
73
+ const program = new Command()
74
+ .name('optimal')
75
+ .description('Optimal CLI — unified skills for financial analytics, content, and infra')
76
+ .version('0.1.0')
77
+ .addHelpText('after', `
78
+ Examples:
79
+ $ optimal board view View the kanban board
80
+ $ optimal board view -s in_progress Filter board by status
81
+ $ optimal board claim --id <uuid> --agent bot1 Claim a task
82
+ $ optimal project list List all projects
83
+ $ optimal publish-instagram --brand CRE-11TRUST Publish to Instagram
84
+ $ optimal social-queue --brand CRE-11TRUST View social post queue
85
+ $ optimal generate-newsletter --brand CRE-11TRUST Generate a newsletter
86
+ $ optimal audit-financials --months 2025-01 Audit a single month
87
+ $ optimal export-kpis --format csv > kpis.csv Export KPIs as CSV
88
+ $ optimal deploy dashboard --prod Deploy to production
89
+ $ optimal bot agents List active bot agents
90
+ $ optimal config doctor Validate local config
91
+ `)
92
+
93
+ // --- Board commands ---
94
+ const board = program.command('board').description('Kanban board operations')
95
+ .addHelpText('after', `
96
+ Examples:
97
+ $ optimal board view Show full board
98
+ $ optimal board view -p cli-consolidation Filter by project
99
+ $ optimal board view -s ready --mine bot1 Show bot1's ready tasks
100
+ $ optimal board create -t "Fix bug" -p cli-consolidation
101
+ $ optimal board claim --id <uuid> --agent bot1
102
+ $ optimal board log --actor bot1 --limit 5
103
+ `)
104
+
105
+ board
106
+ .command('view')
107
+ .description('Display the kanban board')
108
+ .option('-p, --project <slug>', 'Project slug')
109
+ .option('-s, --status <status>', 'Filter by status')
110
+ .option('--mine <agent>', 'Show only tasks claimed by agent')
111
+ .action(async (opts) => {
112
+ const filters: { project_id?: string; status?: TaskStatus; claimed_by?: string } = {}
113
+ if (opts.project) {
114
+ const proj = await getProjectBySlug(opts.project)
115
+ filters.project_id = proj.id
116
+ }
117
+ if (opts.status) filters.status = opts.status as TaskStatus
118
+ if (opts.mine) filters.claimed_by = opts.mine
119
+ const tasks = await listTasks(filters)
120
+ console.log(formatBoardTable(tasks))
121
+ })
122
+
123
+ board
124
+ .command('create')
125
+ .description('Create a new task')
126
+ .addHelpText('after', `
127
+ Example:
128
+ $ optimal board create -t "Migrate auth" -p cli-consolidation --priority 1 --labels infra,migration
129
+ `)
130
+ .requiredOption('-t, --title <title>', 'Task title')
131
+ .requiredOption('-p, --project <slug>', 'Project slug')
132
+ .option('-d, --description <desc>', 'Task description')
133
+ .option('--priority <n>', 'Priority 1-4', '3')
134
+ .option('--skill <ref>', 'Skill reference')
135
+ .option('--source <repo>', 'Source repo')
136
+ .option('--target <module>', 'Target module')
137
+ .option('--effort <size>', 'Effort: xs, s, m, l, xl')
138
+ .option('--blocked-by <ids>', 'Comma-separated blocking task IDs')
139
+ .option('--labels <labels>', 'Comma-separated labels')
140
+ .action(async (opts) => {
141
+ const project = await getProjectBySlug(opts.project)
142
+ const task = await createTask({
143
+ project_id: project.id,
144
+ title: opts.title,
145
+ description: opts.description,
146
+ priority: parseInt(opts.priority) as 1 | 2 | 3 | 4,
147
+ skill_required: opts.skill,
148
+ source_repo: opts.source,
149
+ target_module: opts.target,
150
+ estimated_effort: opts.effort,
151
+ blocked_by: opts.blockedBy?.split(',') ?? [],
152
+ labels: opts.labels?.split(',') ?? [],
153
+ })
154
+ success(`Created task: ${colorize(task.id, 'dim')}\n ${task.title} [${statusBadge(task.status)}] ${priorityBadge(task.priority)}`)
155
+ })
156
+
157
+ board
158
+ .command('update')
159
+ .description('Update a task')
160
+ .requiredOption('--id <uuid>', 'Task ID')
161
+ .option('-s, --status <status>', 'New status')
162
+ .option('-a, --agent <name>', 'Assign to agent')
163
+ .option('--priority <n>', 'New priority')
164
+ .option('-m, --message <msg>', 'Log message (adds comment)')
165
+ .action(async (opts) => {
166
+ const updates: Record<string, unknown> = {}
167
+ if (opts.status) updates.status = opts.status
168
+ if (opts.agent) updates.assigned_to = opts.agent
169
+ if (opts.priority) updates.priority = parseInt(opts.priority)
170
+ if (opts.status === 'done') updates.completed_at = new Date().toISOString()
171
+ const task = await updateTask(opts.id, updates, opts.agent ?? 'cli')
172
+ if (opts.message) await addComment({ task_id: task.id, author: opts.agent ?? 'cli', body: opts.message })
173
+ success(`Updated: ${task.title} -> ${statusBadge(task.status)}`)
174
+ })
175
+
176
+ board
177
+ .command('claim')
178
+ .description('Claim a task (bot pull model)')
179
+ .requiredOption('--id <uuid>', 'Task ID')
180
+ .requiredOption('--agent <name>', 'Agent name')
181
+ .action(async (opts) => {
182
+ const task = await claimTask(opts.id, opts.agent)
183
+ success(`Claimed: ${colorize(task.title, 'cyan')} by ${colorize(opts.agent, 'bold')}`)
184
+ })
185
+
186
+ board
187
+ .command('comment')
188
+ .description('Add a comment to a task')
189
+ .requiredOption('--id <uuid>', 'Task ID')
190
+ .requiredOption('--author <name>', 'Author name')
191
+ .requiredOption('--body <text>', 'Comment body')
192
+ .action(async (opts) => {
193
+ const comment = await addComment({ task_id: opts.id, author: opts.author, body: opts.body })
194
+ success(`Comment added by ${colorize(comment.author, 'bold')} at ${colorize(comment.created_at, 'dim')}`)
195
+ })
196
+
197
+ board
198
+ .command('log')
199
+ .description('View activity log')
200
+ .option('--task <uuid>', 'Filter by task ID')
201
+ .option('--actor <name>', 'Filter by actor')
202
+ .option('--limit <n>', 'Max entries', '20')
203
+ .action(async (opts) => {
204
+ const entries = await listActivity({
205
+ task_id: opts.task,
206
+ actor: opts.actor,
207
+ limit: parseInt(opts.limit),
208
+ })
209
+ for (const e of entries) {
210
+ console.log(`${e.created_at} | ${e.actor.padEnd(8)} | ${e.action.padEnd(15)} | ${JSON.stringify(e.new_value ?? {})}`)
211
+ }
212
+ console.log(`\n${entries.length} entries`)
213
+ })
214
+
215
+ // --- Project commands ---
216
+ const proj = program.command('project').description('Project management')
217
+ .addHelpText('after', `
218
+ Examples:
219
+ $ optimal project list
220
+ $ optimal project create --slug my-proj --name "My Project" --priority 1
221
+ $ optimal project update --slug my-proj -s active
222
+ `)
223
+
224
+ proj
225
+ .command('list')
226
+ .description('List all projects')
227
+ .action(async () => {
228
+ const projects = await listProjects()
229
+ console.log('| Status | P | Slug | Owner | Name |')
230
+ console.log('|----------|---|-------------------------|---------|------|')
231
+ for (const p of projects) {
232
+ console.log(`| ${p.status.padEnd(8)} | ${p.priority} | ${p.slug.padEnd(23)} | ${(p.owner ?? '—').padEnd(7)} | ${p.name} |`)
233
+ }
234
+ })
235
+
236
+ proj
237
+ .command('create')
238
+ .description('Create a project')
239
+ .requiredOption('--slug <slug>', 'Project slug')
240
+ .requiredOption('--name <name>', 'Project name')
241
+ .option('--owner <name>', 'Owner')
242
+ .option('--priority <n>', 'Priority 1-4', '3')
243
+ .action(async (opts) => {
244
+ const p = await createProject({
245
+ slug: opts.slug,
246
+ name: opts.name,
247
+ owner: opts.owner,
248
+ priority: parseInt(opts.priority) as 1 | 2 | 3 | 4,
249
+ })
250
+ success(`Created project: ${colorize(p.slug, 'cyan')} (${colorize(p.id, 'dim')})`)
251
+ })
252
+
253
+ proj
254
+ .command('update')
255
+ .description('Update a project')
256
+ .requiredOption('--slug <slug>', 'Project slug')
257
+ .option('-s, --status <status>', 'New status')
258
+ .option('--owner <name>', 'New owner')
259
+ .action(async (opts) => {
260
+ const updates: Record<string, unknown> = {}
261
+ if (opts.status) updates.status = opts.status
262
+ if (opts.owner) updates.owner = opts.owner
263
+ const p = await updateProject(opts.slug, updates)
264
+ success(`Updated project: ${colorize(p.slug, 'cyan')} -> ${statusBadge(p.status)}`)
265
+ })
266
+
267
+ // --- Milestone commands ---
268
+ const ms = program.command('milestone').description('Milestone management')
269
+ .addHelpText('after', `
270
+ Examples:
271
+ $ optimal milestone list --project cli-consolidation
272
+ $ optimal milestone create --project cli-consolidation --name "v1.0" --due 2026-04-01
273
+ `)
274
+
275
+ ms
276
+ .command('create')
277
+ .description('Create a milestone')
278
+ .requiredOption('--project <slug>', 'Project slug')
279
+ .requiredOption('--name <name>', 'Milestone name')
280
+ .option('--due <date>', 'Due date (YYYY-MM-DD)')
281
+ .action(async (opts) => {
282
+ const project = await getProjectBySlug(opts.project)
283
+ const m = await createMilestone({ project_id: project.id, name: opts.name, due_date: opts.due })
284
+ success(`Created milestone: ${colorize(m.name, 'cyan')} (${colorize(m.id, 'dim')})`)
285
+ })
286
+
287
+ ms
288
+ .command('list')
289
+ .description('List milestones')
290
+ .option('--project <slug>', 'Filter by project')
291
+ .action(async (opts) => {
292
+ let projectId: string | undefined
293
+ if (opts.project) {
294
+ const p = await getProjectBySlug(opts.project)
295
+ projectId = p.id
296
+ }
297
+ const milestones = await listMilestones(projectId)
298
+ for (const m of milestones) {
299
+ console.log(`${m.status.padEnd(10)} | ${m.due_date ?? 'no date'} | ${m.name}`)
300
+ }
301
+ })
302
+
303
+ // --- Label commands ---
304
+ const lbl = program.command('label').description('Label management')
305
+
306
+ lbl
307
+ .command('create')
308
+ .description('Create a label')
309
+ .requiredOption('--name <name>', 'Label name')
310
+ .option('--color <hex>', 'Color hex code')
311
+ .action(async (opts) => {
312
+ const l = await createLabel(opts.name, opts.color)
313
+ success(`Created label: ${colorize(l.name, 'cyan')} (${colorize(l.id, 'dim')})`)
314
+ })
315
+
316
+ lbl
317
+ .command('list')
318
+ .description('List all labels')
319
+ .action(async () => {
320
+ const labels = await listLabels()
321
+ for (const l of labels) console.log(`${l.name}${l.color ? ` (${l.color})` : ''}`)
322
+ })
323
+
324
+ // Audit financials command
325
+ program
326
+ .command('audit-financials')
327
+ .description('Compare staged financials against confirmed income statements')
328
+ .option('--months <csv>', 'Comma-separated YYYY-MM months to audit (default: all)')
329
+ .option('--tolerance <n>', 'Dollar tolerance for match detection', '1.00')
330
+ .action(async (opts) => {
331
+ const months = opts.months
332
+ ? opts.months.split(',').map((m: string) => m.trim())
333
+ : undefined
334
+ const tolerance = parseFloat(opts.tolerance)
335
+
336
+ console.log('Fetching financial data...')
337
+ const result = await runAuditComparison(months, tolerance)
338
+
339
+ console.log(`\nStaging rows: ${result.totalStagingRows} | Confirmed rows: ${result.totalConfirmedRows}`)
340
+ console.log(`Tolerance: $${tolerance.toFixed(2)}\n`)
341
+
342
+ // Header
343
+ console.log(
344
+ '| Month | Confirmed | Staged | Match | SignFlip | Mismatch | C-Only | S-Only | Accuracy |'
345
+ )
346
+ console.log(
347
+ '|---------|-----------|--------|-------|---------|----------|--------|--------|----------|'
348
+ )
349
+
350
+ let flagged = false
351
+ for (const s of result.summaries) {
352
+ const acc = s.accuracy !== null ? `${s.accuracy}%` : 'N/A'
353
+ const warn = s.accuracy !== null && s.accuracy < 100 ? ' *' : ''
354
+ if (warn) flagged = true
355
+
356
+ console.log(
357
+ `| ${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)} |`
358
+ )
359
+ }
360
+
361
+ if (flagged) {
362
+ console.log('\n* Months below 100% accuracy — investigate mismatches')
363
+ }
364
+
365
+ // Totals row
366
+ if (result.summaries.length > 1) {
367
+ const totals = result.summaries.reduce(
368
+ (acc, s) => ({
369
+ confirmed: acc.confirmed + s.confirmedAccounts,
370
+ staged: acc.staged + s.stagedAccounts,
371
+ exact: acc.exact + s.exactMatch,
372
+ flip: acc.flip + s.signFlipMatch,
373
+ mismatch: acc.mismatch + s.mismatch,
374
+ cOnly: acc.cOnly + s.confirmedOnly,
375
+ sOnly: acc.sOnly + s.stagingOnly,
376
+ }),
377
+ { confirmed: 0, staged: 0, exact: 0, flip: 0, mismatch: 0, cOnly: 0, sOnly: 0 },
378
+ )
379
+ const totalOverlap = totals.exact + totals.flip + totals.mismatch
380
+ const totalAcc = totalOverlap > 0
381
+ ? Math.round(((totals.exact + totals.flip) / totalOverlap) * 1000) / 10
382
+ : null
383
+
384
+ console.log(
385
+ `| 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)} |`
386
+ )
387
+ }
388
+ })
389
+
390
+ // Export KPIs command
391
+ program
392
+ .command('export-kpis')
393
+ .description('Export KPI totals by program/client from ReturnPro financial data')
394
+ .option('--months <csv>', 'Comma-separated YYYY-MM months (default: 3 most recent)')
395
+ .option('--programs <csv>', 'Comma-separated program name substrings to filter')
396
+ .option('--format <fmt>', 'Output format: table or csv', 'table')
397
+ .action(async (opts) => {
398
+ const months = opts.months
399
+ ? opts.months.split(',').map((m: string) => m.trim())
400
+ : undefined
401
+ const programs = opts.programs
402
+ ? opts.programs.split(',').map((p: string) => p.trim())
403
+ : undefined
404
+ const format: string = opts.format
405
+
406
+ if (format !== 'table' && format !== 'csv') {
407
+ console.error(`Invalid format "${format}". Use "table" or "csv".`)
408
+ process.exit(1)
409
+ }
410
+
411
+ console.error('Fetching KPI data...')
412
+ const rows = await exportKpis({ months, programs })
413
+ console.error(`Fetched ${rows.length} KPI rows`)
414
+
415
+ if (format === 'csv') {
416
+ console.log(formatKpiCsv(rows))
417
+ } else {
418
+ console.log(formatKpiTable(rows))
419
+ }
420
+ })
421
+
422
+ // Deploy command
423
+ program
424
+ .command('deploy')
425
+ .description('Deploy an app to Vercel (preview or production)')
426
+ .argument('<app>', `App to deploy (${listApps().join(', ')})`)
427
+ .option('--prod', 'Deploy to production', false)
428
+ .action(async (app: string, opts: { prod: boolean }) => {
429
+ fmtInfo(`Deploying ${colorize(app, 'cyan')}${opts.prod ? colorize(' (production)', 'yellow') : ' (preview)'}...`)
430
+ try {
431
+ const url = await deploy(app, opts.prod)
432
+ success(`Deployed: ${colorize(url, 'green')}`)
433
+ } catch (err) {
434
+ const msg = err instanceof Error ? err.message : String(err)
435
+ fmtError(`Deploy failed: ${msg}`)
436
+ process.exit(1)
437
+ }
438
+ })
439
+
440
+ // Health check command
441
+ program
442
+ .command('health-check')
443
+ .description('Run health check across all Optimal services')
444
+ .action(async () => {
445
+ try {
446
+ const output = await healthCheck()
447
+ console.log(output)
448
+ } catch (err) {
449
+ const msg = err instanceof Error ? err.message : String(err)
450
+ console.error(`Health check failed: ${msg}`)
451
+ process.exit(1)
452
+ }
453
+ })
454
+
455
+ // Budget projection commands
456
+
457
+ async function loadProjectionData(opts: {
458
+ file?: string
459
+ fiscalYear?: string
460
+ userId?: string
461
+ }) {
462
+ if (opts.file) {
463
+ const raw = readFileSync(opts.file, 'utf-8')
464
+ return parseSummaryFromJson(raw)
465
+ }
466
+ const fy = opts.fiscalYear ? parseInt(opts.fiscalYear) : 2025
467
+ return fetchWesImports({ fiscalYear: fy, userId: opts.userId })
468
+ }
469
+
470
+ function resolveAdjustmentType(
471
+ raw?: string,
472
+ ): 'percentage' | 'flat' {
473
+ if (raw === 'flat') return 'flat'
474
+ return 'percentage'
475
+ }
476
+
477
+ program
478
+ .command('project-budget')
479
+ .description('Run FY26 budget projections with adjustments on FY25 checked-in units')
480
+ .option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
481
+ .option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
482
+ .option('--format <fmt>', 'Output format: table or csv', 'table')
483
+ .option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
484
+ .option('--user-id <uuid>', 'Supabase user UUID to filter by')
485
+ .option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
486
+ .action(async (opts) => {
487
+ const format: string = opts.format
488
+ if (format !== 'table' && format !== 'csv') {
489
+ console.error(`Invalid format "${format}". Use "table" or "csv".`)
490
+ process.exit(1)
491
+ }
492
+
493
+ console.error('Loading projection data...')
494
+ const summary = await loadProjectionData(opts)
495
+ console.error(`Loaded ${summary.length} programs`)
496
+
497
+ let projections = initializeProjections(summary)
498
+ const adjType = resolveAdjustmentType(opts.adjustmentType)
499
+ const adjValue = parseFloat(opts.adjustmentValue)
500
+
501
+ if (adjValue !== 0) {
502
+ projections = applyUniformAdjustment(projections, adjType, adjValue)
503
+ console.error(
504
+ `Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`,
505
+ )
506
+ }
507
+
508
+ const totals = calculateTotals(projections)
509
+ console.error(
510
+ `Totals: ${totals.totalActual} actual -> ${totals.totalProjected} projected (${totals.percentageChange >= 0 ? '+' : ''}${totals.percentageChange.toFixed(1)}%)`,
511
+ )
512
+
513
+ if (format === 'csv') {
514
+ console.log(exportToCSV(projections))
515
+ } else {
516
+ console.log(formatProjectionTable(projections))
517
+ }
518
+ })
519
+
520
+ program
521
+ .command('export-budget')
522
+ .description('Export FY26 budget projections as CSV')
523
+ .option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
524
+ .option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
525
+ .option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
526
+ .option('--user-id <uuid>', 'Supabase user UUID to filter by')
527
+ .option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
528
+ .action(async (opts) => {
529
+ console.error('Loading projection data...')
530
+ const summary = await loadProjectionData(opts)
531
+ console.error(`Loaded ${summary.length} programs`)
532
+
533
+ let projections = initializeProjections(summary)
534
+ const adjType = resolveAdjustmentType(opts.adjustmentType)
535
+ const adjValue = parseFloat(opts.adjustmentValue)
536
+
537
+ if (adjValue !== 0) {
538
+ projections = applyUniformAdjustment(projections, adjType, adjValue)
539
+ console.error(
540
+ `Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`,
541
+ )
542
+ }
543
+
544
+ console.log(exportToCSV(projections))
545
+ })
546
+
547
+ // Newsletter generation command
548
+ program
549
+ .command('generate-newsletter')
550
+ .description('Generate a branded newsletter with AI content and push to Strapi CMS')
551
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
552
+ .option('--date <date>', 'Edition date as YYYY-MM-DD (default: today)')
553
+ .option('--excel <path>', 'Path to Excel file with property listings (CRE-11TRUST only)')
554
+ .option('--dry-run', 'Generate content but do NOT push to Strapi', false)
555
+ .action(async (opts: { brand: string; date?: string; excel?: string; dryRun: boolean }) => {
556
+ try {
557
+ const result = await generateNewsletter({
558
+ brand: opts.brand,
559
+ date: opts.date,
560
+ excelPath: opts.excel,
561
+ dryRun: opts.dryRun,
562
+ })
563
+
564
+ if (result.strapiDocumentId) {
565
+ success(`Strapi documentId: ${colorize(result.strapiDocumentId, 'cyan')}`)
566
+ }
567
+ } catch (err) {
568
+ const msg = err instanceof Error ? err.message : String(err)
569
+ fmtError(`Newsletter generation failed: ${msg}`)
570
+ process.exit(1)
571
+ }
572
+ })
573
+
574
+ // Scrape Meta Ad Library command
575
+ program
576
+ .command('scrape-ads')
577
+ .description('Scrape Meta Ad Library for competitor ad intelligence')
578
+ .requiredOption(
579
+ '--companies <csv-or-file>',
580
+ 'Comma-separated company names or path to a text file (one per line)',
581
+ )
582
+ .option('--output <path>', 'Save CSV results to file (default: stdout)')
583
+ .option('--batch-size <n>', 'Companies per batch', '6')
584
+ .action(
585
+ async (opts: {
586
+ companies: string
587
+ output?: string
588
+ batchSize: string
589
+ }) => {
590
+ // Parse companies: file path or comma-separated list
591
+ let companies: string[]
592
+ if (existsSync(opts.companies)) {
593
+ const raw = readFileSync(opts.companies, 'utf-8')
594
+ companies = raw
595
+ .split('\n')
596
+ .map((l) => l.trim())
597
+ .filter((l) => l.length > 0 && !l.startsWith('#'))
598
+ } else {
599
+ companies = opts.companies
600
+ .split(',')
601
+ .map((c) => c.trim())
602
+ .filter((c) => c.length > 0)
603
+ }
604
+
605
+ if (companies.length === 0) {
606
+ console.error('No companies specified')
607
+ process.exit(1)
608
+ }
609
+
610
+ const batchSize = parseInt(opts.batchSize)
611
+ if (isNaN(batchSize) || batchSize < 1) {
612
+ console.error('Invalid batch size')
613
+ process.exit(1)
614
+ }
615
+
616
+ try {
617
+ const result = await scrapeCompanies({
618
+ companies,
619
+ outputPath: opts.output,
620
+ batchSize,
621
+ })
622
+
623
+ // If no output file, write CSV to stdout
624
+ if (!opts.output) {
625
+ process.stdout.write(formatCsv(result.ads))
626
+ }
627
+ } catch (err) {
628
+ const msg = err instanceof Error ? err.message : String(err)
629
+ console.error(`Scrape failed: ${msg}`)
630
+ process.exit(1)
631
+ }
632
+ },
633
+ )
634
+
635
+ // Ingest transactions command
636
+ program
637
+ .command('ingest-transactions')
638
+ .description('Parse & deduplicate bank CSV files into the transactions table')
639
+ .requiredOption('--file <path>', 'Path to the CSV file')
640
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
641
+ .action(async (opts: { file: string; userId: string }) => {
642
+ if (!existsSync(opts.file)) {
643
+ console.error(`File not found: ${opts.file}`)
644
+ process.exit(1)
645
+ }
646
+
647
+ console.log(`Ingesting transactions from: ${opts.file}`)
648
+ try {
649
+ const result = await ingestTransactions(opts.file, opts.userId)
650
+
651
+ console.log(`\nFormat detected: ${result.format}`)
652
+ console.log(
653
+ `Inserted: ${result.inserted} | Skipped (duplicates): ${result.skipped} | Failed: ${result.failed}`,
654
+ )
655
+
656
+ if (result.errors.length > 0) {
657
+ console.log(`\nWarnings/Errors (${result.errors.length}):`)
658
+ for (const err of result.errors.slice(0, 20)) {
659
+ console.log(` - ${err}`)
660
+ }
661
+ if (result.errors.length > 20) {
662
+ console.log(` ... and ${result.errors.length - 20} more`)
663
+ }
664
+ }
665
+ } catch (err) {
666
+ const msg = err instanceof Error ? err.message : String(err)
667
+ console.error(`Ingest failed: ${msg}`)
668
+ process.exit(1)
669
+ }
670
+ })
671
+
672
+ // Stamp transactions command
673
+ program
674
+ .command('stamp-transactions')
675
+ .description('Auto-categorize unclassified transactions using rule-based matching')
676
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
677
+ .option('--dry-run', 'Preview matches without writing to database', false)
678
+ .action(async (opts: { userId: string; dryRun: boolean }) => {
679
+ console.log(
680
+ `Stamping transactions for user: ${opts.userId}${opts.dryRun ? ' (DRY RUN)' : ''}`,
681
+ )
682
+ try {
683
+ const result = await stampTransactions(opts.userId, { dryRun: opts.dryRun })
684
+
685
+ console.log(`\nTotal unclassified: ${result.total}`)
686
+ console.log(`Stamped: ${result.stamped} | Unmatched: ${result.unmatched}`)
687
+ console.log(
688
+ `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}`,
689
+ )
690
+
691
+ if (result.dryRun) {
692
+ console.log('\n(Dry run — no database changes made)')
693
+ }
694
+ } catch (err) {
695
+ const msg = err instanceof Error ? err.message : String(err)
696
+ console.error(`Stamp failed: ${msg}`)
697
+ process.exit(1)
698
+ }
699
+ })
700
+
701
+ // ── Upload R1 data ──────────────────────────────────────────────────
702
+ program
703
+ .command('upload-r1')
704
+ .description('Upload R1 XLSX file to ReturnPro staging')
705
+ .requiredOption('--file <path>', 'Path to R1 XLSX file')
706
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
707
+ .requiredOption('--month <YYYY-MM>', 'Month in YYYY-MM format')
708
+ .action(async (opts: { file: string; userId: string; month: string }) => {
709
+ if (!existsSync(opts.file)) {
710
+ console.error(`File not found: ${opts.file}`)
711
+ process.exit(1)
712
+ }
713
+ try {
714
+ const result = await processR1Upload(opts.file, opts.userId, opts.month)
715
+ console.log(`R1 upload complete: ${result.rowsInserted} rows inserted, ${result.rowsSkipped} skipped (${result.programGroupsFound} program groups)`)
716
+ if (result.warnings.length > 0) {
717
+ console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`)
718
+ }
719
+ } catch (err) {
720
+ console.error(`R1 upload failed: ${err instanceof Error ? err.message : String(err)}`)
721
+ process.exit(1)
722
+ }
723
+ })
724
+
725
+ // ── Upload NetSuite data ────────────────────────────────────────────
726
+ program
727
+ .command('upload-netsuite')
728
+ .description('Upload NetSuite CSV/XLSX to ReturnPro staging')
729
+ .requiredOption('--file <path>', 'Path to NetSuite file (CSV, XLSX, or XLSM)')
730
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
731
+ .action(async (opts: { file: string; userId: string }) => {
732
+ if (!existsSync(opts.file)) {
733
+ console.error(`File not found: ${opts.file}`)
734
+ process.exit(1)
735
+ }
736
+ try {
737
+ const result = await processNetSuiteUpload(opts.file, opts.userId)
738
+ console.log(`NetSuite upload: ${result.inserted} rows inserted (months: ${result.monthsCovered.join(', ')})`)
739
+ if (result.warnings.length > 0) {
740
+ console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`)
741
+ }
742
+ } catch (err) {
743
+ console.error(`NetSuite upload failed: ${err instanceof Error ? err.message : String(err)}`)
744
+ process.exit(1)
745
+ }
746
+ })
747
+
748
+ // ── Upload income statements ────────────────────────────────────────
749
+ program
750
+ .command('upload-income-statements')
751
+ .description('Upload confirmed income statement CSV to ReturnPro')
752
+ .requiredOption('--file <path>', 'Path to income statement CSV')
753
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
754
+ .action(async (opts: { file: string; userId: string }) => {
755
+ if (!existsSync(opts.file)) {
756
+ console.error(`File not found: ${opts.file}`)
757
+ process.exit(1)
758
+ }
759
+ try {
760
+ const result = await uploadIncomeStatements(opts.file, opts.userId)
761
+ console.log(`Income statements: ${result.upserted} rows upserted, ${result.skipped} skipped (period: ${result.period})`)
762
+ } catch (err) {
763
+ console.error(`Upload failed: ${err instanceof Error ? err.message : String(err)}`)
764
+ process.exit(1)
765
+ }
766
+ })
767
+
768
+ // ── Rate anomalies ──────────────────────────────────────────────────
769
+ program
770
+ .command('rate-anomalies')
771
+ .description('Detect rate anomalies via z-score analysis on ReturnPro data')
772
+ .option('--from <YYYY-MM>', 'Start month')
773
+ .option('--to <YYYY-MM>', 'End month')
774
+ .option('--threshold <n>', 'Z-score threshold', '2.0')
775
+ .action(async (opts: { from?: string; to?: string; threshold: string }) => {
776
+ try {
777
+ const months = opts.from && opts.to
778
+ ? (() => {
779
+ const result: string[] = []
780
+ const [fy, fm] = opts.from!.split('-').map(Number)
781
+ const [ty, tm] = opts.to!.split('-').map(Number)
782
+ let y = fy, m = fm
783
+ while (y < ty || (y === ty && m <= tm)) {
784
+ result.push(`${y}-${String(m).padStart(2, '0')}`)
785
+ m++
786
+ if (m > 12) { m = 1; y++ }
787
+ }
788
+ return result
789
+ })()
790
+ : undefined
791
+ const result = await detectRateAnomalies({
792
+ months,
793
+ threshold: parseFloat(opts.threshold),
794
+ })
795
+ console.log(`Found ${result.anomalies.length} anomalies (threshold: ${opts.threshold}σ)`)
796
+ for (const a of result.anomalies.slice(0, 30)) {
797
+ console.log(` ${a.month} | ${a.program_code ?? a.master_program} | z=${a.zscore.toFixed(2)} | rate=${a.rate_per_unit}`)
798
+ }
799
+ if (result.anomalies.length > 30) console.log(` ... and ${result.anomalies.length - 30} more`)
800
+ } catch (err) {
801
+ console.error(`Anomaly detection failed: ${err instanceof Error ? err.message : String(err)}`)
802
+ process.exit(1)
803
+ }
804
+ })
805
+
806
+ // ── Diagnose months ─────────────────────────────────────────────────
807
+ program
808
+ .command('diagnose-months')
809
+ .description('Run diagnostic checks on staging data for specified months')
810
+ .option('--months <csv>', 'Comma-separated YYYY-MM months (default: all)')
811
+ .action(async (opts: { months?: string }) => {
812
+ const months = opts.months?.split(',').map(m => m.trim())
813
+ try {
814
+ const result = await diagnoseMonths(months ? { months } : undefined)
815
+ console.log(`Analysed months: ${result.monthsAnalysed.join(', ')}`)
816
+ console.log(`Total staging rows: ${result.totalRows} (median: ${result.medianRowCount}/month)\n`)
817
+ for (const issue of result.issues) {
818
+ console.log(` ✗ [${issue.kind}] ${issue.month ?? 'global'}: ${issue.message}`)
819
+ }
820
+ if (result.issues.length === 0) {
821
+ console.log(' ✓ No issues found')
822
+ }
823
+ console.log(`\nSummary: ${result.summary.totalIssues} issues found`)
824
+ } catch (err) {
825
+ console.error(`Diagnosis failed: ${err instanceof Error ? err.message : String(err)}`)
826
+ process.exit(1)
827
+ }
828
+ })
829
+
830
+ // ── Generate NetSuite template ──────────────────────────────────────
831
+ program
832
+ .command('generate-netsuite-template')
833
+ .description('Generate a blank NetSuite XLSX upload template')
834
+ .option('--output <path>', 'Output file path', 'netsuite-template.xlsx')
835
+ .action(async (opts: { output: string }) => {
836
+ try {
837
+ const result = await generateNetSuiteTemplate(opts.output)
838
+ console.log(`Template saved: ${result.outputPath} (${result.accountCount} accounts)`)
839
+ } catch (err) {
840
+ console.error(`Template generation failed: ${err instanceof Error ? err.message : String(err)}`)
841
+ process.exit(1)
842
+ }
843
+ })
844
+
845
+ // ── Distribute newsletter ───────────────────────────────────────────
846
+ program
847
+ .command('distribute-newsletter')
848
+ .description('Trigger newsletter distribution via n8n webhook')
849
+ .requiredOption('--document-id <id>', 'Strapi newsletter documentId')
850
+ .option('--channel <ch>', 'Distribution channel: email or all', 'all')
851
+ .action(async (opts: { documentId: string; channel: string }) => {
852
+ try {
853
+ const result = await distributeNewsletter(opts.documentId, {
854
+ channel: opts.channel as 'email' | 'all',
855
+ })
856
+ if (result.success) {
857
+ console.log(`Distribution triggered for ${opts.documentId} (channel: ${opts.channel})`)
858
+ } else {
859
+ console.error(`Distribution failed: ${result.error}`)
860
+ process.exit(1)
861
+ }
862
+ } catch (err) {
863
+ console.error(`Distribution failed: ${err instanceof Error ? err.message : String(err)}`)
864
+ process.exit(1)
865
+ }
866
+ })
867
+
868
+ // ── Check distribution status ───────────────────────────────────────
869
+ program
870
+ .command('distribution-status')
871
+ .description('Check delivery status of a newsletter')
872
+ .requiredOption('--document-id <id>', 'Strapi newsletter documentId')
873
+ .action(async (opts: { documentId: string }) => {
874
+ const status = await checkDistributionStatus(opts.documentId)
875
+ console.log(`Status: ${status.delivery_status}`)
876
+ if (status.delivered_at) console.log(`Delivered: ${status.delivered_at}`)
877
+ if (status.recipients_count) console.log(`Recipients: ${status.recipients_count}`)
878
+ if (status.ghl_campaign_id) console.log(`GHL Campaign: ${status.ghl_campaign_id}`)
879
+ })
880
+
881
+ // ── Generate social posts ───────────────────────────────────────────
882
+ program
883
+ .command('generate-social-posts')
884
+ .description('Generate AI-powered social media ad posts and push to Strapi')
885
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
886
+ .option('--count <n>', 'Number of posts to generate', '9')
887
+ .option('--week-of <date>', 'Week start date YYYY-MM-DD (default: next Monday)')
888
+ .option('--dry-run', 'Generate without pushing to Strapi', false)
889
+ .action(async (opts: { brand: string; count: string; weekOf?: string; dryRun: boolean }) => {
890
+ try {
891
+ const result = await generateSocialPosts({
892
+ brand: opts.brand,
893
+ count: parseInt(opts.count),
894
+ weekOf: opts.weekOf,
895
+ dryRun: opts.dryRun,
896
+ })
897
+ console.log(`Created ${result.postsCreated} posts for ${result.brand}`)
898
+ for (const p of result.posts) {
899
+ console.log(` ${p.scheduled_date} | ${p.platform} | ${p.headline}`)
900
+ }
901
+ if (result.errors.length > 0) {
902
+ console.log(`\nErrors: ${result.errors.join(', ')}`)
903
+ }
904
+ } catch (err) {
905
+ console.error(`Post generation failed: ${err instanceof Error ? err.message : String(err)}`)
906
+ process.exit(1)
907
+ }
908
+ })
909
+
910
+ // ── Publish social posts ────────────────────────────────────────────
911
+ program
912
+ .command('publish-social-posts')
913
+ .description('Publish pending social posts to platforms via n8n')
914
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
915
+ .option('--limit <n>', 'Max posts to publish')
916
+ .option('--dry-run', 'Preview without publishing', false)
917
+ .option('--retry', 'Retry previously failed posts', false)
918
+ .action(async (opts: { brand: string; limit?: string; dryRun: boolean; retry: boolean }) => {
919
+ try {
920
+ let result
921
+ if (opts.retry) {
922
+ result = await retryFailed(opts.brand)
923
+ } else {
924
+ result = await publishSocialPosts({
925
+ brand: opts.brand,
926
+ limit: opts.limit ? parseInt(opts.limit) : undefined,
927
+ dryRun: opts.dryRun,
928
+ })
929
+ }
930
+ console.log(`Published: ${result.published} | Failed: ${result.failed} | Skipped: ${result.skipped}`)
931
+ for (const d of result.details) {
932
+ console.log(` ${d.status} | ${d.headline}${d.error ? ` — ${d.error}` : ''}`)
933
+ }
934
+ } catch (err) {
935
+ console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
936
+ process.exit(1)
937
+ }
938
+ })
939
+
940
+ // ── Social post queue ───────────────────────────────────────────────
941
+ program
942
+ .command('social-queue')
943
+ .description('View pending social posts ready for publishing')
944
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
945
+ .action(async (opts: { brand: string }) => {
946
+ const queue = await getPublishQueue(opts.brand)
947
+ if (queue.length === 0) {
948
+ console.log('No posts in queue')
949
+ return
950
+ }
951
+ console.log('| Date | Platform | Headline |')
952
+ console.log('|------|----------|----------|')
953
+ for (const p of queue) {
954
+ console.log(`| ${p.scheduled_date} | ${p.platform} | ${p.headline} |`)
955
+ }
956
+ console.log(`\n${queue.length} posts queued`)
957
+ })
958
+
959
+ // ── Publish to Instagram via Meta Graph API ─────────────────────────
960
+ program
961
+ .command('publish-instagram')
962
+ .description('Publish pending social posts to Instagram via Meta Graph API')
963
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
964
+ .option('--limit <n>', 'Max posts to publish')
965
+ .option('--dry-run', 'Preview without publishing', false)
966
+ .action(async (opts: { brand: string; limit?: string; dryRun: boolean }) => {
967
+ try {
968
+ const config = getMetaConfigForBrand(opts.brand)
969
+
970
+ // Fetch pending instagram posts from Strapi
971
+ const result = await strapiGet<StrapiPage>('/api/social-posts', {
972
+ 'filters[brand][$eq]': opts.brand,
973
+ 'filters[delivery_status][$eq]': 'pending',
974
+ 'filters[platform][$eq]': 'instagram',
975
+ 'sort': 'scheduled_date:asc',
976
+ 'pagination[pageSize]': opts.limit ?? '50',
977
+ })
978
+
979
+ const posts = result.data
980
+ if (posts.length === 0) {
981
+ console.log('No pending Instagram posts found')
982
+ return
983
+ }
984
+
985
+ console.log(`Found ${posts.length} pending Instagram post(s) for ${opts.brand}`)
986
+ let published = 0
987
+ let failed = 0
988
+
989
+ for (const post of posts) {
990
+ const headline = (post.headline as string) ?? '(no headline)'
991
+ const imageUrl = post.image_url as string | undefined
992
+ const caption = ((post.body as string) ?? (post.headline as string) ?? '').trim()
993
+
994
+ if (!imageUrl) {
995
+ console.log(` SKIP | ${headline} — no image_url`)
996
+ failed++
997
+ continue
998
+ }
999
+
1000
+ if (opts.dryRun) {
1001
+ console.log(` DRY | ${headline}`)
1002
+ continue
1003
+ }
1004
+
1005
+ try {
1006
+ const igResult = await publishIgPhoto(config, { imageUrl, caption })
1007
+ await strapiPut('/api/social-posts', post.documentId, {
1008
+ delivery_status: 'delivered',
1009
+ platform_post_id: igResult.mediaId,
1010
+ })
1011
+ console.log(` OK | ${headline} → ${igResult.mediaId}`)
1012
+ published++
1013
+ } catch (err) {
1014
+ const errMsg = err instanceof Error ? err.message : String(err)
1015
+ await strapiPut('/api/social-posts', post.documentId, {
1016
+ delivery_status: 'failed',
1017
+ delivery_errors: [{ timestamp: new Date().toISOString(), error: errMsg }],
1018
+ }).catch(() => {})
1019
+ console.log(` FAIL | ${headline} — ${errMsg}`)
1020
+ failed++
1021
+ }
1022
+ }
1023
+
1024
+ console.log(`\nPublished: ${published} | Failed: ${failed}${opts.dryRun ? ' | (dry run)' : ''}`)
1025
+ } catch (err) {
1026
+ console.error(`Instagram publish failed: ${err instanceof Error ? err.message : String(err)}`)
1027
+ process.exit(1)
1028
+ }
1029
+ })
1030
+
1031
+ // ── Publish blog ────────────────────────────────────────────────────
1032
+ program
1033
+ .command('publish-blog')
1034
+ .description('Publish a Strapi blog post and optionally deploy portfolio site')
1035
+ .requiredOption('--slug <slug>', 'Blog post slug')
1036
+ .option('--deploy', 'Deploy portfolio site after publishing', false)
1037
+ .action(async (opts: { slug: string; deploy: boolean }) => {
1038
+ try {
1039
+ const result = await publishBlog({ slug: opts.slug, deployAfter: opts.deploy })
1040
+ console.log(`Published: ${result.slug} (${result.documentId})`)
1041
+ if (result.deployUrl) console.log(`Deployed: ${result.deployUrl}`)
1042
+ } catch (err) {
1043
+ console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
1044
+ process.exit(1)
1045
+ }
1046
+ })
1047
+
1048
+ // ── Blog drafts ─────────────────────────────────────────────────────
1049
+ program
1050
+ .command('blog-drafts')
1051
+ .description('List unpublished blog post drafts')
1052
+ .option('--site <site>', 'Filter by site (portfolio, insurance)')
1053
+ .action(async (opts: { site?: string }) => {
1054
+ const drafts = await listBlogDrafts(opts.site)
1055
+ if (drafts.length === 0) {
1056
+ console.log('No drafts found')
1057
+ return
1058
+ }
1059
+ console.log('| Created | Site | Title | Slug |')
1060
+ console.log('|---------|------|-------|------|')
1061
+ for (const d of drafts) {
1062
+ console.log(`| ${d.createdAt.slice(0, 10)} | ${d.site} | ${d.title} | ${d.slug} |`)
1063
+ }
1064
+ })
1065
+
1066
+ // ── Database migration ──────────────────────────────────────────────
1067
+ const migrate = program.command('migrate').description('Supabase database migration operations')
1068
+ .addHelpText('after', `
1069
+ Examples:
1070
+ $ optimal migrate pending --target optimalos
1071
+ $ optimal migrate push --target returnpro --dry-run
1072
+ $ optimal migrate create --target optimalos --name "add-index"
1073
+ `)
1074
+
1075
+ migrate
1076
+ .command('push')
1077
+ .description('Run supabase db push --linked on a target project')
1078
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
1079
+ .option('--dry-run', 'Preview without applying', false)
1080
+ .action(async (opts: { target: string; dryRun: boolean }) => {
1081
+ const target = opts.target as 'returnpro' | 'optimalos'
1082
+ if (target !== 'returnpro' && target !== 'optimalos') {
1083
+ console.error('Target must be "returnpro" or "optimalos"')
1084
+ process.exit(1)
1085
+ }
1086
+ console.log(`Migrating ${target}${opts.dryRun ? ' (dry run)' : ''}...`)
1087
+ const result = await migrateDb({ target, dryRun: opts.dryRun })
1088
+ if (result.success) {
1089
+ console.log(result.output)
1090
+ } else {
1091
+ console.error(`Migration failed:\n${result.errors}`)
1092
+ process.exit(1)
1093
+ }
1094
+ })
1095
+
1096
+ migrate
1097
+ .command('pending')
1098
+ .description('List pending migration files')
1099
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
1100
+ .action(async (opts: { target: string }) => {
1101
+ const files = await listPendingMigrations(opts.target as 'returnpro' | 'optimalos')
1102
+ if (files.length === 0) {
1103
+ console.log('No migration files found')
1104
+ return
1105
+ }
1106
+ for (const f of files) console.log(` ${f}`)
1107
+ console.log(`\n${files.length} migration files`)
1108
+ })
1109
+
1110
+ migrate
1111
+ .command('create')
1112
+ .description('Create a new empty migration file')
1113
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
1114
+ .requiredOption('--name <name>', 'Migration name')
1115
+ .action(async (opts: { target: string; name: string }) => {
1116
+ const path = await createMigration(opts.target as 'returnpro' | 'optimalos', opts.name)
1117
+ console.log(`Created: ${path}`)
1118
+ })
1119
+
1120
+ // ── Budget scenarios ────────────────────────────────────────────────
1121
+ const scenario = program.command('scenario').description('Budget scenario management')
1122
+ .addHelpText('after', `
1123
+ Examples:
1124
+ $ optimal scenario list
1125
+ $ optimal scenario save --name "4pct-growth" --adjustment-type percentage --adjustment-value 4
1126
+ $ optimal scenario compare --names "baseline,4pct-growth"
1127
+ $ optimal scenario delete --name "old-scenario"
1128
+ `)
1129
+
1130
+ scenario
1131
+ .command('save')
1132
+ .description('Save current projections as a named scenario')
1133
+ .requiredOption('--name <name>', 'Scenario name')
1134
+ .requiredOption('--adjustment-type <type>', 'Adjustment type: percentage or flat')
1135
+ .requiredOption('--adjustment-value <n>', 'Adjustment value')
1136
+ .option('--description <desc>', 'Description')
1137
+ .option('--fiscal-year <fy>', 'Fiscal year', '2025')
1138
+ .option('--user-id <uuid>', 'User UUID')
1139
+ .action(async (opts) => {
1140
+ try {
1141
+ const path = await saveScenario({
1142
+ name: opts.name,
1143
+ adjustmentType: opts.adjustmentType as 'percentage' | 'flat',
1144
+ adjustmentValue: parseFloat(opts.adjustmentValue),
1145
+ fiscalYear: parseInt(opts.fiscalYear),
1146
+ userId: opts.userId,
1147
+ description: opts.description,
1148
+ })
1149
+ console.log(`Scenario saved: ${path}`)
1150
+ } catch (err) {
1151
+ console.error(`Save failed: ${err instanceof Error ? err.message : String(err)}`)
1152
+ process.exit(1)
1153
+ }
1154
+ })
1155
+
1156
+ scenario
1157
+ .command('list')
1158
+ .description('List all saved budget scenarios')
1159
+ .action(async () => {
1160
+ const scenarios = await listScenarios()
1161
+ if (scenarios.length === 0) {
1162
+ console.log('No scenarios saved')
1163
+ return
1164
+ }
1165
+ console.log('| Name | Adjustment | Projected | Change | Created |')
1166
+ console.log('|------|------------|-----------|--------|---------|')
1167
+ for (const s of scenarios) {
1168
+ const adj = s.adjustmentType === 'percentage' ? `${s.adjustmentValue}%` : `+${s.adjustmentValue}`
1169
+ console.log(`| ${s.name} | ${adj} | ${s.totalProjected.toLocaleString()} | ${s.percentageChange.toFixed(1)}% | ${s.createdAt.slice(0, 10)} |`)
1170
+ }
1171
+ })
1172
+
1173
+ scenario
1174
+ .command('compare')
1175
+ .description('Compare two or more scenarios side by side')
1176
+ .requiredOption('--names <csv>', 'Comma-separated scenario names')
1177
+ .action(async (opts: { names: string }) => {
1178
+ const names = opts.names.split(',').map(n => n.trim())
1179
+ if (names.length < 2) {
1180
+ console.error('Need at least 2 scenario names to compare')
1181
+ process.exit(1)
1182
+ }
1183
+ try {
1184
+ const result = await compareScenarios(names)
1185
+ // Print header
1186
+ const header = ['Program', 'Actual', ...result.scenarioNames].join(' | ')
1187
+ console.log(`| ${header} |`)
1188
+ console.log(`|${result.scenarioNames.map(() => '---').concat(['---', '---']).join('|')}|`)
1189
+ for (const p of result.programs.slice(0, 50)) {
1190
+ const vals = result.scenarioNames.map(n => String(p.projectedByScenario[n] ?? 0))
1191
+ console.log(`| ${p.programCode} | ${p.actual} | ${vals.join(' | ')} |`)
1192
+ }
1193
+ // Totals
1194
+ console.log('\nTotals:')
1195
+ for (const name of result.scenarioNames) {
1196
+ const t = result.totalsByScenario[name]
1197
+ console.log(` ${name}: ${t.totalProjected.toLocaleString()} (${t.percentageChange >= 0 ? '+' : ''}${t.percentageChange.toFixed(1)}%)`)
1198
+ }
1199
+ } catch (err) {
1200
+ console.error(`Compare failed: ${err instanceof Error ? err.message : String(err)}`)
1201
+ process.exit(1)
1202
+ }
1203
+ })
1204
+
1205
+ scenario
1206
+ .command('delete')
1207
+ .description('Delete a saved scenario')
1208
+ .requiredOption('--name <name>', 'Scenario name')
1209
+ .action(async (opts: { name: string }) => {
1210
+ try {
1211
+ await deleteScenario(opts.name)
1212
+ console.log(`Deleted scenario: ${opts.name}`)
1213
+ } catch (err) {
1214
+ console.error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`)
1215
+ process.exit(1)
1216
+ }
1217
+ })
1218
+
1219
+ // ── Delete batch ────────────────────────────────────────────────────
1220
+ program
1221
+ .command('delete-batch')
1222
+ .description('Batch delete transactions or staging rows (safe: dry-run by default)')
1223
+ .requiredOption('--table <t>', 'Table: transactions or stg_financials_raw')
1224
+ .option('--user-id <uuid>', 'User UUID filter')
1225
+ .option('--date-from <date>', 'Start date YYYY-MM-DD')
1226
+ .option('--date-to <date>', 'End date YYYY-MM-DD')
1227
+ .option('--source <src>', 'Source filter')
1228
+ .option('--category <cat>', 'Category filter (transactions)')
1229
+ .option('--account-code <code>', 'Account code filter (staging)')
1230
+ .option('--month <YYYY-MM>', 'Month filter (staging)')
1231
+ .option('--execute', 'Actually delete (default is dry-run preview)', false)
1232
+ .action(async (opts) => {
1233
+ const table = opts.table as 'transactions' | 'stg_financials_raw'
1234
+ const filters = {
1235
+ dateFrom: opts.dateFrom,
1236
+ dateTo: opts.dateTo,
1237
+ source: opts.source,
1238
+ category: opts.category,
1239
+ accountCode: opts.accountCode,
1240
+ month: opts.month,
1241
+ }
1242
+ const dryRun = !opts.execute
1243
+
1244
+ if (dryRun) {
1245
+ const preview = await previewBatch({ table, userId: opts.userId, filters })
1246
+ console.log(`Preview: ${preview.matchCount} rows would be deleted from ${table}`)
1247
+ if (Object.keys(preview.groupedCounts).length > 0) {
1248
+ console.log('\nGrouped:')
1249
+ for (const [key, count] of Object.entries(preview.groupedCounts)) {
1250
+ console.log(` ${key}: ${count}`)
1251
+ }
1252
+ }
1253
+ if (preview.sample.length > 0) {
1254
+ console.log(`\nSample (first ${preview.sample.length}):`)
1255
+ for (const row of preview.sample) {
1256
+ console.log(` ${JSON.stringify(row)}`)
1257
+ }
1258
+ }
1259
+ console.log('\nUse --execute to actually delete')
1260
+ } else {
1261
+ const result = await deleteBatch({ table, userId: opts.userId, filters, dryRun: false })
1262
+ console.log(`Deleted ${result.deletedCount} rows from ${table}`)
1263
+ }
1264
+ })
1265
+
1266
+ // ── Config registry (v1 scaffold) ─────────────────────────────────
1267
+ const config = program.command('config').description('Manage optimal-cli local/shared config profile')
1268
+ .addHelpText('after', `
1269
+ Examples:
1270
+ $ optimal config init --owner oracle --brand CRE-11TRUST
1271
+ $ optimal config doctor
1272
+ $ optimal config export --out ./backup.json
1273
+ $ optimal config import --in ./backup.json
1274
+ $ optimal config sync pull
1275
+ $ optimal config sync push --agent bot1
1276
+ `)
1277
+
1278
+ config
1279
+ .command('init')
1280
+ .description('Create a local config scaffold (overwrites with --force)')
1281
+ .option('--owner <owner>', 'Config owner (default: $OPTIMAL_CONFIG_OWNER or $USER)')
1282
+ .option('--profile <name>', 'Profile name', 'default')
1283
+ .option('--brand <brand>', 'Default brand', 'CRE-11TRUST')
1284
+ .option('--timezone <tz>', 'Default timezone', 'America/New_York')
1285
+ .option('--force', 'Overwrite existing config', false)
1286
+ .action(async (opts: { owner?: string; profile: string; brand: string; timezone: string; force?: boolean }) => {
1287
+ try {
1288
+ const existing = await readLocalConfig()
1289
+ if (existing && !opts.force) {
1290
+ console.error(`Config already exists at ${getLocalConfigPath()} (use --force to overwrite)`)
1291
+ process.exit(1)
1292
+ }
1293
+
1294
+ const owner = opts.owner || process.env.OPTIMAL_CONFIG_OWNER || process.env.USER || 'oracle'
1295
+ const payload: OptimalConfigV1 = {
1296
+ version: '1.0.0',
1297
+ profile: {
1298
+ name: opts.profile,
1299
+ owner,
1300
+ updated_at: new Date().toISOString(),
1301
+ },
1302
+ providers: {
1303
+ supabase: {
1304
+ project_ref: process.env.OPTIMAL_SUPABASE_PROJECT_REF || 'unset',
1305
+ url: process.env.OPTIMAL_SUPABASE_URL || 'unset',
1306
+ anon_key_present: Boolean(process.env.OPTIMAL_SUPABASE_ANON_KEY),
1307
+ },
1308
+ strapi: {
1309
+ base_url: process.env.STRAPI_BASE_URL || 'unset',
1310
+ token_present: Boolean(process.env.STRAPI_TOKEN),
1311
+ },
1312
+ },
1313
+ defaults: {
1314
+ brand: opts.brand,
1315
+ timezone: opts.timezone,
1316
+ },
1317
+ features: {
1318
+ cms: true,
1319
+ tasks: true,
1320
+ deploy: true,
1321
+ },
1322
+ }
1323
+
1324
+ await writeLocalConfig(payload)
1325
+ await appendHistory(`${new Date().toISOString()} init profile=${opts.profile} owner=${owner} hash=${hashConfig(payload)}`)
1326
+ console.log(`Initialized config at ${getLocalConfigPath()}`)
1327
+ } catch (err) {
1328
+ console.error(`Config init failed: ${err instanceof Error ? err.message : String(err)}`)
1329
+ process.exit(1)
1330
+ }
1331
+ })
1332
+
1333
+ config
1334
+ .command('doctor')
1335
+ .description('Validate local config file and print health details')
1336
+ .action(async () => {
1337
+ try {
1338
+ const cfg = await readLocalConfig()
1339
+ if (!cfg) {
1340
+ console.log(`No local config found at ${getLocalConfigPath()}`)
1341
+ process.exit(1)
1342
+ }
1343
+ const digest = hashConfig(cfg)
1344
+ console.log(`config: ok`)
1345
+ console.log(`path: ${getLocalConfigPath()}`)
1346
+ console.log(`profile: ${cfg.profile.name}`)
1347
+ console.log(`owner: ${cfg.profile.owner}`)
1348
+ console.log(`version: ${cfg.version}`)
1349
+ console.log(`hash: ${digest}`)
1350
+ console.log(`history: ${getHistoryPath()}`)
1351
+ } catch (err) {
1352
+ console.error(`Config doctor failed: ${err instanceof Error ? err.message : String(err)}`)
1353
+ process.exit(1)
1354
+ }
1355
+ })
1356
+
1357
+ config
1358
+ .command('export')
1359
+ .description('Export local config to a JSON path')
1360
+ .requiredOption('--out <path>', 'Output path for JSON export')
1361
+ .action(async (opts: { out: string }) => {
1362
+ try {
1363
+ const cfg = await readLocalConfig()
1364
+ if (!cfg) {
1365
+ console.error(`No local config found at ${getLocalConfigPath()}`)
1366
+ process.exit(1)
1367
+ }
1368
+ const payload: OptimalConfigV1 = {
1369
+ ...cfg,
1370
+ profile: {
1371
+ ...cfg.profile,
1372
+ updated_at: new Date().toISOString(),
1373
+ },
1374
+ }
1375
+ const json = `${JSON.stringify(payload, null, 2)}\n`
1376
+ writeFileSync(opts.out, json, 'utf-8')
1377
+ await appendHistory(`${new Date().toISOString()} export out=${opts.out} hash=${hashConfig(payload)}`)
1378
+ console.log(`Exported config to ${opts.out}`)
1379
+ } catch (err) {
1380
+ console.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`)
1381
+ process.exit(1)
1382
+ }
1383
+ })
1384
+
1385
+ config
1386
+ .command('import')
1387
+ .description('Import local config from a JSON path')
1388
+ .requiredOption('--in <path>', 'Input path for JSON config')
1389
+ .action(async (opts: { in: string }) => {
1390
+ try {
1391
+ if (!existsSync(opts.in)) {
1392
+ console.error(`Input file not found: ${opts.in}`)
1393
+ process.exit(1)
1394
+ }
1395
+ const raw = readFileSync(opts.in, 'utf-8')
1396
+ const parsed = JSON.parse(raw)
1397
+ const payload = assertOptimalConfigV1(parsed)
1398
+ await writeLocalConfig(payload)
1399
+ await appendHistory(`${new Date().toISOString()} import in=${opts.in} hash=${hashConfig(payload)}`)
1400
+ console.log(`Imported config from ${opts.in}`)
1401
+ } catch (err) {
1402
+ console.error(`Config import failed: ${err instanceof Error ? err.message : String(err)}`)
1403
+ process.exit(1)
1404
+ }
1405
+ })
1406
+
1407
+ const configSync = config.command('sync').description('Sync local profile with shared registry (scaffold)')
1408
+
1409
+ configSync
1410
+ .command('pull')
1411
+ .description('Pull config profile from shared registry into local config')
1412
+ .option('--profile <name>', 'Registry profile name', 'default')
1413
+ .action(async (opts: { profile: string }) => {
1414
+ const result = await pullRegistryProfile(opts.profile)
1415
+ const stamp = new Date().toISOString()
1416
+ await appendHistory(`${stamp} sync.pull profile=${opts.profile} ok=${result.ok} msg=${result.message}`)
1417
+ if (!result.ok) {
1418
+ console.error(result.message)
1419
+ process.exit(1)
1420
+ }
1421
+ console.log(result.message)
1422
+ })
1423
+
1424
+ configSync
1425
+ .command('push')
1426
+ .description('Push local config profile to shared registry')
1427
+ .requiredOption('--agent <name>', 'Agent/owner name for the config profile')
1428
+ .option('--profile <name>', 'Registry profile name', 'default')
1429
+ .option('--force', 'Force write even on conflict', false)
1430
+ .action(async (opts: { agent: string; profile: string; force?: boolean }) => {
1431
+ const result = await pushRegistryProfile(opts.profile, Boolean(opts.force), opts.agent)
1432
+ const stamp = new Date().toISOString()
1433
+ await appendHistory(`${stamp} sync.push agent=${opts.agent} profile=${opts.profile} force=${Boolean(opts.force)} ok=${result.ok} msg=${result.message}`)
1434
+ if (!result.ok) {
1435
+ console.error(result.message)
1436
+ process.exit(1)
1437
+ }
1438
+ console.log(result.message)
1439
+ })
1440
+
1441
+ // --- Bot commands ---
1442
+ const bot = program.command('bot').description('Bot agent orchestration')
1443
+ .addHelpText('after', `
1444
+ Examples:
1445
+ $ optimal bot agents List active agents
1446
+ $ optimal bot heartbeat --agent bot1 Send heartbeat
1447
+ $ optimal bot claim --agent bot1 Claim next task
1448
+ $ optimal bot report --task <id> --agent bot1 --message "50% done"
1449
+ $ optimal bot complete --task <id> --agent bot1 --summary "All tests pass"
1450
+ `)
1451
+
1452
+ bot
1453
+ .command('heartbeat')
1454
+ .description('Send agent heartbeat')
1455
+ .requiredOption('--agent <id>', 'Agent ID')
1456
+ .option('--status <s>', 'Status: idle, working, error', 'idle')
1457
+ .action(async (opts) => {
1458
+ await sendHeartbeat(opts.agent, opts.status as 'idle' | 'working' | 'error')
1459
+ success(`Heartbeat sent: ${colorize(opts.agent, 'bold')} [${colorize(opts.status, 'cyan')}]`)
1460
+ })
1461
+
1462
+ bot
1463
+ .command('agents')
1464
+ .description('List active agents (heartbeat in last 5 min)')
1465
+ .action(async () => {
1466
+ const agents = await getActiveAgents()
1467
+ if (agents.length === 0) {
1468
+ console.log('No active agents.')
1469
+ return
1470
+ }
1471
+ console.log('| Agent | Status | Last Seen |')
1472
+ console.log('|------------------|---------|---------------------|')
1473
+ for (const a of agents) {
1474
+ console.log(`| ${a.agent.padEnd(16)} | ${a.status.padEnd(7)} | ${a.lastSeen} |`)
1475
+ }
1476
+ })
1477
+
1478
+ bot
1479
+ .command('claim')
1480
+ .description('Claim the next available task')
1481
+ .requiredOption('--agent <id>', 'Agent ID')
1482
+ .option('--skill <s>', 'Skill filter (comma-separated)')
1483
+ .action(async (opts) => {
1484
+ const skills = opts.skill ? opts.skill.split(',') : undefined
1485
+ const task = await claimNextTask(opts.agent, skills)
1486
+ if (!task) {
1487
+ console.log('No claimable tasks found.')
1488
+ return
1489
+ }
1490
+ success(`Claimed: ${colorize(task.title, 'cyan')} (${colorize(task.id, 'dim')}) by ${colorize(opts.agent, 'bold')}`)
1491
+ })
1492
+
1493
+ bot
1494
+ .command('report')
1495
+ .description('Report progress on a task')
1496
+ .requiredOption('--task <id>', 'Task ID')
1497
+ .requiredOption('--agent <id>', 'Agent ID')
1498
+ .requiredOption('--message <msg>', 'Progress message')
1499
+ .action(async (opts) => {
1500
+ await reportProgress(opts.task, opts.agent, opts.message)
1501
+ success(`Progress reported on ${colorize(opts.task, 'dim')}`)
1502
+ })
1503
+
1504
+ bot
1505
+ .command('complete')
1506
+ .description('Mark a task as done')
1507
+ .requiredOption('--task <id>', 'Task ID')
1508
+ .requiredOption('--agent <id>', 'Agent ID')
1509
+ .requiredOption('--summary <s>', 'Completion summary')
1510
+ .action(async (opts) => {
1511
+ await reportCompletion(opts.task, opts.agent, opts.summary)
1512
+ success(`Task ${colorize(opts.task, 'dim')} marked ${statusBadge('done')} by ${colorize(opts.agent, 'bold')}`)
1513
+ })
1514
+
1515
+ bot
1516
+ .command('release')
1517
+ .description('Release a claimed task back to ready')
1518
+ .requiredOption('--task <id>', 'Task ID')
1519
+ .requiredOption('--agent <id>', 'Agent ID')
1520
+ .option('--reason <r>', 'Release reason')
1521
+ .action(async (opts) => {
1522
+ await releaseTask(opts.task, opts.agent, opts.reason)
1523
+ fmtInfo(`Task ${colorize(opts.task, 'dim')} released by ${colorize(opts.agent, 'bold')}`)
1524
+ })
1525
+
1526
+ bot
1527
+ .command('blocked')
1528
+ .description('Mark a task as blocked')
1529
+ .requiredOption('--task <id>', 'Task ID')
1530
+ .requiredOption('--agent <id>', 'Agent ID')
1531
+ .requiredOption('--reason <r>', 'Block reason')
1532
+ .action(async (opts) => {
1533
+ await reportBlocked(opts.task, opts.agent, opts.reason)
1534
+ fmtWarn(`Task ${colorize(opts.task, 'dim')} marked ${statusBadge('blocked')}: ${opts.reason}`)
1535
+ })
1536
+
1537
+ // --- Coordinator commands ---
1538
+ const coordinator = program.command('coordinator').description('Multi-agent coordination')
1539
+ .addHelpText('after', `
1540
+ Examples:
1541
+ $ optimal coordinator start Run coordinator loop
1542
+ $ optimal coordinator start --interval 10000 Poll every 10s
1543
+ $ optimal coordinator status Show coordinator status
1544
+ $ optimal coordinator assign --task <id> --agent bot1
1545
+ $ optimal coordinator rebalance Release stale tasks
1546
+ `)
1547
+
1548
+ coordinator
1549
+ .command('start')
1550
+ .description('Run the coordinator loop')
1551
+ .option('--interval <ms>', 'Poll interval in milliseconds', '30000')
1552
+ .option('--max-agents <n>', 'Maximum agents to manage', '10')
1553
+ .action(async (opts) => {
1554
+ await runCoordinatorLoop({
1555
+ pollIntervalMs: parseInt(opts.interval),
1556
+ maxAgents: parseInt(opts.maxAgents),
1557
+ })
1558
+ })
1559
+
1560
+ coordinator
1561
+ .command('status')
1562
+ .description('Show coordinator status')
1563
+ .action(async () => {
1564
+ const s = await getCoordinatorStatus()
1565
+ console.log(`Last poll: ${s.lastPollAt ?? 'never'}`)
1566
+ console.log(`Tasks — ready: ${s.tasksReady}, in progress: ${s.tasksInProgress}, blocked: ${s.tasksBlocked}`)
1567
+ console.log(`\nActive agents (${s.activeAgents.length}):`)
1568
+ for (const a of s.activeAgents) {
1569
+ console.log(` ${a.agent.padEnd(16)} ${a.status.padEnd(8)} last seen ${a.lastSeen}`)
1570
+ }
1571
+ console.log(`\nIdle agents (${s.idleAgents.length}):`)
1572
+ for (const a of s.idleAgents) {
1573
+ console.log(` ${a.id.padEnd(16)} skills: ${a.skills.join(', ')}`)
1574
+ }
1575
+ })
1576
+
1577
+ coordinator
1578
+ .command('assign')
1579
+ .description('Manually assign a task to an agent')
1580
+ .requiredOption('--task <id>', 'Task ID')
1581
+ .requiredOption('--agent <id>', 'Agent ID')
1582
+ .action(async (opts) => {
1583
+ const task = await assignTask(opts.task, opts.agent)
1584
+ success(`Assigned: ${colorize(task.title, 'cyan')} -> ${colorize(opts.agent, 'bold')}`)
1585
+ })
1586
+
1587
+ coordinator
1588
+ .command('rebalance')
1589
+ .description('Release stale tasks and rebalance')
1590
+ .action(async () => {
1591
+ const result = await rebalance()
1592
+ if (result.releasedTasks.length === 0) {
1593
+ fmtInfo('No stale tasks found.')
1594
+ return
1595
+ }
1596
+ console.log(`Released ${result.releasedTasks.length} stale task(s):`)
1597
+ for (const t of result.releasedTasks) {
1598
+ console.log(` ${colorize(t.id, 'dim')} ${t.title}`)
1599
+ }
1600
+ if (result.reassignedTasks.length > 0) {
1601
+ console.log(`Reassigned ${result.reassignedTasks.length} task(s):`)
1602
+ for (const t of result.reassignedTasks) {
1603
+ console.log(` ${colorize(t.id, 'dim')} ${t.title} -> ${t.claimed_by}`)
1604
+ }
1605
+ }
1606
+ })
1607
+
1608
+ // --- Asset commands ---
1609
+ const asset = program.command('asset').description('Digital asset tracking (domains, servers, API keys, services, repos)')
1610
+ .addHelpText('after', `
1611
+ Examples:
1612
+ $ optimal asset list List all assets
1613
+ $ optimal asset list --type domain --status active Filter by type/status
1614
+ $ optimal asset add --name "op-hub.com" --type domain --owner clenisa
1615
+ $ optimal asset update --id <uuid> --status inactive
1616
+ $ optimal asset usage --id <uuid> View usage log
1617
+ `)
1618
+
1619
+ asset
1620
+ .command('list')
1621
+ .description('List tracked assets')
1622
+ .option('-t, --type <type>', 'Filter by type (domain, server, api_key, service, repo, other)')
1623
+ .option('-s, --status <status>', 'Filter by status (active, inactive, expired, pending)')
1624
+ .option('-o, --owner <owner>', 'Filter by owner')
1625
+ .option('--json', 'Output as JSON')
1626
+ .action(async (opts) => {
1627
+ const assets = await listAssets({
1628
+ type: opts.type as AssetType | undefined,
1629
+ status: opts.status as AssetStatus | undefined,
1630
+ owner: opts.owner,
1631
+ })
1632
+ if (opts.json) {
1633
+ console.log(JSON.stringify(assets, null, 2))
1634
+ } else {
1635
+ console.log(formatAssetTable(assets))
1636
+ }
1637
+ })
1638
+
1639
+ asset
1640
+ .command('add')
1641
+ .description('Add a new asset')
1642
+ .requiredOption('-n, --name <name>', 'Asset name')
1643
+ .requiredOption('-t, --type <type>', 'Asset type (domain, server, api_key, service, repo, other)')
1644
+ .option('-s, --status <status>', 'Status (default: active)')
1645
+ .option('-o, --owner <owner>', 'Owner')
1646
+ .option('--expires <date>', 'Expiration date (YYYY-MM-DD or ISO)')
1647
+ .option('--meta <json>', 'Metadata JSON string')
1648
+ .action(async (opts) => {
1649
+ const metadata = opts.meta ? JSON.parse(opts.meta) : undefined
1650
+ const created = await createAsset({
1651
+ name: opts.name,
1652
+ type: opts.type as AssetType,
1653
+ status: opts.status as AssetStatus | undefined,
1654
+ owner: opts.owner,
1655
+ expires_at: opts.expires,
1656
+ metadata,
1657
+ })
1658
+ success(`Created asset: ${colorize(created.name, 'cyan')} [${created.type}] (${colorize(created.id, 'dim')})`)
1659
+ })
1660
+
1661
+ asset
1662
+ .command('update')
1663
+ .description('Update an existing asset')
1664
+ .requiredOption('--id <uuid>', 'Asset ID')
1665
+ .option('-n, --name <name>', 'New name')
1666
+ .option('-t, --type <type>', 'New type')
1667
+ .option('-s, --status <status>', 'New status')
1668
+ .option('-o, --owner <owner>', 'New owner')
1669
+ .option('--expires <date>', 'New expiration date')
1670
+ .option('--meta <json>', 'New metadata JSON')
1671
+ .action(async (opts) => {
1672
+ const updates: Record<string, unknown> = {}
1673
+ if (opts.name) updates.name = opts.name
1674
+ if (opts.type) updates.type = opts.type
1675
+ if (opts.status) updates.status = opts.status
1676
+ if (opts.owner) updates.owner = opts.owner
1677
+ if (opts.expires) updates.expires_at = opts.expires
1678
+ if (opts.meta) updates.metadata = JSON.parse(opts.meta)
1679
+ const updated = await updateAsset(opts.id, updates)
1680
+ success(`Updated: ${colorize(updated.name, 'cyan')} -> status=${colorize(updated.status, 'bold')}`)
1681
+ })
1682
+
1683
+ asset
1684
+ .command('get')
1685
+ .description('Get a single asset by ID')
1686
+ .requiredOption('--id <uuid>', 'Asset ID')
1687
+ .action(async (opts) => {
1688
+ const a = await getAsset(opts.id)
1689
+ console.log(JSON.stringify(a, null, 2))
1690
+ })
1691
+
1692
+ asset
1693
+ .command('remove')
1694
+ .description('Delete an asset')
1695
+ .requiredOption('--id <uuid>', 'Asset ID')
1696
+ .action(async (opts) => {
1697
+ await deleteAsset(opts.id)
1698
+ success(`Deleted asset ${colorize(opts.id, 'dim')}`)
1699
+ })
1700
+
1701
+ asset
1702
+ .command('track')
1703
+ .description('Log a usage event for an asset')
1704
+ .requiredOption('--id <uuid>', 'Asset ID')
1705
+ .requiredOption('-e, --event <event>', 'Event name (e.g. "renewed", "deployed", "rotated")')
1706
+ .option('--actor <name>', 'Who performed the action')
1707
+ .option('--meta <json>', 'Event metadata JSON')
1708
+ .action(async (opts) => {
1709
+ const metadata = opts.meta ? JSON.parse(opts.meta) : undefined
1710
+ const entry = await trackAssetUsage(opts.id, opts.event, opts.actor, metadata)
1711
+ success(`Tracked: ${colorize(opts.event, 'cyan')} on ${colorize(opts.id, 'dim')} at ${colorize(entry.created_at, 'dim')}`)
1712
+ })
1713
+
1714
+ asset
1715
+ .command('usage')
1716
+ .description('View usage log for an asset')
1717
+ .requiredOption('--id <uuid>', 'Asset ID')
1718
+ .option('--limit <n>', 'Max entries', '20')
1719
+ .action(async (opts) => {
1720
+ const events = await listAssetUsage(opts.id, parseInt(opts.limit))
1721
+ if (events.length === 0) {
1722
+ console.log('No usage events found.')
1723
+ return
1724
+ }
1725
+ for (const e of events) {
1726
+ console.log(`${e.created_at} | ${(e.actor ?? '-').padEnd(10)} | ${e.event} ${Object.keys(e.metadata).length > 0 ? JSON.stringify(e.metadata) : ''}`)
1727
+ }
1728
+ console.log(`\n${events.length} events`)
1729
+ })
1730
+
1731
+ program.parseAsync()