feat(cli/boulder): implement boulder() entry point and register subcommand

This commit is contained in:
YeonGyu-Kim
2026-05-11 13:41:18 +09:00
parent 30984939eb
commit c34508235f
4 changed files with 368 additions and 0 deletions
+215
View File
@@ -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")
})
})
+136
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
export { boulder } from "./boulder"
+16
View File
@@ -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 {