Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
69 changes: 69 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,69 @@
import { describe, expect, test } from "vitest";
import { runSupabase } from "../../../tests/helpers/cli.ts";

function stripAnsi(output: string): string {
let stripped = "";
for (let i = 0; i < output.length; i++) {
const charCode = output.charCodeAt(i);
if (charCode !== 0x1b || output[i + 1] !== "[") {
stripped += output[i];
continue;
}

i += 2;
while (i < output.length) {
const code = output.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
break;
}
i++;
}
}
return stripped;
}

function parseJsonLines(output: string): Array<unknown> {
return stripAnsi(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([
expect.objectContaining({
_tag: "Errors",
errors: [expect.objectContaining({ code: "UnknownSubcommand" })],
}),
]);
});

test("keeps parse errors in text mode when --output-format=text is explicit", async () => {
const { exitCode, stdout, stderr } = await runSupabase(
["--output-format", "text", "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"');
});
});
18 changes: 14 additions & 4 deletions apps/cli/src/legacy/cli/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ 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,
LegacyCreateTicketFlag,
LegacyDebugFlag,
LegacyDnsResolverFlag,
Expand Down Expand Up @@ -96,7 +98,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 @@ -106,7 +108,16 @@ export const legacyRoot = Command.make("supabase").pipe(
const yes = yield* LegacyYesFlag;
const dnsResolver = yield* LegacyDnsResolverFlag;
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,
detectedAgentName: aiTool.name,
});

// Build args to prepend to every proxy exec call.
// --output: use explicit --output if set, otherwise map from --output-format.
Expand All @@ -124,7 +135,6 @@ export const legacyRoot = Command.make("supabase").pipe(
if (yes) globalArgs.push("--yes");
if (dnsResolver !== "native") globalArgs.push("--dns-resolver", dnsResolver);
if (createTicket) globalArgs.push("--create-ticket");
if (agent !== "auto") globalArgs.push("--agent", agent);

// Go's `-o {json,yaml,toml,env}` selects a machine encoder the handler
// writes via `output.raw`. Keep the text layer (so errors still render
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
95 changes: 95 additions & 0 deletions apps/cli/src/shared/cli/agent-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Option } from "effect";
import type { OutputFormat } from "../output/types.ts";

type LegacyOutputFormat = "env" | "pretty" | "json" | "toml" | "yaml";

interface AgentOutputOptions {
readonly explicitOutputFormat: Option.Option<OutputFormat>;
readonly legacyOutputFormat?: Option.Option<LegacyOutputFormat>;
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();
}
}

export function resolveAgentOutputFormat(options: AgentOutputOptions): OutputFormat {
const legacyOutputFormat = options.legacyOutputFormat ?? Option.none<LegacyOutputFormat>();
const detectedAgentName = options.detectedAgentName ?? Option.none<string>();
const isCodingAgent = 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));

return resolveAgentOutputFormat({
explicitOutputFormat,
legacyOutputFormat,
detectedAgentName,
});
}
73 changes: 73 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,73 @@
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("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(
["--output-format", "text", "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
Loading