optimal-cli 1.0.0 → 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 (85) 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 +278 -591
  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/config/registry.ts +5 -4
  17. package/lib/kanban-obsidian.ts +232 -0
  18. package/lib/kanban-sync.ts +258 -0
  19. package/lib/kanban.ts +239 -0
  20. package/lib/obsidian-tasks.ts +231 -0
  21. package/package.json +5 -19
  22. package/pnpm-workspace.yaml +3 -0
  23. package/scripts/check-table.ts +24 -0
  24. package/scripts/create-tables.ts +94 -0
  25. package/scripts/migrate-kanban.sh +28 -0
  26. package/scripts/migrate-v2.ts +78 -0
  27. package/scripts/migrate.ts +79 -0
  28. package/scripts/run-migration.ts +59 -0
  29. package/scripts/seed-board.ts +203 -0
  30. package/scripts/test-kanban.ts +21 -0
  31. package/skills/audit-financials/SKILL.md +33 -0
  32. package/skills/board-create/SKILL.md +28 -0
  33. package/skills/board-update/SKILL.md +27 -0
  34. package/skills/board-view/SKILL.md +27 -0
  35. package/skills/delete-batch/SKILL.md +77 -0
  36. package/skills/deploy/SKILL.md +40 -0
  37. package/skills/diagnose-months/SKILL.md +68 -0
  38. package/skills/distribute-newsletter/SKILL.md +58 -0
  39. package/skills/export-budget/SKILL.md +44 -0
  40. package/skills/export-kpis/SKILL.md +52 -0
  41. package/skills/generate-netsuite-template/SKILL.md +51 -0
  42. package/skills/generate-newsletter/SKILL.md +53 -0
  43. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  44. package/skills/generate-social-posts/SKILL.md +67 -0
  45. package/skills/health-check/SKILL.md +42 -0
  46. package/skills/ingest-transactions/SKILL.md +51 -0
  47. package/skills/manage-cms/SKILL.md +50 -0
  48. package/skills/manage-scenarios/SKILL.md +83 -0
  49. package/skills/migrate-db/SKILL.md +79 -0
  50. package/skills/preview-newsletter/SKILL.md +50 -0
  51. package/skills/project-budget/SKILL.md +60 -0
  52. package/skills/publish-blog/SKILL.md +70 -0
  53. package/skills/publish-social-posts/SKILL.md +70 -0
  54. package/skills/rate-anomalies/SKILL.md +62 -0
  55. package/skills/scrape-ads/SKILL.md +49 -0
  56. package/skills/stamp-transactions/SKILL.md +62 -0
  57. package/skills/upload-income-statements/SKILL.md +54 -0
  58. package/skills/upload-netsuite/SKILL.md +56 -0
  59. package/skills/upload-r1/SKILL.md +45 -0
  60. package/supabase/.temp/cli-latest +1 -0
  61. package/supabase/migrations/.gitkeep +0 -0
  62. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  63. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  64. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  65. package/tests/config-command-smoke.test.ts +395 -0
  66. package/tests/config-registry.test.ts +173 -0
  67. package/tsconfig.json +19 -0
  68. package/agents/profiles.json +0 -5
  69. package/docs/CLI-REFERENCE.md +0 -361
  70. package/lib/assets/index.ts +0 -225
  71. package/lib/assets.ts +0 -124
  72. package/lib/auth/index.ts +0 -189
  73. package/lib/board/index.ts +0 -309
  74. package/lib/board/types.ts +0 -124
  75. package/lib/bot/claim.ts +0 -43
  76. package/lib/bot/coordinator.ts +0 -254
  77. package/lib/bot/heartbeat.ts +0 -37
  78. package/lib/bot/index.ts +0 -9
  79. package/lib/bot/protocol.ts +0 -99
  80. package/lib/bot/reporter.ts +0 -42
  81. package/lib/bot/skills.ts +0 -81
  82. package/lib/errors.ts +0 -129
  83. package/lib/format.ts +0 -120
  84. package/lib/returnpro/validate.ts +0 -154
  85. package/lib/social/meta.ts +0 -228
