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 { doctor } from "./doctor"
|
||||||
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
import { refreshModelCapabilities } from "./refresh-model-capabilities"
|
||||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||||
|
import { boulder } from "./boulder"
|
||||||
import type { InstallArgs } from "./types"
|
import type { InstallArgs } from "./types"
|
||||||
import type { RunOptions } from "./run"
|
import type { RunOptions } from "./run"
|
||||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||||
@@ -202,6 +203,21 @@ program
|
|||||||
console.log(`oh-my-opencode v${VERSION}`)
|
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())
|
program.addCommand(createMcpOAuthCommand())
|
||||||
|
|
||||||
export function runCli(): void {
|
export function runCli(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user