diff --git a/TELEMETRY.md b/TELEMETRY.md index c3f56eb..a98f672 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -10,7 +10,7 @@ Telemetry data is **pseudonymous, not anonymous**. Events include a randomly gen On your first CLI command, a telemetry notice is displayed and **no telemetry is collected**. -Telemetry starts from your next command, giving you an opportunity to opt out before any event is recorded. +Telemetry starts from your next command, giving you an opportunity to opt out before any event is recorded. That first recorded event carries the date the notice was shown (`first_run`) and is sent straight away, so Chargebee can count new installs; later events are sent in batches. ## Commands @@ -25,7 +25,9 @@ chargebee telemetry status --pending # View events waiting to be sent ## What is Collected -The CLI normally records **one event per command**. `chargebee listen` may record multiple lifecycle events while it is running. +The CLI normally records **one event per command**, including `--help` and `--version`. `chargebee listen` may record multiple lifecycle events while it is running. + +Events are queued locally and sent in the background, in batches of five or once the oldest queued event is five minutes old, checked when a command finishes. An install's first recorded event is sent immediately. Telemetry is sent to a Chargebee owned endpoint. The destination cannot be changed through CLI configuration or environment variables. @@ -48,6 +50,9 @@ A telemetry request has the following structure: "os": "darwin", "arch": "arm64", "rt": "node", + "rtv": "22.12", + "tty": "true", + "im": "npm", "ci": "false" } } @@ -68,15 +73,19 @@ A telemetry request has the following structure: | `os` | Always | Operating system, such as `darwin`. | | `arch` | Always | System architecture, such as `arm64`. | | `rt` | Always | Runtime used by the CLI, such as `node` or `bun`. | +| `rtv` | Always | Runtime version as major.minor, such as `22.12`. | +| `tty` | Always | `true` when both input and output are a terminal; `false` for pipes, scripts and most agents. | +| `im` | When detected | How the CLI was installed: `npm`, `pnpm`, `yarn`, `bun-global`, `github` (installer or release binary) or `source`. | | `status` | Always | Whether the command completed with `ok` or `error`. | | `ci` | Always | `false` for recorded events because telemetry is disabled in CI. | -| `flags` | When flags are used | Flag names only, such as `data,fields`. **Flag values are never collected.** | +| `flags` | When flags are used | Flag names only, such as `data,fields`. **Flag values are never collected.** `--help` and `--version` are recorded as `help` and `version`. | | `dur_ms` | Non-interactive commands | Command duration in milliseconds. Omitted for interactive commands and `listen`. | | `err_type` | Errors | Coarse error category such as `api_404` or `cli_error`. Error messages are never collected. | | `pcv` | When available | Product Catalog version (`v1` or `v2`) of the configured site. | | `code_lang` | Code generation | Generated code language, such as `go` or `js`. | -| `agent` | When detected | Supported agent runtime, such as `cursor` or `claude-code`. | +| `agent` | When detected | Supported agent runtime: `claude-code`, `cursor`, `codex` (sandboxed runs) or `gemini-cli`, detected from environment variables those tools set. | | `listen_phase` | `chargebee listen` | Tunnel lifecycle state: `established`, `closed`, or `error`. | +| `first_run` | First recorded event only | Date (`YYYY-MM-DD`) the first-run notice was shown. | ## What is Never Collected @@ -92,6 +101,8 @@ Chargebee CLI telemetry does **not** collect: The command itself and flag **names** may be collected, but their values are never included. +Like any HTTPS request, telemetry requests are handled by network infrastructure whose request logs record the connecting IP address and user agent for security and operations. The CLI does not add the IP address or user agent to telemetry events. + ## Environment Variables and CI Telemetry is automatically disabled when: diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts index ac35207..9b03b8a 100644 --- a/src/commands/telemetry.ts +++ b/src/commands/telemetry.ts @@ -100,7 +100,7 @@ function showStatus(): void { } } humanLog(); - humanLog(" Sent : command name, flag names, outcome, CLI version, OS/arch, the install id above, and your site name."); + humanLog(" Sent : command name, flag names, outcome, CLI version and install method, OS/arch and runtime, the install id above, and your site name."); humanLog(" Never sent : argument values, API keys, customer data or webhook payloads."); humanLog(" Learn more : https://github.com/chargebee/cli/blob/main/TELEMETRY.md"); humanLog(disabled ? " Enable with : chargebee telemetry enable" : " Disable with: chargebee telemetry disable"); diff --git a/src/lib/telemetry/index.ts b/src/lib/telemetry/index.ts index 406287b..ea31bd4 100644 --- a/src/lib/telemetry/index.ts +++ b/src/lib/telemetry/index.ts @@ -22,7 +22,8 @@ import { wasInteractive } from "./interactive.js"; import { isTelemetryDisabled, maybeShowFirstRunNotice } from "./optout.js"; import { getVisitorId } from "./identity.js"; import { appendRecord, spoolStats } from "./spool.js"; -import { readState } from "./state.js"; +import { readState, writeState } from "./state.js"; +import { detectInstallMethod } from "../update/index.js"; import { knownCommandPath } from "./command-path.js"; import type { SpoolRecord } from "./types.js"; @@ -89,26 +90,57 @@ interface PendingInvocation { siteName: string; pcv?: string; env: string; + installMethod?: string; } let pending: PendingInvocation | null = null; -function ensurePendingForUsage(program: Command): void { +/** Install method for the event; never lets detection break telemetry. */ +function safeInstallMethod(): string | undefined { + try { + return detectInstallMethod(); + } catch { + return undefined; + } +} + +/** + * Seed an event for an invocation that never reaches preAction: a parse failure + * (`usage`), or `--help` / `--version`, which Commander handles itself. + */ +function ensurePendingWithoutAction(program: Command, fallbackName: string, flagNames: string[]): void { if (pending || isTelemetryDisabled()) return; - // Parse failures skip preAction, so the notice may not have been printed yet. + // These paths skip preAction, so the notice may not have been printed yet. // Nothing is recorded until the user has seen the notice at least once. if (!readState().notice_shown) return; const command = knownCommandPath(program, process.argv.slice(2)); if (command === FLUSH_COMMAND) return; pending = { - command: command || "unknown", - flagNames: [], + command: command || fallbackName, + flagNames, visitorId: getVisitorId(), siteName: "unconfigured", env: "production", + installMethod: safeInstallMethod(), }; } +/** + * The notice date to stamp on this install's first recorded event, or undefined. + * Installs that saw the notice before `notice_shown_at` existed have no date and + * are never reported as new. + */ +function pendingFirstRun(): string | undefined { + const state = readState(); + if (state.first_event_recorded || !state.notice_shown_at) return undefined; + return state.notice_shown_at; +} + +/** Mark the activation event as recorded (also for upgraded installs, so the check stays cheap). */ +function markFirstEventRecorded(): void { + if (!readState().first_event_recorded) writeState({ first_event_recorded: true }); +} + /** Build the dotted command path (e.g. "addon create"), excluding the root program. */ function commandPath(cmd: Command): string { const names: string[] = []; @@ -183,6 +215,7 @@ async function begin(actionCommand: Command): Promise { siteName: siteName || "unconfigured", pcv, env: telemetryEnvLabel(host), + installMethod: safeInstallMethod(), }; } catch { // telemetry must never break a command @@ -207,6 +240,8 @@ export function emitListenPhase(phase: ListenPhase, errorType?: string): void { phase === "error" ? (errorType ?? "listen_connect_error") : undefined, productCatalogVersion: pending.pcv, listenPhase: phase, + installMethod: pending.installMethod, + firstRun: pendingFirstRun(), }); appendRecord({ @@ -220,6 +255,7 @@ export function emitListenPhase(phase: ListenPhase, errorType?: string): void { metadata, }, }); + markFirstEventRecorded(); spawnFlush(); } catch { // telemetry must never break listen @@ -238,6 +274,7 @@ function finalize(exitCode: number): void { const status: "ok" | "error" = exitCode === 0 ? "ok" : "error"; const errorType = status === "error" ? takeTelemetryError() ?? "nonzero_exit" : undefined; const skipDuration = wasInteractive() || pending.command === "listen"; + const firstRun = pendingFirstRun(); const metadata = buildMetadata({ flagNames: pending.flagNames, @@ -248,6 +285,8 @@ function finalize(exitCode: number): void { errorType, productCatalogVersion: pending.pcv, generatedResource: pending.generatedResource, + installMethod: pending.installMethod, + firstRun, }); const record: SpoolRecord = { @@ -259,9 +298,12 @@ function finalize(exitCode: number): void { }; appendRecord(record); + markFirstEventRecorded(); + // Send a new install's first event right away: otherwise someone who tries the + // CLI once or twice never reaches BATCH_SIZE and is never seen. const { count, oldestAgeMs } = spoolStats(); - if (count >= BATCH_SIZE || oldestAgeMs >= MAX_AGE_MS) spawnFlush(); + if (firstRun || count >= BATCH_SIZE || oldestAgeMs >= MAX_AGE_MS) spawnFlush(); } catch { // swallow — never block process exit } @@ -309,20 +351,31 @@ export function installTelemetry(program: Command, version: string): void { await begin(actionCommand as Command); }); - // Commander prints the usage error, then calls this instead of process.exit. - // Parse-time failures (unknown command, missing required arg) skip preAction, - // so seed a pending event from registered command names only. - program.exitOverride((err: CommanderError) => { + // Commander prints the usage error (or help / version), then calls this instead of + // process.exit. These paths skip preAction, so seed a pending event from + // registered command names only. Installed on every command: Commander raises a + // subcommand's parse error or `--help` through that subcommand's own callback, + // and `configureJsonOutput` has already given each one an override. + const onCommanderExit = (err: CommanderError): never => { try { if (err.exitCode !== 0) { recordTelemetryError("usage"); - ensurePendingForUsage(program); + ensurePendingWithoutAction(program, "unknown", []); + } else if (err.code === "commander.helpDisplayed" || err.code === "commander.help") { + ensurePendingWithoutAction(program, "help", ["help"]); + } else if (err.code === "commander.version") { + ensurePendingWithoutAction(program, "version", ["version"]); } } catch { // telemetry must never break commander } - exitCommand(err.exitCode, err.code); - }); + return exitCommand(err.exitCode, err.code); + }; + const overrideAll = (cmd: Command): void => { + cmd.exitOverride(onCommanderExit); + for (const sub of cmd.commands) overrideAll(sub); + }; + overrideAll(program); if (exitHandler) process.removeListener("exit", exitHandler); exitHandler = onProcessExit; diff --git a/src/lib/telemetry/metadata.ts b/src/lib/telemetry/metadata.ts index 5ddcc60..f21121d 100644 --- a/src/lib/telemetry/metadata.ts +++ b/src/lib/telemetry/metadata.ts @@ -15,6 +15,10 @@ export interface MetadataInput { productCatalogVersion?: string; generatedResource?: string; listenPhase?: "established" | "closed" | "error"; + /** How this copy was installed (npm, github, pnpm, ...), from the update module. */ + installMethod?: string; + /** Date (YYYY-MM-DD) the first-run notice was shown; set only on an install's first event. */ + firstRun?: string; } const MAX_VALUE_LEN = 1024; @@ -72,15 +76,34 @@ export function detectIsCi(): boolean { /** * Best-effort detection of the AI agent driving the CLI, if any. * - * Not User-Agent regexes — these are env vars the agent runtimes set. Order - * matters (first match wins): Claude Code, then Cursor. Closed set on purpose. + * Not User-Agent regexes — these are env vars the agent runtimes set for the + * commands they spawn. Order matters (first match wins). Closed set on purpose: + * only agents with a documented marker are listed. Codex sets its markers only + * when it sandboxes the command, so unsandboxed Codex runs are not detected. */ export function detectAiAgent(): string | undefined { if (process.env.CLAUDECODE || process.env.CLAUDE_CODE) return "claude-code"; if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_AGENT) return "cursor"; + if (process.env.CODEX_SANDBOX || process.env.CODEX_SANDBOX_NETWORK_DISABLED) return "codex"; + if (process.env.GEMINI_CLI) return "gemini-cli"; return undefined; } +/** Runtime version as major.minor (Bun wins over the Node compatibility version it reports). */ +export function runtimeVersion(versions: { node?: string; bun?: string } = process.versions): string { + const v = versions.bun ?? versions.node; + const m = v ? /^(\d+)\.(\d+)/.exec(v) : null; + return m ? `${m[1]}.${m[2]}` : "unknown"; +} + +/** Whether a person is at a terminal: both stdin and stdout are TTYs (false for pipes, scripts, most agents). */ +export function isTerminal( + stdin: { isTTY?: boolean } = process.stdin, + stdout: { isTTY?: boolean } = process.stdout, +): boolean { + return stdin.isTTY === true && stdout.isTTY === true; +} + function detectRuntime(): string { return (process as unknown as { versions?: { bun?: string } }).versions?.bun ? "bun" : "node"; } @@ -95,6 +118,8 @@ export function buildMetadata(input: MetadataInput): Record { os: process.platform, arch: process.arch, rt: detectRuntime(), + rtv: runtimeVersion(), + tty: String(isTerminal()), status: input.status, ci: String(detectIsCi()), }; @@ -105,6 +130,8 @@ export function buildMetadata(input: MetadataInput): Record { if (input.productCatalogVersion) meta.pcv = input.productCatalogVersion; if (input.generatedResource) meta.code_lang = clip(input.generatedResource); if (input.listenPhase) meta.listen_phase = input.listenPhase; + if (input.installMethod) meta.im = input.installMethod; + if (input.firstRun) meta.first_run = input.firstRun; const agent = detectAiAgent(); if (agent) meta.agent = agent; diff --git a/src/lib/telemetry/optout.ts b/src/lib/telemetry/optout.ts index 9b0cdb5..9299f56 100644 --- a/src/lib/telemetry/optout.ts +++ b/src/lib/telemetry/optout.ts @@ -82,7 +82,7 @@ export function setTelemetryEnabled(enabled: boolean): boolean { const NOTICE = [ "", " Chargebee CLI sends usage data to Chargebee: command name, flag names, exit status, error category,", - " CLI version, OS/arch, a random install id, and the Chargebee site name of the active profile.", + " CLI version and install method, OS/arch and runtime, a random install id, and the Chargebee site name of the active profile.", " Never sent: argument values, API keys, customer data or webhook payloads. This run was not recorded.", " Opt out anytime: chargebee telemetry disable | https://github.com/chargebee/cli/blob/main/TELEMETRY.md", "", @@ -104,6 +104,6 @@ export function maybeShowFirstRunNotice(): boolean { } catch { // ignore } - writeState({ notice_shown: true }); + writeState({ notice_shown: true, notice_shown_at: new Date().toISOString().slice(0, 10) }); return true; } diff --git a/src/lib/telemetry/state.ts b/src/lib/telemetry/state.ts index 8d03530..dcd8b9c 100644 --- a/src/lib/telemetry/state.ts +++ b/src/lib/telemetry/state.ts @@ -19,6 +19,13 @@ export interface TelemetryState { enabled: boolean; /** Whether the one-time first-run notice has been shown. */ notice_shown: boolean; + /** + * UTC date (YYYY-MM-DD) the notice was shown. Empty for installs that saw the + * notice before this field existed, so they are never reported as new. + */ + notice_shown_at: string; + /** Whether this install has recorded its first event (the activation event). */ + first_event_recorded: boolean; /** Consecutive flush runs that ended with at least one failed batch. */ consecutive_flush_failures: number; /** Epoch ms before which `spawnFlush` skips spawning a new flush child. */ @@ -29,6 +36,8 @@ const DEFAULT_STATE: TelemetryState = { anonymous_id: "", enabled: true, notice_shown: false, + notice_shown_at: "", + first_event_recorded: false, consecutive_flush_failures: 0, next_flush_attempt_at: 0, }; @@ -43,6 +52,11 @@ export function readState(): TelemetryState { typeof parsed.anonymous_id === "string" ? parsed.anonymous_id : "", enabled: parsed.enabled !== false, notice_shown: parsed.notice_shown === true, + notice_shown_at: + typeof parsed.notice_shown_at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.notice_shown_at) + ? parsed.notice_shown_at + : "", + first_event_recorded: parsed.first_event_recorded === true, consecutive_flush_failures: typeof parsed.consecutive_flush_failures === "number" ? parsed.consecutive_flush_failures : 0, next_flush_attempt_at: diff --git a/src/tests/unit/lib/telemetry/index.test.ts b/src/tests/unit/lib/telemetry/index.test.ts index 3e4917b..d2fe77b 100644 --- a/src/tests/unit/lib/telemetry/index.test.ts +++ b/src/tests/unit/lib/telemetry/index.test.ts @@ -24,6 +24,7 @@ import { __resetKeychainForTest, __setKeychainForTest, type KeychainStore } from import { saveProfile } from "../../../../lib/config/profiles.js"; import { writeConfig } from "../../../../lib/config/store.js"; import { __resetRuntimeState } from "../../../../lib/api/sdk.js"; +import { __setInstallMethodForTest } from "../../../../lib/update/index.js"; function sample(over: Partial = {}): SpoolRecord { return { @@ -310,6 +311,165 @@ describe("installTelemetry recorder", () => { }); }); + describe("activation (first recorded event)", () => { + function pingProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.command("ping").action(() => undefined); + installTelemetry(program, "1.0.0-test"); + return program; + } + + function spooled(): SpoolRecord[] { + return readFileSync(spoolPath(), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as SpoolRecord); + } + + it("stamps the notice date on a new install's first event and flushes it at once", async () => { + writeState({ notice_shown: false }); + const quiet = process.stderr.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + try { + await pingProgram().parseAsync(["ping"], { from: "user" }); // notice run, records nothing + __finalizeForTest(0); + } finally { + process.stderr.write = quiet; + } + const noticeDate = readState().notice_shown_at; + expect(noticeDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(spawns).toHaveLength(0); + + __resetTelemetryRecorderForTest(); + await pingProgram().parseAsync(["ping"], { from: "user" }); + __finalizeForTest(0); + expect(spooled()[0].event.metadata.first_run).toBe(noticeDate); + expect(spawns).toHaveLength(1); // below BATCH_SIZE, flushed anyway + expect(readState().first_event_recorded).toBe(true); + + __resetTelemetryRecorderForTest(); + spawns = []; + await pingProgram().parseAsync(["ping"], { from: "user" }); + __finalizeForTest(0); + expect("first_run" in spooled()[1].event.metadata).toBe(false); + expect(spawns).toHaveLength(0); + }); + + it("does not treat an upgraded install (no notice date) as new", async () => { + // beforeEach: notice_shown true, no notice_shown_at, like installs from older versions. + await pingProgram().parseAsync(["ping"], { from: "user" }); + __finalizeForTest(0); + expect("first_run" in spooled()[0].event.metadata).toBe(false); + expect(spawns).toHaveLength(0); + expect(readState().first_event_recorded).toBe(true); + }); + + it("stamps first_run on a listen lifecycle event when listen is the first command", async () => { + writeState({ notice_shown: true, notice_shown_at: "2026-10-01" }); + const program = new Command(); + program.exitOverride(); + program.command("listen").action(() => { + emitListenPhase("established"); + emitListenPhase("closed"); + }); + installTelemetry(program, "1.0.0-test"); + await program.parseAsync(["listen"], { from: "user" }); + __finalizeForTest(0); + const recs = spooled(); + expect(recs[0].event.metadata.first_run).toBe("2026-10-01"); + expect("first_run" in recs[1].event.metadata).toBe(false); + }); + + it("records the install method", async () => { + __setInstallMethodForTest("npm"); + try { + await pingProgram().parseAsync(["ping"], { from: "user" }); + __finalizeForTest(0); + } finally { + __setInstallMethodForTest(null); + } + expect(spooled()[0].event.metadata.im).toBe("npm"); + }); + }); + + describe("help and version", () => { + function program(): Command { + const p = new Command(); + p.name("chargebee").version("1.0.0-test", "-v, --version"); + p.command("customer").command("list").action(() => undefined); + installTelemetry(p, "1.0.0-test"); + return p; + } + + async function run(argv: string[]): Promise { + const out = process.stdout.write; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.argv = ["bun", "chargebee", ...argv]; + try { + await program().parseAsync(argv, { from: "user" }); + } catch { + // process.exit stub throws + } finally { + process.stdout.write = out; + } + __finalizeForTest(0); + return JSON.parse(readFileSync(spoolPath(), "utf-8").trim()) as SpoolRecord; + } + + it("records root --help as an ok help event", async () => { + const rec = await run(["--help"]); + expect(rec.event.name).toBe("help"); + expect(rec.event.metadata.status).toBe("ok"); + expect(rec.event.metadata.flags).toBe("help"); + }); + + it("records subcommand --help under the command path", async () => { + const rec = await run(["customer", "list", "--help"]); + expect(rec.event.name).toBe("customer list"); + expect(rec.event.metadata.flags).toBe("help"); + }); + + it("records --version", async () => { + const rec = await run(["--version"]); + expect(rec.event.name).toBe("version"); + expect(rec.event.metadata.status).toBe("ok"); + expect(rec.event.metadata.flags).toBe("version"); + }); + + it("records a subcommand usage error (unknown option) under the command path", async () => { + const err = process.stderr.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ["bun", "chargebee", "customer", "list", "--bogus"]; + try { + await program().parseAsync(["customer", "list", "--bogus"], { from: "user" }); + } catch { + // process.exit stub throws + } finally { + process.stderr.write = err; + } + __finalizeForTest(1); + const rec = JSON.parse(readFileSync(spoolPath(), "utf-8").trim()) as SpoolRecord; + expect(rec.event.name).toBe("customer list"); + expect(rec.event.metadata.err_type).toBe("usage"); + }); + + it("records nothing for help before the notice has been shown", async () => { + writeState({ notice_shown: false }); + const out = process.stdout.write; + process.stdout.write = (() => true) as typeof process.stdout.write; + try { + await program().parseAsync(["--help"], { from: "user" }); + } catch { + // process.exit stub throws + } finally { + process.stdout.write = out; + } + __finalizeForTest(0); + expect(() => readFileSync(spoolPath(), "utf-8")).toThrow(); + }); + }); + describe("flush child launch modes (#113)", () => { /** Seed BATCH_SIZE-1 records, run one command, finalize: exactly one spawnFlush(). */ async function triggerFlush(): Promise { diff --git a/src/tests/unit/lib/telemetry/metadata.test.ts b/src/tests/unit/lib/telemetry/metadata.test.ts index e800e25..6622a30 100644 --- a/src/tests/unit/lib/telemetry/metadata.test.ts +++ b/src/tests/unit/lib/telemetry/metadata.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { buildMetadata, detectAiAgent, normalizeGeneratedResource } from "../../../../lib/telemetry/metadata.js"; +import { + buildMetadata, + detectAiAgent, + isTerminal, + normalizeGeneratedResource, + runtimeVersion, +} from "../../../../lib/telemetry/metadata.js"; describe("buildMetadata duration", () => { it("includes dur_ms when provided", () => { @@ -73,6 +79,43 @@ describe("buildMetadata allow-list", () => { }); }); +describe("buildMetadata runtime context", () => { + it("emits the runtime major.minor as rtv", () => { + const meta = buildMetadata({ flagNames: [], status: "ok" }); + expect(meta.rtv).toMatch(/^\d+\.\d+$/); + }); + + it("emits tty as a boolean string", () => { + const meta = buildMetadata({ flagNames: [], status: "ok" }); + expect(meta.tty).toMatch(/^(true|false)$/); + }); + + it("emits im and first_run only when provided", () => { + const bare = buildMetadata({ flagNames: [], status: "ok" }); + expect("im" in bare).toBe(false); + expect("first_run" in bare).toBe(false); + const meta = buildMetadata({ flagNames: [], status: "ok", installMethod: "npm", firstRun: "2026-10-07" }); + expect(meta.im).toBe("npm"); + expect(meta.first_run).toBe("2026-10-07"); + }); +}); + +describe("runtimeVersion", () => { + it("reduces a version to major.minor", () => { + expect(runtimeVersion({ node: "22.12.0" })).toBe("22.12"); + expect(runtimeVersion({ node: "24.1.3", bun: "1.3.14" })).toBe("1.3"); + expect(runtimeVersion({})).toBe("unknown"); + }); +}); + +describe("isTerminal", () => { + it("is true only when both stdin and stdout are TTYs", () => { + expect(isTerminal({ isTTY: true }, { isTTY: true })).toBe(true); + expect(isTerminal({ isTTY: false }, { isTTY: true })).toBe(false); + expect(isTerminal({}, { isTTY: true })).toBe(false); + }); +}); + describe("normalizeGeneratedResource", () => { it("accepts known languages, lower-cased and trimmed", () => { expect(normalizeGeneratedResource("curl")).toBe("curl"); @@ -107,6 +150,9 @@ describe("detectAiAgent", () => { "CURSOR_AGENT", "AIDER_VERSION", "GITHUB_COPILOT_CLI", + "CODEX_SANDBOX", + "CODEX_SANDBOX_NETWORK_DISABLED", + "GEMINI_CLI", ]; const prev: Record = {}; @@ -134,6 +180,19 @@ describe("detectAiAgent", () => { expect(detectAiAgent()).toBe("cursor"); }); + it("returns codex for either Codex sandbox marker", () => { + process.env.CODEX_SANDBOX = "seatbelt"; + expect(detectAiAgent()).toBe("codex"); + delete process.env.CODEX_SANDBOX; + process.env.CODEX_SANDBOX_NETWORK_DISABLED = "1"; + expect(detectAiAgent()).toBe("codex"); + }); + + it("returns gemini-cli for GEMINI_CLI", () => { + process.env.GEMINI_CLI = "1"; + expect(detectAiAgent()).toBe("gemini-cli"); + }); + it("ignores Aider and Copilot", () => { process.env.AIDER_VERSION = "0.1"; process.env.GITHUB_COPILOT_CLI = "1";