feat(cli): enable timestamped run output by default
This commit is contained in:
parent
d485ba2d4c
commit
d5bd9cae98
@ -74,6 +74,7 @@ program
|
|||||||
.option("--attach <url>", "Attach to existing opencode server URL")
|
.option("--attach <url>", "Attach to existing opencode server URL")
|
||||||
.option("--on-complete <command>", "Shell command to run after completion")
|
.option("--on-complete <command>", "Shell command to run after completion")
|
||||||
.option("--json", "Output structured JSON result to stdout")
|
.option("--json", "Output structured JSON result to stdout")
|
||||||
|
.option("--no-timestamp", "Disable timestamp prefix in run output")
|
||||||
.option("--verbose", "Show full event stream (default: messages/tools only)")
|
.option("--verbose", "Show full event stream (default: messages/tools only)")
|
||||||
.option("--session-id <id>", "Resume existing session instead of creating new one")
|
.option("--session-id <id>", "Resume existing session instead of creating new one")
|
||||||
.addHelpText("after", `
|
.addHelpText("after", `
|
||||||
@ -112,6 +113,7 @@ Unlike 'opencode run', this command waits until:
|
|||||||
attach: options.attach,
|
attach: options.attach,
|
||||||
onComplete: options.onComplete,
|
onComplete: options.onComplete,
|
||||||
json: options.json ?? false,
|
json: options.json ?? false,
|
||||||
|
timestamp: options.timestamp ?? true,
|
||||||
verbose: options.verbose ?? false,
|
verbose: options.verbose ?? false,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { resolveRunAgent } from "./agent-resolver"
|
|||||||
import { pollForCompletion } from "./poll-for-completion"
|
import { pollForCompletion } from "./poll-for-completion"
|
||||||
import { loadAgentProfileColors } from "./agent-profile-colors"
|
import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||||
import { suppressRunInput } from "./stdin-suppression"
|
import { suppressRunInput } from "./stdin-suppression"
|
||||||
|
import { createTimestampedStdoutController } from "./timestamp-output"
|
||||||
|
|
||||||
export { resolveRunAgent }
|
export { resolveRunAgent }
|
||||||
|
|
||||||
@ -38,6 +39,10 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
|
|
||||||
const jsonManager = options.json ? createJsonOutputManager() : null
|
const jsonManager = options.json ? createJsonOutputManager() : null
|
||||||
if (jsonManager) jsonManager.redirectToStderr()
|
if (jsonManager) jsonManager.redirectToStderr()
|
||||||
|
const timestampOutput = options.json || options.timestamp === false
|
||||||
|
? null
|
||||||
|
: createTimestampedStdoutController()
|
||||||
|
timestampOutput?.enable()
|
||||||
|
|
||||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||||
@ -138,10 +143,13 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (jsonManager) jsonManager.restore()
|
if (jsonManager) jsonManager.restore()
|
||||||
|
timestampOutput?.restore()
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
return 130
|
return 130
|
||||||
}
|
}
|
||||||
console.error(pc.red(`Error: ${serializeError(err)}`))
|
console.error(pc.red(`Error: ${serializeError(err)}`))
|
||||||
return 1
|
return 1
|
||||||
|
} finally {
|
||||||
|
timestampOutput?.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/cli/run/timestamp-output.test.ts
Normal file
127
src/cli/run/timestamp-output.test.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { createTimestampTransformer, createTimestampedStdoutController } from "./timestamp-output"
|
||||||
|
|
||||||
|
interface MockWriteStream {
|
||||||
|
write: (
|
||||||
|
chunk: Uint8Array | string,
|
||||||
|
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
||||||
|
callback?: (error?: Error | null) => void,
|
||||||
|
) => boolean
|
||||||
|
writes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWriteStream(): MockWriteStream {
|
||||||
|
const writes: string[] = []
|
||||||
|
|
||||||
|
const write: MockWriteStream["write"] = (
|
||||||
|
chunk,
|
||||||
|
encodingOrCallback,
|
||||||
|
callback,
|
||||||
|
) => {
|
||||||
|
const text = typeof chunk === "string"
|
||||||
|
? chunk
|
||||||
|
: Buffer.from(chunk).toString(typeof encodingOrCallback === "string" ? encodingOrCallback : undefined)
|
||||||
|
|
||||||
|
writes.push(text)
|
||||||
|
|
||||||
|
if (typeof encodingOrCallback === "function") {
|
||||||
|
encodingOrCallback(null)
|
||||||
|
} else if (callback) {
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { write, writes }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createTimestampTransformer", () => {
|
||||||
|
it("prefixes each output line with timestamp", () => {
|
||||||
|
// given
|
||||||
|
const now = () => new Date("2026-02-19T12:34:56.000Z")
|
||||||
|
const transform = createTimestampTransformer(now)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = transform("hello\nworld")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("[12:34:56] hello\n[12:34:56] world")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps line-start state across chunk boundaries", () => {
|
||||||
|
// given
|
||||||
|
const now = () => new Date("2026-02-19T01:02:03.000Z")
|
||||||
|
const transform = createTimestampTransformer(now)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const first = transform("hello")
|
||||||
|
const second = transform(" world")
|
||||||
|
const third = transform("\nnext")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(first).toBe("[01:02:03] hello")
|
||||||
|
expect(second).toBe(" world")
|
||||||
|
expect(third).toBe("\n[01:02:03] next")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string for empty chunk", () => {
|
||||||
|
// given
|
||||||
|
const transform = createTimestampTransformer(() => new Date("2026-02-19T01:02:03.000Z"))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const output = transform("")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(output).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createTimestampedStdoutController", () => {
|
||||||
|
it("prefixes stdout writes when enabled", () => {
|
||||||
|
// given
|
||||||
|
const stdout = createMockWriteStream()
|
||||||
|
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream)
|
||||||
|
|
||||||
|
// when
|
||||||
|
controller.enable()
|
||||||
|
stdout.write("hello\nworld")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(stdout.writes).toHaveLength(1)
|
||||||
|
expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] hello\n\[\d{2}:\d{2}:\d{2}\] world$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("restores original write function", () => {
|
||||||
|
// given
|
||||||
|
const stdout = createMockWriteStream()
|
||||||
|
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream)
|
||||||
|
controller.enable()
|
||||||
|
|
||||||
|
// when
|
||||||
|
stdout.write("before restore")
|
||||||
|
controller.restore()
|
||||||
|
stdout.write("after restore")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(stdout.writes).toHaveLength(2)
|
||||||
|
expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] before restore$/)
|
||||||
|
expect(stdout.writes[1]).toBe("after restore")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports Uint8Array chunks and encoding", () => {
|
||||||
|
// given
|
||||||
|
const stdout = createMockWriteStream()
|
||||||
|
const controller = createTimestampedStdoutController(stdout as unknown as NodeJS.WriteStream)
|
||||||
|
|
||||||
|
// when
|
||||||
|
controller.enable()
|
||||||
|
stdout.write(Buffer.from("byte line"), "utf8")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(stdout.writes).toHaveLength(1)
|
||||||
|
expect(stdout.writes[0]!).toMatch(/^\[\d{2}:\d{2}:\d{2}\] byte line$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
70
src/cli/run/timestamp-output.ts
Normal file
70
src/cli/run/timestamp-output.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
function formatTimestamp(date: Date): string {
|
||||||
|
const hh = String(date.getHours()).padStart(2, "0")
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
const ss = String(date.getSeconds()).padStart(2, "0")
|
||||||
|
return `${hh}:${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTimestampTransformer(now: () => Date = () => new Date()): (chunk: string) => string {
|
||||||
|
let atLineStart = true
|
||||||
|
|
||||||
|
return (chunk: string): string => {
|
||||||
|
if (!chunk) return ""
|
||||||
|
|
||||||
|
let output = ""
|
||||||
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
|
const ch = chunk[i]
|
||||||
|
if (atLineStart) {
|
||||||
|
output += `[${formatTimestamp(now())}] `
|
||||||
|
atLineStart = false
|
||||||
|
}
|
||||||
|
|
||||||
|
output += ch
|
||||||
|
|
||||||
|
if (ch === "\n") {
|
||||||
|
atLineStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFn = NodeJS.WriteStream["write"]
|
||||||
|
|
||||||
|
export function createTimestampedStdoutController(stdout: NodeJS.WriteStream = process.stdout): {
|
||||||
|
enable: () => void
|
||||||
|
restore: () => void
|
||||||
|
} {
|
||||||
|
const originalWrite = stdout.write.bind(stdout)
|
||||||
|
const transform = createTimestampTransformer()
|
||||||
|
|
||||||
|
function enable(): void {
|
||||||
|
const write: WriteFn = (
|
||||||
|
chunk: Uint8Array | string,
|
||||||
|
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
||||||
|
callback?: (error?: Error | null) => void,
|
||||||
|
): boolean => {
|
||||||
|
const text = typeof chunk === "string"
|
||||||
|
? chunk
|
||||||
|
: Buffer.from(chunk).toString(typeof encodingOrCallback === "string" ? encodingOrCallback : undefined)
|
||||||
|
const stamped = transform(text)
|
||||||
|
|
||||||
|
if (typeof encodingOrCallback === "function") {
|
||||||
|
return originalWrite(stamped, encodingOrCallback)
|
||||||
|
}
|
||||||
|
if (encodingOrCallback !== undefined) {
|
||||||
|
return originalWrite(stamped, encodingOrCallback, callback)
|
||||||
|
}
|
||||||
|
return originalWrite(stamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.write = write
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(): void {
|
||||||
|
stdout.write = originalWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enable, restore }
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ export type { OpencodeClient }
|
|||||||
export interface RunOptions {
|
export interface RunOptions {
|
||||||
message: string
|
message: string
|
||||||
agent?: string
|
agent?: string
|
||||||
|
timestamp?: boolean
|
||||||
verbose?: boolean
|
verbose?: boolean
|
||||||
directory?: string
|
directory?: string
|
||||||
port?: number
|
port?: number
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user