Skip to content

Commit 3b517e7

Browse files
pamelachiajgoux
andauthored
feat(cli): default output to JSON for coding agents (#5532)
## Summary Coding agents that run the CLI now get JSON output by default, so they can parse structured results instead of scraping human-formatted tables. I resolve the effective output format once at the TypeScript entrypoint (both the legacy and next roots): an explicit `--output` / `--output-format` always wins, otherwise a detected coding agent gets `json` and everyone else keeps `text`. For Go-proxied commands this flows down as `--output json` to the Go subprocess, which renders JSON for the data-returning commands and ignores the flag elsewhere, so no Go changes were needed. ## Changes - Resolve output format from coding-agent detection in both CLI roots, defaulting agents to `json` when no format is set explicitly. - Add a shared `resolveAgentOutputFormat` helper (`explicit ?? (agent ? json : text)`) reused by both roots, with unit tests. - Make the `--output-format` global flag optional so an explicitly-passed format is distinguishable from the default. - Suppress the agent JSON default whenever the Go-compat `-o`/`--output` flag is passed explicitly: `-o json|yaml|toml|env` already short-circuits inside handlers, and `-o pretty` now keeps rendering the human table on natively ported commands instead of being overridden to JSON. ## Behavior notes - Humans and CI keep their current output: production telemetry shows ~99.6% of CI invocations carry no coding-agent env signal, so they don't flip. - Error rendering follows the resolved format. In text mode errors stay as red text on stderr, unchanged. In agent-default JSON mode handler errors emit the existing structured envelope (`{"_tag":"Error","error":{code,message}}`) on stdout with a non-zero exit code, the same behavior `--output-format json` already had, so agents get parseable failures instead of prose. - Interactive prompts already refuse to block in JSON mode (`NonInteractiveError`), so a detected agent cannot hang on a confirm prompt. - `--help` and `--version` render before the output layer engages and stay text in both roots. - `--agent yes|no` overrides detection in both directions for the default-format decision. ## Linear - fixes GROWTH-913 --------- Co-authored-by: Julien Goux <hi@jgoux.dev>
1 parent 2f86caa commit 3b517e7

12 files changed

Lines changed: 588 additions & 76 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, test } from "vitest";
2+
import { runSupabase } from "../../../tests/helpers/cli.ts";
3+
4+
function stripAnsi(output: string): string {
5+
let stripped = "";
6+
for (let i = 0; i < output.length; i++) {
7+
const charCode = output.charCodeAt(i);
8+
if (charCode !== 0x1b || output[i + 1] !== "[") {
9+
stripped += output[i];
10+
continue;
11+
}
12+
13+
i += 2;
14+
while (i < output.length) {
15+
const code = output.charCodeAt(i);
16+
if (code >= 0x40 && code <= 0x7e) {
17+
break;
18+
}
19+
i++;
20+
}
21+
}
22+
return stripped;
23+
}
24+
25+
function parseJsonLines(output: string): Array<unknown> {
26+
return stripAnsi(output)
27+
.trim()
28+
.split("\n")
29+
.filter((line) => line.length > 0)
30+
.map((line) => JSON.parse(line));
31+
}
32+
33+
describe("legacy CLI agent output", () => {
34+
test("formats parse errors as JSON for detected coding agents", async () => {
35+
const { exitCode, stdout, stderr } = await runSupabase(["definitely-not-a-command"], {
36+
entrypoint: "legacy",
37+
env: { CODEX_SANDBOX: "1" },
38+
});
39+
40+
expect(exitCode).toBe(1);
41+
expect(parseJsonLines(stdout)).toEqual([
42+
expect.objectContaining({ _tag: "Help" }),
43+
expect.objectContaining({
44+
_tag: "Error",
45+
error: expect.objectContaining({ code: "ShowHelp" }),
46+
}),
47+
]);
48+
expect(parseJsonLines(stderr)).toEqual([
49+
expect.objectContaining({
50+
_tag: "Errors",
51+
errors: [expect.objectContaining({ code: "UnknownSubcommand" })],
52+
}),
53+
]);
54+
});
55+
56+
test("keeps parse errors in text mode when --output-format=text is explicit", async () => {
57+
const { exitCode, stdout, stderr } = await runSupabase(
58+
["--output-format", "text", "definitely-not-a-command"],
59+
{
60+
entrypoint: "legacy",
61+
env: { CODEX_SANDBOX: "1" },
62+
},
63+
);
64+
65+
expect(exitCode).toBe(1);
66+
expect(stdout).toContain("DESCRIPTION");
67+
expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"');
68+
});
69+
70+
test("keeps parse errors in text mode when --agent=no is explicit", async () => {
71+
const { exitCode, stdout, stderr } = await runSupabase(
72+
["--agent", "no", "definitely-not-a-command"],
73+
{
74+
entrypoint: "legacy",
75+
env: { CODEX_SANDBOX: "1" },
76+
},
77+
);
78+
79+
expect(exitCode).toBe(1);
80+
expect(stdout).toContain("DESCRIPTION");
81+
expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"');
82+
});
83+
84+
test("formats parse errors as JSON when --agent=yes is explicit", async () => {
85+
const { exitCode, stdout, stderr } = await runSupabase(
86+
["--agent", "yes", "definitely-not-a-command"],
87+
{
88+
entrypoint: "legacy",
89+
env: {},
90+
},
91+
);
92+
93+
expect(exitCode).toBe(1);
94+
expect(parseJsonLines(stdout)).toEqual([
95+
expect.objectContaining({ _tag: "Help" }),
96+
expect.objectContaining({
97+
_tag: "Error",
98+
error: expect.objectContaining({ code: "ShowHelp" }),
99+
}),
100+
]);
101+
expect(parseJsonLines(stderr)).toEqual([
102+
expect.objectContaining({
103+
_tag: "Errors",
104+
errors: [expect.objectContaining({ code: "UnknownSubcommand" })],
105+
}),
106+
]);
107+
});
108+
109+
test("keeps built-in version and help in text mode for detected coding agents", async () => {
110+
const version = await runSupabase(["--version"], {
111+
entrypoint: "legacy",
112+
env: { CODEX_SANDBOX: "1" },
113+
});
114+
const help = await runSupabase(["--help"], {
115+
entrypoint: "legacy",
116+
env: { CODEX_SANDBOX: "1" },
117+
});
118+
119+
expect(version.exitCode).toBe(0);
120+
expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
121+
expect(() => JSON.parse(version.stdout)).toThrow();
122+
expect(help.exitCode).toBe(0);
123+
expect(help.stdout).toContain("DESCRIPTION");
124+
expect(() => JSON.parse(help.stdout)).toThrow();
125+
});
126+
});

