optimal-cli 1.0.1 → 1.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 (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. package/docs/CLI-REFERENCE.md +0 -361
package/bin/optimal.ts ADDED
@@ -0,0 +1,1418 @@
1
+ #!/usr/bin/env tsx
2
+ import { Command } from 'commander'
3
+ import 'dotenv/config'
4
+ import {
5
+ getBoard,
6
+ createTask,
7
+ updateTask,
8
+ logActivity,
9
+ type Task,
10
+ } from '../lib/kanban.js'
11
+ import {
12
+ getBoardByProject,
13
+ createTask as createObsidianTask,
14
+ updateTaskStatus as updateObsidianTaskStatus,
15
+ loadAllTasks,
16
+ type BoardColumn,
17
+ type ObsidianTask,
18
+ } from '../lib/kanban-obsidian.js'
19
+ import { runAuditComparison } from '../lib/returnpro/audit.js'
20
+ import { exportKpis, formatKpiTable, formatKpiCsv } from '../lib/returnpro/kpis.js'
21
+ import { deploy, healthCheck, listApps } from '../lib/infra/deploy.js'
22
+ import {
23
+ fetchWesImports,
24
+ parseSummaryFromJson,
25
+ initializeProjections,
26
+ applyUniformAdjustment,
27
+ calculateTotals,
28
+ exportToCSV,
29
+ formatProjectionTable,
30
+ } from '../lib/budget/projections.js'
31
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs'
32
+ import { generateNewsletter } from '../lib/newsletter/generate.js'
33
+ import { scrapeCompanies, formatCsv } from '../lib/social/scraper.js'
34
+ import { ingestTransactions } from '../lib/transactions/ingest.js'
35
+ import { stampTransactions } from '../lib/transactions/stamp.js'
36
+ import { processR1Upload } from '../lib/returnpro/upload-r1.js'
37
+ import { processNetSuiteUpload } from '../lib/returnpro/upload-netsuite.js'
38
+ import { uploadIncomeStatements } from '../lib/returnpro/upload-income.js'
39
+ import { detectRateAnomalies } from '../lib/returnpro/anomalies.js'
40
+ import { diagnoseMonths } from '../lib/returnpro/diagnose.js'
41
+ import { generateNetSuiteTemplate } from '../lib/returnpro/templates.js'
42
+ import { distributeNewsletter, checkDistributionStatus } from '../lib/newsletter/distribute.js'
43
+ import { generateSocialPosts } from '../lib/social/post-generator.js'
44
+ import { publishSocialPosts, getPublishQueue, retryFailed } from '../lib/social/publish.js'
45
+ import { publishBlog, createBlogPost, listBlogDrafts } from '../lib/cms/publish-blog.js'
46
+ import { migrateDb, listPendingMigrations, createMigration } from '../lib/infra/migrate.js'
47
+ import { saveScenario, loadScenario, listScenarios, compareScenarios, deleteScenario } from '../lib/budget/scenarios.js'
48
+ import { deleteBatch, previewBatch } from '../lib/transactions/delete-batch.js'
49
+ import { assertOptimalConfigV1, type OptimalConfigV1 } from '../lib/config/schema.js'
50
+ import {
51
+ appendHistory,
52
+ getHistoryPath,
53
+ getLocalConfigPath,
54
+ hashConfig,
55
+ pullRegistryProfile,
56
+ pushRegistryProfile,
57
+ readLocalConfig,
58
+ writeLocalConfig,
59
+ } from '../lib/config/registry.js'
60
+
61
+ const program = new Command()
62
+ .name('optimal')
63
+ .description('Optimal CLI — unified skills for financial analytics, content, and infra')
64
+ .version('0.1.0')
65
+
66
+ // Board commands (supabase-backed)
67
+ const board = program.command('board').description('Kanban board operations (supabase)')
68
+
69
+ board
70
+ .command('view')
71
+ .description('Display the kanban board')
72
+ .option('-p, --project <slug>', 'Project slug', 'optimal-cli')
73
+ .option('-s, --status <status>', 'Filter by status')
74
+ .action(async (opts) => {
75
+ let tasks = await getBoard(opts.project)
76
+ if (opts.status) tasks = tasks.filter(t => t.status === opts.status)
77
+
78
+ const grouped = new Map<string, Task[]>()
79
+ for (const t of tasks) {
80
+ const list = grouped.get(t.status) ?? []
81
+ list.push(t)
82
+ grouped.set(t.status, list)
83
+ }
84
+
85
+ const order = ['in_progress', 'blocked', 'ready', 'backlog', 'review', 'done']
86
+ console.log('| Status | P | Title | Claimed By |')
87
+ console.log('|--------|---|-------|------------|')
88
+ for (const status of order) {
89
+ const list = grouped.get(status) ?? []
90
+ for (const t of list) {
91
+ console.log(
92
+ `| ${t.status} | ${t.priority} | ${t.title} | ${t.claimed_by ?? '—'} |`
93
+ )
94
+ }
95
+ }
96
+ console.log(`\nTotal: ${tasks.length} tasks`)
97
+ })
98
+
99
+ board
100
+ .command('create')
101
+ .description('Create a new task')
102
+ .requiredOption('-t, --title <title>', 'Task title')
103
+ .option('-p, --project <slug>', 'Project slug', 'optimal-cli')
104
+ .option('-d, --description <desc>', 'Task description')
105
+ .option('--priority <n>', 'Priority 1-4', '3')
106
+ .option('--skill <ref>', 'Skill required')
107
+ .option('--source <repo>', 'Source repo')
108
+ .action(async (opts) => {
109
+ const task = await createTask({
110
+ project_slug: opts.project,
111
+ title: opts.title,
112
+ description: opts.description,
113
+ priority: parseInt(opts.priority),
114
+ skill_required: opts.skill,
115
+ source_repo: opts.source,
116
+ })
117
+ console.log(`Created task: ${task.id} — "${task.title}" (priority ${task.priority}, status ${task.status})`)
118
+ })
119
+
120
+ board
121
+ .command('update')
122
+ .description('Update a task')
123
+ .requiredOption('--id <taskId>', 'Task UUID')
124
+ .option('-s, --status <status>', 'New status')
125
+ .option('-a, --agent <name>', 'Assign to agent')
126
+ .option('--priority <n>', 'New priority')
127
+ .option('-m, --message <msg>', 'Log message')
128
+ .action(async (opts) => {
129
+ const updates: Record<string, unknown> = {}
130
+ if (opts.status) updates.status = opts.status
131
+ if (opts.agent) updates.assigned_to = opts.agent
132
+ if (opts.priority) updates.priority = parseInt(opts.priority)
133
+
134
+ const task = await updateTask(opts.id, updates)
135
+ if (opts.message) {
136
+ await logActivity(opts.id, {
137
+ agent: opts.agent ?? 'cli',
138
+ action: 'status_change',
139
+ details: opts.message,
140
+ })
141
+ }
142
+ console.log(`Updated task ${task.id}: status → ${task.status}, assigned_to → ${task.assigned_to ?? '—'}`)
143
+ })
144
+
145
+ board
146
+ .command('claim')
147
+ .description('Claim a task for an agent')
148
+ .requiredOption('--id <taskId>', 'Task UUID')
149
+ .option('-a, --agent <name>', 'Agent name', 'oracle')
150
+ .action(async (opts) => {
151
+ const { claimTask } = await import('../lib/kanban.js')
152
+ const task = await claimTask(opts.id, opts.agent)
153
+ console.log(`Claimed task: ${task.title} by ${task.claimed_by} (status: ${task.status})`)
154
+ })
155
+
156
+ board
157
+ .command('my-tasks')
158
+ .description('Show tasks claimed by an agent')
159
+ .option('-a, --agent <name>', 'Agent name', 'oracle')
160
+ .option('-p, --project <slug>', 'Project slug', 'optimal-cli')
161
+ .action(async (opts) => {
162
+ const { getBoard } = await import('../lib/kanban.js')
163
+ const tasks = await getBoard(opts.project)
164
+ const myTasks = tasks.filter(t => t.claimed_by === opts.agent)
165
+
166
+ console.log(`\n📋 Tasks claimed by ${opts.agent}:\n`)
167
+ for (const t of myTasks) {
168
+ console.log(` [${t.status}] ${t.title} (priority ${t.priority})`)
169
+ }
170
+ console.log(`\nTotal: ${myTasks.length} tasks`)
171
+ })
172
+
173
+ // Sync commands for 3-way sync
174
+ const syncCmd = program.command('sync').description('Sync tasks between obsidian and supabase')
175
+
176
+ syncCmd
177
+ .command('push')
178
+ .description('Push obsidian tasks to supabase')
179
+ .option('--project <slug>', 'Target project slug', 'optimal-tasks')
180
+ .option('--dry-run', 'Show what would be synced without writing', false)
181
+ .action(async (opts) => {
182
+ const { syncObsidianToSupabase } = await import('../lib/kanban-sync.js')
183
+ await syncObsidianToSupabase(opts.project, opts.dryRun)
184
+ })
185
+
186
+ syncCmd
187
+ .command('pull')
188
+ .description('Pull supabase tasks to obsidian')
189
+ .option('--project <slug>', 'Source project slug', 'optimal-tasks')
190
+ .option('--dry-run', 'Show what would be synced without writing', false)
191
+ .action(async (opts) => {
192
+ const { syncSupabaseToObsidian } = await import('../lib/kanban-sync.js')
193
+ await syncSupabaseToObsidian(opts.project, opts.dryRun)
194
+ })
195
+
196
+ syncCmd
197
+ .command('status')
198
+ .description('Show sync status between obsidian and supabase')
199
+ .option('--project <slug>', 'Project slug', 'optimal-tasks')
200
+ .action(async (opts) => {
201
+ const { getSyncStatus } = await import('../lib/kanban-sync.js')
202
+ await getSyncStatus(opts.project)
203
+ })
204
+
205
+ // Obsidian-backed board commands (primary)
206
+ const oboard = program.command('oboard').description('Obsidian Kanban board (primary)')
207
+
208
+ oboard
209
+ .command('view')
210
+ .description('Display the obsidian task board')
211
+ .option('-p, --project <slug>', 'Project filter')
212
+ .option('-s, --status <status>', 'Filter by status (pending|in-progress|done)')
213
+ .action(async (opts) => {
214
+ const columns = await getBoardByProject(opts.project)
215
+
216
+ if (opts.status) {
217
+ const col = columns.find(c => c.status === opts.status)
218
+ if (col) {
219
+ console.log(`\n## ${col.name}\n`)
220
+ for (const task of col.tasks) {
221
+ console.log(`- [${task.priority}] ${task.title} (${task.assignee || 'unassigned'})`)
222
+ }
223
+ console.log(`\nTotal: ${col.tasks.length} tasks`)
224
+ }
225
+ return
226
+ }
227
+
228
+ for (const col of columns) {
229
+ console.log(`\n## ${col.name} (${col.tasks.length})\n`)
230
+ for (const task of col.tasks) {
231
+ const tags = task.tags.length ? ` [${task.tags.join(', ')}]` : ''
232
+ console.log(`- [${task.priority}] ${task.title}${tags}`)
233
+ }
234
+ }
235
+ console.log(`\nTotal: ${columns.reduce((sum, c) => sum + c.tasks.length, 0)} tasks`)
236
+ })
237
+
238
+ oboard
239
+ .command('list')
240
+ .description('List all tasks with details')
241
+ .option('-p, --project <slug>', 'Project filter')
242
+ .option('-s, --status <status>', 'Status filter')
243
+ .option('--assignee <name>', 'Filter by assignee')
244
+ .option('--owner <name>', 'Filter by owner')
245
+ .option('--json', 'Output as JSON (for agent consumption)', false)
246
+ .action(async (opts: { project?: string; status?: string; assignee?: string; owner?: string; json?: boolean }) => {
247
+ let tasks = await loadAllTasks()
248
+
249
+ if (opts.project) tasks = tasks.filter(t => t.project === opts.project)
250
+ if (opts.status) tasks = tasks.filter(t => t.status === opts.status)
251
+ if (opts.assignee) tasks = tasks.filter(t => t.assignee === opts.assignee)
252
+ if (opts.owner) tasks = tasks.filter(t => t.owner === opts.owner)
253
+
254
+ if (opts.json) {
255
+ // Agent-friendly JSON output
256
+ console.log(JSON.stringify(tasks.map(t => ({
257
+ id: t.id,
258
+ title: t.title,
259
+ status: t.status,
260
+ priority: t.priority,
261
+ owner: t.owner,
262
+ assignee: t.assignee,
263
+ project: t.project,
264
+ tags: t.tags,
265
+ updatedAt: t.updatedAt,
266
+ createdAt: t.createdAt
267
+ })), null, 2))
268
+ return
269
+ }
270
+
271
+ console.log(`\nFound ${tasks.length} tasks:\n`)
272
+ for (const task of tasks) {
273
+ console.log(`ID: ${task.id}`)
274
+ console.log(` Title: ${task.title}`)
275
+ console.log(` Status: ${task.status} | Priority: ${task.priority}`)
276
+ console.log(` Owner: ${task.owner || '—'} | Assignee: ${task.assignee || '—'}`)
277
+ console.log(` Project: ${task.project || '—'}`)
278
+ console.log(` Tags: ${task.tags.join(', ') || 'none'}`)
279
+ console.log(` Updated: ${task.updatedAt || 'never'}`)
280
+ console.log('')
281
+ }
282
+ })
283
+
284
+ oboard
285
+ .command('create')
286
+ .description('Create a new obsidian task')
287
+ .requiredOption('-t, --title <title>', 'Task title')
288
+ .option('-d, --description <desc>', 'Task description')
289
+ .option('-p, --project <slug>', 'Project slug')
290
+ .option('--priority <n>', 'Priority 1-4', '3')
291
+ .option('--owner <name>', 'Owner', 'oracle')
292
+ .option('--assignee <name>', 'Assignee')
293
+ .option('--tags <labels>', 'Comma-separated tags')
294
+ .action(async (opts) => {
295
+ const result = await createObsidianTask({
296
+ title: opts.title,
297
+ description: opts.description,
298
+ project: opts.project,
299
+ priority: parseInt(opts.priority) || 3,
300
+ owner: opts.owner,
301
+ assignee: opts.assignee,
302
+ tags: opts.tags?.split(',').map((t: string) => t.trim()),
303
+ })
304
+
305
+ if (result.ok) {
306
+ console.log(`✓ ${result.message}`)
307
+ console.log(` ID: ${result.task?.id}`)
308
+ } else {
309
+ console.error(`✗ ${result.message}`)
310
+ process.exit(1)
311
+ }
312
+ })
313
+
314
+ oboard
315
+ .command('update')
316
+ .description('Update an obsidian task')
317
+ .requiredOption('--id <taskId>', 'Task ID (or partial match)')
318
+ .option('-s, --status <status>', 'New status (pending|in-progress|done)')
319
+ .option('-a, --assignee <name>', 'Assign to agent')
320
+ .option('--owner <name>', 'Set owner')
321
+ .option('--priority <n>', 'Set priority 1-4')
322
+ .option('-m, --message <msg>', 'Log progress message')
323
+ .action(async (opts) => {
324
+ if (!opts.status && !opts.assignee && !opts.owner && !opts.priority && !opts.message) {
325
+ console.error('No updates specified. Use --status, --assignee, --owner, --priority, or --message')
326
+ process.exit(1)
327
+ }
328
+
329
+ if (opts.status) {
330
+ const result = await updateObsidianTaskStatus(opts.id, opts.status, opts.message)
331
+ if (result.ok) {
332
+ console.log(`✓ ${result.message}`)
333
+ } else {
334
+ console.error(`✗ ${result.message}`)
335
+ process.exit(1)
336
+ }
337
+ }
338
+
339
+ // TODO: Add other update fields
340
+ if (opts.assignee || opts.owner || opts.priority) {
341
+ console.log('Note: Full field updates not yet implemented, use status for now')
342
+ }
343
+ })
344
+
345
+ oboard
346
+ .command('done')
347
+ .description('Mark a task as done')
348
+ .requiredOption('--id <taskId>', 'Task ID (or partial match)')
349
+ .option('-m, --message <msg>', 'Completion message')
350
+ .action(async (opts) => {
351
+ const result = await updateObsidianTaskStatus(opts.id, 'done', opts.message)
352
+ if (result.ok) {
353
+ console.log(`✓ Completed: ${result.message}`)
354
+ } else {
355
+ console.error(`✗ ${result.message}`)
356
+ process.exit(1)
357
+ }
358
+ })
359
+
360
+ oboard
361
+ .command('start')
362
+ .description('Mark a task as in-progress')
363
+ .requiredOption('--id <taskId>', 'Task ID (or partial match)')
364
+ .action(async (opts) => {
365
+ const result = await updateObsidianTaskStatus(opts.id, 'in-progress')
366
+ if (result.ok) {
367
+ console.log(`✓ Started: ${result.message}`)
368
+ } else {
369
+ console.error(`✗ ${result.message}`)
370
+ process.exit(1)
371
+ }
372
+ })
373
+
374
+ // Audit financials command
375
+ program
376
+ .command('audit-financials')
377
+ .description('Compare staged financials against confirmed income statements')
378
+ .option('--months <csv>', 'Comma-separated YYYY-MM months to audit (default: all)')
379
+ .option('--tolerance <n>', 'Dollar tolerance for match detection', '1.00')
380
+ .action(async (opts) => {
381
+ const months = opts.months
382
+ ? opts.months.split(',').map((m: string) => m.trim())
383
+ : undefined
384
+ const tolerance = parseFloat(opts.tolerance)
385
+
386
+ console.log('Fetching financial data...')
387
+ const result = await runAuditComparison(months, tolerance)
388
+
389
+ console.log(`\nStaging rows: ${result.totalStagingRows} | Confirmed rows: ${result.totalConfirmedRows}`)
390
+ console.log(`Tolerance: $${tolerance.toFixed(2)}\n`)
391
+
392
+ // Header
393
+ console.log(
394
+ '| Month | Confirmed | Staged | Match | SignFlip | Mismatch | C-Only | S-Only | Accuracy |'
395
+ )
396
+ console.log(
397
+ '|---------|-----------|--------|-------|---------|----------|--------|--------|----------|'
398
+ )
399
+
400
+ let flagged = false
401
+ for (const s of result.summaries) {
402
+ const acc = s.accuracy !== null ? `${s.accuracy}%` : 'N/A'
403
+ const warn = s.accuracy !== null && s.accuracy < 100 ? ' *' : ''
404
+ if (warn) flagged = true
405
+
406
+ console.log(
407
+ `| ${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)} |`
408
+ )
409
+ }
410
+
411
+ if (flagged) {
412
+ console.log('\n* Months below 100% accuracy — investigate mismatches')
413
+ }
414
+
415
+ // Totals row
416
+ if (result.summaries.length > 1) {
417
+ const totals = result.summaries.reduce(
418
+ (acc, s) => ({
419
+ confirmed: acc.confirmed + s.confirmedAccounts,
420
+ staged: acc.staged + s.stagedAccounts,
421
+ exact: acc.exact + s.exactMatch,
422
+ flip: acc.flip + s.signFlipMatch,
423
+ mismatch: acc.mismatch + s.mismatch,
424
+ cOnly: acc.cOnly + s.confirmedOnly,
425
+ sOnly: acc.sOnly + s.stagingOnly,
426
+ }),
427
+ { confirmed: 0, staged: 0, exact: 0, flip: 0, mismatch: 0, cOnly: 0, sOnly: 0 },
428
+ )
429
+ const totalOverlap = totals.exact + totals.flip + totals.mismatch
430
+ const totalAcc = totalOverlap > 0
431
+ ? Math.round(((totals.exact + totals.flip) / totalOverlap) * 1000) / 10
432
+ : null
433
+
434
+ console.log(
435
+ `| 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)} |`
436
+ )
437
+ }
438
+ })
439
+
440
+ // Export KPIs command
441
+ program
442
+ .command('export-kpis')
443
+ .description('Export KPI totals by program/client from ReturnPro financial data')
444
+ .option('--months <csv>', 'Comma-separated YYYY-MM months (default: 3 most recent)')
445
+ .option('--programs <csv>', 'Comma-separated program name substrings to filter')
446
+ .option('--format <fmt>', 'Output format: table or csv', 'table')
447
+ .action(async (opts) => {
448
+ const months = opts.months
449
+ ? opts.months.split(',').map((m: string) => m.trim())
450
+ : undefined
451
+ const programs = opts.programs
452
+ ? opts.programs.split(',').map((p: string) => p.trim())
453
+ : undefined
454
+ const format: string = opts.format
455
+
456
+ if (format !== 'table' && format !== 'csv') {
457
+ console.error(`Invalid format "${format}". Use "table" or "csv".`)
458
+ process.exit(1)
459
+ }
460
+
461
+ console.error('Fetching KPI data...')
462
+ const rows = await exportKpis({ months, programs })
463
+ console.error(`Fetched ${rows.length} KPI rows`)
464
+
465
+ if (format === 'csv') {
466
+ console.log(formatKpiCsv(rows))
467
+ } else {
468
+ console.log(formatKpiTable(rows))
469
+ }
470
+ })
471
+
472
+ // Deploy command
473
+ program
474
+ .command('deploy')
475
+ .description('Deploy an app to Vercel (preview or production)')
476
+ .argument('<app>', `App to deploy (${listApps().join(', ')})`)
477
+ .option('--prod', 'Deploy to production', false)
478
+ .action(async (app: string, opts: { prod: boolean }) => {
479
+ console.log(`Deploying ${app}${opts.prod ? ' (production)' : ' (preview)'}...`)
480
+ try {
481
+ const url = await deploy(app, opts.prod)
482
+ console.log(`Deployed: ${url}`)
483
+ } catch (err) {
484
+ const msg = err instanceof Error ? err.message : String(err)
485
+ console.error(`Deploy failed: ${msg}`)
486
+ process.exit(1)
487
+ }
488
+ })
489
+
490
+ // Health check command
491
+ program
492
+ .command('health-check')
493
+ .description('Run health check across all Optimal services')
494
+ .action(async () => {
495
+ try {
496
+ const output = await healthCheck()
497
+ console.log(output)
498
+ } catch (err) {
499
+ const msg = err instanceof Error ? err.message : String(err)
500
+ console.error(`Health check failed: ${msg}`)
501
+ process.exit(1)
502
+ }
503
+ })
504
+
505
+ // Budget projection commands
506
+
507
+ async function loadProjectionData(opts: {
508
+ file?: string
509
+ fiscalYear?: string
510
+ userId?: string
511
+ }) {
512
+ if (opts.file) {
513
+ const raw = readFileSync(opts.file, 'utf-8')
514
+ return parseSummaryFromJson(raw)
515
+ }
516
+ const fy = opts.fiscalYear ? parseInt(opts.fiscalYear) : 2025
517
+ return fetchWesImports({ fiscalYear: fy, userId: opts.userId })
518
+ }
519
+
520
+ function resolveAdjustmentType(
521
+ raw?: string,
522
+ ): 'percentage' | 'flat' {
523
+ if (raw === 'flat') return 'flat'
524
+ return 'percentage'
525
+ }
526
+
527
+ program
528
+ .command('project-budget')
529
+ .description('Run FY26 budget projections with adjustments on FY25 checked-in units')
530
+ .option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
531
+ .option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
532
+ .option('--format <fmt>', 'Output format: table or csv', 'table')
533
+ .option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
534
+ .option('--user-id <uuid>', 'Supabase user UUID to filter by')
535
+ .option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
536
+ .action(async (opts) => {
537
+ const format: string = opts.format
538
+ if (format !== 'table' && format !== 'csv') {
539
+ console.error(`Invalid format "${format}". Use "table" or "csv".`)
540
+ process.exit(1)
541
+ }
542
+
543
+ console.error('Loading projection data...')
544
+ const summary = await loadProjectionData(opts)
545
+ console.error(`Loaded ${summary.length} programs`)
546
+
547
+ let projections = initializeProjections(summary)
548
+ const adjType = resolveAdjustmentType(opts.adjustmentType)
549
+ const adjValue = parseFloat(opts.adjustmentValue)
550
+
551
+ if (adjValue !== 0) {
552
+ projections = applyUniformAdjustment(projections, adjType, adjValue)
553
+ console.error(
554
+ `Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`,
555
+ )
556
+ }
557
+
558
+ const totals = calculateTotals(projections)
559
+ console.error(
560
+ `Totals: ${totals.totalActual} actual -> ${totals.totalProjected} projected (${totals.percentageChange >= 0 ? '+' : ''}${totals.percentageChange.toFixed(1)}%)`,
561
+ )
562
+
563
+ if (format === 'csv') {
564
+ console.log(exportToCSV(projections))
565
+ } else {
566
+ console.log(formatProjectionTable(projections))
567
+ }
568
+ })
569
+
570
+ program
571
+ .command('export-budget')
572
+ .description('Export FY26 budget projections as CSV')
573
+ .option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
574
+ .option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
575
+ .option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
576
+ .option('--user-id <uuid>', 'Supabase user UUID to filter by')
577
+ .option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
578
+ .action(async (opts) => {
579
+ console.error('Loading projection data...')
580
+ const summary = await loadProjectionData(opts)
581
+ console.error(`Loaded ${summary.length} programs`)
582
+
583
+ let projections = initializeProjections(summary)
584
+ const adjType = resolveAdjustmentType(opts.adjustmentType)
585
+ const adjValue = parseFloat(opts.adjustmentValue)
586
+
587
+ if (adjValue !== 0) {
588
+ projections = applyUniformAdjustment(projections, adjType, adjValue)
589
+ console.error(
590
+ `Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`,
591
+ )
592
+ }
593
+
594
+ console.log(exportToCSV(projections))
595
+ })
596
+
597
+ // Newsletter generation command
598
+ program
599
+ .command('generate-newsletter')
600
+ .description('Generate a branded newsletter with AI content and push to Strapi CMS')
601
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
602
+ .option('--date <date>', 'Edition date as YYYY-MM-DD (default: today)')
603
+ .option('--excel <path>', 'Path to Excel file with property listings (CRE-11TRUST only)')
604
+ .option('--dry-run', 'Generate content but do NOT push to Strapi', false)
605
+ .action(async (opts: { brand: string; date?: string; excel?: string; dryRun: boolean }) => {
606
+ try {
607
+ const result = await generateNewsletter({
608
+ brand: opts.brand,
609
+ date: opts.date,
610
+ excelPath: opts.excel,
611
+ dryRun: opts.dryRun,
612
+ })
613
+
614
+ if (result.strapiDocumentId) {
615
+ console.log(`\nStrapi documentId: ${result.strapiDocumentId}`)
616
+ }
617
+ } catch (err) {
618
+ const msg = err instanceof Error ? err.message : String(err)
619
+ console.error(`Newsletter generation failed: ${msg}`)
620
+ process.exit(1)
621
+ }
622
+ })
623
+
624
+ // Scrape Meta Ad Library command
625
+ program
626
+ .command('scrape-ads')
627
+ .description('Scrape Meta Ad Library for competitor ad intelligence')
628
+ .requiredOption(
629
+ '--companies <csv-or-file>',
630
+ 'Comma-separated company names or path to a text file (one per line)',
631
+ )
632
+ .option('--output <path>', 'Save CSV results to file (default: stdout)')
633
+ .option('--batch-size <n>', 'Companies per batch', '6')
634
+ .action(
635
+ async (opts: {
636
+ companies: string
637
+ output?: string
638
+ batchSize: string
639
+ }) => {
640
+ // Parse companies: file path or comma-separated list
641
+ let companies: string[]
642
+ if (existsSync(opts.companies)) {
643
+ const raw = readFileSync(opts.companies, 'utf-8')
644
+ companies = raw
645
+ .split('\n')
646
+ .map((l) => l.trim())
647
+ .filter((l) => l.length > 0 && !l.startsWith('#'))
648
+ } else {
649
+ companies = opts.companies
650
+ .split(',')
651
+ .map((c) => c.trim())
652
+ .filter((c) => c.length > 0)
653
+ }
654
+
655
+ if (companies.length === 0) {
656
+ console.error('No companies specified')
657
+ process.exit(1)
658
+ }
659
+
660
+ const batchSize = parseInt(opts.batchSize)
661
+ if (isNaN(batchSize) || batchSize < 1) {
662
+ console.error('Invalid batch size')
663
+ process.exit(1)
664
+ }
665
+
666
+ try {
667
+ const result = await scrapeCompanies({
668
+ companies,
669
+ outputPath: opts.output,
670
+ batchSize,
671
+ })
672
+
673
+ // If no output file, write CSV to stdout
674
+ if (!opts.output) {
675
+ process.stdout.write(formatCsv(result.ads))
676
+ }
677
+ } catch (err) {
678
+ const msg = err instanceof Error ? err.message : String(err)
679
+ console.error(`Scrape failed: ${msg}`)
680
+ process.exit(1)
681
+ }
682
+ },
683
+ )
684
+
685
+ // Ingest transactions command
686
+ program
687
+ .command('ingest-transactions')
688
+ .description('Parse & deduplicate bank CSV files into the transactions table')
689
+ .requiredOption('--file <path>', 'Path to the CSV file')
690
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
691
+ .action(async (opts: { file: string; userId: string }) => {
692
+ if (!existsSync(opts.file)) {
693
+ console.error(`File not found: ${opts.file}`)
694
+ process.exit(1)
695
+ }
696
+
697
+ console.log(`Ingesting transactions from: ${opts.file}`)
698
+ try {
699
+ const result = await ingestTransactions(opts.file, opts.userId)
700
+
701
+ console.log(`\nFormat detected: ${result.format}`)
702
+ console.log(
703
+ `Inserted: ${result.inserted} | Skipped (duplicates): ${result.skipped} | Failed: ${result.failed}`,
704
+ )
705
+
706
+ if (result.errors.length > 0) {
707
+ console.log(`\nWarnings/Errors (${result.errors.length}):`)
708
+ for (const err of result.errors.slice(0, 20)) {
709
+ console.log(` - ${err}`)
710
+ }
711
+ if (result.errors.length > 20) {
712
+ console.log(` ... and ${result.errors.length - 20} more`)
713
+ }
714
+ }
715
+ } catch (err) {
716
+ const msg = err instanceof Error ? err.message : String(err)
717
+ console.error(`Ingest failed: ${msg}`)
718
+ process.exit(1)
719
+ }
720
+ })
721
+
722
+ // Stamp transactions command
723
+ program
724
+ .command('stamp-transactions')
725
+ .description('Auto-categorize unclassified transactions using rule-based matching')
726
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
727
+ .option('--dry-run', 'Preview matches without writing to database', false)
728
+ .action(async (opts: { userId: string; dryRun: boolean }) => {
729
+ console.log(
730
+ `Stamping transactions for user: ${opts.userId}${opts.dryRun ? ' (DRY RUN)' : ''}`,
731
+ )
732
+ try {
733
+ const result = await stampTransactions(opts.userId, { dryRun: opts.dryRun })
734
+
735
+ console.log(`\nTotal unclassified: ${result.total}`)
736
+ console.log(`Stamped: ${result.stamped} | Unmatched: ${result.unmatched}`)
737
+ console.log(
738
+ `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}`,
739
+ )
740
+
741
+ if (result.dryRun) {
742
+ console.log('\n(Dry run — no database changes made)')
743
+ }
744
+ } catch (err) {
745
+ const msg = err instanceof Error ? err.message : String(err)
746
+ console.error(`Stamp failed: ${msg}`)
747
+ process.exit(1)
748
+ }
749
+ })
750
+
751
+ // ── Upload R1 data ──────────────────────────────────────────────────
752
+ program
753
+ .command('upload-r1')
754
+ .description('Upload R1 XLSX file to ReturnPro staging')
755
+ .requiredOption('--file <path>', 'Path to R1 XLSX file')
756
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
757
+ .requiredOption('--month <YYYY-MM>', 'Month in YYYY-MM format')
758
+ .action(async (opts: { file: string; userId: string; month: string }) => {
759
+ if (!existsSync(opts.file)) {
760
+ console.error(`File not found: ${opts.file}`)
761
+ process.exit(1)
762
+ }
763
+ try {
764
+ const result = await processR1Upload(opts.file, opts.userId, opts.month)
765
+ console.log(`R1 upload complete: ${result.rowsInserted} rows inserted, ${result.rowsSkipped} skipped (${result.programGroupsFound} program groups)`)
766
+ if (result.warnings.length > 0) {
767
+ console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`)
768
+ }
769
+ } catch (err) {
770
+ console.error(`R1 upload failed: ${err instanceof Error ? err.message : String(err)}`)
771
+ process.exit(1)
772
+ }
773
+ })
774
+
775
+ // ── Upload NetSuite data ────────────────────────────────────────────
776
+ program
777
+ .command('upload-netsuite')
778
+ .description('Upload NetSuite CSV/XLSX to ReturnPro staging')
779
+ .requiredOption('--file <path>', 'Path to NetSuite file (CSV, XLSX, or XLSM)')
780
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
781
+ .action(async (opts: { file: string; userId: string }) => {
782
+ if (!existsSync(opts.file)) {
783
+ console.error(`File not found: ${opts.file}`)
784
+ process.exit(1)
785
+ }
786
+ try {
787
+ const result = await processNetSuiteUpload(opts.file, opts.userId)
788
+ console.log(`NetSuite upload: ${result.inserted} rows inserted (months: ${result.monthsCovered.join(', ')})`)
789
+ if (result.warnings.length > 0) {
790
+ console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`)
791
+ }
792
+ } catch (err) {
793
+ console.error(`NetSuite upload failed: ${err instanceof Error ? err.message : String(err)}`)
794
+ process.exit(1)
795
+ }
796
+ })
797
+
798
+ // ── Upload income statements ────────────────────────────────────────
799
+ program
800
+ .command('upload-income-statements')
801
+ .description('Upload confirmed income statement CSV to ReturnPro')
802
+ .requiredOption('--file <path>', 'Path to income statement CSV')
803
+ .requiredOption('--user-id <uuid>', 'Supabase user UUID')
804
+ .action(async (opts: { file: string; userId: string }) => {
805
+ if (!existsSync(opts.file)) {
806
+ console.error(`File not found: ${opts.file}`)
807
+ process.exit(1)
808
+ }
809
+ try {
810
+ const result = await uploadIncomeStatements(opts.file, opts.userId)
811
+ console.log(`Income statements: ${result.upserted} rows upserted, ${result.skipped} skipped (period: ${result.period})`)
812
+ } catch (err) {
813
+ console.error(`Upload failed: ${err instanceof Error ? err.message : String(err)}`)
814
+ process.exit(1)
815
+ }
816
+ })
817
+
818
+ // ── Rate anomalies ──────────────────────────────────────────────────
819
+ program
820
+ .command('rate-anomalies')
821
+ .description('Detect rate anomalies via z-score analysis on ReturnPro data')
822
+ .option('--from <YYYY-MM>', 'Start month')
823
+ .option('--to <YYYY-MM>', 'End month')
824
+ .option('--threshold <n>', 'Z-score threshold', '2.0')
825
+ .action(async (opts: { from?: string; to?: string; threshold: string }) => {
826
+ try {
827
+ const months = opts.from && opts.to
828
+ ? (() => {
829
+ const result: string[] = []
830
+ const [fy, fm] = opts.from!.split('-').map(Number)
831
+ const [ty, tm] = opts.to!.split('-').map(Number)
832
+ let y = fy, m = fm
833
+ while (y < ty || (y === ty && m <= tm)) {
834
+ result.push(`${y}-${String(m).padStart(2, '0')}`)
835
+ m++
836
+ if (m > 12) { m = 1; y++ }
837
+ }
838
+ return result
839
+ })()
840
+ : undefined
841
+ const result = await detectRateAnomalies({
842
+ months,
843
+ threshold: parseFloat(opts.threshold),
844
+ })
845
+ console.log(`Found ${result.anomalies.length} anomalies (threshold: ${opts.threshold}σ)`)
846
+ for (const a of result.anomalies.slice(0, 30)) {
847
+ console.log(` ${a.month} | ${a.program_code ?? a.master_program} | z=${a.zscore.toFixed(2)} | rate=${a.rate_per_unit}`)
848
+ }
849
+ if (result.anomalies.length > 30) console.log(` ... and ${result.anomalies.length - 30} more`)
850
+ } catch (err) {
851
+ console.error(`Anomaly detection failed: ${err instanceof Error ? err.message : String(err)}`)
852
+ process.exit(1)
853
+ }
854
+ })
855
+
856
+ // ── Diagnose months ─────────────────────────────────────────────────
857
+ program
858
+ .command('diagnose-months')
859
+ .description('Run diagnostic checks on staging data for specified months')
860
+ .option('--months <csv>', 'Comma-separated YYYY-MM months (default: all)')
861
+ .action(async (opts: { months?: string }) => {
862
+ const months = opts.months?.split(',').map(m => m.trim())
863
+ try {
864
+ const result = await diagnoseMonths(months ? { months } : undefined)
865
+ console.log(`Analysed months: ${result.monthsAnalysed.join(', ')}`)
866
+ console.log(`Total staging rows: ${result.totalRows} (median: ${result.medianRowCount}/month)\n`)
867
+ for (const issue of result.issues) {
868
+ console.log(` ✗ [${issue.kind}] ${issue.month ?? 'global'}: ${issue.message}`)
869
+ }
870
+ if (result.issues.length === 0) {
871
+ console.log(' ✓ No issues found')
872
+ }
873
+ console.log(`\nSummary: ${result.summary.totalIssues} issues found`)
874
+ } catch (err) {
875
+ console.error(`Diagnosis failed: ${err instanceof Error ? err.message : String(err)}`)
876
+ process.exit(1)
877
+ }
878
+ })
879
+
880
+ // ── Generate NetSuite template ──────────────────────────────────────
881
+ program
882
+ .command('generate-netsuite-template')
883
+ .description('Generate a blank NetSuite XLSX upload template')
884
+ .option('--output <path>', 'Output file path', 'netsuite-template.xlsx')
885
+ .action(async (opts: { output: string }) => {
886
+ try {
887
+ const result = await generateNetSuiteTemplate(opts.output)
888
+ console.log(`Template saved: ${result.outputPath} (${result.accountCount} accounts)`)
889
+ } catch (err) {
890
+ console.error(`Template generation failed: ${err instanceof Error ? err.message : String(err)}`)
891
+ process.exit(1)
892
+ }
893
+ })
894
+
895
+ // ── Distribute newsletter ───────────────────────────────────────────
896
+ program
897
+ .command('distribute-newsletter')
898
+ .description('Trigger newsletter distribution via n8n webhook')
899
+ .requiredOption('--document-id <id>', 'Strapi newsletter documentId')
900
+ .option('--channel <ch>', 'Distribution channel: email or all', 'all')
901
+ .action(async (opts: { documentId: string; channel: string }) => {
902
+ try {
903
+ const result = await distributeNewsletter(opts.documentId, {
904
+ channel: opts.channel as 'email' | 'all',
905
+ })
906
+ if (result.success) {
907
+ console.log(`Distribution triggered for ${opts.documentId} (channel: ${opts.channel})`)
908
+ } else {
909
+ console.error(`Distribution failed: ${result.error}`)
910
+ process.exit(1)
911
+ }
912
+ } catch (err) {
913
+ console.error(`Distribution failed: ${err instanceof Error ? err.message : String(err)}`)
914
+ process.exit(1)
915
+ }
916
+ })
917
+
918
+ // ── Check distribution status ───────────────────────────────────────
919
+ program
920
+ .command('distribution-status')
921
+ .description('Check delivery status of a newsletter')
922
+ .requiredOption('--document-id <id>', 'Strapi newsletter documentId')
923
+ .action(async (opts: { documentId: string }) => {
924
+ const status = await checkDistributionStatus(opts.documentId)
925
+ console.log(`Status: ${status.delivery_status}`)
926
+ if (status.delivered_at) console.log(`Delivered: ${status.delivered_at}`)
927
+ if (status.recipients_count) console.log(`Recipients: ${status.recipients_count}`)
928
+ if (status.ghl_campaign_id) console.log(`GHL Campaign: ${status.ghl_campaign_id}`)
929
+ })
930
+
931
+ // ── Generate social posts ───────────────────────────────────────────
932
+ program
933
+ .command('generate-social-posts')
934
+ .description('Generate AI-powered social media ad posts and push to Strapi')
935
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
936
+ .option('--count <n>', 'Number of posts to generate', '9')
937
+ .option('--week-of <date>', 'Week start date YYYY-MM-DD (default: next Monday)')
938
+ .option('--dry-run', 'Generate without pushing to Strapi', false)
939
+ .action(async (opts: { brand: string; count: string; weekOf?: string; dryRun: boolean }) => {
940
+ try {
941
+ const result = await generateSocialPosts({
942
+ brand: opts.brand,
943
+ count: parseInt(opts.count),
944
+ weekOf: opts.weekOf,
945
+ dryRun: opts.dryRun,
946
+ })
947
+ console.log(`Created ${result.postsCreated} posts for ${result.brand}`)
948
+ for (const p of result.posts) {
949
+ console.log(` ${p.scheduled_date} | ${p.platform} | ${p.headline}`)
950
+ }
951
+ if (result.errors.length > 0) {
952
+ console.log(`\nErrors: ${result.errors.join(', ')}`)
953
+ }
954
+ } catch (err) {
955
+ console.error(`Post generation failed: ${err instanceof Error ? err.message : String(err)}`)
956
+ process.exit(1)
957
+ }
958
+ })
959
+
960
+ // ── Publish social posts ────────────────────────────────────────────
961
+ program
962
+ .command('publish-social-posts')
963
+ .description('Publish pending social posts to platforms via n8n')
964
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
965
+ .option('--limit <n>', 'Max posts to publish')
966
+ .option('--dry-run', 'Preview without publishing', false)
967
+ .option('--retry', 'Retry previously failed posts', false)
968
+ .action(async (opts: { brand: string; limit?: string; dryRun: boolean; retry: boolean }) => {
969
+ try {
970
+ let result
971
+ if (opts.retry) {
972
+ result = await retryFailed(opts.brand)
973
+ } else {
974
+ result = await publishSocialPosts({
975
+ brand: opts.brand,
976
+ limit: opts.limit ? parseInt(opts.limit) : undefined,
977
+ dryRun: opts.dryRun,
978
+ })
979
+ }
980
+ console.log(`Published: ${result.published} | Failed: ${result.failed} | Skipped: ${result.skipped}`)
981
+ for (const d of result.details) {
982
+ console.log(` ${d.status} | ${d.headline}${d.error ? ` — ${d.error}` : ''}`)
983
+ }
984
+ } catch (err) {
985
+ console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
986
+ process.exit(1)
987
+ }
988
+ })
989
+
990
+ // ── Social post queue ───────────────────────────────────────────────
991
+ program
992
+ .command('social-queue')
993
+ .description('View pending social posts ready for publishing')
994
+ .requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
995
+ .action(async (opts: { brand: string }) => {
996
+ const queue = await getPublishQueue(opts.brand)
997
+ if (queue.length === 0) {
998
+ console.log('No posts in queue')
999
+ return
1000
+ }
1001
+ console.log('| Date | Platform | Headline |')
1002
+ console.log('|------|----------|----------|')
1003
+ for (const p of queue) {
1004
+ console.log(`| ${p.scheduled_date} | ${p.platform} | ${p.headline} |`)
1005
+ }
1006
+ console.log(`\n${queue.length} posts queued`)
1007
+ })
1008
+
1009
+ // ── Publish blog ────────────────────────────────────────────────────
1010
+ program
1011
+ .command('publish-blog')
1012
+ .description('Publish a Strapi blog post and optionally deploy portfolio site')
1013
+ .requiredOption('--slug <slug>', 'Blog post slug')
1014
+ .option('--deploy', 'Deploy portfolio site after publishing', false)
1015
+ .action(async (opts: { slug: string; deploy: boolean }) => {
1016
+ try {
1017
+ const result = await publishBlog({ slug: opts.slug, deployAfter: opts.deploy })
1018
+ console.log(`Published: ${result.slug} (${result.documentId})`)
1019
+ if (result.deployUrl) console.log(`Deployed: ${result.deployUrl}`)
1020
+ } catch (err) {
1021
+ console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`)
1022
+ process.exit(1)
1023
+ }
1024
+ })
1025
+
1026
+ // ── Blog drafts ─────────────────────────────────────────────────────
1027
+ program
1028
+ .command('blog-drafts')
1029
+ .description('List unpublished blog post drafts')
1030
+ .option('--site <site>', 'Filter by site (portfolio, insurance)')
1031
+ .action(async (opts: { site?: string }) => {
1032
+ const drafts = await listBlogDrafts(opts.site)
1033
+ if (drafts.length === 0) {
1034
+ console.log('No drafts found')
1035
+ return
1036
+ }
1037
+ console.log('| Created | Site | Title | Slug |')
1038
+ console.log('|---------|------|-------|------|')
1039
+ for (const d of drafts) {
1040
+ console.log(`| ${d.createdAt.slice(0, 10)} | ${d.site} | ${d.title} | ${d.slug} |`)
1041
+ }
1042
+ })
1043
+
1044
+ // ── Database migration ──────────────────────────────────────────────
1045
+ const migrate = program.command('migrate').description('Supabase database migration operations')
1046
+
1047
+ migrate
1048
+ .command('push')
1049
+ .description('Run supabase db push --linked on a target project')
1050
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
1051
+ .option('--dry-run', 'Preview without applying', false)
1052
+ .action(async (opts: { target: string; dryRun: boolean }) => {
1053
+ const target = opts.target as 'returnpro' | 'optimalos'
1054
+ if (target !== 'returnpro' && target !== 'optimalos') {
1055
+ console.error('Target must be "returnpro" or "optimalos"')
1056
+ process.exit(1)
1057
+ }
1058
+ console.log(`Migrating ${target}${opts.dryRun ? ' (dry run)' : ''}...`)
1059
+ const result = await migrateDb({ target, dryRun: opts.dryRun })
1060
+ if (result.success) {
1061
+ console.log(result.output)
1062
+ } else {
1063
+ console.error(`Migration failed:\n${result.errors}`)
1064
+ process.exit(1)
1065
+ }
1066
+ })
1067
+
1068
+ migrate
1069
+ .command('pending')
1070
+ .description('List pending migration files')
1071
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
1072
+ .action(async (opts: { target: string }) => {
1073
+ const files = await listPendingMigrations(opts.target as 'returnpro' | 'optimalos')
1074
+ if (files.length === 0) {
1075
+ console.log('No migration files found')
1076
+ return
1077
+ }
1078
+ for (const f of files) console.log(` ${f}`)
1079
+ console.log(`\n${files.length} migration files`)
1080
+ })
1081
+
1082
+ migrate
1083
+ .command('create')
1084
+ .description('Create a new empty migration file')
1085
+ .requiredOption('--target <t>', 'Target: returnpro or optimalos')
1086
+ .requiredOption('--name <name>', 'Migration name')
1087
+ .action(async (opts: { target: string; name: string }) => {
1088
+ const path = await createMigration(opts.target as 'returnpro' | 'optimalos', opts.name)
1089
+ console.log(`Created: ${path}`)
1090
+ })
1091
+
1092
+ // ── Budget scenarios ────────────────────────────────────────────────
1093
+ const scenario = program.command('scenario').description('Budget scenario management')
1094
+
1095
+ scenario
1096
+ .command('save')
1097
+ .description('Save current projections as a named scenario')
1098
+ .requiredOption('--name <name>', 'Scenario name')
1099
+ .requiredOption('--adjustment-type <type>', 'Adjustment type: percentage or flat')
1100
+ .requiredOption('--adjustment-value <n>', 'Adjustment value')
1101
+ .option('--description <desc>', 'Description')
1102
+ .option('--fiscal-year <fy>', 'Fiscal year', '2025')
1103
+ .option('--user-id <uuid>', 'User UUID')
1104
+ .action(async (opts) => {
1105
+ try {
1106
+ const path = await saveScenario({
1107
+ name: opts.name,
1108
+ adjustmentType: opts.adjustmentType as 'percentage' | 'flat',
1109
+ adjustmentValue: parseFloat(opts.adjustmentValue),
1110
+ fiscalYear: parseInt(opts.fiscalYear),
1111
+ userId: opts.userId,
1112
+ description: opts.description,
1113
+ })
1114
+ console.log(`Scenario saved: ${path}`)
1115
+ } catch (err) {
1116
+ console.error(`Save failed: ${err instanceof Error ? err.message : String(err)}`)
1117
+ process.exit(1)
1118
+ }
1119
+ })
1120
+
1121
+ scenario
1122
+ .command('list')
1123
+ .description('List all saved budget scenarios')
1124
+ .action(async () => {
1125
+ const scenarios = await listScenarios()
1126
+ if (scenarios.length === 0) {
1127
+ console.log('No scenarios saved')
1128
+ return
1129
+ }
1130
+ console.log('| Name | Adjustment | Projected | Change | Created |')
1131
+ console.log('|------|------------|-----------|--------|---------|')
1132
+ for (const s of scenarios) {
1133
+ const adj = s.adjustmentType === 'percentage' ? `${s.adjustmentValue}%` : `+${s.adjustmentValue}`
1134
+ console.log(`| ${s.name} | ${adj} | ${s.totalProjected.toLocaleString()} | ${s.percentageChange.toFixed(1)}% | ${s.createdAt.slice(0, 10)} |`)
1135
+ }
1136
+ })
1137
+
1138
+ scenario
1139
+ .command('compare')
1140
+ .description('Compare two or more scenarios side by side')
1141
+ .requiredOption('--names <csv>', 'Comma-separated scenario names')
1142
+ .action(async (opts: { names: string }) => {
1143
+ const names = opts.names.split(',').map(n => n.trim())
1144
+ if (names.length < 2) {
1145
+ console.error('Need at least 2 scenario names to compare')
1146
+ process.exit(1)
1147
+ }
1148
+ try {
1149
+ const result = await compareScenarios(names)
1150
+ // Print header
1151
+ const header = ['Program', 'Actual', ...result.scenarioNames].join(' | ')
1152
+ console.log(`| ${header} |`)
1153
+ console.log(`|${result.scenarioNames.map(() => '---').concat(['---', '---']).join('|')}|`)
1154
+ for (const p of result.programs.slice(0, 50)) {
1155
+ const vals = result.scenarioNames.map(n => String(p.projectedByScenario[n] ?? 0))
1156
+ console.log(`| ${p.programCode} | ${p.actual} | ${vals.join(' | ')} |`)
1157
+ }
1158
+ // Totals
1159
+ console.log('\nTotals:')
1160
+ for (const name of result.scenarioNames) {
1161
+ const t = result.totalsByScenario[name]
1162
+ console.log(` ${name}: ${t.totalProjected.toLocaleString()} (${t.percentageChange >= 0 ? '+' : ''}${t.percentageChange.toFixed(1)}%)`)
1163
+ }
1164
+ } catch (err) {
1165
+ console.error(`Compare failed: ${err instanceof Error ? err.message : String(err)}`)
1166
+ process.exit(1)
1167
+ }
1168
+ })
1169
+
1170
+ scenario
1171
+ .command('delete')
1172
+ .description('Delete a saved scenario')
1173
+ .requiredOption('--name <name>', 'Scenario name')
1174
+ .action(async (opts: { name: string }) => {
1175
+ try {
1176
+ await deleteScenario(opts.name)
1177
+ console.log(`Deleted scenario: ${opts.name}`)
1178
+ } catch (err) {
1179
+ console.error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`)
1180
+ process.exit(1)
1181
+ }
1182
+ })
1183
+
1184
+ // ── Delete batch ────────────────────────────────────────────────────
1185
+ program
1186
+ .command('delete-batch')
1187
+ .description('Batch delete transactions or staging rows (safe: dry-run by default)')
1188
+ .requiredOption('--table <t>', 'Table: transactions or stg_financials_raw')
1189
+ .option('--user-id <uuid>', 'User UUID filter')
1190
+ .option('--date-from <date>', 'Start date YYYY-MM-DD')
1191
+ .option('--date-to <date>', 'End date YYYY-MM-DD')
1192
+ .option('--source <src>', 'Source filter')
1193
+ .option('--category <cat>', 'Category filter (transactions)')
1194
+ .option('--account-code <code>', 'Account code filter (staging)')
1195
+ .option('--month <YYYY-MM>', 'Month filter (staging)')
1196
+ .option('--execute', 'Actually delete (default is dry-run preview)', false)
1197
+ .action(async (opts) => {
1198
+ const table = opts.table as 'transactions' | 'stg_financials_raw'
1199
+ const filters = {
1200
+ dateFrom: opts.dateFrom,
1201
+ dateTo: opts.dateTo,
1202
+ source: opts.source,
1203
+ category: opts.category,
1204
+ accountCode: opts.accountCode,
1205
+ month: opts.month,
1206
+ }
1207
+ const dryRun = !opts.execute
1208
+
1209
+ if (dryRun) {
1210
+ const preview = await previewBatch({ table, userId: opts.userId, filters })
1211
+ console.log(`Preview: ${preview.matchCount} rows would be deleted from ${table}`)
1212
+ if (Object.keys(preview.groupedCounts).length > 0) {
1213
+ console.log('\nGrouped:')
1214
+ for (const [key, count] of Object.entries(preview.groupedCounts)) {
1215
+ console.log(` ${key}: ${count}`)
1216
+ }
1217
+ }
1218
+ if (preview.sample.length > 0) {
1219
+ console.log(`\nSample (first ${preview.sample.length}):`)
1220
+ for (const row of preview.sample) {
1221
+ console.log(` ${JSON.stringify(row)}`)
1222
+ }
1223
+ }
1224
+ console.log('\nUse --execute to actually delete')
1225
+ } else {
1226
+ const result = await deleteBatch({ table, userId: opts.userId, filters, dryRun: false })
1227
+ console.log(`Deleted ${result.deletedCount} rows from ${table}`)
1228
+ }
1229
+ })
1230
+
1231
+ // ── Config registry (v1 scaffold) ─────────────────────────────────
1232
+ const config = program.command('config').description('Manage optimal-cli local/shared config profile')
1233
+
1234
+ config
1235
+ .command('init')
1236
+ .description('Create a local config scaffold (overwrites with --force)')
1237
+ .option('--owner <owner>', 'Config owner (default: $OPTIMAL_CONFIG_OWNER or $USER)')
1238
+ .option('--profile <name>', 'Profile name', 'default')
1239
+ .option('--brand <brand>', 'Default brand', 'CRE-11TRUST')
1240
+ .option('--timezone <tz>', 'Default timezone', 'America/New_York')
1241
+ .option('--force', 'Overwrite existing config', false)
1242
+ .action(async (opts: { owner?: string; profile: string; brand: string; timezone: string; force?: boolean }) => {
1243
+ try {
1244
+ const existing = await readLocalConfig()
1245
+ if (existing && !opts.force) {
1246
+ console.error(`Config already exists at ${getLocalConfigPath()} (use --force to overwrite)`)
1247
+ process.exit(1)
1248
+ }
1249
+
1250
+ const owner = opts.owner || process.env.OPTIMAL_CONFIG_OWNER || process.env.USER || 'oracle'
1251
+ const payload: OptimalConfigV1 = {
1252
+ version: '1.0.0',
1253
+ profile: {
1254
+ name: opts.profile,
1255
+ owner,
1256
+ updated_at: new Date().toISOString(),
1257
+ },
1258
+ providers: {
1259
+ supabase: {
1260
+ project_ref: process.env.OPTIMAL_SUPABASE_PROJECT_REF || 'unset',
1261
+ url: process.env.OPTIMAL_SUPABASE_URL || 'unset',
1262
+ anon_key_present: Boolean(process.env.OPTIMAL_SUPABASE_ANON_KEY),
1263
+ },
1264
+ strapi: {
1265
+ base_url: process.env.STRAPI_BASE_URL || 'unset',
1266
+ token_present: Boolean(process.env.STRAPI_TOKEN),
1267
+ },
1268
+ },
1269
+ defaults: {
1270
+ brand: opts.brand,
1271
+ timezone: opts.timezone,
1272
+ },
1273
+ features: {
1274
+ cms: true,
1275
+ tasks: true,
1276
+ deploy: true,
1277
+ },
1278
+ }
1279
+
1280
+ await writeLocalConfig(payload)
1281
+ await appendHistory(`${new Date().toISOString()} init profile=${opts.profile} owner=${owner} hash=${hashConfig(payload)}`)
1282
+ console.log(`Initialized config at ${getLocalConfigPath()}`)
1283
+ } catch (err) {
1284
+ console.error(`Config init failed: ${err instanceof Error ? err.message : String(err)}`)
1285
+ process.exit(1)
1286
+ }
1287
+ })
1288
+
1289
+ config
1290
+ .command('doctor')
1291
+ .description('Validate local config file and print health details')
1292
+ .action(async () => {
1293
+ try {
1294
+ const cfg = await readLocalConfig()
1295
+ if (!cfg) {
1296
+ console.log(`No local config found at ${getLocalConfigPath()}`)
1297
+ process.exit(1)
1298
+ }
1299
+ const digest = hashConfig(cfg)
1300
+ console.log(`config: ok`)
1301
+ console.log(`path: ${getLocalConfigPath()}`)
1302
+ console.log(`profile: ${cfg.profile.name}`)
1303
+ console.log(`owner: ${cfg.profile.owner}`)
1304
+ console.log(`version: ${cfg.version}`)
1305
+ console.log(`hash: ${digest}`)
1306
+ console.log(`history: ${getHistoryPath()}`)
1307
+ } catch (err) {
1308
+ console.error(`Config doctor failed: ${err instanceof Error ? err.message : String(err)}`)
1309
+ process.exit(1)
1310
+ }
1311
+ })
1312
+
1313
+ config
1314
+ .command('export')
1315
+ .description('Export local config to a JSON path')
1316
+ .requiredOption('--out <path>', 'Output path for JSON export')
1317
+ .action(async (opts: { out: string }) => {
1318
+ try {
1319
+ const cfg = await readLocalConfig()
1320
+ if (!cfg) {
1321
+ console.error(`No local config found at ${getLocalConfigPath()}`)
1322
+ process.exit(1)
1323
+ }
1324
+ const payload: OptimalConfigV1 = {
1325
+ ...cfg,
1326
+ profile: {
1327
+ ...cfg.profile,
1328
+ updated_at: new Date().toISOString(),
1329
+ },
1330
+ }
1331
+ const json = `${JSON.stringify(payload, null, 2)}\n`
1332
+ writeFileSync(opts.out, json, 'utf-8')
1333
+ await appendHistory(`${new Date().toISOString()} export out=${opts.out} hash=${hashConfig(payload)}`)
1334
+ console.log(`Exported config to ${opts.out}`)
1335
+ } catch (err) {
1336
+ console.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`)
1337
+ process.exit(1)
1338
+ }
1339
+ })
1340
+
1341
+ config
1342
+ .command('import')
1343
+ .description('Import local config from a JSON path')
1344
+ .requiredOption('--in <path>', 'Input path for JSON config')
1345
+ .action(async (opts: { in: string }) => {
1346
+ try {
1347
+ if (!existsSync(opts.in)) {
1348
+ console.error(`Input file not found: ${opts.in}`)
1349
+ process.exit(1)
1350
+ }
1351
+ const raw = readFileSync(opts.in, 'utf-8')
1352
+ const parsed = JSON.parse(raw)
1353
+ const payload = assertOptimalConfigV1(parsed)
1354
+ await writeLocalConfig(payload)
1355
+ await appendHistory(`${new Date().toISOString()} import in=${opts.in} hash=${hashConfig(payload)}`)
1356
+ console.log(`Imported config from ${opts.in}`)
1357
+ } catch (err) {
1358
+ console.error(`Config import failed: ${err instanceof Error ? err.message : String(err)}`)
1359
+ process.exit(1)
1360
+ }
1361
+ })
1362
+
1363
+ const configSync = config.command('sync').description('Sync local profile with shared registry (scaffold)')
1364
+
1365
+ configSync
1366
+ .command('pull')
1367
+ .description('Pull config profile from shared registry into local config')
1368
+ .option('--profile <name>', 'Registry profile name', 'default')
1369
+ .option('--dry-run', 'Show what would be pulled without writing', false)
1370
+ .action(async (opts: { profile: string; dryRun?: boolean }) => {
1371
+ if (opts.dryRun) {
1372
+ const local = await readLocalConfig()
1373
+ const owner = local?.profile.owner || process.env.OPTIMAL_CONFIG_OWNER || 'unknown'
1374
+ console.log(`[dry-run] Would pull profile '${opts.profile}' for owner '${owner}' from registry`)
1375
+ console.log(`[dry-run] Local config: ${local ? 'exists' : 'none'}`)
1376
+ return
1377
+ }
1378
+ const result = await pullRegistryProfile(opts.profile)
1379
+ const stamp = new Date().toISOString()
1380
+ await appendHistory(`${stamp} sync.pull profile=${opts.profile} ok=${result.ok} msg=${result.message}`)
1381
+ if (!result.ok) {
1382
+ console.error(result.message)
1383
+ process.exit(1)
1384
+ }
1385
+ console.log(result.message)
1386
+ })
1387
+
1388
+ configSync
1389
+ .command('push')
1390
+ .description('Push local config profile to shared registry')
1391
+ .requiredOption('--agent <name>', 'Agent/owner name for the config profile')
1392
+ .option('--profile <name>', 'Registry profile name', 'default')
1393
+ .option('--force', 'Force write even on conflict', false)
1394
+ .option('--dry-run', 'Show what would be pushed without writing', false)
1395
+ .action(async (opts: { agent: string; profile: string; force?: boolean; dryRun?: boolean }) => {
1396
+ if (opts.dryRun) {
1397
+ const local = await readLocalConfig()
1398
+ if (!local) {
1399
+ console.error('[dry-run] No local config to push')
1400
+ process.exit(1)
1401
+ }
1402
+ const hash = hashConfig(local)
1403
+ console.log(`[dry-run] Would push profile '${opts.profile}' for agent '${opts.agent}' to registry`)
1404
+ console.log(`[dry-run] Local config hash: ${hash}`)
1405
+ console.log(`[dry-run] Force: ${Boolean(opts.force)}`)
1406
+ return
1407
+ }
1408
+ const result = await pushRegistryProfile(opts.profile, Boolean(opts.force), opts.agent)
1409
+ const stamp = new Date().toISOString()
1410
+ await appendHistory(`${stamp} sync.push agent=${opts.agent} profile=${opts.profile} force=${Boolean(opts.force)} ok=${result.ok} msg=${result.message}`)
1411
+ if (!result.ok) {
1412
+ console.error(result.message)
1413
+ process.exit(1)
1414
+ }
1415
+ console.log(result.message)
1416
+ })
1417
+
1418
+ program.parseAsync()