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
@@ -0,0 +1,37 @@
1
+ # Supabase Migration Required
2
+
3
+ The `cli_config_registry` table needs to be created in your Supabase database for the config sync feature to work.
4
+
5
+ ## Manual Steps
6
+
7
+ 1. Go to your Supabase project: https://app.supabase.com/project/_/sql
8
+ 2. Run this SQL:
9
+
10
+ ```sql
11
+ create table if not exists public.cli_config_registry (
12
+ id uuid primary key default gen_random_uuid(),
13
+ owner text not null,
14
+ profile text not null default 'default',
15
+ config_version text not null,
16
+ payload jsonb not null,
17
+ payload_hash text not null,
18
+ source text not null default 'optimal-cli',
19
+ updated_by text,
20
+ updated_at timestamptz not null default now(),
21
+ created_at timestamptz not null default now(),
22
+ unique (owner, profile)
23
+ );
24
+
25
+ create index if not exists idx_cli_config_registry_owner_profile
26
+ on public.cli_config_registry (owner, profile);
27
+
28
+ create index if not exists idx_cli_config_registry_updated_at
29
+ on public.cli_config_registry (updated_at desc);
30
+ ```
31
+
32
+ 3. After running the migration, the `optimal config sync push/pull` commands will work.
33
+
34
+ ## File Location
35
+
36
+ The migration file is also available at:
37
+ `optimal-cli/supabase/migrations/20260305111300_create_cli_config_registry.sql`
File without changes
@@ -0,0 +1,71 @@
1
+ # optimal-cli shared config registry v1 (draft)
2
+
3
+ ## objective
4
+ define a repeatable config-sharing model for `optimal-cli` with versioned schema, supabase-backed sync, and clear command surface.
5
+
6
+ ## v1 scope
7
+ - single-user + team-ready config profile storage
8
+ - deterministic import/export format
9
+ - pull/push sync with conflict visibility
10
+ - schema migration path (`version` field)
11
+
12
+ ## config schema (v1)
13
+ ```json
14
+ {
15
+ "version": "1.0.0",
16
+ "profile": {
17
+ "name": "default",
18
+ "owner": "clenisa",
19
+ "updated_at": "2026-03-05T04:40:00-05:00"
20
+ },
21
+ "providers": {
22
+ "supabase": {
23
+ "project_ref": "<ref>",
24
+ "url": "<url>",
25
+ "anon_key_present": true
26
+ },
27
+ "strapi": {
28
+ "base_url": "https://strapi.op-hub.com",
29
+ "token_present": true
30
+ }
31
+ },
32
+ "defaults": {
33
+ "brand": "CRE-11TRUST",
34
+ "timezone": "America/New_York"
35
+ },
36
+ "features": {
37
+ "cms": true,
38
+ "tasks": true,
39
+ "deploy": false
40
+ }
41
+ }
42
+ ```
43
+
44
+ ## supabase model (proposed)
45
+ `cli_config_registry`
46
+ - `id uuid pk`
47
+ - `owner text not null`
48
+ - `profile_name text not null`
49
+ - `schema_version text not null`
50
+ - `config_json jsonb not null`
51
+ - `config_hash text not null`
52
+ - `updated_at timestamptz not null default now()`
53
+ - unique `(owner, profile_name)`
54
+
55
+ ## command surface (v1)
56
+ - `optimal config init [--profile default]`
57
+ - `optimal config export --out ./optimal.config.json`
58
+ - `optimal config import --file ./optimal.config.json [--merge|--replace]`
59
+ - `optimal config sync pull [--profile default]`
60
+ - `optimal config sync push [--profile default] [--force]`
61
+ - `optimal config doctor`
62
+
63
+ ## conflict model
64
+ - compare local `config_hash` vs remote `config_hash`
65
+ - if diverged and no `--force`, abort with resolution hints
66
+ - write pull/merge decisions to local audit log (`~/.optimal/config-history.log`)
67
+
68
+ ## next implementation step
69
+ 1. add `lib/config/schema.ts` + zod validator
70
+ 2. add `bin/optimal.ts` `config` command group with `doctor` + `export`
71
+ 3. scaffold supabase read/write adapter in `lib/config/registry.ts`
package/hooks/.gitkeep ADDED
File without changes
@@ -6,9 +6,10 @@ import { createHash } from 'node:crypto'
6
6
  import { getSupabase } from '../supabase.js'
