From e547776501aad75a989b96b9d6ab4232901a3a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 15 Oct 2026 14:02:59 +0200 Subject: [PATCH] feat(cli): detect coding agents for structured output Enable the existing agent preset for recognized agent environments when stdout is piped and no explicit output format was requested. Select it before parsing so help and parse errors remain machine readable. Add --no-agent and ALTERTABLE_AGENT_MODE overrides, preserve explicit format behavior, and isolate human-mode tests from inherited agent markers. --- COMMANDS.md | 1 + README.md | 9 +++ cli-reference.json | 10 +++ cli/scripts/smoke-data-app.ts | 2 + cli/scripts/smoke-npm-bundle.ts | 2 +- cli/src/cli.ts | 5 ++ cli/src/commands/completion/lib/spec.test.ts | 1 + cli/src/lib/env.ts | 1 + cli/src/lib/global-flags.test.ts | 68 +++++++++++++++-- cli/src/lib/global-flags.ts | 47 +++++++++++- cli/src/lib/usage.test.ts | 1 + tests/agent-mode.test.ts | 79 ++++++++++++++++++++ tests/helpers.ts | 4 + 13 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 tests/agent-mode.test.ts diff --git a/COMMANDS.md b/COMMANDS.md index a7a16330..d0fa0f0b 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -13,6 +13,7 @@ Altertable CLI v1.11.0 • Query and manage your data platform from the terminal | `-d, --debug` | Enable debug output | | `--json` | Machine-readable JSON output | | `--agent` | Agent-friendly preset: structured JSON output, no pager or terminal styling | +| `--no-agent` | Disable agent mode, including automatic detection | | `--no-color` | Disable terminal colors and styling | | `--profile ` | Use a named profile for this command only | | `--connect-timeout ` | HTTP connect timeout in seconds (default 5) | diff --git a/README.md b/README.md index fd20e1ec..7a535e11 100644 --- a/README.md +++ b/README.md @@ -492,6 +492,7 @@ These flags apply to every command and may be placed before or after commands: | ----------------------- | --------------------------------------------------------------------------- | | `--profile ` | Use a named profile for this invocation | | `--json` | Output raw JSON (machine-readable success; JSON error envelope on stderr) | +| `--no-agent` | Disable agent mode, including automatic detection | | `--agent` | Agent preset: structured JSON output, no pager, colors, or terminal styling | | `--debug`, `-d` | Enable debug output | | `--connect-timeout ` | HTTP connect timeout in seconds (default: `5`) | @@ -512,6 +513,14 @@ Stream endpoints (lakehouse query streams) treat `--read-timeout 0` as unlimited ## Scripting +Agent mode also activates automatically when stdout is piped, a nonempty agent marker is present (`AI_AGENT`, `AGENT`, `CLAUDECODE`, `CODEX_CI`, `CODEX_SANDBOX`, `CODEX_THREAD_ID`, `CURSOR_AGENT`, `GEMINI_CLI`, `COPILOT_AGENT`, `COPILOT_CLI`, or `COPILOT_AGENT_SESSION_ID`), and neither `--json` nor `--format` was supplied. It uses the existing structured JSON preset. Automatic detection leaves explicit formats alone. + +Use `--agent` to force the preset or `--no-agent` to disable it. `ALTERTABLE_AGENT_MODE=1` (or `true`) forces it on from the environment; `0` (or `false`) disables it. Flags take precedence over the environment, and `--no-agent` wins if both flags are supplied. Forcing agent mode retains the preset’s existing format restrictions, including incompatibility with API-native `--format` values. Commands such as `app dev` and `app build` stream subprocess output directly and do not support the JSON preset; use `--no-agent` when running them in a detected agent environment. They do not require an interactive terminal. For example: + +```sh +ALTERTABLE_AGENT_MODE=1 altertable profile show +``` + Use `--json` or `--agent` for machine-readable output. Command errors are JSON objects on stderr and leave stdout empty. Completed health checks are different: unhealthy `doctor` and `profile status` reports remain on stdout and exit `1` without an error envelope. ### Output tiers diff --git a/cli-reference.json b/cli-reference.json index ab0bcb64..1b349058 100644 --- a/cli-reference.json +++ b/cli-reference.json @@ -58,6 +58,16 @@ "scope": "global", "values": [] }, + { + "name": "no-agent", + "aliases": [], + "type": "boolean", + "description": "Disable agent mode, including automatic detection", + "required": false, + "repeatable": false, + "scope": "global", + "values": [] + }, { "name": "no-color", "aliases": [], diff --git a/cli/scripts/smoke-data-app.ts b/cli/scripts/smoke-data-app.ts index 9a01be56..293bd581 100644 --- a/cli/scripts/smoke-data-app.ts +++ b/cli/scripts/smoke-data-app.ts @@ -9,6 +9,8 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro let upstream: Bun.Server | undefined; const env = { ...process.env }; for (const key of Object.keys(env)) if (key.startsWith("ALTERTABLE_")) delete env[key]; + // App scripts stream their own output and require human mode. + env.ALTERTABLE_AGENT_MODE = "0"; env.ALTERTABLE_CONFIG_HOME = join(directory, "config"); async function run(arguments_: string[], cwd: string): Promise { const child = Bun.spawn(arguments_, { cwd, env, stdout: "pipe", stderr: "pipe" }); diff --git a/cli/scripts/smoke-npm-bundle.ts b/cli/scripts/smoke-npm-bundle.ts index 021ebb95..f3f3f3e6 100644 --- a/cli/scripts/smoke-npm-bundle.ts +++ b/cli/scripts/smoke-npm-bundle.ts @@ -34,7 +34,7 @@ export async function smokeNpmBundle(arguments_ = Bun.argv.slice(2)): Promise { expect(findNode(spec, "update")).toBeDefined(); expect(findNode(spec, "upgrade")).toBeDefined(); expect(spec.flags.some((flag) => flag.name === "agent")).toBe(true); + expect(spec.flags.some((flag) => flag.name === "no-agent")).toBe(true); expect(catalog).toBeDefined(); expect(findNode(spec, "catalogs")).toBeUndefined(); expect(api).toBeDefined(); diff --git a/cli/src/lib/env.ts b/cli/src/lib/env.ts index 96e54733..99407d5f 100644 --- a/cli/src/lib/env.ts +++ b/cli/src/lib/env.ts @@ -57,6 +57,7 @@ function invalidValue(name: string, value: string, expectation: string): Configu } const ENV_SCHEMA = { + ALTERTABLE_AGENT_MODE: { parse: booleanValue }, ALTERTABLE_ALLOW_INSECURE_HTTP: { parse: booleanValue }, ALTERTABLE_API_BASE: { parse: optional }, ALTERTABLE_API_KEY: { parse: optional, secret: true }, diff --git a/cli/src/lib/global-flags.test.ts b/cli/src/lib/global-flags.test.ts index 1452932b..5be94171 100644 --- a/cli/src/lib/global-flags.test.ts +++ b/cli/src/lib/global-flags.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import { isJsonOutput, setCliContext } from "@/context.ts"; -import { mergeGlobalFlagsFromArgs, parseGlobalOutputFlags } from "@/lib/global-flags.ts"; +import { + AGENT_ENV_VARIABLES, + mergeGlobalFlagsFromArgs, + parseGlobalOutputFlags, +} from "@/lib/global-flags.ts"; import { createCliRuntime, getOutputSink, @@ -30,13 +34,10 @@ describe("parseGlobalOutputFlags", () => { }); test("isolates non-throwing output flags for early error rendering", () => { - const context = parseGlobalOutputFlags([ - "query", - "SELECT 1", - "--connect-timeout", - "nope", - "--json", - ]); + const context = parseGlobalOutputFlags( + ["query", "SELECT 1", "--connect-timeout", "nope", "--json"], + { env: {}, stdoutIsTTY: false }, + ); expect(context).toEqual({ debug: false, @@ -93,3 +94,54 @@ describe("agent output preset", () => { refreshCliRuntimeContext({ debug: false, json: false, agent: false }); }); }); + +describe("automatic agent mode", () => { + for (const marker of AGENT_ENV_VARIABLES) { + test(`detects ${marker} only outside a terminal`, () => { + const env = { [marker]: "1" }; + expect(parseGlobalOutputFlags([], { env, stdoutIsTTY: false }).agent).toBe(true); + expect(parseGlobalOutputFlags([], { env, stdoutIsTTY: true }).agent).toBe(false); + }); + } + test("leaves ordinary pipes and empty markers alone", () => { + expect(parseGlobalOutputFlags([], { env: {}, stdoutIsTTY: false }).agent).toBe(false); + expect(parseGlobalOutputFlags([], { env: { AI_AGENT: "" }, stdoutIsTTY: false }).agent).toBe( + false, + ); + }); + test("preserves explicit output formats", () => { + for (const flags of [["--json"], ["--format", "csv"], ["--format=markdown"]]) { + expect( + parseGlobalOutputFlags(flags, { env: { AI_AGENT: "1" }, stdoutIsTTY: false }).agent, + ).toBe(false); + } + }); + test("applies flags before environment overrides and detection", () => { + for (const value of ["1", "true", "0", "false"]) { + const environment = { + env: { AI_AGENT: "1", ALTERTABLE_AGENT_MODE: value }, + stdoutIsTTY: true, + }; + expect(parseGlobalOutputFlags([], environment).agent).toBe(value === "1" || value === "true"); + expect(parseGlobalOutputFlags(["--agent"], environment).agent).toBe(true); + expect(parseGlobalOutputFlags(["--agent", "--no-agent"], environment).agent).toBe(false); + } + }); + test("ignores flags after the option separator", () => { + expect( + parseGlobalOutputFlags(["--", "--no-agent"], { env: { AI_AGENT: "1" }, stdoutIsTTY: false }) + .agent, + ).toBe(true); + }); + test("preserves detection when merging parsed flags", () => { + expect(mergeGlobalFlagsFromArgs({ debug: false, json: false, agent: true }, {}).agent).toBe( + true, + ); + expect( + mergeGlobalFlagsFromArgs( + { debug: false, json: false, agent: true }, + { agent: true, "no-agent": true }, + ).agent, + ).toBe(false); + }); +}); diff --git a/cli/src/lib/global-flags.ts b/cli/src/lib/global-flags.ts index 65046f40..6941b94b 100644 --- a/cli/src/lib/global-flags.ts +++ b/cli/src/lib/global-flags.ts @@ -1,14 +1,56 @@ +import { copyProcessEnv } from "@/lib/env.ts"; import type { CliContext } from "@/context.ts"; import { asCliArgString } from "@/lib/cli-args.ts"; import { parseTimeoutSeconds } from "@/lib/timeout-args.ts"; -export function parseGlobalOutputFlags(argv: readonly string[]): CliContext { +export const AGENT_ENV_VARIABLES = [ + "AI_AGENT", + "AGENT", + "CLAUDECODE", + "CODEX_CI", + "CODEX_SANDBOX", + "CODEX_THREAD_ID", + "CURSOR_AGENT", + "GEMINI_CLI", + "COPILOT_AGENT", + "COPILOT_CLI", + "COPILOT_AGENT_SESSION_ID", +] as const; + +type AgentEnvironment = { + env: Readonly>; + stdoutIsTTY: boolean; +}; + +function resolveAgentMode( + argv: readonly string[], + { env, stdoutIsTTY }: AgentEnvironment, +): boolean { + if (argv.includes("--no-agent")) return false; + if (argv.includes("--agent")) return true; + // Keep early output selection non-throwing; validateEnvironment reports invalid values later. + const override = env.ALTERTABLE_AGENT_MODE?.toLowerCase(); + if (override === "1" || override === "true") return true; + if (override === "0" || override === "false") return false; + const explicitFormat = argv.some( + (arg) => arg === "--json" || arg === "--format" || arg.startsWith("--format="), + ); + return !stdoutIsTTY && !explicitFormat && AGENT_ENV_VARIABLES.some((name) => Boolean(env[name])); +} + +export function parseGlobalOutputFlags( + argv: readonly string[], + environment: AgentEnvironment = { + env: copyProcessEnv(), + stdoutIsTTY: Boolean(process.stdout.isTTY), + }, +): CliContext { const separatorIndex = argv.indexOf("--"); const globalArgs = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex); return { debug: globalArgs.includes("--debug") || globalArgs.includes("-d"), json: globalArgs.includes("--json"), - agent: globalArgs.includes("--agent"), + agent: resolveAgentMode(globalArgs, environment), noColor: globalArgs.includes("--no-color"), }; } @@ -21,6 +63,7 @@ export function mergeGlobalFlagsFromArgs( if (args.debug !== undefined) merged.debug = Boolean(args.debug); if (args.json !== undefined) merged.json = Boolean(args.json); if (args.agent !== undefined) merged.agent = Boolean(args.agent); + if (args["no-agent"]) merged.agent = false; if (args["no-color"] !== undefined) merged.noColor = Boolean(args["no-color"]); const profile = asCliArgString(args.profile); diff --git a/cli/src/lib/usage.test.ts b/cli/src/lib/usage.test.ts index 6596185a..94593ca3 100644 --- a/cli/src/lib/usage.test.ts +++ b/cli/src/lib/usage.test.ts @@ -244,6 +244,7 @@ describe("renderAltertableUsage", () => { "debug", "json", "agent", + "no-agent", "no-color", "profile", "connect-timeout", diff --git a/tests/agent-mode.test.ts b/tests/agent-mode.test.ts new file mode 100644 index 00000000..ba856438 --- /dev/null +++ b/tests/agent-mode.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { createTestWorkspace, type TestWorkspace } from "./helpers.ts"; + +describe("automatic agent mode", () => { + let workspace: TestWorkspace; + + beforeAll(async () => { + workspace = await createTestWorkspace({ + ALTERTABLE_AGENT_MODE: undefined, + AI_AGENT: "1", + }); + }); + + test("detects an agent for structured help and profile output", async () => { + const help = await workspace.runCommand("altertable --help"); + expect(help.exitCode).toBe(0); + expect(JSON.parse(help.stdout)).toBeObject(); + const profile = await workspace.runCommand("altertable profile show"); + expect(profile.exitCode).toBe(0); + expect(JSON.parse(profile.stdout).profile.name).toBe("default"); + expect(profile.stderr).toBe(""); + }); + + test("uses JSON for parse errors before a command executes", async () => { + const result = await workspace.runCommand("altertable unknown-command"); + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error).toBeDefined(); + }); + + test("keeps explicit query formats instead of forcing JSON", async () => { + for (const format of ["csv", "markdown"]) { + const body = format === "csv" + ? "id\n1\n" + : ['{"statement":"SELECT 1"}', '["id"]', '[1]'].join("\n"); + await workspace.setupMockHttp([{ method: "POST", urlPattern: "/query", body }]); + const result = await workspace.runCommand(`altertable query 'SELECT 1' --format=${format}`, { + env: { + ALTERTABLE_API_BASE: "https://lakehouse.example.test", + ALTERTABLE_LAKEHOUSE_USERNAME: "testuser", + ALTERTABLE_LAKEHOUSE_PASSWORD: "testpass", + }, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + if (format === "csv") expect(result.stdout).toBe(body); + else expect(result.stdout).toContain("| id |"); + } + }); + + test("explicit opt-out restores human output and preserves --json", async () => { + const result = await workspace.runCommand("altertable profile show --no-agent"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Profile"); + const json = await workspace.runCommand("altertable --agent --no-agent --json profile show"); + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout).profile.name).toBe("default"); + }); + + test("environment overrides and CLI flags take precedence", async () => { + const disabled = await workspace.runCommand("altertable profile show", { + env: { ALTERTABLE_AGENT_MODE: "0" }, + }); + expect(disabled.stdout).toContain("Profile"); + const forced = await workspace.runCommand("altertable --agent profile show", { + env: { ALTERTABLE_AGENT_MODE: "0" }, + }); + expect(JSON.parse(forced.stdout).profile.name).toBe("default"); + }); + + test("validates the environment override inside the JSON error boundary", async () => { + const result = await workspace.runCommand("altertable --json profile show", { + env: { ALTERTABLE_AGENT_MODE: "invalid" }, + }); + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stderr).error).toBeDefined(); + expect(result.stderr).toContain("ALTERTABLE_AGENT_MODE"); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index f4b18696..f3384287 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -9,6 +9,8 @@ const repoRoot = dirname(testsDir); type EnvValue = string | undefined; type TestEnvName = + | "ALTERTABLE_AGENT_MODE" + | "AI_AGENT" | "ALTERTABLE_API_BASE" | "ALTERTABLE_API_KEY" | "ALTERTABLE_BASIC_AUTH_TOKEN" @@ -225,6 +227,8 @@ function buildEnv(overrides: TestEnv = {}): Record { // Keep CLI output deterministic regardless of the developer's terminal: the CLI // colorizes arg-validation errors unless NO_COLOR/CI is set (CI has it, local shells don't). env.NO_COLOR = "1"; + // Human-output tests must not inherit the agent running this suite. + env.ALTERTABLE_AGENT_MODE = "0"; for (const [key, value] of Object.entries(overrides)) { if (value === undefined) { delete env[key];