Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
48 changes: 48 additions & 0 deletions apps/cli/src/legacy/cli/agent-output.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import { runSupabase } from "../../../tests/helpers/cli.ts";

function parseJsonLines(output: string): Array<unknown> {
return output
.trim()
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line));
}

describe("legacy CLI agent output", () => {
test("formats parse errors as JSON for detected coding agents", async () => {
const { exitCode, stdout, stderr } = await runSupabase(["definitely-not-a-command"], {
entrypoint: "legacy",
env: { CODEX_SANDBOX: "1" },
});

expect(exitCode).toBe(1);
expect(parseJsonLines(stdout)).toEqual([
expect.objectContaining({ _tag: "Help" }),
expect.objectContaining({
_tag: "Error",
error: expect.objectContaining({ code: "ShowHelp" }),
}),
]);
expect(parseJsonLines(stderr)).toEqual([

Check failure on line 27 in apps/cli/src/legacy/cli/agent-output.e2e.test.ts

View workflow job for this annotation

GitHub Actions / Run end-to-end tests (shard 1/3)

[e2e] src/legacy/cli/agent-output.e2e.test.ts > legacy CLI agent output > formats parse errors as JSON for detected coding agents

SyntaxError: JSON Parse error: Unrecognized token ' ❯ src/legacy/cli/agent-output.e2e.test.ts:27:12
expect.objectContaining({
_tag: "Errors",
errors: [expect.objectContaining({ code: "UnknownSubcommand" })],
}),
]);
});

test("keeps parse errors in text mode when --agent=no is explicit", async () => {
const { exitCode, stdout, stderr } = await runSupabase(
["--agent", "no", "definitely-not-a-command"],
{
entrypoint: "legacy",
env: { CODEX_SANDBOX: "1" },
},
);

expect(exitCode).toBe(1);
expect(stdout).toContain("DESCRIPTION");
expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"');
});
});
16 changes: 15 additions & 1 deletion apps/cli/src/legacy/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import { OutputFormatFlag } from "../../shared/cli/global-flags.ts";
import { outputLayerFor } from "../../shared/output/output.layer.ts";
import { legacyQuietProgressTextOutputLayer } from "../output/legacy-quiet-progress-text-output.layer.ts";
import { makeGoProxyLayer } from "../../shared/legacy/go-proxy.layer.ts";
import { AiTool } from "../../shared/telemetry/ai-tool.service.ts";
import { aiToolLayer } from "../../shared/telemetry/ai-tool.layer.ts";
import { resolveAgentOutputFormat } from "../../shared/cli/agent-output.ts";
import {
LEGACY_GLOBAL_FLAGS,
LegacyAgentFlag,
Expand Down Expand Up @@ -96,7 +99,7 @@ export const legacyRoot = Command.make("supabase").pipe(
Command.provide(
Layer.unwrap(
Effect.gen(function* () {
const outputFormat = yield* OutputFormatFlag;
const explicitOutputFormat = yield* OutputFormatFlag;
const goOutput = yield* LegacyOutputFlag;
const profile = yield* LegacyProfileFlag;
const debug = yield* LegacyDebugFlag;
Expand All @@ -108,6 +111,17 @@ export const legacyRoot = Command.make("supabase").pipe(
const createTicket = yield* LegacyCreateTicketFlag;
const agent = yield* LegacyAgentFlag;

const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer));
// An explicit Go --output is a complete format choice (even `-o pretty`
// must keep its human table), so the agent JSON default only applies
// when that flag is absent.
const outputFormat = resolveAgentOutputFormat({
explicitOutputFormat,
legacyOutputFormat: goOutput,
agentOverride: agent,
detectedAgentName: aiTool.name,
});

// Build args to prepend to every proxy exec call.
// --output: use explicit --output if set, otherwise map from --output-format.
const globalArgs: string[] = [];
Expand Down
10 changes: 9 additions & 1 deletion apps/cli/src/next/cli/root.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Effect, Layer } from "effect";
import { CliOutput, Command } from "effect/unstable/cli";
import { OutputFormatFlag } from "../../shared/cli/global-flags.ts";
import { AiTool } from "../../shared/telemetry/ai-tool.service.ts";
import { aiToolLayer } from "../../shared/telemetry/ai-tool.layer.ts";
import { resolveAgentOutputFormat } from "../../shared/cli/agent-output.ts";
import { branchesCommand } from "../commands/branches/branches.command.ts";
import { functionsCommand } from "../commands/functions/functions.command.ts";
import { linkCommand } from "../commands/link/link.command.ts";
Expand Down Expand Up @@ -47,7 +50,12 @@ export const nextRoot = Command.make("supabase").pipe(
Command.provide(
Layer.unwrap(
Effect.gen(function* () {
const outputFormat = yield* OutputFormatFlag;
const explicitOutputFormat = yield* OutputFormatFlag;
const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer));
const outputFormat = resolveAgentOutputFormat({
explicitOutputFormat,
detectedAgentName: aiTool.name,
});
Comment thread
jgoux marked this conversation as resolved.
const base = outputLayerFor(outputFormat);
if (outputFormat === "text") return base;
return Layer.merge(base, CliOutput.layer(jsonCliOutputFormatter()));
Expand Down
111 changes: 111 additions & 0 deletions apps/cli/src/shared/cli/agent-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Option } from "effect";
import type { OutputFormat } from "../output/types.ts";

type LegacyOutputFormat = "env" | "pretty" | "json" | "toml" | "yaml";
type AgentOverride = "auto" | "yes" | "no";

interface AgentOutputOptions {
readonly explicitOutputFormat: Option.Option<OutputFormat>;
readonly legacyOutputFormat?: Option.Option<LegacyOutputFormat>;
readonly agentOverride?: AgentOverride;
readonly detectedAgentName?: Option.Option<string>;
}

function readLongFlag(args: ReadonlyArray<string>, name: string): string | undefined {
const prefix = `${name}=`;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) {
continue;
}
if (arg === name) {
return args[i + 1];
}
if (arg.startsWith(prefix)) {
return arg.slice(prefix.length);
}
}
}

function readOutputFlag(args: ReadonlyArray<string>): string | undefined {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) {
continue;
}
if (arg === "--output" || arg === "-o") {
return args[i + 1];
}
if (arg.startsWith("--output=")) {
return arg.slice("--output=".length);
}
if (arg.startsWith("-o=")) {
return arg.slice("-o=".length);
}
if (arg.length > 2 && arg.startsWith("-o")) {
return arg.slice("-o".length);
}
}
}

function outputFormatFromArg(value: string | undefined): Option.Option<OutputFormat> {
switch (value) {
case "text":
case "json":
case "stream-json":
return Option.some(value);
default:
return Option.none();
}
}

function legacyOutputFormatFromArg(value: string | undefined): Option.Option<LegacyOutputFormat> {
switch (value) {
case "env":
case "pretty":
case "json":
case "toml":
case "yaml":
return Option.some(value);
default:
return Option.none();
}
}

function agentOverrideFromArg(value: string | undefined): AgentOverride {
switch (value) {
case "yes":
case "no":
return value;
default:
return "auto";
}
}

export function resolveAgentOutputFormat(options: AgentOutputOptions): OutputFormat {
const legacyOutputFormat = options.legacyOutputFormat ?? Option.none<LegacyOutputFormat>();
const agentOverride = options.agentOverride ?? "auto";
const detectedAgentName = options.detectedAgentName ?? Option.none<string>();
const isCodingAgent =
agentOverride === "yes" || (agentOverride !== "no" && Option.isSome(detectedAgentName));

return Option.getOrElse(options.explicitOutputFormat, () =>
isCodingAgent && Option.isNone(legacyOutputFormat) ? "json" : "text",
);
}

export function resolveAgentOutputFormatFromArgs(
args: ReadonlyArray<string>,
detectedAgentName: Option.Option<string>,
): OutputFormat {
const explicitOutputFormat = outputFormatFromArg(readLongFlag(args, "--output-format"));
const legacyOutputFormat = legacyOutputFormatFromArg(readOutputFlag(args));
const agentOverride = agentOverrideFromArg(readLongFlag(args, "--agent"));

return resolveAgentOutputFormat({
explicitOutputFormat,
legacyOutputFormat,
agentOverride,
detectedAgentName,
});
}
87 changes: 87 additions & 0 deletions apps/cli/src/shared/cli/agent-output.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Option } from "effect";
import { describe, expect, it } from "vitest";
import { resolveAgentOutputFormat, resolveAgentOutputFormatFromArgs } from "./agent-output.ts";

describe("resolveAgentOutputFormat", () => {
it("defaults a coding agent to json", () => {
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.none(),
detectedAgentName: Option.some("codex"),
}),
).toBe("json");
});

