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
1 change: 1 addition & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PROFILE>` | Use a named profile for this command only |
| `--connect-timeout <CONNECT-TIMEOUT>` | HTTP connect timeout in seconds (default 5) |
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ These flags apply to every command and may be placed before or after commands:
| ----------------------- | --------------------------------------------------------------------------- |
| `--profile <name>` | 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 <s>` | HTTP connect timeout in seconds (default: `5`) |
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cli-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
2 changes: 2 additions & 0 deletions cli/scripts/smoke-data-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export async function smokeDataApp(command: string[], scaffoldOnly = false): Pro
let upstream: Bun.Server<undefined> | 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<void> {
const child = Bun.spawn(arguments_, { cwd, env, stdout: "pipe", stderr: "pipe" });
Expand Down
2 changes: 1 addition & 1 deletion cli/scripts/smoke-npm-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function smokeNpmBundle(arguments_ = Bun.argv.slice(2)): Promise<vo
throw new Error(`npm bundle returned an invalid version: ${version}.`);
}

const help = await runBundle(bundlePath, ["--help"]);
const help = await runBundle(bundlePath, ["--no-agent", "--help"]);
if (!help.includes("Altertable CLI") || !help.includes("Commands")) {
throw new Error("npm bundle help output is incomplete.");
}
Expand Down
5 changes: 5 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ const ROOT_ARGS = defineArguments({
description: "Agent-friendly preset: structured JSON output, no pager or terminal styling",
flagScope: "global",
},
"no-agent": {
type: "boolean",
description: "Disable agent mode, including automatic detection",
flagScope: "global",
},
"no-color": {
type: "boolean",
description: "Disable terminal colors and styling",
Expand Down
1 change: 1 addition & 0 deletions cli/src/commands/completion/lib/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ describe("buildCompletionSpec", () => {
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();
Expand Down
1 change: 1 addition & 0 deletions cli/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
68 changes: 60 additions & 8 deletions cli/src/lib/global-flags.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
47 changes: 45 additions & 2 deletions cli/src/lib/global-flags.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>;
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"),
};
}
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions cli/src/lib/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ describe("renderAltertableUsage", () => {
"debug",
"json",
"agent",
"no-agent",
"no-color",
"profile",
"connect-timeout",
Expand Down
79 changes: 79 additions & 0 deletions tests/agent-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
4 changes: 4 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -225,6 +227,8 @@ function buildEnv(overrides: TestEnv = {}): Record<string, string> {
// 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];
Expand Down
Loading