bbdb68a6de
Esta commit conteudo a estrutura atomica completa:
- types: Result<T,E>, AsyncState<T>, Paginated<T>, SortConfig<T>
- utils: date, str, num, cn, debounce, throttle, storage, arr, obj
- validators: Zod schemas — email, password, uuid, url, phone, CPF/CNPJ, sanitizedStr, safeParse
- hooks: useToggle, useAsync, useDebounce, useLocalStorage, useMedia, useInterval, useOnClickOutside, useClipboard, useFetch
- components: Button, Input, Alert, Card, Spinner (atomic design pattern)
- build: tsup v8 ESM+CJS + DTS + sourcemaps — 0 erros
- tests: 57 testes 100% usuarios
- docker: multi-stage Dockerfile (node 20-alpine)
- config: vitest, tsup, tsconfig strict, .npmignore
Filosofia atomica:/utils ← /types ← /validators ← /hooks ← /components
Build: npm run build | Test: npm test | Publish: npm publish
🤖 Generated with Pulse (openclaw + nova-self-improver)
1213 lines
44 KiB
JavaScript
1213 lines
44 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* taskflow — CLI entry point (taskflow-011)
|
|
*
|
|
* Commands:
|
|
* taskflow setup Interactive first-run onboarding
|
|
* taskflow status Pretty terminal summary (all projects)
|
|
* taskflow export JSON export to stdout
|
|
* taskflow sync <mode> Sync markdown ↔ SQLite (modes: files-to-db | db-to-files | check)
|
|
* taskflow init Bootstrap SQLite schema
|
|
* taskflow install-daemon Install periodic-sync daemon (LaunchAgent on macOS, systemd on Linux)
|
|
* taskflow add <project> <title> Create a new task in markdown (source of truth)
|
|
* taskflow list <project> List tasks for a project (current tasks by default)
|
|
* taskflow help Show this help
|
|
*/
|
|
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const SCRIPTS = path.resolve(__dirname, '..', 'scripts')
|
|
|
|
// Workspace / DB resolution
|
|
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
|
|
const dbPath = path.join(workspace, 'memory', 'taskflow.sqlite')
|
|
|
|
// ── Colour / terminal helpers ─────────────────────────────────────────────
|
|
const isTTY = process.stdout.isTTY
|
|
const c = {
|
|
reset: isTTY ? '\x1b[0m' : '',
|
|
bold: isTTY ? '\x1b[1m' : '',
|
|
dim: isTTY ? '\x1b[2m' : '',
|
|
green: isTTY ? '\x1b[32m' : '',
|
|
yellow: isTTY ? '\x1b[33m' : '',
|
|
blue: isTTY ? '\x1b[34m' : '',
|
|
cyan: isTTY ? '\x1b[36m' : '',
|
|
red: isTTY ? '\x1b[31m' : '',
|
|
gray: isTTY ? '\x1b[90m' : '',
|
|
}
|
|
|
|
function badge(text, color) {
|
|
return `${color}${text}${c.reset}`
|
|
}
|
|
|
|
// Unicode block progress bar ▏▎▍▌▋▊▉█
|
|
const BLOCKS = ' ▏▎▍▌▋▊▉█'
|
|
|
|
function progressBar(pct, width = 20) {
|
|
const filled = Math.round((pct / 100) * width * 8)
|
|
const fullBlks = Math.floor(filled / 8)
|
|
const partial = filled % 8
|
|
const empty = width - fullBlks - (partial > 0 ? 1 : 0)
|
|
|
|
return (
|
|
'█'.repeat(fullBlks) +
|
|
(partial > 0 ? BLOCKS[partial] : '') +
|
|
'░'.repeat(Math.max(0, empty))
|
|
)
|
|
}
|
|
|
|
function statusColor(status) {
|
|
switch (status) {
|
|
case 'active': return c.green
|
|
case 'paused': return c.yellow
|
|
case 'done': return c.gray
|
|
default: return c.reset
|
|
}
|
|
}
|
|
|
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
|
|
// --- status ------------------------------------------------------------------
|
|
function cmdStatus() {
|
|
if (!existsSync(dbPath)) {
|
|
console.error(`${c.red}✗${c.reset} DB not found at: ${dbPath}`)
|
|
console.error(` Run ${c.bold}taskflow setup${c.reset} to get started, or ${c.bold}taskflow init${c.reset} to bootstrap the database.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const db = new DatabaseSync(dbPath)
|
|
db.exec('PRAGMA foreign_keys = ON')
|
|
|
|
const projects = db
|
|
.prepare('SELECT id, name, description, status FROM projects ORDER BY id')
|
|
.all()
|
|
|
|
if (projects.length === 0) {
|
|
console.log(`${c.dim}No projects found. Add entries to PROJECTS.md and run: taskflow sync files-to-db${c.reset}`)
|
|
return
|
|
}
|
|
|
|
const countRows = db
|
|
.prepare('SELECT project_id, status, COUNT(*) AS cnt FROM tasks_v2 GROUP BY project_id, status')
|
|
.all()
|
|
|
|
const countsByProject = {}
|
|
for (const row of countRows) {
|
|
if (!countsByProject[row.project_id]) countsByProject[row.project_id] = {}
|
|
countsByProject[row.project_id][row.status] = row.cnt
|
|
}
|
|
|
|
console.log()
|
|
console.log(`${c.bold}${c.cyan} TaskFlow — Project Overview${c.reset}`)
|
|
console.log(`${c.gray} ${'─'.repeat(60)}${c.reset}`)
|
|
|
|
for (const p of projects) {
|
|
const counts = countsByProject[p.id] || {}
|
|
const ip = counts.in_progress ?? 0
|
|
const pv = counts.pending_validation ?? 0
|
|
const bl = counts.backlog ?? 0
|
|
const bk = counts.blocked ?? 0
|
|
const dn = counts.done ?? 0
|
|
const tot = ip + pv + bl + bk + dn
|
|
const pct = tot > 0 ? Math.round((dn / tot) * 10000) / 100 : 0
|
|
|
|
const statusStr = badge(` ${p.status} `, statusColor(p.status))
|
|
|
|
console.log()
|
|
console.log(` ${c.bold}${p.name}${c.reset} ${statusStr} ${c.gray}(${p.id})${c.reset}`)
|
|
if (p.description) {
|
|
console.log(` ${c.dim}${p.description}${c.reset}`)
|
|
}
|
|
|
|
// Progress bar
|
|
const bar = progressBar(pct, 24)
|
|
const pctStr = pct.toFixed(1).padStart(5)
|
|
console.log(` ${c.green}${bar}${c.reset} ${c.bold}${pctStr}%${c.reset} done ${c.gray}(${tot} tasks)${c.reset}`)
|
|
|
|
// Task counts row
|
|
const parts = []
|
|
if (ip) parts.push(`${c.blue}${ip} in-progress${c.reset}`)
|
|
if (pv) parts.push(`${c.yellow}${pv} pending-validation${c.reset}`)
|
|
if (bk) parts.push(`${c.red}${bk} blocked${c.reset}`)
|
|
if (bl) parts.push(`${c.dim}${bl} backlog${c.reset}`)
|
|
if (dn) parts.push(`${c.gray}${dn} done${c.reset}`)
|
|
if (parts.length) {
|
|
console.log(` ${parts.join(' ')}`)
|
|
} else {
|
|
console.log(` ${c.dim}no tasks yet${c.reset}`)
|
|
}
|
|
}
|
|
|
|
console.log()
|
|
console.log(`${c.gray} ${'─'.repeat(60)}${c.reset}`)
|
|
console.log(`${c.dim} Workspace: ${workspace}${c.reset}`)
|
|
console.log()
|
|
}
|
|
|
|
// --- export ------------------------------------------------------------------
|
|
async function cmdExport() {
|
|
// Import the export script — its top-level code runs and writes JSON to stdout.
|
|
await import(path.join(SCRIPTS, 'export-projects-overview.mjs'))
|
|
}
|
|
|
|
// --- sync --------------------------------------------------------------------
|
|
async function cmdSync(mode) {
|
|
const VALID_MODES = ['files-to-db', 'db-to-files', 'check']
|
|
if (!VALID_MODES.includes(mode)) {
|
|
console.error(`${c.red}✗${c.reset} Unknown sync mode: ${JSON.stringify(mode)}`)
|
|
console.error(` Valid modes: ${VALID_MODES.join(' | ')}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const syncScript = path.join(SCRIPTS, 'task-sync.mjs')
|
|
if (!existsSync(syncScript)) {
|
|
console.error(`${c.red}✗${c.reset} task-sync.mjs not found at: ${syncScript}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// task-sync.mjs reads process.argv[2] for the mode.
|
|
process.argv[2] = mode
|
|
await import(syncScript)
|
|
}
|
|
|
|
// --- init --------------------------------------------------------------------
|
|
async function cmdInit() {
|
|
const initScript = path.join(SCRIPTS, 'init-db.mjs')
|
|
if (!existsSync(initScript)) {
|
|
console.error(`${c.red}✗${c.reset} init-db.mjs not found at: ${initScript}`)
|
|
process.exit(1)
|
|
}
|
|
await import(initScript)
|
|
}
|
|
|
|
// --- setup helpers -----------------------------------------------------------
|
|
|
|
/** Convert a human name to a lowercase-hyphenated slug. */
|
|
function toSlug(name) {
|
|
return name
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, '')
|
|
.replace(/[\s_]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
/** Print a summary of what was created and what to do next. */
|
|
function printSetupSummary(createdFiles) {
|
|
console.log()
|
|
console.log(`${c.bold}${c.green} ✓ TaskFlow setup complete!${c.reset}`)
|
|
console.log()
|
|
if (createdFiles.length > 0) {
|
|
console.log(`${c.bold} Created:${c.reset}`)
|
|
for (const f of createdFiles) {
|
|
console.log(` ${c.cyan}${f}${c.reset}`)
|
|
}
|
|
console.log()
|
|
}
|
|
console.log(`${c.bold} Next steps:${c.reset}`)
|
|
console.log(` ${c.cyan}taskflow status${c.reset} — view all projects`)
|
|
console.log(` ${c.cyan}taskflow sync files-to-db${c.reset} — re-sync after editing markdown`)
|
|
console.log(` ${c.cyan}taskflow help${c.reset} — full command reference`)
|
|
console.log()
|
|
}
|
|
|
|
/**
|
|
* Offer to install (or describe) the periodic-sync daemon.
|
|
* macOS → LaunchAgent; Linux → systemd user timer.
|
|
*
|
|
* @param {Function|null} ask - readline ask helper (null when non-interactive)
|
|
* @param {boolean} autoYes - skip interactive prompt and accept
|
|
*/
|
|
async function offerLaunchAgent(ask, autoYes = false) {
|
|
const os = process.platform
|
|
|
|
if (os === 'linux') {
|
|
console.log()
|
|
console.log(`${c.dim} Linux detected. Run ${c.cyan}taskflow install-daemon${c.dim} to install the systemd user timer.${c.reset}`)
|
|
return
|
|
}
|
|
|
|
if (os !== 'darwin') {
|
|
console.log()
|
|
console.log(`${c.dim} Platform '${os}' not supported for automatic daemon install.${c.reset}`)
|
|
console.log(` ${c.gray}Manual cron: * * * * * OPENCLAW_WORKSPACE=${workspace} ${process.execPath} ${path.join(workspace, 'taskflow', 'scripts', 'task-sync.mjs')} files-to-db${c.reset}`)
|
|
return
|
|
}
|
|
|
|
// macOS — offer LaunchAgent
|
|
const plistDest = path.join(
|
|
process.env.HOME || '',
|
|
'Library', 'LaunchAgents', 'com.taskflow.sync.plist'
|
|
)
|
|
|
|
if (existsSync(plistDest)) {
|
|
console.log(` ${c.green}✓${c.reset} LaunchAgent already installed at ${plistDest}`)
|
|
return
|
|
}
|
|
|
|
const tmplPath = path.join(__dirname, '..', 'system', 'com.taskflow.sync.plist.xml')
|
|
if (!existsSync(tmplPath)) return
|
|
|
|
// Non-interactive without --yes: skip LaunchAgent silently
|
|
if (!ask && !autoYes) return
|
|
|
|
// Interactive: prompt user
|
|
if (ask && !autoYes) {
|
|
console.log()
|
|
const ans = (await ask('Install LaunchAgent for automatic 60s sync? [Y/n] ')).trim().toLowerCase()
|
|
if (ans === 'n') return
|
|
}
|
|
|
|
try {
|
|
const nodeBin = process.execPath
|
|
const plistContent = readFileSync(tmplPath, 'utf8')
|
|
.replace(/\{\{workspace\}\}/g, workspace)
|
|
.replace(/<string>\/usr\/local\/bin\/node<\/string>/, `<string>${nodeBin}</string>`)
|
|
|
|
writeFileSync(plistDest, plistContent, 'utf8')
|
|
console.log(` ${c.green}created${c.reset} ${plistDest}`)
|
|
|
|
// Ensure logs dir exists
|
|
const logsDir = path.join(workspace, 'logs')
|
|
if (!existsSync(logsDir)) {
|
|
mkdirSync(logsDir, { recursive: true })
|
|
console.log(` ${c.green}created${c.reset} logs/`)
|
|
}
|
|
|
|
// Load the agent
|
|
const { execSync } = await import('node:child_process')
|
|
try {
|
|
execSync(`launchctl load "${plistDest}"`, { stdio: 'pipe' })
|
|
console.log(` ${c.green}loaded${c.reset} com.taskflow.sync LaunchAgent (sync every 60s)`)
|
|
} catch {
|
|
console.log(` ${c.yellow}!${c.reset} Could not load LaunchAgent automatically. Run manually:`)
|
|
console.log(` launchctl load "${plistDest}"`)
|
|
}
|
|
} catch (e) {
|
|
console.log(` ${c.yellow}!${c.reset} LaunchAgent install failed: ${e.message}`)
|
|
}
|
|
}
|
|
|
|
// --- install-daemon ----------------------------------------------------------
|
|
/**
|
|
* Detect platform and install the appropriate sync daemon:
|
|
* macOS → ~/Library/LaunchAgents/com.taskflow.sync.plist (launchctl)
|
|
* Linux → ~/.config/systemd/user/taskflow-sync.{service,timer} (systemctl --user)
|
|
*/
|
|
async function cmdInstallDaemon() {
|
|
const os = process.platform
|
|
const { execSync } = await import('node:child_process')
|
|
|
|
console.log()
|
|
console.log(`${c.bold}${c.cyan} TaskFlow — Install Sync Daemon${c.reset}`)
|
|
console.log(`${c.gray} ${'─'.repeat(52)}${c.reset}`)
|
|
console.log(` Platform: ${c.bold}${os}${c.reset}`)
|
|
console.log(` Workspace: ${c.bold}${workspace}${c.reset}`)
|
|
console.log()
|
|
|
|
// Ensure logs dir exists
|
|
const logsDir = path.join(workspace, 'logs')
|
|
if (!existsSync(logsDir)) {
|
|
mkdirSync(logsDir, { recursive: true })
|
|
console.log(` ${c.green}created${c.reset} logs/`)
|
|
}
|
|
|
|
if (os === 'darwin') {
|
|
// ── macOS: LaunchAgent ───────────────────────────────────────────────
|
|
const plistDest = path.join(process.env.HOME || '', 'Library', 'LaunchAgents', 'com.taskflow.sync.plist')
|
|
const tmplPath = path.join(__dirname, '..', 'system', 'com.taskflow.sync.plist.xml')
|
|
|
|
if (!existsSync(tmplPath)) {
|
|
console.error(`${c.red}✗${c.reset} Template not found: ${tmplPath}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (existsSync(plistDest)) {
|
|
console.log(` ${c.green}✓${c.reset} LaunchAgent already installed: ${plistDest}`)
|
|
console.log(` ${c.dim}To reinstall, unload and remove it first:${c.reset}`)
|
|
console.log(` launchctl unload "${plistDest}" && rm "${plistDest}"`)
|
|
return
|
|
}
|
|
|
|
const nodeBin = process.execPath
|
|
const plistContent = readFileSync(tmplPath, 'utf8')
|
|
.replace(/\{\{workspace\}\}/g, workspace)
|
|
.replace(/\{\{node\}\}/g, nodeBin)
|
|
|
|
writeFileSync(plistDest, plistContent, 'utf8')
|
|
console.log(` ${c.green}created${c.reset} ${plistDest}`)
|
|
|
|
try {
|
|
execSync(`launchctl load "${plistDest}"`, { stdio: 'pipe' })
|
|
console.log(` ${c.green}loaded${c.reset} com.taskflow.sync (syncs every 60s)`)
|
|
} catch (e) {
|
|
console.log(` ${c.yellow}!${c.reset} Could not load automatically. Run:`)
|
|
console.log(` launchctl load "${plistDest}"`)
|
|
}
|
|
|
|
console.log()
|
|
console.log(` ${c.bold}Verify:${c.reset} launchctl list | grep taskflow`)
|
|
console.log(` ${c.bold}Logs:${c.reset} ${workspace}/logs/taskflow-sync.{stdout,stderr}.log`)
|
|
|
|
} else if (os === 'linux') {
|
|
// ── Linux: systemd user timer ────────────────────────────────────────
|
|
const systemdDir = path.join(process.env.HOME || '', '.config', 'systemd', 'user')
|
|
const svcSrc = path.join(__dirname, '..', 'system', 'taskflow-sync.service')
|
|
const timerSrc = path.join(__dirname, '..', 'system', 'taskflow-sync.timer')
|
|
const svcDest = path.join(systemdDir, 'taskflow-sync.service')
|
|
const timerDest = path.join(systemdDir, 'taskflow-sync.timer')
|
|
|
|
for (const src of [svcSrc, timerSrc]) {
|
|
if (!existsSync(src)) {
|
|
console.error(`${c.red}✗${c.reset} Template not found: ${src}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
// Create systemd user dir if needed
|
|
if (!existsSync(systemdDir)) {
|
|
mkdirSync(systemdDir, { recursive: true })
|
|
console.log(` ${c.green}created${c.reset} ${systemdDir}`)
|
|
}
|
|
|
|
const nodeBin = process.execPath
|
|
|
|
for (const [src, dest] of [[svcSrc, svcDest], [timerSrc, timerDest]]) {
|
|
const content = readFileSync(src, 'utf8')
|
|
.replace(/\{\{workspace\}\}/g, workspace)
|
|
.replace(/\{\{node\}\}/g, nodeBin)
|
|
writeFileSync(dest, content, 'utf8')
|
|
console.log(` ${c.green}created${c.reset} ${dest}`)
|
|
}
|
|
|
|
// Reload systemd user daemon and enable+start the timer
|
|
try {
|
|
execSync('systemctl --user daemon-reload', { stdio: 'pipe' })
|
|
console.log(` ${c.green}reloaded${c.reset} systemd user daemon`)
|
|
} catch {
|
|
console.log(` ${c.yellow}!${c.reset} daemon-reload failed (is systemd --user running?)`)
|
|
}
|
|
|
|
try {
|
|
execSync('systemctl --user enable --now taskflow-sync.timer', { stdio: 'pipe' })
|
|
console.log(` ${c.green}enabled${c.reset} taskflow-sync.timer (syncs every 60s)`)
|
|
} catch (e) {
|
|
console.log(` ${c.yellow}!${c.reset} Could not enable timer automatically. Run:`)
|
|
console.log(` systemctl --user daemon-reload`)
|
|
console.log(` systemctl --user enable --now taskflow-sync.timer`)
|
|
}
|
|
|
|
console.log()
|
|
console.log(` ${c.bold}Verify:${c.reset} systemctl --user status taskflow-sync.timer`)
|
|
console.log(` ${c.bold}Logs:${c.reset} journalctl --user -u taskflow-sync.service`)
|
|
console.log(` ${workspace}/logs/taskflow-sync.{stdout,stderr}.log`)
|
|
|
|
} else {
|
|
console.error(`${c.red}✗${c.reset} Platform '${os}' is not supported by install-daemon.`)
|
|
console.error(` Supported platforms: darwin (macOS), linux`)
|
|
console.error()
|
|
console.error(` Manual cron fallback:`)
|
|
console.error(` * * * * * OPENCLAW_WORKSPACE=${workspace} ${process.execPath} ${path.join(workspace, 'taskflow', 'scripts', 'task-sync.mjs')} files-to-db`)
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log()
|
|
}
|
|
|
|
/**
|
|
* Offer to set up Apple Notes sync (macOS only).
|
|
* Creates the note via apple-notes-export.mjs and saves the ID to config.
|
|
*
|
|
* @param {Function|null} ask - readline ask helper
|
|
*/
|
|
async function offerAppleNotes(ask) {
|
|
if (process.platform !== 'darwin') return
|
|
|
|
if (!ask) return // non-interactive: skip silently
|
|
|
|
console.log()
|
|
const ans = (await ask(
|
|
'Set up Apple Notes sync? This creates a shared note that stays updated with your project status. (y/N) '
|
|
)).trim().toLowerCase()
|
|
|
|
if (ans !== 'y') return
|
|
|
|
const notesScript = path.join(SCRIPTS, 'apple-notes-export.mjs')
|
|
if (!existsSync(notesScript)) {
|
|
console.log(` ${c.yellow}!${c.reset} apple-notes-export.mjs not found — skipping Notes setup.`)
|
|
return
|
|
}
|
|
|
|
console.log()
|
|
console.log(` ${c.bold}Creating Apple Note…${c.reset}`)
|
|
try {
|
|
await import(notesScript)
|
|
console.log()
|
|
console.log(` ${c.green}✓${c.reset} Apple Notes sync configured.`)
|
|
console.log(` Run ${c.cyan}taskflow notes${c.reset} to update manually.`)
|
|
console.log(` For hourly auto-refresh, add a LaunchAgent or cron:`)
|
|
console.log(` ${c.gray}0 * * * * OPENCLAW_WORKSPACE=${workspace} ${process.execPath} ${notesScript}${c.reset}`)
|
|
} catch (e) {
|
|
console.log(` ${c.yellow}!${c.reset} Apple Notes setup failed: ${e.message}`)
|
|
console.log(` You can retry anytime with: ${c.cyan}taskflow notes${c.reset}`)
|
|
}
|
|
}
|
|
|
|
// --- setup -------------------------------------------------------------------
|
|
async function cmdSetup(args) {
|
|
// ── Parse CLI flags (non-interactive mode) ─────────────────────────────
|
|
const nameIdx = args.indexOf('--name')
|
|
const descIdx = args.indexOf('--desc')
|
|
const nonInteractiveName = nameIdx !== -1 ? args[nameIdx + 1] : null
|
|
const nonInteractiveDesc = descIdx !== -1 ? args[descIdx + 1] : null
|
|
const nonInteractive = nonInteractiveName !== null
|
|
const skipLaunchAgent = args.includes('--no-launchagent')
|
|
const autoYes = args.includes('--yes') || args.includes('-y')
|
|
|
|
// ── Common paths ────────────────────────────────────────────────────────
|
|
const projectsFile = path.join(workspace, 'PROJECTS.md')
|
|
const tasksDir = path.join(workspace, 'tasks')
|
|
const plansDir = path.join(workspace, 'plans')
|
|
const memoryDir = path.join(workspace, 'memory')
|
|
|
|
// ── Readline setup ──────────────────────────────────────────────────────
|
|
let rl = null
|
|
let ask = null
|
|
|
|
if (!nonInteractive) {
|
|
const { createInterface } = await import('node:readline/promises')
|
|
rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
rl.on('SIGINT', () => {
|
|
console.log(`\n\n${c.yellow}Setup interrupted. No changes were committed.${c.reset}\n`)
|
|
rl.close()
|
|
process.exit(0)
|
|
})
|
|
ask = (q) => rl.question(`${c.cyan}?${c.reset} ${q}`)
|
|
}
|
|
|
|
const close = () => { if (rl) { rl.close(); rl = null } }
|
|
|
|
// ── Header ──────────────────────────────────────────────────────────────
|
|
console.log()
|
|
console.log(`${c.bold}${c.cyan} TaskFlow Setup${c.reset}`)
|
|
console.log(`${c.gray} ${'─'.repeat(52)}${c.reset}`)
|
|
console.log(` Workspace: ${c.bold}${workspace}${c.reset}`)
|
|
console.log()
|
|
|
|
// ── Detect current state ────────────────────────────────────────────────
|
|
const hasProjects = existsSync(projectsFile)
|
|
const hasDb = existsSync(dbPath)
|
|
|
|
// ── Scenario 1: Fully set up ────────────────────────────────────────────
|
|
if (hasProjects && hasDb) {
|
|
console.log(`${c.green}✓${c.reset} Already set up — PROJECTS.md and DB both present.`)
|
|
console.log()
|
|
cmdStatus()
|
|
|
|
if (!nonInteractive) {
|
|
const ans = await ask('Re-sync markdown → DB now? [y/N] ')
|
|
close()
|
|
if (ans.trim().toLowerCase() === 'y') {
|
|
console.log()
|
|
await cmdSync('files-to-db')
|
|
console.log()
|
|
console.log(`${c.green}✓${c.reset} Sync complete.`)
|
|
console.log()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// ── Scenario 2: PROJECTS.md exists but no DB ────────────────────────────
|
|
if (hasProjects && !hasDb) {
|
|
console.log(`${c.yellow}!${c.reset} PROJECTS.md found but no SQLite DB.`)
|
|
|
|
if (!nonInteractive) {
|
|
const ans = await ask('Initialize DB and sync markdown → DB now? [Y/n] ')
|
|
if (ans.trim().toLowerCase() === 'n') {
|
|
close()
|
|
console.log()
|
|
console.log('Aborted — no changes made.')
|
|
console.log()
|
|
return
|
|
}
|
|
}
|
|
|
|
console.log()
|
|
await cmdInit()
|
|
console.log()
|
|
await cmdSync('files-to-db')
|
|
|
|
if (!skipLaunchAgent) await offerLaunchAgent(ask, autoYes)
|
|
await offerAppleNotes(ask)
|
|
close()
|
|
printSetupSummary([])
|
|
return
|
|
}
|
|
|
|
// ── Scenario 3: Clean slate ──────────────────────────────────────────────
|
|
console.log(`${c.dim} No existing workspace detected. Starting fresh.${c.reset}`)
|
|
console.log()
|
|
|
|
// Create required directories
|
|
const createdFiles = []
|
|
for (const [dir, label] of [[tasksDir, 'tasks/'], [plansDir, 'plans/'], [memoryDir, 'memory/']]) {
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
console.log(` ${c.green}created${c.reset} ${label}`)
|
|
createdFiles.push(label)
|
|
}
|
|
}
|
|
|
|
// Collect project definitions
|
|
const projects = []
|
|
|
|
if (nonInteractive) {
|
|
// Non-interactive: single project from flags
|
|
projects.push({ name: nonInteractiveName, desc: nonInteractiveDesc || '' })
|
|
} else {
|
|
// Interactive: first project
|
|
const firstName = (await ask("What's your first project name? ")).trim()
|
|
if (!firstName) {
|
|
close()
|
|
console.log(`\n${c.red}✗${c.reset} Project name is required. Aborting.`)
|
|
console.log()
|
|
process.exit(1)
|
|
}
|
|
const firstDesc = (await ask('One-liner description (optional, press Enter to skip): ')).trim()
|
|
projects.push({ name: firstName, desc: firstDesc })
|
|
|
|
// Loop for additional projects
|
|
let addMore = true
|
|
while (addMore) {
|
|
const moreAns = (await ask('Add another project? [y/N] ')).trim().toLowerCase()
|
|
if (moreAns !== 'y') {
|
|
addMore = false
|
|
} else {
|
|
const n = (await ask(' Project name: ')).trim()
|
|
if (n) {
|
|
const d = (await ask(' Description (optional): ')).trim()
|
|
projects.push({ name: n, desc: d })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load tasks template
|
|
const tasksTmplPath = path.join(__dirname, '..', 'templates', 'tasks-template.md')
|
|
let tasksTmpl = null
|
|
try { tasksTmpl = readFileSync(tasksTmplPath, 'utf8') } catch { /* use fallback */ }
|
|
|
|
// Build PROJECTS.md content
|
|
let projectsMd = `# Projects\n\n`
|
|
projectsMd += `<!-- ============================================================
|
|
PROJECTS.md — Project Registry
|
|
============================================================
|
|
|
|
FORMAT: One ## block per project. The slug (## heading text)
|
|
is the canonical project ID used in task IDs, file names,
|
|
and SQLite foreign keys. Keep it lowercase and hyphenated.
|
|
|
|
FIELDS:
|
|
Name Human-readable display name (any capitalization).
|
|
Status One of: active | paused | done
|
|
Description One-sentence summary of the project's purpose.
|
|
============================================================ -->\n\n`
|
|
|
|
for (const p of projects) {
|
|
const slug = toSlug(p.name)
|
|
|
|
// Append project block
|
|
projectsMd += `## ${slug}\n- Name: ${p.name}\n- Status: active\n`
|
|
if (p.desc) projectsMd += `- Description: ${p.desc}\n`
|
|
projectsMd += '\n'
|
|
|
|
// Create tasks file from template (or fallback)
|
|
const tasksFile = path.join(tasksDir, `${slug}-tasks.md`)
|
|
const tasksContent = tasksTmpl
|
|
? tasksTmpl
|
|
.replace(/\{\{Project Name\}\}/g, p.name)
|
|
.replace(/\{\{slug\}\}/g, slug)
|
|
: `# ${p.name} — Tasks\n\n## In Progress\n\n## Pending Validation\n\n## Backlog\n\n- [ ] (task:${slug}-001) [P2] First task for this project\n\n## Blocked\n\n## Done\n`
|
|
|
|
writeFileSync(tasksFile, tasksContent, 'utf8')
|
|
createdFiles.push(`tasks/${slug}-tasks.md`)
|
|
console.log(` ${c.green}created${c.reset} tasks/${slug}-tasks.md`)
|
|
}
|
|
|
|
// Write PROJECTS.md
|
|
writeFileSync(projectsFile, projectsMd, 'utf8')
|
|
createdFiles.push('PROJECTS.md')
|
|
console.log(` ${c.green}created${c.reset} PROJECTS.md`)
|
|
console.log()
|
|
|
|
// Init DB
|
|
console.log(`${c.bold} Initializing database…${c.reset}`)
|
|
await cmdInit()
|
|
console.log()
|
|
|
|
// Sync files → DB
|
|
console.log(`${c.bold} Syncing markdown → DB…${c.reset}`)
|
|
await cmdSync('files-to-db')
|
|
|
|
// Offer LaunchAgent / cron (interactive only — rl still open)
|
|
if (!skipLaunchAgent) await offerLaunchAgent(ask, autoYes)
|
|
await offerAppleNotes(ask)
|
|
close()
|
|
|
|
printSetupSummary(createdFiles)
|
|
}
|
|
|
|
|
|
// --- add ---------------------------------------------------------------------
|
|
function parseAddArgs(rawArgs) {
|
|
const out = {
|
|
project: null,
|
|
title: null,
|
|
priority: 'P2',
|
|
owner: null,
|
|
status: 'backlog',
|
|
note: null,
|
|
json: false,
|
|
dryRun: false,
|
|
sync: false,
|
|
}
|
|
|
|
const rest = [...rawArgs]
|
|
if (!rest.length) return out
|
|
|
|
out.project = rest.shift() || null
|
|
if (rest[0] && !rest[0].startsWith('--')) {
|
|
out.title = (rest.shift() || '').trim()
|
|
}
|
|
|
|
while (rest.length) {
|
|
const tok = rest.shift()
|
|
if (!tok.startsWith('--')) {
|
|
if (!out.title) out.title = tok.trim()
|
|
else out.title += ` ${tok.trim()}`
|
|
continue
|
|
}
|
|
|
|
if (tok === '--priority') out.priority = (rest.shift() || '').trim()
|
|
else if (tok === '--owner') out.owner = (rest.shift() || '').trim()
|
|
else if (tok === '--status') out.status = (rest.shift() || '').trim()
|
|
else if (tok === '--note') out.note = (rest.shift() || '').trim()
|
|
else if (tok === '--json') out.json = true
|
|
else if (tok === '--dry-run') out.dryRun = true
|
|
else if (tok === '--sync') out.sync = true
|
|
else {
|
|
console.error(`${c.red}✗${c.reset} Unknown flag for add: ${tok}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function escapeTaskTitle(title) {
|
|
return title.replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
async function cmdAdd(rawArgs) {
|
|
const args = parseAddArgs(rawArgs)
|
|
const allowedStatus = ['backlog', 'in_progress', 'pending_validation', 'blocked', 'done']
|
|
const allowedPriority = ['P0', 'P1', 'P2', 'P3', 'P9']
|
|
const sectionByStatus = {
|
|
in_progress: 'In Progress',
|
|
pending_validation: 'Pending Validation',
|
|
backlog: 'Backlog',
|
|
blocked: 'Blocked',
|
|
done: 'Done',
|
|
}
|
|
|
|
if (!args.project || !args.title) {
|
|
console.error(`${c.red}✗${c.reset} Usage: taskflow add <project> <title> [--priority P2] [--owner codex] [--status backlog|in_progress|pending_validation|blocked|done] [--note "..."] [--json] [--dry-run] [--sync]`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const project = args.project.trim().toLowerCase()
|
|
const title = escapeTaskTitle(args.title)
|
|
const status = args.status.toLowerCase()
|
|
const priority = args.priority.toUpperCase()
|
|
const BANNED_HEADERS = /^(in progress|pending validation|backlog|blocked|done)$/i
|
|
|
|
if (!title || BANNED_HEADERS.test(title) || /\r|\n/.test(title)) {
|
|
console.error(`${c.red}✗${c.reset} Invalid task title.`)
|
|
process.exit(1)
|
|
}
|
|
if (!allowedStatus.includes(status)) {
|
|
console.error(`${c.red}✗${c.reset} Invalid --status '${args.status}'. Allowed: ${allowedStatus.join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
if (!allowedPriority.includes(priority)) {
|
|
console.error(`${c.red}✗${c.reset} Invalid --priority '${args.priority}'. Allowed: ${allowedPriority.join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
if (args.owner && !/^[A-Za-z0-9._-]{1,64}$/.test(args.owner)) {
|
|
console.error(`${c.red}✗${c.reset} Invalid --owner '${args.owner}'. Use letters, numbers, dot, underscore, dash.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const projectsFile = path.join(workspace, 'PROJECTS.md')
|
|
if (!existsSync(projectsFile)) {
|
|
console.error(`${c.red}✗${c.reset} PROJECTS.md not found at: ${projectsFile}`)
|
|
process.exit(1)
|
|
}
|
|
const projectsText = readFileSync(projectsFile, 'utf8')
|
|
const projectHeader = new RegExp(`^##\\s+${escapeRegex(project)}\\s*$`, 'm')
|
|
if (!projectHeader.test(projectsText)) {
|
|
console.error(`${c.red}✗${c.reset} Unknown project '${project}'. Add it to PROJECTS.md first.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const taskFile = path.join(workspace, 'tasks', `${project}-tasks.md`)
|
|
if (!existsSync(taskFile)) {
|
|
console.error(`${c.red}✗${c.reset} Task file not found: ${taskFile}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const original = readFileSync(taskFile, 'utf8')
|
|
const idRe = new RegExp(`\\(task:${escapeRegex(project)}-(\\d{1,})\\)`, 'g')
|
|
let m
|
|
let maxN = 0
|
|
while ((m = idRe.exec(original)) !== null) {
|
|
const n = Number(m[1])
|
|
if (Number.isFinite(n) && n > maxN) maxN = n
|
|
}
|
|
const nextId = `${project}-${String(maxN + 1).padStart(3, '0')}`
|
|
|
|
const sectionHeader = `## ${sectionByStatus[status]}`
|
|
const lines = original.split(/\n/)
|
|
const secIdx = lines.findIndex(l => l.trim() === sectionHeader)
|
|
if (secIdx < 0) {
|
|
console.error(`${c.red}✗${c.reset} Section '${sectionHeader}' not found in ${taskFile}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
let insertIdx = lines.length
|
|
for (let i = secIdx + 1; i < lines.length; i++) {
|
|
if (/^##\s+/.test(lines[i])) {
|
|
insertIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
const checkbox = status === 'done' ? '[x]' : '[ ]'
|
|
let taskLine = `- ${checkbox} (task:${nextId}) [${priority}]`
|
|
if (args.owner) taskLine += ` [${args.owner}]`
|
|
taskLine += ` ${title}`
|
|
|
|
const insertion = [taskLine]
|
|
if (args.note) insertion.push(` - note: ${args.note.replace(/\s+/g, ' ').trim()}`)
|
|
if (insertIdx > 0 && lines[insertIdx - 1] !== '') insertion.unshift('')
|
|
|
|
lines.splice(insertIdx, 0, ...insertion)
|
|
const updated = lines.join('\n')
|
|
|
|
const payload = {
|
|
id: nextId,
|
|
project,
|
|
title,
|
|
priority,
|
|
status,
|
|
owner: args.owner || null,
|
|
note: args.note || null,
|
|
file: taskFile,
|
|
section: sectionByStatus[status],
|
|
dryRun: args.dryRun,
|
|
}
|
|
|
|
if (args.dryRun) {
|
|
if (args.json) console.log(JSON.stringify(payload, null, 2))
|
|
else {
|
|
console.log(`${c.cyan}[dry-run]${c.reset} would create ${c.bold}${nextId}${c.reset} in ${taskFile}`)
|
|
console.log(` section: ${sectionByStatus[status]}`)
|
|
console.log(` line: ${taskLine}`)
|
|
if (args.note) console.log(` note: ${args.note}`)
|
|
}
|
|
return
|
|
}
|
|
|
|
writeFileSync(taskFile, updated, 'utf8')
|
|
if (args.sync) await cmdSync('files-to-db')
|
|
|
|
if (args.json) console.log(JSON.stringify(payload, null, 2))
|
|
else {
|
|
console.log(`${c.green}✓${c.reset} Added ${c.bold}${nextId}${c.reset} to ${project} (${sectionByStatus[status]}).`)
|
|
console.log(` ${taskFile}`)
|
|
}
|
|
}
|
|
|
|
// --- list --------------------------------------------------------------------
|
|
function parseListArgs(rawArgs) {
|
|
const out = {
|
|
project: null,
|
|
all: false,
|
|
status: null,
|
|
priority: null,
|
|
owner: null,
|
|
json: false,
|
|
limit: null,
|
|
}
|
|
|
|
const rest = [...rawArgs]
|
|
if (!rest.length) return out
|
|
|
|
if (rest[0] && !rest[0].startsWith('--')) {
|
|
out.project = (rest.shift() || '').trim() || null
|
|
}
|
|
|
|
while (rest.length) {
|
|
const tok = rest.shift()
|
|
if (tok === '--all') out.all = true
|
|
else if (tok === '--status') out.status = (rest.shift() || '').trim()
|
|
else if (tok === '--priority') out.priority = (rest.shift() || '').trim()
|
|
else if (tok === '--owner') out.owner = (rest.shift() || '').trim()
|
|
else if (tok === '--project') out.project = (rest.shift() || '').trim() || null
|
|
else if (tok === '--json') out.json = true
|
|
else if (tok === '--limit') {
|
|
const n = Number(rest.shift())
|
|
if (!Number.isFinite(n) || n <= 0) {
|
|
console.error(`${c.red}✗${c.reset} --limit must be a positive number`)
|
|
process.exit(1)
|
|
}
|
|
out.limit = Math.floor(n)
|
|
} else {
|
|
console.error(`${c.red}✗${c.reset} Unknown flag for list: ${tok}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function splitCsv(input) {
|
|
return (input || '')
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function firstNoteLine(notes) {
|
|
if (!notes) return null
|
|
const line = String(notes).split(/\r?\n/).map(s => s.trim()).find(Boolean)
|
|
return line || null
|
|
}
|
|
|
|
function formatTaskLine(task) {
|
|
const owner = task.owner_model ? ` [${task.owner_model}]` : ''
|
|
const note = task.first_note ? `\n ${c.dim}note:${c.reset} ${task.first_note}` : ''
|
|
return ` - (${task.id}) [${task.priority}]${owner} ${task.title}${note}`
|
|
}
|
|
|
|
function resolveProject(db, projectRef) {
|
|
const raw = (projectRef || '').trim()
|
|
if (!raw) return { project: null }
|
|
|
|
const exactId = db.prepare('SELECT id, name FROM projects WHERE id = ?').get(raw.toLowerCase())
|
|
if (exactId) return { project: exactId }
|
|
|
|
const all = db.prepare('SELECT id, name FROM projects ORDER BY id').all()
|
|
const ref = raw.toLowerCase()
|
|
|
|
const exactName = all.filter(p => String(p.name || '').toLowerCase() === ref)
|
|
if (exactName.length === 1) return { project: exactName[0] }
|
|
|
|
const starts = all.filter(p => p.id.toLowerCase().startsWith(ref) || String(p.name || '').toLowerCase().startsWith(ref))
|
|
if (starts.length === 1) return { project: starts[0] }
|
|
|
|
const contains = all.filter(p => p.id.toLowerCase().includes(ref) || String(p.name || '').toLowerCase().includes(ref))
|
|
if (contains.length === 1) return { project: contains[0] }
|
|
|
|
const candidates = starts.length ? starts : contains
|
|
if (candidates.length > 1) return { project: null, ambiguous: true, candidates }
|
|
|
|
return { project: null }
|
|
}
|
|
|
|
function cmdList(rawArgs) {
|
|
const args = parseListArgs(rawArgs)
|
|
const allowedStatus = ['in_progress', 'pending_validation', 'blocked', 'backlog', 'done']
|
|
const allowedPriority = ['P0', 'P1', 'P2', 'P3', 'P9']
|
|
const statusOrder = ['in_progress', 'pending_validation', 'blocked', 'backlog', 'done']
|
|
const statusLabel = {
|
|
in_progress: 'In Progress',
|
|
pending_validation: 'Pending Validation',
|
|
blocked: 'Blocked',
|
|
backlog: 'Backlog',
|
|
done: 'Done',
|
|
}
|
|
const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3, P9: 4 }
|
|
|
|
if (!args.project) {
|
|
console.error(`${c.red}✗${c.reset} Usage: taskflow list <project> [--project <slug|name>] [--all] [--status in_progress,backlog] [--priority P1,P2] [--owner codex] [--json] [--limit N]`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!existsSync(dbPath)) {
|
|
console.error(`${c.red}✗${c.reset} DB not found at: ${dbPath}`)
|
|
console.error(` Run ${c.bold}taskflow init${c.reset} and ${c.bold}taskflow sync files-to-db${c.reset} first.`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const selectedStatuses = args.status ? splitCsv(args.status).map(s => s.toLowerCase()) : null
|
|
if (selectedStatuses) {
|
|
const invalid = selectedStatuses.filter(s => !allowedStatus.includes(s))
|
|
if (invalid.length) {
|
|
console.error(`${c.red}✗${c.reset} Invalid --status value(s): ${invalid.join(', ')}`)
|
|
console.error(` Allowed: ${allowedStatus.join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
const selectedPriorities = args.priority ? splitCsv(args.priority).map(p => p.toUpperCase()) : null
|
|
if (selectedPriorities) {
|
|
const invalid = selectedPriorities.filter(p => !allowedPriority.includes(p))
|
|
if (invalid.length) {
|
|
console.error(`${c.red}✗${c.reset} Invalid --priority value(s): ${invalid.join(', ')}`)
|
|
console.error(` Allowed: ${allowedPriority.join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
const db = new DatabaseSync(dbPath)
|
|
db.exec('PRAGMA foreign_keys = ON')
|
|
|
|
const resolved = resolveProject(db, args.project)
|
|
if (!resolved.project) {
|
|
if (resolved.ambiguous) {
|
|
console.error(`${c.red}✗${c.reset} Ambiguous project reference '${args.project}'.`)
|
|
console.error(` Matches: ${resolved.candidates.map(p => `${p.id} (${p.name})`).join(', ')}`)
|
|
process.exit(1)
|
|
}
|
|
console.error(`${c.red}✗${c.reset} Unknown project '${args.project}' in DB.`)
|
|
console.error(` Run ${c.bold}taskflow sync files-to-db${c.reset} if it exists in PROJECTS.md.`)
|
|
process.exit(1)
|
|
}
|
|
const project = resolved.project
|
|
|
|
const rows = db.prepare(`
|
|
SELECT id, project_id, title, status, priority, owner_model, notes
|
|
FROM tasks_v2
|
|
WHERE project_id = ?
|
|
`).all(project.id)
|
|
|
|
const defaultStatuses = ['in_progress', 'pending_validation', 'blocked', 'backlog']
|
|
const wantedStatuses = selectedStatuses || (args.all ? [...statusOrder] : defaultStatuses)
|
|
|
|
let filtered = rows
|
|
.filter(r => wantedStatuses.includes(r.status))
|
|
.filter(r => !selectedPriorities || selectedPriorities.includes(r.priority))
|
|
.filter(r => !args.owner || (r.owner_model || '').toLowerCase() === args.owner.toLowerCase())
|
|
.map(r => ({ ...r, first_note: firstNoteLine(r.notes) }))
|
|
|
|
filtered.sort((a, b) => {
|
|
const s = statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)
|
|
if (s !== 0) return s
|
|
const p = (priorityRank[a.priority] ?? 999) - (priorityRank[b.priority] ?? 999)
|
|
if (p !== 0) return p
|
|
return a.id.localeCompare(b.id)
|
|
})
|
|
|
|
if (args.limit) filtered = filtered.slice(0, args.limit)
|
|
|
|
if (args.json) {
|
|
const grouped = {}
|
|
for (const s of statusOrder) grouped[s] = []
|
|
for (const row of filtered) grouped[row.status].push(row)
|
|
|
|
console.log(JSON.stringify({
|
|
project: { id: project.id, name: project.name },
|
|
filters: {
|
|
all: args.all,
|
|
status: wantedStatuses,
|
|
priority: selectedPriorities,
|
|
owner: args.owner || null,
|
|
limit: args.limit || null,
|
|
},
|
|
total: filtered.length,
|
|
tasks: filtered,
|
|
grouped,
|
|
}, null, 2))
|
|
return
|
|
}
|
|
|
|
console.log()
|
|
console.log(`${c.bold}${project.name}${c.reset} ${c.gray}(${project.id})${c.reset}`)
|
|
console.log(`${c.dim}Showing ${filtered.length} task(s)${c.reset}`)
|
|
|
|
if (!filtered.length) {
|
|
console.log(`${c.dim}No matching tasks.${c.reset}`)
|
|
console.log()
|
|
return
|
|
}
|
|
|
|
for (const status of statusOrder) {
|
|
const bucket = filtered.filter(r => r.status === status)
|
|
if (!bucket.length) continue
|
|
console.log()
|
|
console.log(`${c.bold}${statusLabel[status]}${c.reset} ${c.gray}(${bucket.length})${c.reset}`)
|
|
for (const task of bucket) console.log(formatTaskLine(task))
|
|
}
|
|
|
|
console.log()
|
|
}
|
|
|
|
// --- notes -------------------------------------------------------------------
|
|
async function cmdNotes() {
|
|
const notesScript = path.join(SCRIPTS, 'apple-notes-export.mjs')
|
|
if (!existsSync(notesScript)) {
|
|
console.error(`${c.red}✗${c.reset} apple-notes-export.mjs not found at: ${notesScript}`)
|
|
process.exit(1)
|
|
}
|
|
await import(notesScript)
|
|
}
|
|
|
|
// --- help --------------------------------------------------------------------
|
|
function cmdHelp() {
|
|
console.log(`
|
|
${c.bold}taskflow${c.reset} — TaskFlow CLI
|
|
|
|
${c.bold}USAGE${c.reset}
|
|
taskflow <command> [options]
|
|
|
|
${c.bold}COMMANDS${c.reset}
|
|
${c.cyan}setup${c.reset} Interactive first-run onboarding. Creates workspace
|
|
directories, PROJECTS.md, task files, initializes the
|
|
DB, syncs, and optionally installs the LaunchAgent.
|
|
${c.dim}--name <name>${c.reset} Non-interactive: project name (skips all prompts)
|
|
${c.dim}--desc <desc>${c.reset} Non-interactive: project description
|
|
${c.dim}--no-launchagent${c.reset} Skip LaunchAgent / cron prompt
|
|
${c.dim}--yes, -y${c.reset} Auto-accept all yes/no prompts
|
|
|
|
${c.cyan}status${c.reset} Pretty terminal summary of all projects with task counts
|
|
and progress bars.
|
|
|
|
${c.cyan}export${c.reset} Output a full JSON snapshot of all projects and tasks to
|
|
stdout. Pipe to a file for dashboard consumption.
|
|
|
|
${c.cyan}sync${c.reset} <mode> Sync task markdown files ↔ SQLite.
|
|
${c.dim}files-to-db${c.reset} Parse markdown, write to DB (markdown is authoritative)
|
|
${c.dim}db-to-files${c.reset} Regenerate markdown from DB state
|
|
${c.dim}check${c.reset} Detect drift, exit 1 if mismatch (good for CI/cron)
|
|
|
|
${c.cyan}init${c.reset} Bootstrap (or re-bootstrap) the SQLite schema. Idempotent.
|
|
|
|
${c.cyan}install-daemon${c.reset} Install the periodic-sync daemon for your OS.
|
|
macOS → ~/Library/LaunchAgents/com.taskflow.sync.plist (launchctl)
|
|
Linux → ~/.config/systemd/user/taskflow-sync.{service,timer}
|
|
Detects platform automatically; templates are in taskflow/system/.
|
|
|
|
${c.cyan}add${c.reset} <project> <title> Create a task line in markdown (source of truth).
|
|
${c.dim}--priority <P0|P1|P2|P3|P9>${c.reset} Task priority (default: P2)
|
|
${c.dim}--owner <tag>${c.reset} Optional owner/model tag (e.g. codex)
|
|
${c.dim}--status <status>${c.reset} backlog|in_progress|pending_validation|blocked|done
|
|
${c.dim}--note <text>${c.reset} Optional note line under the task
|
|
${c.dim}--json${c.reset} Emit machine-readable JSON output
|
|
${c.dim}--dry-run${c.reset} Show what would be written without editing files
|
|
${c.dim}--sync${c.reset} Run files-to-db sync after write
|
|
|
|
${c.cyan}list${c.reset} <project> List tasks for one project (current tasks by default).
|
|
${c.dim}--project <slug|name>${c.reset} Alternate project selector flag (supports fuzzy match)
|
|
${c.dim}--all${c.reset} Include done tasks
|
|
${c.dim}--status <csv>${c.reset} Filter statuses (e.g. in_progress,backlog)
|
|
${c.dim}--priority <csv>${c.reset} Filter priorities (e.g. P1,P2)
|
|
${c.dim}--owner <tag>${c.reset} Filter by exact owner/model tag
|
|
${c.dim}--json${c.reset} Emit machine-readable JSON output
|
|
${c.dim}--limit <n>${c.reset} Limit total tasks returned after sorting
|
|
|
|
${c.cyan}notes${c.reset} Push current project status to Apple Notes (macOS only).
|
|
Creates a new note on first run; edits in-place on subsequent
|
|
runs (preserves any share link). Note ID is saved to
|
|
\$OPENCLAW_WORKSPACE/taskflow.config.json.
|
|
|
|
${c.cyan}help${c.reset} Show this message.
|
|
|
|
${c.bold}ENVIRONMENT${c.reset}
|
|
OPENCLAW_WORKSPACE Root workspace directory (default: cwd)
|
|
DB is resolved as \$OPENCLAW_WORKSPACE/memory/taskflow.sqlite
|
|
|
|
${c.bold}EXAMPLES${c.reset}
|
|
taskflow setup
|
|
taskflow setup --name "My Project" --desc "A cool thing" --no-launchagent
|
|
taskflow init
|
|
taskflow sync files-to-db
|
|
taskflow status
|
|
taskflow export > /tmp/projects.json
|
|
taskflow sync check && echo "in sync"
|
|
taskflow add dashboard "Ship calendar API retry logic" --priority P1 --owner codex
|
|
taskflow add taskflow "Document parser edge case" --status blocked --note "Waiting on repro"
|
|
taskflow list taskflow
|
|
taskflow list --project "TaskFlow" --all
|
|
taskflow list task --status backlog,pending_validation --json
|
|
taskflow notes
|
|
`)
|
|
}
|
|
|
|
// ── Dispatch ─────────────────────────────────────────────────────────────
|
|
const [,, cmd, ...args] = process.argv
|
|
|
|
switch (cmd) {
|
|
case 'setup':
|
|
await cmdSetup(args)
|
|
break
|
|
|
|
case 'status':
|
|
cmdStatus()
|
|
break
|
|
|
|
case 'export':
|
|
await cmdExport()
|
|
break
|
|
|
|
case 'sync':
|
|
await cmdSync(args[0] || 'check')
|
|
break
|
|
|
|
case 'init':
|
|
await cmdInit()
|
|
break
|
|
|
|
case 'install-daemon':
|
|
await cmdInstallDaemon()
|
|
break
|
|
|
|
case 'add':
|
|
cmdAdd(args)
|
|
break
|
|
|
|
case 'list':
|
|
cmdList(args)
|
|
break
|
|
|
|
case 'notes':
|
|
await cmdNotes()
|
|
break
|
|
|
|
case 'help':
|
|
case '--help':
|
|
case '-h':
|
|
cmdHelp()
|
|
break
|
|
|
|
case undefined:
|
|
// No args: show status as a sensible default
|
|
cmdStatus()
|
|
break
|
|
|
|
default:
|
|
console.error(`${c.red}✗${c.reset} Unknown command: ${JSON.stringify(cmd)}`)
|
|
console.error(` Run ${c.bold}taskflow help${c.reset} for usage.`)
|
|
process.exit(1)
|
|
}
|