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];