it("defaults a non-agent to text", () => {
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.none(),
detectedAgentName: Option.none(),
}),
).toBe("text");
});

it("honors an explicit format over agent detection", () => {
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.some("text"),
detectedAgentName: Option.some("codex"),
}),
).toBe("text");
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.some("stream-json"),
detectedAgentName: Option.none(),
}),
).toBe("stream-json");
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.some("json"),
detectedAgentName: Option.some("codex"),
}),
).toBe("json");
});

it("honors the --agent override", () => {
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.none(),
agentOverride: "yes",
detectedAgentName: Option.none(),
}),
).toBe("json");
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.none(),
agentOverride: "no",
detectedAgentName: Option.some("codex"),
}),
).toBe("text");
});

it("keeps legacy --output authoritative over the agent JSON default", () => {
expect(
resolveAgentOutputFormat({
explicitOutputFormat: Option.none(),
legacyOutputFormat: Option.some("pretty"),
detectedAgentName: Option.some("codex"),
}),
).toBe("text");
});

it("resolves the effective format from raw argv for runtime error formatting", () => {
expect(resolveAgentOutputFormatFromArgs(["bad-command"], Option.some("codex"))).toBe("json");
expect(
resolveAgentOutputFormatFromArgs(["--agent", "no", "bad-command"], Option.some("codex")),
).toBe("text");
expect(
resolveAgentOutputFormatFromArgs(["-o", "pretty", "bad-command"], Option.some("codex")),
).toBe("text");
expect(
resolveAgentOutputFormatFromArgs(
["--output-format=stream-json", "bad-command"],
Option.some("codex"),
),
).toBe("stream-json");
});
});
3 changes: 1 addition & 2 deletions apps/cli/src/shared/cli/global-flags.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Flag, GlobalFlag } from "effect/unstable/cli";
import type { OutputFormat } from "../output/types.ts";