@@ -1,254 +0,0 @@
1
- import {
2
- listTasks,
3
- updateTask,
4
- claimTask,
5
- logActivity,
6
- listActivity,
7
- type Task,
8
- } from '../board/index.js'
9
- import { sendHeartbeat, getActiveAgents } from './heartbeat.js'
10
- import { claimNextTask } from './claim.js'
11
- import { getAgentProfiles, matchTasksToAgent, type AgentProfile } from './skills.js'
12
-
13
- // --- Types ---
14
-
15
- export interface CoordinatorConfig {
16
- pollIntervalMs: number
17
- maxAgents: number
18
- autoAssign: boolean
19
- }
20
-
21
- export interface CoordinatorStatus {
22
- activeAgents: { agent: string; status: string; lastSeen: string }[]
23
- idleAgents: AgentProfile[]
24
- tasksInProgress: number
25
- tasksReady: number
26
- tasksBlocked: number
27
- lastPollAt: string | null
28
- }
29
-
30
- export interface RebalanceResult {
31
- releasedTasks: Task[]
32
- reassignedTasks: Task[]
33
- }
34
-
35
- // --- State ---
36
-
37
- const DEFAULT_CONFIG: CoordinatorConfig = {
38
- pollIntervalMs: 30_000,
39
- maxAgents: 10,
40
- autoAssign: true,
41
- }
42
-
43
- let lastPollAt: string | null = null
44
-
45
- // --- Coordinator loop ---
46
-
47
- export async function runCoordinatorLoop(
48
- config?: Partial<CoordinatorConfig>,
49
- ): Promise<void> {
50
- const cfg: CoordinatorConfig = { ...DEFAULT_CONFIG, ...config }
51
- let running = true
52
-
53
- const shutdown = () => {
54
- running = false
55
- console.log('\nCoordinator shutting down...')
56
- }
57
- process.on('SIGINT', shutdown)
58
-
59
- console.log(
60
- `Coordinator started — poll every ${cfg.pollIntervalMs}ms, max ${cfg.maxAgents} agents, autoAssign=${cfg.autoAssign}`,
61
- )
62
-
63
- while (running) {
64
- try {
65
- await pollOnce(cfg)
66
- } catch (err) {
67
- const msg = err instanceof Error ? err.message : String(err)
68
- console.error(`Coordinator poll error: ${msg}`)
69
- await logActivity({
70
- actor: 'coordinator',
71
- action: 'poll_error',
72
- new_value: { error: msg },
73
- })
74
- }
75
-
76
- // Wait for next poll or until interrupted
77
- await new Promise<void>((resolve) => {
78
- const timer = setTimeout(resolve, cfg.pollIntervalMs)
79
- if (!running) {
80
- clearTimeout(timer)
81
- resolve()
82
- }
83
- })
84
- }
85
-
86
- process.removeListener('SIGINT', shutdown)
87
- console.log('Coordinator stopped.')
88
- }
89
-
90
- async function pollOnce(cfg: CoordinatorConfig): Promise<void> {
91
- lastPollAt = new Date().toISOString()
92
-
93
- const profiles = getAgentProfiles().slice(0, cfg.maxAgents)
94
- const readyTasks = await listTasks({ status: 'ready' })
95
- const claimedTasks = await listTasks({ status: 'claimed' })
96
-
97
- // Send heartbeats for all active agents that have claimed tasks
98
- const activeAgentIds = new Set(
99
- claimedTasks.map((t) => t.claimed_by).filter(Boolean) as string[],
100
- )
101
- for (const agentId of activeAgentIds) {
102
- await sendHeartbeat(agentId, 'working')
103
- }
104
-
105
- if (!cfg.autoAssign) {
106
- await logActivity({
107
- actor: 'coordinator',
108
- action: 'poll',
109
- new_value: {
110
- readyTasks: readyTasks.length,
111
- activeAgents: activeAgentIds.size,
112
- autoAssign: false,
113
- ts: lastPollAt,
114
- },
115
- })
116
- return
117
- }
118
-
119
- // Find idle agents and try to assign tasks
120
- let assignedCount = 0
121
- for (const agent of profiles) {
122
- // Check if agent has capacity
123
- const agentClaimed = claimedTasks.filter((t) => t.claimed_by === agent.id)
124
- if (agentClaimed.length >= agent.maxConcurrent) continue
125
-
126
- // Match available tasks to this agent
127
- const matched = matchTasksToAgent(agent, readyTasks)
128
- if (matched.length === 0) continue
129
-
130
- // Claim the top-priority matched task
131
- const task = await claimNextTask(agent.id, agent.skills)
132
- if (task) {
133
- assignedCount++
134
- console.log(` Assigned "${task.title}" -> ${agent.id}`)
135
- // Remove from local readyTasks to avoid double-assign
136
- const idx = readyTasks.findIndex((t) => t.id === task.id)
137
- if (idx >= 0) readyTasks.splice(idx, 1)
138
- }
139
- }
140
-
141
- await logActivity({
142
- actor: 'coordinator',
143
- action: 'poll',
144
- new_value: {
145
- readyTasks: readyTasks.length,
146
- activeAgents: activeAgentIds.size,
147
- assigned: assignedCount,
148
- ts: lastPollAt,
149
- },
150
- })
151
-
152
- if (assignedCount > 0) {
153
- console.log(`Poll complete: assigned ${assignedCount} task(s)`)
154
- }
155
- }
156
-
157
- // --- Status ---
158
-
159
- export async function getCoordinatorStatus(): Promise<CoordinatorStatus> {
160
- const profiles = getAgentProfiles()
161
- const activeAgents = await getActiveAgents()
162
- const readyTasks = await listTasks({ status: 'ready' })
163
- const claimedTasks = await listTasks({ status: 'claimed' })
164
- const inProgressTasks = await listTasks({ status: 'in_progress' })
165
- const blockedTasks = await listTasks({ status: 'blocked' })
166
-
167
- const activeIds = new Set(activeAgents.map((a) => a.agent))
168
- const idleAgents = profiles.filter((p) => !activeIds.has(p.id))
169
-
170
- return {
171
- activeAgents,
172
- idleAgents,
173
- tasksInProgress: claimedTasks.length + inProgressTasks.length,
174
- tasksReady: readyTasks.length,
175
- tasksBlocked: blockedTasks.length,
176
- lastPollAt,
177
- }
178
- }
179
-
180
- // --- Manual assignment ---
181
-
182
- export async function assignTask(
183
- taskId: string,
184
- agentId: string,
185
- ): Promise<Task> {
186
- const task = await claimTask(taskId, agentId)
187
-
188
- await logActivity({
189
- actor: 'coordinator',
190
- action: 'manual_assign',
191
- task_id: taskId,
192
- new_value: { agentId, title: task.title },
193
- })
194
-
195
- console.log(`Manually assigned "${task.title}" -> ${agentId}`)
196
- return task
197
- }
198
-
199
- // --- Rebalance ---
200
-
201
- export async function rebalance(): Promise<RebalanceResult> {
202
- const claimedTasks = await listTasks({ status: 'claimed' })
203
- const now = Date.now()
204
- const staleThreshold = 60 * 60 * 1000 // 1 hour
205
-
206
- const releasedTasks: Task[] = []
207
- const reassignedTasks: Task[] = []
208
-
209
- for (const task of claimedTasks) {
210
- if (!task.claimed_at) continue
211
-
212
- const claimedAge = now - new Date(task.claimed_at).getTime()
213
- if (claimedAge < staleThreshold) continue
214
-
215
- // Check for recent activity on this task
216
- const activity = await listActivity({ task_id: task.id, limit: 5 })
217
- const recentActivity = activity.some((a) => {
218
- const age = now - new Date(a.created_at).getTime()
219
- return age < staleThreshold && a.action !== 'poll'
220
- })
221
-
222
- if (recentActivity) continue
223
-
224
- // Release stale task
225
- const released = await updateTask(
226
- task.id,
227
- {
228
- status: 'ready',
229
- claimed_by: null,
230
- claimed_at: null,
231
- },
232
- 'coordinator',
233
- )
234
-
235
- releasedTasks.push(released)
236
-
237
- await logActivity({
238
- actor: 'coordinator',
239
- action: 'rebalance_release',
240
- task_id: task.id,
241
- new_value: {
242
- previousAgent: task.claimed_by,
243
- claimedAt: task.claimed_at,
244
- reason: 'stale_claim',
245
- },
246
- })
247
-
248
- console.log(
249
- `Released stale task "${task.title}" (was claimed by ${task.claimed_by})`,
250
- )
251
- }
252
-
253
- return { releasedTasks, reassignedTasks }
254
- }
@@ -1,37 +0,0 @@
1
- import { logActivity, listActivity } from '../board/index.js'
2
-
3
- export async function sendHeartbeat(
4
- agentId: string,
5
- status: 'idle' | 'working' | 'error',
6
- ): Promise<void> {
7
- await logActivity({
8
- actor: agentId,
9
- action: 'heartbeat',
10
- new_value: { status, ts: new Date().toISOString() },
11
- })
12
- }
13
-
14
- export async function getActiveAgents(): Promise<
15
- { agent: string; status: string; lastSeen: string }[]
16
- > {
17
- const entries = await listActivity({ limit: 200 })
18
- const cutoff = Date.now() - 5 * 60 * 1000
19
-
20
- const latest = new Map<string, { status: string; lastSeen: string }>()
21
-
22
- for (const e of entries) {
23
- if (e.action !== 'heartbeat') continue
24
- if (new Date(e.created_at).getTime() < cutoff) continue
25
- if (latest.has(e.actor)) continue
26
- const nv = e.new_value as { status?: string } | null
27
- latest.set(e.actor, {
28
- status: nv?.status ?? 'unknown',
29
- lastSeen: e.created_at,
30
- })
31
- }
32
-
33
- return Array.from(latest.entries()).map(([agent, info]) => ({
34
- agent,
35
- ...info,
36
- }))
37
- }
package/lib/bot/index.ts DELETED
@@ -1,9 +0,0 @@
1
- export { sendHeartbeat, getActiveAgents } from './heartbeat.js'
2
- export { claimNextTask, releaseTask } from './claim.js'
3
- export { reportProgress, reportCompletion, reportBlocked } from './reporter.js'
4
- export { getAgentProfiles, matchTasksToAgent, findBestAgent } from './skills.js'
5
- export type { AgentProfile } from './skills.js'
6
- export { runCoordinatorLoop, getCoordinatorStatus, assignTask, rebalance } from './coordinator.js'
7
- export type { CoordinatorConfig, CoordinatorStatus, RebalanceResult } from './coordinator.js'
8
- export { processAgentMessage } from './protocol.js'
9
- export type { AgentMessage, AgentResponse } from './protocol.js'
@@ -1,99 +0,0 @@
1
- import { sendHeartbeat } from './heartbeat.js'
2
- import { claimNextTask, releaseTask } from './claim.js'
3
- import { reportProgress, reportCompletion, reportBlocked } from './reporter.js'
4
-
5
- // --- Types ---
6
-
7
- export interface AgentMessage {
8
- type: 'heartbeat' | 'claim' | 'progress' | 'complete' | 'blocked' | 'release'
9
- agentId: string
10
- taskId?: string
11
- payload?: Record<string, unknown>
12
- }
13
-
14
- export interface AgentResponse {
15
- success: boolean
16
- data?: unknown
17
- error?: string
18
- }
19
-
20
- // --- Message processor ---
21
-
22
- export async function processAgentMessage(
23
- msg: AgentMessage,
24
- ): Promise<AgentResponse> {
25
- try {
26
- switch (msg.type) {
27
- case 'heartbeat':
28
- return await handleHeartbeat(msg)
29
- case 'claim':
30
- return await handleClaim(msg)
31
- case 'progress':
32
- return await handleProgress(msg)
33
- case 'complete':
34
- return await handleComplete(msg)
35
- case 'blocked':
36
- return await handleBlocked(msg)
37
- case 'release':
38
- return await handleRelease(msg)
39
- default:
40
- return { success: false, error: `Unknown message type: ${(msg as AgentMessage).type}` }
41
- }
42
- } catch (err) {
43
- const errorMsg = err instanceof Error ? err.message : String(err)
44
- return { success: false, error: errorMsg }
45
- }
46
- }
47
-
48
- // --- Handlers ---
49
-
50
- async function handleHeartbeat(msg: AgentMessage): Promise<AgentResponse> {
51
- const status = (msg.payload?.status as 'idle' | 'working' | 'error') ?? 'idle'
52
- await sendHeartbeat(msg.agentId, status)
53
- return { success: true, data: { agentId: msg.agentId, status } }
54
- }
55
-
56
- async function handleClaim(msg: AgentMessage): Promise<AgentResponse> {
57
- const skills = msg.payload?.skills as string[] | undefined
58
- const task = await claimNextTask(msg.agentId, skills)
59
- if (!task) {
60
- return { success: true, data: null }
61
- }
62
- return { success: true, data: { taskId: task.id, title: task.title } }
63
- }
64
-
65
- async function handleProgress(msg: AgentMessage): Promise<AgentResponse> {
66
- if (!msg.taskId) {
67
- return { success: false, error: 'taskId is required for progress messages' }
68
- }
69
- const message = (msg.payload?.message as string) ?? 'Progress update'
70
- await reportProgress(msg.taskId, msg.agentId, message)
71
- return { success: true, data: { taskId: msg.taskId } }
72
- }
73
-
74
- async function handleComplete(msg: AgentMessage): Promise<AgentResponse> {
75
- if (!msg.taskId) {
76
- return { success: false, error: 'taskId is required for complete messages' }
77
- }
78
- const summary = (msg.payload?.summary as string) ?? 'Task completed'
79
- await reportCompletion(msg.taskId, msg.agentId, summary)
80
- return { success: true, data: { taskId: msg.taskId, status: 'done' } }
81
- }
82
-
83
- async function handleBlocked(msg: AgentMessage): Promise<AgentResponse> {
84
- if (!msg.taskId) {
85
- return { success: false, error: 'taskId is required for blocked messages' }
86
- }
87
- const reason = (msg.payload?.reason as string) ?? 'No reason given'
88
- await reportBlocked(msg.taskId, msg.agentId, reason)
89
- return { success: true, data: { taskId: msg.taskId, status: 'blocked' } }
90
- }
91
-
92
- async function handleRelease(msg: AgentMessage): Promise<AgentResponse> {
93
- if (!msg.taskId) {
94
- return { success: false, error: 'taskId is required for release messages' }
95
- }
96
- const reason = msg.payload?.reason as string | undefined
97
- const task = await releaseTask(msg.taskId, msg.agentId, reason)
98
- return { success: true, data: { taskId: task.id, status: 'ready' } }
99
- }
@@ -1,42 +0,0 @@
1
- import { addComment, updateTask, completeTask } from '../board/index.js'
2
-
3
- export async function reportProgress(
4
- taskId: string,
5
- agentId: string,
6
- message: string,
7
- ): Promise<void> {
8
- await addComment({
9
- task_id: taskId,
10
- author: agentId,
11
- body: message,
12
- comment_type: 'comment',
13
- })
14
- }
15
-
16
- export async function reportCompletion(
17
- taskId: string,
18
- agentId: string,
19
- summary: string,
20
- ): Promise<void> {
21
- await completeTask(taskId, agentId)
22
- await addComment({
23
- task_id: taskId,
24
- author: agentId,
25
- body: summary,
26
- comment_type: 'status_change',
27
- })
28
- }
29
-
30
- export async function reportBlocked(
31
- taskId: string,
32
- agentId: string,
33
- reason: string,
34
- ): Promise<void> {
35
- await updateTask(taskId, { status: 'blocked' }, agentId)
36
- await addComment({
37
- task_id: taskId,
38
- author: agentId,
39
- body: `Blocked: ${reason}`,
40
- comment_type: 'status_change',
41
- })
42
- }
package/lib/bot/skills.ts DELETED
@@ -1,81 +0,0 @@
1
- import { readFileSync, existsSync } from 'node:fs'
2
- import { resolve, dirname } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import type { Task } from '../board/types.js'
5
-
6
- export interface AgentProfile {
7
- id: string
8
- skills: string[]
9
- maxConcurrent: number
10
- status: 'idle' | 'working' | 'error'
11
- }
12
-
13
- const DEFAULT_PROFILE: AgentProfile = {
14
- id: 'default',
15
- skills: ['*'],
16
- maxConcurrent: 1,
17
- status: 'idle',
18
- }
19
-
20
- /**
21
- * Reads agent profiles from agents/profiles.json.
22
- * Falls back to a single default wildcard profile if the file doesn't exist.
23
- */
24
- export function getAgentProfiles(): AgentProfile[] {
25
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
26
- const profilesPath = resolve(root, 'agents', 'profiles.json')
27
-
28
- if (!existsSync(profilesPath)) {
29
- return [DEFAULT_PROFILE]
30
- }
31
-
32
- try {
33
- const raw = readFileSync(profilesPath, 'utf-8')
34
- const parsed = JSON.parse(raw) as AgentProfile[]
35
- return parsed
36
- } catch {
37
- return [DEFAULT_PROFILE]
38
- }
39
- }
40
-
41
- /**
42
- * Filters and ranks tasks an agent can work on.
43
- *
44
- * - Includes tasks whose skill_required is in the agent's skills list,
45
- * or tasks with no skill_required, or if agent has wildcard '*'.
46
- * - Sorts by priority (P1 first), then sort_order.
47
- * - Returns at most agent.maxConcurrent tasks.
48
- */
49
- export function matchTasksToAgent(agent: AgentProfile, tasks: Task[]): Task[] {
50
- const hasWildcard = agent.skills.includes('*')
51
-
52
- const matched = tasks.filter((t) => {
53
- if (hasWildcard) return true
54
- if (!t.skill_required) return true
55
- return agent.skills.includes(t.skill_required)
56
- })
57
-
58
- matched.sort((a, b) => {
59
- if (a.priority !== b.priority) return a.priority - b.priority
60
- return a.sort_order - b.sort_order
61
- })
62
-
63
- return matched.slice(0, agent.maxConcurrent)
64
- }
65
-
66
- /**
67
- * Finds the first idle agent whose skills match the task's skill_required.
68
- * Returns null if no suitable idle agent exists.
69
- */
70
- export function findBestAgent(
71
- profiles: AgentProfile[],
72
- task: Task,
73
- ): AgentProfile | null {
74
- for (const agent of profiles) {
75
- if (agent.status !== 'idle') continue
76
- if (agent.skills.includes('*')) return agent
77
- if (!task.skill_required) return agent
78
- if (agent.skills.includes(task.skill_required)) return agent
79
- }
80
- return null
81
- }
package/lib/errors.ts DELETED
@@ -1,129 +0,0 @@
1
- /**
2
- * Centralized error handling for the Optimal CLI.
3
- *
4
- * Provides a typed CliError class, a user-friendly formatter,
5
- * and a wrapCommand helper for Commander action handlers.
6
- */
7
-
8
- // ── Error codes ──────────────────────────────────────────────────────────────
9
-
10
- export type ErrorCode =
11
- | 'MISSING_ENV'
12
- | 'NOT_FOUND'
13
- | 'SUPABASE_ERROR'
14
- | 'VALIDATION_ERROR'
15
- | 'AUTH_ERROR'
16
- | 'NETWORK_ERROR'
17
- | 'FILE_ERROR'
18
- | 'UNKNOWN'
19
-
20
- // ── CliError ─────────────────────────────────────────────────────────────────
21
-
22
- export class CliError extends Error {
23
- constructor(
24
- message: string,
25
- public code: ErrorCode,
26
- public suggestion?: string,
27
- ) {
28
- super(message)
29
- this.name = 'CliError'
30
- }
31
- }
32
-
33
- // ── Helpers ──────────────────────────────────────────────────────────────────
34
-
35
- const SUGGESTIONS: Record<string, string> = {
36
- MISSING_ENV:
37
- 'Ensure the required environment variables are set in your .env file or shell.',
38
- NOT_FOUND: 'Double-check the identifier (slug, ID, or name) and try again.',
39
- SUPABASE_ERROR:
40
- 'Verify your Supabase URL and service key are correct and the database is reachable.',
41
- VALIDATION_ERROR: 'Review the command options with --help.',
42
- AUTH_ERROR:
43
- 'Check your API token or credentials and make sure they have not expired.',
44
- NETWORK_ERROR:
45
- 'Check your internet connection and verify the remote service is available.',
46
- FILE_ERROR: 'Verify the file path exists and you have read/write permissions.',
47
- }
48
-
49
- function classifyError(err: unknown): { code: ErrorCode; message: string } {
50
- if (err instanceof CliError) {
51
- return { code: err.code, message: err.message }
52
- }
53
-
54
- if (err instanceof Error) {
55
- const msg = err.message
56
-
57
- // Supabase / fetch errors
58
- if (msg.includes('PGRST') || msg.includes('supabase') || msg.includes('relation')) {
59
- return { code: 'SUPABASE_ERROR', message: msg }
60
- }
61
- if (msg.includes('ENOENT') || msg.includes('no such file')) {
62
- return { code: 'FILE_ERROR', message: msg }
63
- }
64
- if (msg.includes('ECONNREFUSED') || msg.includes('fetch failed') || msg.includes('ETIMEDOUT')) {
65
- return { code: 'NETWORK_ERROR', message: msg }
66
- }
67
- if (
68
- msg.includes('OPTIMAL_SUPABASE_URL') ||
69
- msg.includes('OPTIMAL_SUPABASE_SERVICE_KEY') ||
70
- msg.includes('env')
71
- ) {
72
- return { code: 'MISSING_ENV', message: msg }
73
- }
74
-
75
- return { code: 'UNKNOWN', message: msg }
76
- }
77
-
78
- return { code: 'UNKNOWN', message: String(err) }
79
- }
80
-
81
- // ── handleError ──────────────────────────────────────────────────────────────
82
-
83
- /**
84
- * Format an error for CLI output, print it to stderr, and exit with code 1.
85
- */
86
- export function handleError(err: unknown): never {
87
- const { code, message } = classifyError(err)
88
-
89
- const suggestion =
90
- err instanceof CliError && err.suggestion
91
- ? err.suggestion
92
- : SUGGESTIONS[code] ?? ''
93
-
94
- const lines: string[] = [
95
- '',
96
- ` Error [${code}]: ${message}`,
97
- ]
98
-
99
- if (suggestion) {
100
- lines.push(` Suggestion: ${suggestion}`)
101
- }
102
-
103
- lines.push('')
104
-
105
- process.stderr.write(lines.join('\n'))
106
- process.exit(1)
107
- }
108
-
109
- // ── wrapCommand ──────────────────────────────────────────────────────────────
110
-
111
- /**
112
- * Wrap a Commander action handler so any thrown error is routed through
113
- * handleError, giving the user a consistent, friendly message instead of
114
- * an unhandled-rejection stack trace.
115
- *
116
- * Usage:
117
- * .action(wrapCommand(async (opts) => { ... }))
118
- */
119
- export function wrapCommand<A extends unknown[]>(
120
- fn: (...args: A) => Promise<void>,
121
- ): (...args: A) => Promise<void> {
122
- return async (...args: A) => {
123
- try {
124
- await fn(...args)
125
- } catch (err) {
126
- handleError(err)
127
- }
128
- }
129
- }