feat(cli/boulder): implement boulder() entry point and register subcommand
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { boulder } from "./boulder"
|
||||
|
||||
function createTempDirectory(): string {
|
||||
return mkdtempSync(join(tmpdir(), "omo-boulder-cli-"))
|
||||
}
|
||||
|
||||
function seedPlanAndState(directory: string): void {
|
||||
const planDirectory = join(directory, ".sisyphus", "plans")
|
||||
mkdirSync(planDirectory, { recursive: true })
|
||||
|
||||
const planAPath = join(planDirectory, "alpha.md")
|
||||
const planBPath = join(planDirectory, "beta.md")
|
||||
|
||||
writeFileSync(
|
||||
planAPath,
|
||||
[
|
||||
"## TODOs",
|
||||
"- [x] 1. Alpha task done",
|
||||
"- [ ] 2. Alpha task running",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
)
|
||||
writeFileSync(
|
||||
planBPath,
|
||||
[
|
||||
"## TODOs",
|
||||
"- [x] 1. Beta task done",
|
||||
"- [x] 2. Beta task done too",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
const boulderDirectory = join(directory, ".sisyphus")
|
||||
mkdirSync(boulderDirectory, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(boulderDirectory, "boulder.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schema_version: 2,
|
||||
active_work_id: "work-alpha",
|
||||
active_plan: planAPath,
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
ended_at: "2026-05-10T00:30:00.000Z",
|
||||
elapsed_ms: 1_800_000,
|
||||
status: "active",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
session_ids: ["ses-1", "ses-2"],
|
||||
plan_name: "alpha",
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Alpha task running",
|
||||
session_id: "ses-2",
|
||||
elapsed_ms: 60000,
|
||||
status: "running",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
},
|
||||
},
|
||||
works: {
|
||||
"work-alpha": {
|
||||
work_id: "work-alpha",
|
||||
active_plan: planAPath,
|
||||
plan_name: "alpha",
|
||||
status: "active",
|
||||
started_at: "2026-05-10T00:00:00.000Z",
|
||||
elapsed_ms: 1_800_000,
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
session_ids: ["ses-1", "ses-2"],
|
||||
task_sessions: {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Alpha task running",
|
||||
session_id: "ses-2",
|
||||
elapsed_ms: 60000,
|
||||
status: "running",
|
||||
updated_at: "2026-05-10T00:30:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
"work-beta": {
|
||||
work_id: "work-beta",
|
||||
active_plan: planBPath,
|
||||
plan_name: "beta",
|
||||
status: "completed",
|
||||
started_at: "2026-05-10T01:00:00.000Z",
|
||||
ended_at: "2026-05-10T01:10:00.000Z",
|
||||
elapsed_ms: 600000,
|
||||
updated_at: "2026-05-10T01:10:00.000Z",
|
||||
session_ids: ["ses-3"],
|
||||
task_sessions: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
describe("boulder command", () => {
|
||||
const createdDirectories: string[] = []
|
||||
const outputRestores: Array<() => void> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of createdDirectories) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
createdDirectories.length = 0
|
||||
for (const restoreOutput of outputRestores) {
|
||||
restoreOutput()
|
||||
}
|
||||
outputRestores.length = 0
|
||||
})
|
||||
|
||||
function captureOutput(target: "stdout" | "stderr", sink: { value: string }): void {
|
||||
const originalWrite = process[target].write
|
||||
process[target].write = ((chunk: string | Uint8Array) => {
|
||||
sink.value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")
|
||||
return true
|
||||
}) as typeof process.stdout.write
|
||||
|
||||
outputRestores.push(() => {
|
||||
process[target].write = originalWrite
|
||||
})
|
||||
}
|
||||
|
||||
it("prints multi-work text mode with plan names and percentages", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory })
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stderr.value).toBe("")
|
||||
expect(stdout.value).toContain("plan: alpha")
|
||||
expect(stdout.value).toContain("plan: beta")
|
||||
expect(stdout.value).toContain("progress: 50% (1/2)")
|
||||
expect(stdout.value).toContain("progress: 100% (2/2)")
|
||||
expect(stdout.value).toContain("elapsed:")
|
||||
})
|
||||
|
||||
it("prints json mode with expected fields", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
|
||||
const exitCode = await boulder({ directory, json: true })
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(stdout.value)
|
||||
expect(parsed.works).toHaveLength(2)
|
||||
expect(parsed.works[0]).toHaveProperty("work_id")
|
||||
expect(parsed.works[0]).toHaveProperty("percentage")
|
||||
expect(parsed.works[0]).toHaveProperty("remaining_tasks")
|
||||
})
|
||||
|
||||
it("returns 1 when boulder state does not exist", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory })
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr.value).toContain("No boulder state found")
|
||||
})
|
||||
|
||||
it("returns 1 when workId filter matches none", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stderr = { value: "" }
|
||||
captureOutput("stderr", stderr)
|
||||
|
||||
const exitCode = await boulder({ directory, workId: "missing" })
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stderr.value).toContain("No boulder state found")
|
||||
})
|
||||
|
||||
it("returns one work when workId filter matches", async () => {
|
||||
const directory = createTempDirectory()
|
||||
createdDirectories.push(directory)
|
||||
seedPlanAndState(directory)
|
||||
|
||||
const stdout = { value: "" }
|
||||
captureOutput("stdout", stdout)
|
||||
|
||||
const exitCode = await boulder({ directory, workId: "work-beta", json: true })
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(stdout.value)
|
||||
expect(parsed.works).toHaveLength(1)
|
||||
expect(parsed.works[0].work_id).toBe("work-beta")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { boulder } from "./boulder"
|
||||
@@ -5,6 +5,7 @@ import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||
import { boulder } from "./boulder"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
@@ -202,6 +203,21 @@ program
|
||||
console.log(`oh-my-opencode v${VERSION}`)
|
||||
})
|
||||
|
||||
program
|
||||
.command("boulder")
|
||||
.description("Show boulder progress, elapsed time, and per-task statistics")
|
||||
.option("-d, --directory <path>", "Working directory")
|
||||
.option("-w, --work-id <id>", "Filter to a specific work")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options) => {
|
||||
const exitCode = await boulder({
|
||||
directory: options.directory,
|
||||
workId: options.workId,
|
||||
json: options.json ?? false,
|
||||
})
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program.addCommand(createMcpOAuthCommand())
|
||||
|
||||
export function runCli(): void {
|
||||
|
||||
Reference in New Issue
Block a user