Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/cli/export-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
// `--out` is the path that writes the selected client's native format.
// Format metadata rides in the human lines below.
printData(clientConfig, wantsJson, [
text.trimEnd(),
// `lines` entries print one console line each and are control-escaped, so
// the document goes in as individual lines rather than one multi-line blob.
...text.trimEnd().split("\n"),
"",
...(out !== undefined ? [`Wrote ${out}`] : []),
`Destination: ${spec.destination(process.env)}`,
Expand Down
2 changes: 1 addition & 1 deletion src/cli/runtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ export async function readSecretBytes(

export function printData(value: unknown, wantsJson: boolean, lines?: string[]): void {
if (wantsJson || !lines) console.log(JSON.stringify(value, null, 2));
else for (const line of lines) console.log(line);
else for (const line of lines) console.log(terminalSafeText(line));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ this wire projection does not change the usage ledger.

## CLI readiness diagnostics

Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4.
Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. `printData` in `src/cli/runtime-api.ts` escapes each human-output line at the shared renderer, including role and delegation-model suggestion rationale. JSON output retains the exact original values. `src/cli/export-command.ts` passes native serialized documents as individual lines so their multiline layout is preserved. `tests/cli/cli-headless-parity.test.ts` checks both suggestion paths independently from their parsed JSON output; `tests/cli/cli-export-command.test.ts` covers export framing.

## CLI resolve and stop contracts for embedding shells

Expand Down
4 changes: 4 additions & 0 deletions tests/cli/cli-export-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,10 @@ describe("ocx export human output (accept criterion 2)", () => {

expect(result.code).toBe(0);
expect(result.stdout.startsWith("{\n")).toBe(true);
logs.length = 0;
const structured = await run(["--client", "opencode", "--json"], { baseUrl: proxy.baseUrl });
expect(structured.code).toBe(0);
expect(JSON.parse(result.stdout.split("\n\n")[0]!)).toEqual(JSON.parse(structured.stdout));
expect(result.stdout).toContain(join("opencode", "opencode.json"));
expect(result.stdout).toContain("Merge this generated configuration into that file; do not replace it.");
expect(result.stdout).toContain("export OPENCODEX_OPENCODE_API_KEY=");
Expand Down
17 changes: 15 additions & 2 deletions tests/cli/cli-headless-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,15 +1000,19 @@ describe("headless GUI parity CLI", () => {
sizingModel: "gpt-5.5",
proposals: [
{ role: "explorer", model: "gpt-5.5", status: "proposed", tier: "fast", effortIntent: "glance", proposedModel: "a/small", proposedEffort: "low" },
{ role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null },
{ role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", rationale: "Use \x1b]52;c;cG9pc29uZWQ=\x07 carefully.", proposedModel: "a/mid", proposedEffort: null },
{ role: "vague", model: null, status: "unsized", reason: "no JSON" },
],
};
const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true });
const logSpy = spyOn(console, "log").mockImplementation(() => {});
let output = "";
try {
expect(await handleAgentCommand(["roles", "suggest", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0);
expect(JSON.parse(logSpy.mock.calls.flat().join("\n"))).toEqual(proposals);
logSpy.mockClear();
expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).toBe(0);
output = logSpy.mock.calls.flat().join("\n");
} finally {
logSpy.mockRestore();
}
Expand All @@ -1018,6 +1022,8 @@ describe("headless GUI parity CLI", () => {
{ path: "/api/codex-agent-roles/explorer", method: "PUT", body: { model: "a/small", effort: "low" } },
{ path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid" } },
]);
expect(output).not.toMatch(/[\x07\x1b]/);
expect(output).toContain("Use \\x1b]52;c;cG9pc29uZWQ=\\x07 carefully.");
});

test("agent roles suggest --apply skips proposals that already match the role's pin and says so", async () => {
Expand Down Expand Up @@ -1102,13 +1108,20 @@ describe("headless GUI parity CLI", () => {
test("agent injection suggest prints the proposal, and --apply writes it through PUT /api/injection-model", async () => {
const suggestion = {
sizingModel: "gpt-5.5",
proposal: { model: "a/big", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Bounded edits.", moveUpIf: "It crosses modules.", moveDownIf: "Never.", proposedModel: "a/small", proposedEffort: "low", reason: null },
proposal: { model: "a/big", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Bounded\x1b]52;c;payload\x07 edits.", moveUpIf: "It crosses\rmodules.", moveDownIf: "Never\u009b31m.", proposedModel: "a/small", proposedEffort: "low", reason: null },
};
const runtime = fakeRuntime(req => req.method === "POST" ? suggestion : { ok: true });
const logSpy = spyOn(console, "log").mockImplementation(() => {});
try {
expect(await handleAgentCommand(["injection", "suggest", "rename", "symbols", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0);
expect(JSON.parse(logSpy.mock.calls.flat().join("\n"))).toEqual(suggestion);
logSpy.mockClear();
expect(await handleAgentCommand(["injection", "suggest", "rename symbols", "--apply"], runtime.deps)).toBe(0);
const output = logSpy.mock.calls.flat().join("\n");
expect(output).not.toMatch(/[\x07\x1b\r\u009b]/);
expect(output).toContain("Bounded\\x1b]52;c;payload\\x07 edits.");
expect(output).toContain("It crosses\\x0dmodules.");
expect(output).toContain("Never\\u009b31m.");
expect(await handleAgentCommand(["injection", "suggest"], runtime.deps)).not.toBe(0);
} finally {
logSpy.mockRestore();
Expand Down
Loading