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
126 changes: 126 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,126 @@
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"');
});

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"');
});

test("formats parse errors as JSON when --agent=yes is explicit", async () => {
const { exitCode, stdout, stderr } = await runSupabase(
["--agent", "yes", "definitely-not-a-command"],
{
entrypoint: "legacy",
env: {},
},
);

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 built-in version and help in text mode for detected coding agents", async () => {
const version = await runSupabase(["--version"], {
entrypoint: "legacy",
env: { CODEX_SANDBOX: "1" },
});
const help = await runSupabase(["--help"], {
entrypoint: "legacy",
env: { CODEX_SANDBOX: "1" },
});

expect(version.exitCode).toBe(0);
expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(() => JSON.parse(version.stdout)).toThrow();
expect(help.exitCode).toBe(0);
expect(help.stdout).toContain("DESCRIPTION");
expect(() => JSON.parse(help.stdout)).toThrow();
});
});
19 changes: 18 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,10 @@ 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 { CliArgs } from "../../shared/cli/cli-args.service.ts";
import { isBuiltInTextRequest, resolveAgentOutputFormat } from "../../shared/cli/agent-output.ts";
import {
LEGACY_GLOBAL_FLAGS,
LegacyAgentFlag,
Expand Down Expand Up @@ -96,7 +100,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 @@ -107,6 +111,19 @@ export const legacyRoot = Command.make("supabase").pipe(
const dnsResolver = yield* LegacyDnsResolverFlag;
const createTicket = yield* LegacyCreateTicketFlag;
const agent = yield* LegacyAgentFlag;
const cliArgs = yield* CliArgs;

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,
isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args),
});

// Build args to prepend to every proxy exec call.
// --output: use explicit --output if set, otherwise map from --output-format.
Expand Down
13 changes: 12 additions & 1 deletion apps/cli/src/next/cli/root.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
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 { isBuiltInTextRequest, resolveAgentOutputFormat } from "../../shared/cli/agent-output.ts";
import { CliArgs } from "../../shared/cli/cli-args.service.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 +51,14 @@ export const nextRoot = Command.make("supabase").pipe(
Command.provide(
Layer.unwrap(
Effect.gen(function* () {
const outputFormat = yield* OutputFormatFlag;
const explicitOutputFormat = yield* OutputFormatFlag;
const cliArgs = yield* CliArgs;
const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer));
const outputFormat = resolveAgentOutputFormat({
explicitOutputFormat,
detectedAgentName: aiTool.name,
isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args),
});
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
181 changes: 181 additions & 0 deletions apps/cli/src/shared/cli/agent-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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>;
readonly isBuiltInTextRequest?: boolean;
}

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";
}
}

function isRootValueFlag(arg: string): boolean {
return (
arg === "--output-format" ||
arg === "--output" ||
arg === "-o" ||
arg === "--profile" ||
arg === "--workdir" ||
arg === "--network-id" ||
arg === "--dns-resolver" ||
arg === "--agent"
);
}

function isRootValueFlagWithInlineValue(arg: string): boolean {
return (
arg.startsWith("--output-format=") ||
arg.startsWith("--output=") ||
arg.startsWith("-o=") ||
(arg.length > 2 && arg.startsWith("-o")) ||
arg.startsWith("--profile=") ||
arg.startsWith("--workdir=") ||
arg.startsWith("--network-id=") ||
arg.startsWith("--dns-resolver=") ||
arg.startsWith("--agent=")
);
}

function isRootBooleanFlag(arg: string): boolean {
return (
arg === "--debug" || arg === "--experimental" || arg === "--yes" || arg === "--create-ticket"
);
}

function hasRootVersionRequest(args: ReadonlyArray<string>): boolean {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined || arg === "--") {
return false;
}
if (arg === "--version" || arg === "-v") {
return true;
}
if (isRootValueFlag(arg)) {
i++;
continue;
}
if (isRootValueFlagWithInlineValue(arg) || isRootBooleanFlag(arg)) {
continue;
}
return false;
}
return false;
}

function hasHelpRequest(args: ReadonlyArray<string>): boolean {
for (const arg of args) {
if (arg === "--") return false;
if (arg === "--help" || arg === "-h") return true;
}
return false;
}

export function isBuiltInTextRequest(args: ReadonlyArray<string>): boolean {
return hasHelpRequest(args) || hasRootVersionRequest(args);
}

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

return Option.getOrElse(options.explicitOutputFormat, () =>
isCodingAgent && Option.isNone(legacyOutputFormat) && !options.isBuiltInTextRequest
? "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,
isBuiltInTextRequest: isBuiltInTextRequest(args),
});
}
Loading
Loading