apps/cli/src/legacy/cli/root.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import { OutputFormatFlag } from "../../shared/cli/global-flags.ts";
3939
import { outputLayerFor } from "../../shared/output/output.layer.ts";
4040
import { legacyQuietProgressTextOutputLayer } from "../output/legacy-quiet-progress-text-output.layer.ts";
4141
import { makeGoProxyLayer } from "../../shared/legacy/go-proxy.layer.ts";
42+
import { AiTool } from "../../shared/telemetry/ai-tool.service.ts";
43+
import { aiToolLayer } from "../../shared/telemetry/ai-tool.layer.ts";
44+
import { CliArgs } from "../../shared/cli/cli-args.service.ts";
45+
import { isBuiltInTextRequest, resolveAgentOutputFormat } from "../../shared/cli/agent-output.ts";
4246
import {
4347
LEGACY_GLOBAL_FLAGS,
4448
LegacyAgentFlag,
@@ -96,7 +100,7 @@ export const legacyRoot = Command.make("supabase").pipe(
96100
Command.provide(
97101
Layer.unwrap(
98102
Effect.gen(function* () {
99-
const outputFormat = yield* OutputFormatFlag;
103+
const explicitOutputFormat = yield* OutputFormatFlag;
100104
const goOutput = yield* LegacyOutputFlag;
101105
const profile = yield* LegacyProfileFlag;
102106
const debug = yield* LegacyDebugFlag;
@@ -107,6 +111,19 @@ export const legacyRoot = Command.make("supabase").pipe(
107111
const dnsResolver = yield* LegacyDnsResolverFlag;
108112
const createTicket = yield* LegacyCreateTicketFlag;
109113
const agent = yield* LegacyAgentFlag;
114+
const cliArgs = yield* CliArgs;
115+
116+
const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer));
117+
// An explicit Go --output is a complete format choice (even `-o pretty`
118+
// must keep its human table), so the agent JSON default only applies
119+
// when that flag is absent.
120+
const outputFormat = resolveAgentOutputFormat({
121+
explicitOutputFormat,
122+
legacyOutputFormat: goOutput,
123+
agentOverride: agent,
124+
detectedAgentName: aiTool.name,
125+
isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args),
126+
});
110127

111128
// Build args to prepend to every proxy exec call.
112129
// --output: use explicit --output if set, otherwise map from --output-format.

apps/cli/src/next/cli/root.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Effect, Layer } from "effect";
22
import { CliOutput, Command } from "effect/unstable/cli";
33
import { OutputFormatFlag } from "../../shared/cli/global-flags.ts";
4+
import { AiTool } from "../../shared/telemetry/ai-tool.service.ts";
5+
import { aiToolLayer } from "../../shared/telemetry/ai-tool.layer.ts";
6+
import { isBuiltInTextRequest, resolveAgentOutputFormat } from "../../shared/cli/agent-output.ts";
7+
import { CliArgs } from "../../shared/cli/cli-args.service.ts";
48
import { branchesCommand } from "../commands/branches/branches.command.ts";
59
import { functionsCommand } from "../commands/functions/functions.command.ts";
610
import { linkCommand } from "../commands/link/link.command.ts";
@@ -47,7 +51,14 @@ export const nextRoot = Command.make("supabase").pipe(
4751
Command.provide(
4852
Layer.unwrap(
4953
Effect.gen(function* () {
50-
const outputFormat = yield* OutputFormatFlag;
54+
const explicitOutputFormat = yield* OutputFormatFlag;
55+
const cliArgs = yield* CliArgs;
56+
const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer));
57+
const outputFormat = resolveAgentOutputFormat({
58+
explicitOutputFormat,
59+
detectedAgentName: aiTool.name,
60+
isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args),
61+
});
5162
const base = outputLayerFor(outputFormat);
5263
if (outputFormat === "text") return base;
5364
return Layer.merge(base, CliOutput.layer(jsonCliOutputFormatter()));
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { Option } from "effect";
2+
import type { OutputFormat } from "../output/types.ts";
3+
4+
type LegacyOutputFormat = "env" | "pretty" | "json" | "toml" | "yaml";
5+
type AgentOverride = "auto" | "yes" | "no";
6+
7+
interface AgentOutputOptions {
8+
readonly explicitOutputFormat: Option.Option<OutputFormat>;
9+
readonly legacyOutputFormat?: Option.Option<LegacyOutputFormat>;
10+
readonly agentOverride?: AgentOverride;
11+
readonly detectedAgentName?: Option.Option<string>;
12+
readonly isBuiltInTextRequest?: boolean;
13+
}
14+
15+
function readLongFlag(args: ReadonlyArray<string>, name: string): string | undefined {
16+
const prefix = `${name}=`;
17+
for (let i = 0; i < args.length; i++) {
18+
const arg = args[i];
19+
if (arg === undefined) {
20+
continue;
21+
}
22+
if (arg === name) {
23+
return args[i + 1];
24+
}
25+
if (arg.startsWith(prefix)) {
26+
return arg.slice(prefix.length);
27+
}
28+
}
29+
}
30+
31+
function readOutputFlag(args: ReadonlyArray<string>): string | undefined {
32+
for (let i = 0; i < args.length; i++) {
33+
const arg = args[i];
34+
if (arg === undefined) {
35+
continue;
36+
}
37+
if (arg === "--output" || arg === "-o") {
38+
return args[i + 1];
39+
}
40+
if (arg.startsWith("--output=")) {
41+
return arg.slice("--output=".length);
42+
}
43+
if (arg.startsWith("-o=")) {
44+
return arg.slice("-o=".length);
45+
}
46+
if (arg.length > 2 && arg.startsWith("-o")) {
47+
return arg.slice("-o".length);
48+
}
49+
}
50+
}
51+
52+
function outputFormatFromArg(value: string | undefined): Option.Option<OutputFormat> {
53+
switch (value) {
54+
case "text":
55+
case "json":
56+
case "stream-json":
57+
return Option.some(value);
58+
default:
59+
return Option.none();
60+
}
61+
}
62+
63+
function legacyOutputFormatFromArg(value: string | undefined): Option.Option<LegacyOutputFormat> {
64+
switch (value) {
65+
case "env":
66+
case "pretty":
67+
case "json":
68+
case "toml":
69+
case "yaml":
70+
return Option.some(value);
71+
default:
72+
return Option.none();
73+
}
74+
}
75+
76+
function agentOverrideFromArg(value: string | undefined): AgentOverride {
77+
switch (value) {
78+
case "yes":
79+
case "no":
80+
return value;
81+
default:
82+
return "auto";
83+
}
84+
}
85+
86+
function isRootValueFlag(arg: string): boolean {
87+
return (
88+
arg === "--output-format" ||
89+
arg === "--output" ||
90+
arg === "-o" ||
91+
arg === "--profile" ||
92+
arg === "--workdir" ||
93+
arg === "--network-id" ||
94+
arg === "--dns-resolver" ||
95+
arg === "--agent"
96+
);
97+
}
98+
99+
function isRootValueFlagWithInlineValue(arg: string): boolean {
100+
return (
101+
arg.startsWith("--output-format=") ||
102+
arg.startsWith("--output=") ||
103+
arg.startsWith("-o=") ||
104+
(arg.length > 2 && arg.startsWith("-o")) ||
105+
arg.startsWith("--profile=") ||
106+
arg.startsWith("--workdir=") ||
107+
arg.startsWith("--network-id=") ||
108+
arg.startsWith("--dns-resolver=") ||
109+
arg.startsWith("--agent=")
110+
);
111+
}
112+
113+
function isRootBooleanFlag(arg: string): boolean {
114+
return (
115+
arg === "--debug" || arg === "--experimental" || arg === "--yes" || arg === "--create-ticket"
116+
);
117+
}
118+
119+
function hasRootVersionRequest(args: ReadonlyArray<string>): boolean {
120+
for (let i = 0; i < args.length; i++) {
121+
const arg = args[i];
122+
if (arg === undefined || arg === "--") {
123+
return false;
124+
}
125+
if (arg === "--version" || arg === "-v") {
126+
return true;
127+
}
128+
if (isRootValueFlag(arg)) {
129+
i++;
130+
continue;
131+
}
132+
if (isRootValueFlagWithInlineValue(arg) || isRootBooleanFlag(arg)) {
133+
continue;
134+
}
135+
return false;
136+
}
137+
return false;
138+
}
139+
140+
function hasHelpRequest(args: ReadonlyArray<string>): boolean {
141+
for (const arg of args) {
142+
if (arg === "--") return false;
143+
if (arg === "--help" || arg === "-h") return true;
144+
}
145+
return false;
146+
}
147+
148+
export function isBuiltInTextRequest(args: ReadonlyArray<string>): boolean {
149+
return hasHelpRequest(args) || hasRootVersionRequest(args);
150+
}
151+
152+
export function resolveAgentOutputFormat(options: AgentOutputOptions): OutputFormat {
153+
const legacyOutputFormat = options.legacyOutputFormat ?? Option.none<LegacyOutputFormat>();
154+
const detectedAgentName = options.detectedAgentName ?? Option.none<string>();
155+
const agentOverride = options.agentOverride ?? "auto";
156+
const isCodingAgent =
157+
agentOverride === "yes" || (agentOverride === "auto" && Option.isSome(detectedAgentName));
158+
159+
return Option.getOrElse(options.explicitOutputFormat, () =>
160+
isCodingAgent && Option.isNone(legacyOutputFormat) && !options.isBuiltInTextRequest
161+
? "json"
162+
: "text",
163+
);
164+
}
165+
166+
export function resolveAgentOutputFormatFromArgs(
167+
args: ReadonlyArray<string>,
168+
detectedAgentName: Option.Option<string>,
169+
): OutputFormat {
170+
const explicitOutputFormat = outputFormatFromArg(readLongFlag(args, "--output-format"));
171+
const legacyOutputFormat = legacyOutputFormatFromArg(readOutputFlag(args));
172+
const agentOverride = agentOverrideFromArg(readLongFlag(args, "--agent"));
173+
174+
return resolveAgentOutputFormat({
175+
explicitOutputFormat,
176+
legacyOutputFormat,
177+
agentOverride,
178+
detectedAgentName,
179+
isBuiltInTextRequest: isBuiltInTextRequest(args),
180+
});
181+
}

0 commit comments

Comments
 (0)