Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/cli/src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Yargs from "yargs";
import { getCliVersion } from "./utils/version";
import { writeStderrLine } from "./utils/stdio-helpers";
import { hideBin } from "yargs/helpers";
import { EXEC_OUTPUT_FORMATS, isExecOutputFormat, type ExecOutputFormat } from "./exec-json-output";

// UUID v4 regex pattern for validation
const SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
Expand Down Expand Up @@ -39,6 +40,8 @@ export interface ParsedCliArgs {
help: boolean;
/** True when --last / -l was passed (resume the most recent session for the current project) */
last: boolean;
/** Output format for --exec: "text" (default) or "json" (newline-delimited events). */
outputFormat: ExecOutputFormat;
}

const EPILOG = [
Expand Down Expand Up @@ -111,6 +114,13 @@ async function configureYargs(argv?: string[]) {
default: false,
describe: "Resume the most recent session for the current project directory.",
})
.option("output-format", {
type: "string",
choices: EXEC_OUTPUT_FORMATS,
default: "text",
describe:
"Output format for --exec: text prints the assistant reply, json emits newline-delimited events including the session ID",
})
.check((argv: { [x: string]: unknown }) => {
const query = argv["query"] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query;
Expand Down Expand Up @@ -151,12 +161,22 @@ async function configureYargs(argv?: string[]) {
if (exec && argv["resume"] === "") {
return "--exec cannot use --resume without a session ID.\nUse --exec --resume <sessionId> --prompt <prompt>.";
}
// `--help` runs .check() before defaults are applied, so treat an
// absent value as the "text" default rather than a conflict.
const outputFormat = argv["output-format"];
if (outputFormat !== undefined && outputFormat !== "text" && !exec) {
return "--output-format only applies to --exec / -x.";
}
return true;
})
)
.example("deepcode", "Launch the interactive TUI in the current directory")
.example("deepcode -p <prompt>", "Launch the TUI and submit a prompt")
.example("deepcode -x -p <prompt>", "Run one prompt without launching the TUI")
.example(
"deepcode -x -p <prompt> --output-format json",
"Emit newline-delimited JSON events, including the session ID for --resume"
)
.example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker")
.example("deepcode -f, --fork [sessionId]", "Fork a session or the most recent session")
.example('cat error.log | deepcode -x -p "Explain this error"', "Use piped stdin as additional context")
Expand Down Expand Up @@ -217,5 +237,6 @@ export async function parseArguments(argv?: string[]): Promise<ParsedCliArgs> {
version: parsed.version === true,
help: parsed.help === true,
last: parsed.last === true,
outputFormat: isExecOutputFormat(parsed["output-format"]) ? parsed["output-format"] : "text",
};
}
1 change: 1 addition & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async function main(): Promise<void> {
projectRoot,
resumeSessionId: typeof resumeSessionId === "string" ? resumeSessionId : undefined,
forkSessionId: typeof forkSessionId === "string" ? forkSessionId : undefined,
outputFormat: parsed.outputFormat,
});
return;
}
Expand Down
171 changes: 171 additions & 0 deletions packages/cli/src/exec-json-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Newline-delimited JSON output for `--exec` mode.
*
* The event shape deliberately mirrors Claude Code's `--output-format
* stream-json` convention — one JSON object per line, each carrying a `type`
* discriminator and the `session_id` — so tooling that already drives headless
* coding agents can consume Deep Code without a bespoke parser.
*
* Every run emits exactly one `system`/`init` event first and exactly one
* `result` event last, with zero or more message events in between. Fields
* that have no Deep Code equivalent are omitted rather than invented.
*/

import type { ModelUsage, SessionMessage, SessionStatus } from "@vegamo/deepcode-core";

export const EXEC_OUTPUT_FORMATS = ["text", "json"] as const;

export type ExecOutputFormat = (typeof EXEC_OUTPUT_FORMATS)[number];

export function isExecOutputFormat(value: unknown): value is ExecOutputFormat {
return typeof value === "string" && (EXEC_OUTPUT_FORMATS as readonly string[]).includes(value);
}

/** Why the run ended. `success` is the only non-error outcome. */
export type ExecResultSubtype = "success" | "error" | "interrupted" | "permission_required" | "input_required";

export interface ExecJsonContext {
cwd: string;
model: string;
permissionMode: string;
mcpServers: string[];
resumedFrom?: string;
forkedFrom?: string;
}

export interface ExecResultInput {
subtype: ExecResultSubtype;
/** Assistant reply for a successful turn. */
result?: string | null;
/** Human-readable failure reason, mirroring what goes to stderr. */
error?: string;
/** Deep Code's own session status, passed through verbatim when known. */
status?: SessionStatus;
usage?: ModelUsage | null;
}

/**
* Serializes exec-mode progress as newline-delimited JSON.
*
* The emitter owns the "init is always first" invariant: the session id for a
* fresh session is minted inside `handleUserPrompt`, so `noteSessionId` is
* driven by the first streamed message. That still reaches stdout before the
* turn completes, which is what lets a caller capture the id for a later
* `--resume`.
*/
export class ExecJsonEmitter {
private readonly startedAt = Date.now();
private sessionId: string | null = null;
private initEmitted = false;

constructor(
private readonly write: (line: string) => void,
private readonly context: ExecJsonContext
) {}

/** Records the session id, emitting the `init` event the first time one is known. */
noteSessionId(sessionId: string | null | undefined): void {
if (!sessionId) {
return;
}
this.sessionId = sessionId;
this.emitInit();
}

emitMessage(message: SessionMessage): void {
this.noteSessionId(message.sessionId);
this.emitInit();
const payload: Record<string, unknown> = {
type: message.role,
session_id: this.sessionId,
message: {
id: message.id,
role: message.role,
content: message.content,
visible: message.visible,
create_time: message.createTime,
content_params: message.contentParams ?? null,
message_params: message.messageParams ?? null,
},
};
// `system` is also the init event's type, so keep the two distinguishable.
if (message.role === "system") {
payload.subtype = "message";
}
this.emit(payload, () => ({
type: message.role,
session_id: this.sessionId,
message: {
id: message.id,
role: message.role,
content: message.content,
visible: message.visible,
create_time: message.createTime,
},
}));
}

emitResult(input: ExecResultInput): void {
this.emitInit();
const payload: Record<string, unknown> = {
type: "result",
subtype: input.subtype,
is_error: input.subtype !== "success",
session_id: this.sessionId,
result: input.result ?? "",
duration_ms: Date.now() - this.startedAt,
};
if (input.error) {
payload.error = input.error;
}
if (input.status) {
payload.status = input.status;
}
if (input.usage) {
payload.usage = input.usage;
}
this.emit(payload);
}

private emitInit(): void {
if (this.initEmitted) {
return;
}
this.initEmitted = true;
const payload: Record<string, unknown> = {
type: "system",
subtype: "init",
session_id: this.sessionId,
cwd: this.context.cwd,
model: this.context.model,
permission_mode: this.context.permissionMode,
mcp_servers: this.context.mcpServers,
};
if (this.context.resumedFrom) {
payload.resumed_from = this.context.resumedFrom;
}
if (this.context.forkedFrom) {
payload.forked_from = this.context.forkedFrom;
}
this.emit(payload);
}

/**
* Writes one JSON line. Tool payloads reach us as `unknown`, so a value that
* cannot be serialized falls back to a reduced payload rather than throwing
* and taking the whole run down.
*/
private emit(payload: Record<string, unknown>, fallback?: () => Record<string, unknown>): void {
let line: string;
try {
line = JSON.stringify(payload);
} catch {
try {
line = JSON.stringify(fallback ? fallback() : { type: payload.type, session_id: this.sessionId });
} catch {
return;
}
}
this.write(line);
}
}
64 changes: 54 additions & 10 deletions packages/cli/src/exec-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type SessionManagerOptions,
} from "@vegamo/deepcode-core";
import { buildExecPrompt, type ExecInputStream } from "./exec-input";
import { ExecJsonEmitter, type ExecOutputFormat } from "./exec-json-output";
import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers";

type ExecSessionManager = Pick<
Expand All @@ -32,6 +33,8 @@ export interface ExecRunnerOptions {
projectRoot: string;
resumeSessionId?: string;
forkSessionId?: string;
/** `text` (default) prints the assistant reply; `json` emits NDJSON events. */
outputFormat?: ExecOutputFormat;
input?: ExecInputStream;
}

Expand Down Expand Up @@ -60,6 +63,7 @@ export async function runExecMode(
): Promise<number> {
const deps = { ...defaultDependencies, ...dependencies };
let manager: ExecSessionManager | null = null;
let json: ExecJsonEmitter | null = null;
let interrupted = false;

const handleSigint = (): void => {
Expand All @@ -70,35 +74,51 @@ export async function runExecMode(
deps.signalTarget.on("SIGINT", handleSigint);
try {
const settings = deps.resolveSettings(options.projectRoot);
if (options.outputFormat === "json") {
json = new ExecJsonEmitter(deps.writeStdoutLine, {
cwd: options.projectRoot,
model: settings.model,
permissionMode: settings.permissions.defaultMode,
mcpServers: Object.keys(settings.mcpServers ?? {}),
resumedFrom: options.resumeSessionId,
forkedFrom: options.forkSessionId,
});
}
manager = deps.createSessionManager({
projectRoot: options.projectRoot,
createOpenAIClient: () => createOpenAIClient(options.projectRoot),
getResolvedSettings: () => deps.resolveSettings(options.projectRoot),
renderMarkdown: (text) => text,
nonInteractive: true,
onAssistantMessage: () => {},
onAssistantMessage: json ? (message) => json?.emitMessage(message) : () => {},
});

await manager.initMcpServers(settings.mcpServers);
if (interrupted) {
json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted." });
return 130;
}

if (options.resumeSessionId) {
if (!manager.getSession(options.resumeSessionId)) {
deps.writeStderrLine(`No saved session found with ID "${options.resumeSessionId}".`);
const message = `No saved session found with ID "${options.resumeSessionId}".`;
deps.writeStderrLine(message);
json?.emitResult({ subtype: "error", error: message });
return 1;
}
}
if (options.forkSessionId) {
if (!manager.getSession(options.forkSessionId)) {
deps.writeStderrLine(`No saved session found with ID "${options.forkSessionId}".`);
const message = `No saved session found with ID "${options.forkSessionId}".`;
deps.writeStderrLine(message);
json?.emitResult({ subtype: "error", error: message });
return 1;
}
}

const prompt = await deps.buildPrompt(options.prompt, options.input ?? process.stdin);
if (interrupted) {
json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted." });
return 130;
}

Expand All @@ -107,44 +127,68 @@ export async function runExecMode(
} else if (options.resumeSessionId) {
manager.setActiveSessionId(options.resumeSessionId);
}
// Resumed and forked runs know their id up front; a fresh session mints one
// inside handleUserPrompt and reports it through the first streamed message.
json?.noteSessionId(manager.getActiveSessionId());

await manager.handleUserPrompt({ text: prompt });
const sessionId = manager.getActiveSessionId();
const session = sessionId ? manager.getSession(sessionId) : null;
json?.noteSessionId(sessionId);

if (interrupted || session?.status === "interrupted") {
if (!interrupted) {
deps.writeStderrLine("Execution was interrupted.");
}
json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted.", status: session?.status });
return interrupted ? 130 : 1;
}
if (!session) {
deps.writeStderrLine("Execution failed before a session was created.");
const message = "Execution failed before a session was created.";
deps.writeStderrLine(message);
json?.emitResult({ subtype: "error", error: message });
return 1;
}
if (session.status === "ask_permission") {
deps.writeStderrLine(formatPermissionConfirmationError(session.askPermissions, settings.permissions));
const message = formatPermissionConfirmationError(session.askPermissions, settings.permissions);
deps.writeStderrLine(message);
json?.emitResult({ subtype: "permission_required", error: message, status: session.status });
return 1;
}
if (session.status === "waiting_for_user") {
deps.writeStderrLine("Execution requires user input, which is unavailable in --exec mode.");
const message = "Execution requires user input, which is unavailable in --exec mode.";
deps.writeStderrLine(message);
json?.emitResult({ subtype: "input_required", error: message, status: session.status });
return 1;
}
if (session.status !== "completed") {
deps.writeStderrLine(
session.failReason ? `Execution failed: ${session.failReason}` : `Execution failed (${session.status}).`
);
const message = session.failReason
? `Execution failed: ${session.failReason}`
: `Execution failed (${session.status}).`;
deps.writeStderrLine(message);
json?.emitResult({ subtype: "error", error: message, status: session.status });
return 1;
}

deps.writeStdoutLine(session.assistantReply ?? "");
if (json) {
json.emitResult({
subtype: "success",
result: session.assistantReply ?? "",
status: session.status,
usage: session.usage,
});
} else {
deps.writeStdoutLine(session.assistantReply ?? "");
}
return 0;
} catch (error) {
if (interrupted) {
json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted." });
return 130;
}
const message = error instanceof Error ? error.message : String(error);
deps.writeStderrLine(`deepcode: ${message}`);
json?.emitResult({ subtype: "error", error: message });
return 1;
} finally {
deps.signalTarget.off("SIGINT", handleSigint);
Expand Down
Loading