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.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.env.example +17 -0
- package/CLAUDE.md +67 -0
- package/COMMANDS.md +264 -0
- package/PUBLISH.md +70 -0
- package/agents/content-ops.md +2 -2
- package/agents/financial-ops.md +2 -2
- package/agents/infra-ops.md +2 -2
- package/apps/.gitkeep +0 -0
- package/bin/optimal.ts +278 -591
- package/docs/MIGRATION_NEEDED.md +37 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
- package/hooks/.gitkeep +0 -0
- package/lib/config/registry.ts +5 -4
- package/lib/kanban-obsidian.ts +232 -0
- package/lib/kanban-sync.ts +258 -0
- package/lib/kanban.ts +239 -0
- package/lib/obsidian-tasks.ts +231 -0
- package/package.json +5 -19
- package/pnpm-workspace.yaml +3 -0
- package/scripts/check-table.ts +24 -0
- package/scripts/create-tables.ts +94 -0
- package/scripts/migrate-kanban.sh +28 -0
- package/scripts/migrate-v2.ts +78 -0
- package/scripts/migrate.ts +79 -0
- package/scripts/run-migration.ts +59 -0
- package/scripts/seed-board.ts +203 -0
- package/scripts/test-kanban.ts +21 -0
- package/skills/audit-financials/SKILL.md +33 -0
- package/skills/board-create/SKILL.md +28 -0
- package/skills/board-update/SKILL.md +27 -0
- package/skills/board-view/SKILL.md +27 -0
- package/skills/delete-batch/SKILL.md +77 -0
- package/skills/deploy/SKILL.md +40 -0
- package/skills/diagnose-months/SKILL.md +68 -0
- package/skills/distribute-newsletter/SKILL.md +58 -0
- package/skills/export-budget/SKILL.md +44 -0
- package/skills/export-kpis/SKILL.md +52 -0
- package/skills/generate-netsuite-template/SKILL.md +51 -0
- package/skills/generate-newsletter/SKILL.md +53 -0
- package/skills/generate-newsletter-insurance/SKILL.md +59 -0
- package/skills/generate-social-posts/SKILL.md +67 -0
- package/skills/health-check/SKILL.md +42 -0
- package/skills/ingest-transactions/SKILL.md +51 -0
- package/skills/manage-cms/SKILL.md +50 -0
- package/skills/manage-scenarios/SKILL.md +83 -0
- package/skills/migrate-db/SKILL.md +79 -0
- package/skills/preview-newsletter/SKILL.md +50 -0
- package/skills/project-budget/SKILL.md +60 -0
- package/skills/publish-blog/SKILL.md +70 -0
- package/skills/publish-social-posts/SKILL.md +70 -0
- package/skills/rate-anomalies/SKILL.md +62 -0
- package/skills/scrape-ads/SKILL.md +49 -0
- package/skills/stamp-transactions/SKILL.md +62 -0
- package/skills/upload-income-statements/SKILL.md +54 -0
- package/skills/upload-netsuite/SKILL.md +56 -0
- package/skills/upload-r1/SKILL.md +45 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/migrations/.gitkeep +0 -0
- package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
- package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
- package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
- package/tests/config-command-smoke.test.ts +395 -0
- package/tests/config-registry.test.ts +173 -0
- package/tsconfig.json +19 -0
- package/agents/profiles.json +0 -5
- package/docs/CLI-REFERENCE.md +0 -361
- package/lib/assets/index.ts +0 -225
- package/lib/assets.ts +0 -124
- package/lib/auth/index.ts +0 -189
- package/lib/board/index.ts +0 -309
- package/lib/board/types.ts +0 -124
- package/lib/bot/claim.ts +0 -43
- package/lib/bot/coordinator.ts +0 -254
- package/lib/bot/heartbeat.ts +0 -37
- package/lib/bot/index.ts +0 -9
- package/lib/bot/protocol.ts +0 -99
- package/lib/bot/reporter.ts +0 -42
- package/lib/bot/skills.ts +0 -81
- package/lib/errors.ts +0 -129
- package/lib/format.ts +0 -120
- package/lib/returnpro/validate.ts +0 -154
- 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
|
package/lib/config/registry.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
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(
|
|
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
|
+
}
|