export const OutputFormatFlag = GlobalFlag.setting("output-format")({
flag: Flag.choice("output-format", ["text", "json", "stream-json"]).pipe(
Flag.withDescription("Output format: text (default), json, or stream-json (NDJSON)"),
Flag.withDefault("text" as OutputFormat),
Flag.optional,
),
});
32 changes: 14 additions & 18 deletions apps/cli/src/shared/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,13 @@ import { ttyLayer } from "../runtime/tty.layer.ts";
import { CommandRuntime } from "../runtime/command-runtime.service.ts";
import { ProcessControl } from "../runtime/process-control.service.ts";
import type { Analytics } from "../telemetry/analytics.service.ts";
import { aiToolLayer } from "../telemetry/ai-tool.layer.ts";
import { AiTool } from "../telemetry/ai-tool.service.ts";
import { telemetryRuntimeLayer } from "../telemetry/runtime.layer.ts";
import { tracingLayer } from "../telemetry/tracing.layer.ts";
import { resolveAgentOutputFormatFromArgs } from "./agent-output.ts";

function outputFormatFor(args: ReadonlyArray<string>): OutputFormat {
const inline = args.find((arg) => arg.startsWith("--output-format="));
if (inline) {
const value = inline.slice("--output-format=".length);
if (value === "json" || value === "stream-json" || value === "text") {
return value;
}
}
const formatIdx = args.indexOf("--output-format");
const format = formatIdx !== -1 ? args[formatIdx + 1] : undefined;
return format === "json" || format === "stream-json" ? format : "text";
}

function formatterLayerFor(args: ReadonlyArray<string>) {
const format = outputFormatFor(args);
function formatterLayerFor(format: OutputFormat) {
return format === "json" || format === "stream-json"
? CliOutput.layer(jsonCliOutputFormatter())
: CliOutput.layer(textCliOutputFormatter());
Expand Down Expand Up @@ -75,6 +64,7 @@ function cliProgramFor(
rootCommand: Command.Command.Any,
args: ReadonlyArray<string>,
options: RunCliOptions,
outputFormat: OutputFormat,
) {
const runtimeLayer = Layer.mergeAll(processControlLayer, runtimeInfoLayer, ttyLayer);
const fallbackCommandLayer = Layer.mergeAll(
Expand All @@ -101,7 +91,7 @@ function cliProgramFor(
),
);
return Command.runWith(rootCommand, { version: CLI_VERSION })(args).pipe(
Comment thread
jgoux marked this conversation as resolved.
Effect.provide(formatterLayerFor(args)),
Effect.provide(formatterLayerFor(outputFormat)),
Effect.provide(options.analyticsLayer),
Effect.provide(tracingLayer),
Effect.provide(telemetryRuntimeLayer),
Expand All @@ -125,7 +115,13 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp
);

const useGlobalSignalInterrupt = !args.includes("start");
const cliProgram = cliProgramFor(rootCommand, args, options);
const outputFormat = await Effect.runPromise(
Effect.gen(function* () {
const aiTool = yield* AiTool;
return resolveAgentOutputFormatFromArgs(args, aiTool.name);
}).pipe(Effect.provide(aiToolLayer)),
);
const cliProgram = cliProgramFor(rootCommand, args, options, outputFormat);

const signalAwareProgram = Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -172,7 +168,7 @@ export async function runCli(rootCommand: Command.Command.Any, options: RunCliOp
const exitCode = yield* processControl.getExitCode;
return yield* processControl.exit(exitCode ?? 0);
}).pipe(
Effect.provide(outputLayerFor(outputFormatFor(args))),
Effect.provide(outputLayerFor(outputFormat)),
Effect.provide(telemetryRuntimeLayer),
Effect.provide(projectHomeLayerFor(handledRuntimeLayer)),
Effect.provide(cliConfigLayerFor(handledRuntimeLayer)),
Expand Down
Loading