7
7
  import { assertOptimalConfigV1, type OptimalConfigV1 } from './schema.js'
8
8
 
9
- const DIR = join(homedir(), '.optimal')
10
- const LOCAL_CONFIG_PATH = join(DIR, 'optimal.config.json')
11
- const HISTORY_PATH = join(DIR, 'config-history.log')
9
+ // Support optional config directory override via env var (useful for testing)
10
+ const CONFIG_DIR = process.env.OPTIMAL_CONFIG_DIR || join(homedir(), '.optimal')
11
+ const LOCAL_CONFIG_PATH = join(CONFIG_DIR, 'optimal.config.json')
12
+ const HISTORY_PATH = join(CONFIG_DIR, 'config-history.log')
12
13
  const REGISTRY_TABLE = 'cli_config_registry'
13
14
 
14
15
  let supabaseProvider: typeof getSupabase = getSupabase
@@ -33,7 +34,7 @@ export function resetRegistrySupabaseProviderForTests(): void {
33
34
  }
34
35
 
35
36
  export async function ensureConfigDir(): Promise<void> {
36
- await mkdir(DIR, { recursive: true })
37
+ await mkdir(CONFIG_DIR, { recursive: true })
37
38
  }
38
39
 
39
40
  export function getLocalConfigPath(): string {
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Obsidian-backed Kanban board operations
3
+ * Works directly with markdown task files in the tasks vault
4
+ */
5
+ import { readFile, writeFile, readdir } from 'node:fs/promises'
6
+ import { existsSync } from 'node:fs'
7
+ import { join, basename } from 'node:path'
8
+ import { glob } from 'glob'
9
+
10
+ const TASKS_DIR = process.env.OPTIMAL_TASKS_DIR || '/home/oracle/Documents/optimal/tasks'
11
+
12
+ export interface ObsidianTask {
13
+ id: string
14
+ title: string
15
+ description: string
16
+ status: 'pending' | 'in-progress' | 'done'
17
+ priority: number
18
+ owner: string | null
19
+ assignee: string | null
20
+ project: string | null
21
+ tags: string[]
22
+ createdAt: string | null
23
+ updatedAt: string | null
24
+ source: string | null
25
+ filePath: string
26
+ }
27
+
28
+ export interface BoardColumn {
29
+ name: string
30
+ status: string
31
+ tasks: ObsidianTask[]
32
+ }
33
+
34
+ function parseFrontmatter(content: string): Record<string, string> {
35
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
36
+ if (!fmMatch) return {}
37
+
38
+ const fm: Record<string, string> = {}
39
+ const lines = fmMatch[1].split('\n')
40
+ for (const line of lines) {
41
+ const colonIdx = line.indexOf(':')
42
+ if (colonIdx === -1) continue
43
+ const key = line.slice(0, colonIdx).trim()
44
+ const value = line.slice(colonIdx + 1).trim()
45
+ fm[key] = value
46
+ }
47
+ return fm
48
+ }
49
+
50
+ function getTaskIdFromFilename(filename: string): string {
51
+ // filename format: task__description__hash.md
52
+ return filename.replace(/^task__/, '').replace(/\.md$/, '')
53
+ }
54
+
55
+ export async function listTaskFiles(): Promise<string[]> {
56
+ const files = await readdir(TASKS_DIR)
57
+ return files
58
+ .filter(f => f.startsWith('task__') && f.endsWith('.md'))
59
+ .map(f => join(TASKS_DIR, f))
60
+ }
61
+
62
+ export async function loadTaskFromFile(filePath: string): Promise<ObsidianTask | null> {
63
+ try {
64
+ const content = await readFile(filePath, 'utf-8')
65
+ const fm = parseFrontmatter(content)
66
+
67
+ // Extract body as description (first paragraph after frontmatter)
68
+ const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n\n?(.*)$/)
69
+ const description = bodyMatch ? bodyMatch[1].trim() : ''
70
+
71
+ // Parse status from frontmatter
72
+ let status: 'pending' | 'in-progress' | 'done' = 'pending'
73
+ const statusStr = fm.status?.toLowerCase()
74
+ if (statusStr === 'in-progress' || statusStr === 'inprogress') status = 'in-progress'
75
+ else if (statusStr === 'done' || statusStr === 'completed') status = 'done'
76
+
77
+ // Parse tags from frontmatter
78
+ const tagsStr = fm.tags || '[]'
79
+ let tags: string[] = []
80
+ try {
81
+ tags = JSON.parse(tagsStr)
82
+ } catch {
83
+ if (fm.tags) tags = fm.tags.split(',').map(t => t.trim())
84
+ }
85
+
86
+ return {
87
+ id: fm.id || basename(filePath).replace(/\.md$/, ''),
88
+ title: fm.title || basename(filePath, '.md'),
89
+ description: description.slice(0, 200),
90
+ status,
91
+ priority: parseInt(fm.priority) || 3,
92
+ owner: fm.owner || null,
93
+ assignee: fm.assignee || null,
94
+ project: fm.project || null,
95
+ tags,
96
+ createdAt: fm.created_at || null,
97
+ updatedAt: fm.updated_at || null,
98
+ source: fm.source || null,
99
+ filePath,
100
+ }
101
+ } catch (err) {
102
+ console.error(`Failed to load task from ${filePath}:`, err)
103
+ return null
104
+ }
105
+ }
106
+
107
+ export async function loadAllTasks(): Promise<ObsidianTask[]> {
108
+ const files = await listTaskFiles()
109
+ const tasks: ObsidianTask[] = []
110
+
111
+ for (const file of files) {
112
+ const task = await loadTaskFromFile(file)
113
+ if (task) tasks.push(task)
114
+ }
115
+
116
+ return tasks
117
+ }
118
+
119
+ export async function getBoardByProject(projectSlug?: string): Promise<BoardColumn[]> {
120
+ const allTasks = await loadAllTasks()
121
+
122
+ // Filter by project if specified
123
+ const filtered = projectSlug
124
+ ? allTasks.filter(t => t.project === projectSlug)
125
+ : allTasks
126
+
127
+ return [
128
+ { name: 'To Do', status: 'pending', tasks: filtered.filter(t => t.status === 'pending') },
129
+ { name: 'In Progress', status: 'in-progress', tasks: filtered.filter(t => t.status === 'in-progress') },
130
+ { name: 'Done', status: 'done', tasks: filtered.filter(t => t.status === 'done') },
131
+ ]
132
+ }
133
+
134
+ export async function findTaskById(taskId: string): Promise<ObsidianTask | null> {
135
+ const files = await listTaskFiles()
136
+ const targetFile = files.find(f => f.includes(taskId) || f.includes(taskId.replace(/-/g, '')))
137
+
138
+ if (!targetFile) return null
139
+ return loadTaskFromFile(targetFile)
140
+ }
141
+
142
+ export interface UpdateTaskResult {
143
+ ok: boolean
144
+ message: string
145
+ task?: ObsidianTask
146
+ }
147
+
148
+ export async function updateTaskStatus(taskId: string, newStatus: string, message?: string): Promise<UpdateTaskResult> {
149
+ const task = await findTaskById(taskId)
150
+ if (!task) {
151
+ return { ok: false, message: `Task not found: ${taskId}` }
152
+ }
153
+
154
+ // Map status
155
+ let status = 'pending'
156
+ if (newStatus === 'in-progress' || newStatus === 'inprogress') status = 'in-progress'
157
+ else if (newStatus === 'done' || newStatus === 'completed') status = 'done'
158
+ else if (newStatus === 'pending') status = 'pending'
159
+ else return { ok: false, message: `Invalid status: ${newStatus}` }
160
+
161
+ try {
162
+ let content = await readFile(task.filePath, 'utf-8')
163
+ const now = new Date().toISOString()
164
+
165
+ // Update frontmatter status
166
+ content = content.replace(/^status:.*$/m, `status: ${status}`)
167
+ content = content.replace(/^updated_at:.*$/m, `updated_at: ${now}`)
168
+
169
+ // Add log entry if message provided
170
+ if (message) {
171
+ content = content.replace(
172
+ /^## progress log$/m,
173
+ `## progress log\n- ${now} ${message}`
174
+ )
175
+ }
176
+
177
+ await writeFile(task.filePath, content, 'utf-8')
178
+
179
+ return {
180
+ ok: true,
181
+ message: `Updated task ${taskId} status to ${status}`,
182
+ task: await loadTaskFromFile(task.filePath) || undefined
183
+ }
184
+ } catch (err) {
185
+ return { ok: false, message: `Failed to update: ${err}` }
186
+ }
187
+ }
188
+
189
+ export async function createTask(options: {
190
+ title: string
191
+ description?: string
192
+ project?: string
193
+ priority?: number
194
+ owner?: string
195
+ assignee?: string
196
+ tags?: string[]
197
+ }): Promise<UpdateTaskResult> {
198
+ const id = `task__${options.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')}_${Date.now().toString(36)}`
199
+ const now = new Date().toISOString()
200
+
201
+ const frontmatter = `---
202
+ id: ${id}
203
+ type: task
204
+ status: pending
205
+ owner: ${options.owner || 'oracle'}
206
+ assignee: ${options.assignee || ''}
207
+ project: ${options.project || ''}
208
+ priority: ${options.priority || 3}
209
+ source: cli:board:create
210
+ created_at: ${now}
211
+ updated_at: ${now}
212
+ tags: ${JSON.stringify(options.tags || [])}
213
+ ---
214
+
215
+ # ${options.title}
216
+
217
+ ${options.description || ''}
218
+ `.trim()
219
+
220
+ const filePath = join(TASKS_DIR, `${id}.md`)
221
+
222
+ try {
223
+ await writeFile(filePath, frontmatter, 'utf-8')
224
+ return {
225
+ ok: true,
226
+ message: `Created task: ${options.title}`,
227
+ task: await loadTaskFromFile(filePath) || undefined
228
+ }
229
+ } catch (err) {
230
+ return { ok: false, message: `Failed to create task: ${err}` }
231
+ }
232
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * 3-way sync: Obsidian markdown tasks ↔ Supabase kanban
3
+ *
4
+ * Flow:
5
+ * - obsidian: /home/oracle/Documents/optimal/tasks/*.md (source of truth for humans)
6
+ * - supabase: tasks table (source of truth for agents)
7
+ * - optimal cli: syncs between both
8
+ */
9
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { getSupabase } from './supabase.js'
12
+ import { getProjectBySlug, createTask, updateTask, getBoard, type Task } from './kanban.js'
13
+
14
+ const OBSIDIAN_TASKS_DIR = '/home/oracle/Documents/optimal/tasks'
15
+
16
+ // --- Obsidian task parsing ---
17
+
18
+ interface ObsidianTask {
19
+ id: string // from filename: task__some-id__abc123
20
+ taskId: string // full task__ prefix
21
+ title: string
22
+ status: 'pending' | 'in-progress' | 'done'
23
+ owner: string
24
+ assignee: string
25
+ priority: number
26
+ project: string
27
+ tags: string[]
28
+ source: string
29
+ description: string
30
+ metadata: Record<string, unknown>
31
+ created_at: string
32
+ updated_at: string
33
+ }
34
+
35
+ function parseObsidianTask(filename: string, content: string): ObsidianTask | null {
36
+ // filename format: task__some-slug__abc123.md
37
+ const match = filename.match(/^task__(.+?)__([a-z0-9]+)\.md$/)
38
+ if (!match) return null
39
+
40
+ const [, slug, shortId] = match
41
+ const taskId = `task__${slug}__${shortId}`
42
+
43
+ // Parse frontmatter
44
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
45
+ if (!fmMatch) return null
46
+
47
+ const frontmatter: Record<string, string> = {}
48
+ const lines = fmMatch[1].split('\n')
49
+ for (const line of lines) {
50
+ const [key, ...valueParts] = line.split(':')
51
+ if (key && valueParts.length) {
52
+ frontmatter[key.trim()] = valueParts.join(':').trim()
53
+ }
54
+ }
55
+
56
+ // Extract title from first heading after frontmatter
57
+ const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n#?\s*(.+)/)
58
+ const title = bodyMatch ? bodyMatch[1].trim() : slug.replace(/-/g, ' ')
59
+
60
+ // Parse status
61
+ const statusMap: Record<string, ObsidianTask['status']> = {
62
+ 'pending': 'pending',
63
+ 'in-progress': 'in-progress',
64
+ 'in_progress': 'in-progress',
65
+ 'done': 'done',
66
+ }
67
+ const status = statusMap[frontmatter.status] || 'pending'
68
+
69
+ // Parse priority
70
+ const priorityMap: Record<string, number> = { 'high': 1, 'medium': 2, 'low': 3 }
71
+ const priority = priorityMap[frontmatter.priority] || 3
72
+
73
+ return {
74
+ id: shortId,
75
+ taskId,
76
+ title,
77
+ status,
78
+ owner: frontmatter.owner || 'unassigned',
79
+ assignee: frontmatter.assignee || frontmatter.owner || 'unassigned',
80
+ priority,
81
+ project: frontmatter.project || 'unknown',
82
+ tags: (frontmatter.tags || '[]').replace(/[\[\]]/g, '').split(',').map(t => t.trim()).filter(Boolean),
83
+ source: frontmatter.source || 'obsidian',
84
+ description: content.replace(/^---[\s\S]*?---\n/, '').trim(),
85
+ metadata: {},
86
+ created_at: frontmatter.created_at || new Date().toISOString(),
87
+ updated_at: frontmatter.updated_at || new Date().toISOString(),
88
+ }
89
+ }
90
+
91
+ async function readObsidianTasks(): Promise<ObsidianTask[]> {
92
+ const tasks: ObsidianTask[] = []
93
+
94
+ try {
95
+ const files = await readdir(OBSIDIAN_TASKS_DIR)
96
+ for (const file of files) {
97
+ if (!file.startsWith('task__') || !file.endsWith('.md')) continue
98
+ try {
99
+ const content = await readFile(join(OBSIDIAN_TASKS_DIR, file), 'utf-8')
100
+ const task = parseObsidianTask(file, content)
101
+ if (task) tasks.push(task)
102
+ } catch (e) {
103
+ console.warn(`Failed to read ${file}:`, e)
104
+ }
105
+ }
106
+ } catch (e) {
107
+ console.error(`Failed to read obsidian tasks dir: ${e}`)
108
+ }
109
+
110
+ return tasks
111
+ }
112
+
113
+ // --- Supabase helpers ---
114
+
115
+ function mapStatusToSupabase(status: ObsidianTask['status']): Task['status'] {
116
+ const map: Record<ObsidianTask['status'], Task['status']> = {
117
+ 'pending': 'backlog',
118
+ 'in-progress': 'in_progress',
119
+ 'done': 'done',
120
+ }
121
+ return map[status] || 'backlog'
122
+ }
123
+
124
+ function mapPriorityToSupabase(priority: number): number {
125
+ // obsidian: 1=high, 2=medium, 3=low -> supabase: 1=highest, 4=lowest
126
+ return Math.max(1, Math.min(4, priority))
127
+ }
128
+
129
+ // --- Sync operations ---
130
+
131
+ export async function syncObsidianToSupabase(projectSlug: string, dryRun = false): Promise<void> {
132
+ const sb = getSupabase('optimal')
133
+ const obsidianTasks = await readObsidianTasks()
134
+
135
+ console.log(`\nšŸ”„ Syncing obsidian → supabase (project: ${projectSlug})`)
136
+ console.log(`Found ${obsidianTasks.length} obsidian tasks\n`)
137
+
138
+ // Get or create project
139
+ let projectId: string
140
+ try {
141
+ const { data: project } = await sb.from('projects').select('id').eq('slug', projectSlug).single()
142
+ if (!project) throw new Error('Project not found')
143
+ projectId = project.id
144
+ } catch {
145
+ console.log(`Creating project: ${projectSlug}`)
146
+ if (!dryRun) {
147
+ const { data: newProject } = await sb.from('projects').insert({
148
+ slug: projectSlug,
149
+ name: projectSlug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
150
+ status: 'active',
151
+ priority: 3,
152
+ }).select('id').single()
153
+ projectId = newProject!.id
154
+ } else {
155
+ projectId = 'DRY-RUN-PROJECT-ID'
156
+ }
157
+ }
158
+
159
+ // Get existing supabase tasks
160
+ const { data: existingTasks } = await sb.from('tasks').select('id, title, status, description').eq('project_id', projectId)
161
+ const existingByTitle = new Map((existingTasks || []).map(t => [t.title, t]))
162
+
163
+ let created = 0
164
+ let updated = 0
165
+ let skipped = 0
166
+
167
+ for (const ot of obsidianTasks) {
168
+ const existing = existingByTitle.get(ot.title)
169
+
170
+ if (existing) {
171
+ // Update existing task
172
+ if (!dryRun) {
173
+ await sb.from('tasks').update({
174
+ status: mapStatusToSupabase(ot.status),
175
+ priority: mapPriorityToSupabase(ot.priority),
176
+ assigned_to: ot.assignee !== 'unassigned' ? ot.assignee : null,
177
+ updated_at: new Date().toISOString(),
178
+ description: ot.description || null,
179
+ }).eq('id', existing.id)
180
+ }
181
+ updated++
182
+ console.log(` āœ“ Updated: ${ot.title}`)
183
+ } else {
184
+ // Create new task
185
+ if (!dryRun) {
186
+ await sb.from('tasks').insert({
187
+ project_id: projectId,
188
+ title: ot.title,
189
+ description: ot.description || null,
190
+ status: mapStatusToSupabase(ot.status),
191
+ priority: mapPriorityToSupabase(ot.priority),
192
+ assigned_to: ot.assignee !== 'unassigned' ? ot.assignee : null,
193
+ blocked_by: [],
194
+ sort_order: 0,
195
+ })
196
+ }
197
+ created++
198
+ console.log(` + Created: ${ot.title}`)
199
+ }
200
+ }
201
+
202
+ console.log(`\nšŸ“Š Sync complete:`)
203
+ console.log(` Created: ${created}`)
204
+ console.log(` Updated: ${updated}`)
205
+ console.log(` Skipped: ${skipped}`)
206
+
207
+ if (dryRun) console.log(`\nāš ļø Dry run — no changes written`)
208
+ }
209
+
210
+ export async function syncSupabaseToObsidian(projectSlug: string, dryRun = false): Promise<void> {
211
+ console.log(`\nšŸ”„ Syncing supabase → obsidian (project: ${projectSlug})`)
212
+ console.log(`Not yet implemented — need to reverse the flow\n`)
213
+ }
214
+
215
+ export async function getSyncStatus(projectSlug: string): Promise<void> {
216
+ const obsidianTasks = await readObsidianTasks()
217
+ const sb = getSupabase('optimal')
218
+
219
+ // Get project
220
+ const { data: project } = await sb.from('projects').select('id').eq('slug', projectSlug).single()
221
+ if (!project) {
222
+ console.log(`Project "${projectSlug}" not found in supabase`)
223
+ return
224
+ }
225
+
226
+ // Get supabase tasks
227
+ const { data: sbTasks } = await sb.from('tasks').select('title, status').eq('project_id', project.id)
228
+
229
+ const sbTitles = new Set((sbTasks || []).map(t => t.title))
230
+ const obTitles = new Set(obsidianTasks.map(t => t.title))
231
+
232
+ const onlyInObsidian = obsidianTasks.filter(t => !sbTitles.has(t.title))
233
+ const onlyInSupabase = (sbTasks || []).filter(t => !obTitles.has(t.title))
234
+ const inBoth = obsidianTasks.filter(t => sbTitles.has(t.title))
235
+
236
+ console.log(`\nšŸ“Š Sync Status for "${projectSlug}":\n`)
237
+ console.log(` Obsidian tasks: ${obsidianTasks.length}`)
238
+ console.log(` Supabase tasks: ${sbTasks?.length || 0}`)
239
+ console.log(` In both: ${inBoth.length}`)
240
+ console.log(` Only in obsidian: ${onlyInObsidian.length}`)
241
+ console.log(` Only in supabase: ${onlyInSupabase.length}`)
242
+
243
+ if (onlyInObsidian.length > 0) {
244
+ console.log(`\nšŸ“ Only in obsidian (run 'optimal sync push' to sync):`)
245
+ for (const t of onlyInObsidian.slice(0, 5)) {
246
+ console.log(` - ${t.title}`)
247
+ }
248
+ if (onlyInObsidian.length > 5) console.log(` ... and ${onlyInObsidian.length - 5} more`)
249
+ }
250
+
251
+ if (onlyInSupabase.length > 0) {
252
+ console.log(`\nšŸ—„ļø Only in supabase (run 'optimal sync pull' to sync):`)
253
+ for (const t of onlyInSupabase.slice(0, 5)) {
254
+ console.log(` - ${t.title}`)
255
+ }
256
+ if (onlyInSupabase.length > 5) console.log(` ... and ${onlyInSupabase.length - 5} more`)
257
+ }
258
+ }