feat(cli/boulder): implement boulder() entry point and register subcommand
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
import {
|
||||
getBoulderFilePath,
|
||||
getBoulderWorks,
|
||||
getPlanProgress,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPathForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderWorkState } from "../../features/boulder-state"
|
||||
import {
|
||||
formatJsonOutput,
|
||||
formatNoBoulderMessage,
|
||||
formatReadErrorMessage,
|
||||
formatTextOutput,
|
||||
} from "./formatter"
|
||||
import type { BoulderCliResult, BoulderCliWork, BoulderOptions } from "./types"
|
||||
|
||||
function formatDurationHuman(durationMs: number): string {
|
||||
if (durationMs < 1000) {
|
||||
return `${durationMs}ms`
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(durationMs / 1000)
|
||||
const seconds = totalSeconds % 60
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function getElapsedMs(work: BoulderWorkState): number | undefined {
|
||||
if (work.elapsed_ms !== undefined) {
|
||||
return work.elapsed_ms
|
||||
}
|
||||
|
||||
const startedAtMs = Date.parse(work.started_at)
|
||||
if (Number.isNaN(startedAtMs)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const endedAtMs = work.ended_at ? Date.parse(work.ended_at) : Date.now()
|
||||
if (Number.isNaN(endedAtMs)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Math.max(0, endedAtMs - startedAtMs)
|
||||
}
|
||||
|
||||
function buildCliWork(directory: string, work: BoulderWorkState): BoulderCliWork {
|
||||
const planPath = resolveBoulderPlanPathForWork(directory, work)
|
||||
const progress = getPlanProgress(planPath)
|
||||
const elapsedMs = getElapsedMs(work)
|
||||
const currentTask = readCurrentTopLevelTask(planPath)
|
||||
const taskSession = currentTask ? work.task_sessions?.[currentTask.key] : undefined
|
||||
|
||||
let currentTaskElapsedHuman: string | undefined
|
||||
if (taskSession?.elapsed_ms !== undefined) {
|
||||
currentTaskElapsedHuman = formatDurationHuman(taskSession.elapsed_ms)
|
||||
} else if (taskSession?.started_at) {
|
||||
const startedAtMs = Date.parse(taskSession.started_at)
|
||||
if (!Number.isNaN(startedAtMs)) {
|
||||
currentTaskElapsedHuman = formatDurationHuman(Math.max(0, Date.now() - startedAtMs))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
work_id: work.work_id,
|
||||
plan_name: work.plan_name,
|
||||
active_plan: work.active_plan,
|
||||
worktree_path: work.worktree_path,
|
||||
status: work.status ?? "active",
|
||||
started_at: work.started_at,
|
||||
ended_at: work.ended_at,
|
||||
elapsed_ms: elapsedMs,
|
||||
elapsed_human: elapsedMs !== undefined ? formatDurationHuman(elapsedMs) : undefined,
|
||||
total_tasks: progress.total,
|
||||
completed_tasks: progress.completed,
|
||||
remaining_tasks: Math.max(0, progress.total - progress.completed),
|
||||
percentage: progress.total > 0
|
||||
? Math.round((progress.completed / progress.total) * 100)
|
||||
: 0,
|
||||
session_count: work.session_ids.length,
|
||||
current_task: currentTask
|
||||
? {
|
||||
task_key: currentTask.key,
|
||||
task_title: currentTask.title,
|
||||
elapsed_human: currentTaskElapsedHuman,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function boulder(options: BoulderOptions): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
const boulderFilePath = getBoulderFilePath(directory)
|
||||
const state = readBoulderState(directory)
|
||||
if (!state) {
|
||||
const message = existsSync(boulderFilePath)
|
||||
? formatReadErrorMessage(options.json)
|
||||
: formatNoBoulderMessage(options.json)
|
||||
|
||||
process.stderr.write(`${message}\n`)
|
||||
return existsSync(boulderFilePath) ? 2 : 1
|
||||
}
|
||||
|
||||
const works = getBoulderWorks(state)
|
||||
const filteredWorks = options.workId
|
||||
? works.filter((work) => work.work_id === options.workId)
|
||||
: works
|
||||
|
||||
if (filteredWorks.length === 0) {
|
||||
process.stderr.write(`${formatNoBoulderMessage(options.json)}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const cliWorks = filteredWorks.map((work) => buildCliWork(directory, work))
|
||||
const result: BoulderCliResult = { works: cliWorks }
|
||||
|
||||
const output = options.json
|
||||
? formatJsonOutput(result)
|
||||
: formatTextOutput(result)
|
||||
|
||||
process.stdout.write(`${output}\n`)
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user