From f2ab4fcaeaafe69eb7a2fe313d5a4f13279f2997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 9 Sep 2026 19:02:29 +0200 Subject: [PATCH] feat(telemetry): refresh coding agent detection --- packages/cli/src/lib/detect-agent.ts | 34 +++++- packages/cli/test/lib/detect-agent.test.ts | 107 +++++++++++++++++- .../cli/test/lib/init/wizard-runner.test.ts | 1 + 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index 63d432ca44..b99b5a35b3 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -4,7 +4,8 @@ * * Detection uses two strategies: * 1. **Environment variables** (sync) — agents inject these into child - * processes. Adapted from Vercel's @vercel/detect-agent (Apache-2.0). + * processes. Selected rules adapted from Vercel's detect-agent v1.2.0 + * (Apache-2.0): https://github.com/vercel/detect-agent/tree/3ab1df1 * 2. **Process tree walking** (async) — scan parent/grandparent process * names for known agent executables. Runs as a non-blocking background * task so it never delays CLI startup. @@ -37,6 +38,12 @@ export type AgentInfo = { export const AGENT_ALIASES = new Map([ ["claude-code", "claude"], ["claudecode", "claude"], + ["claude_code", "claude"], + ["codex_cli", "codex"], + ["gemini_cli", "gemini"], + ["open_code", "opencode"], + ["cursor-cli", "cursor"], + ["augment-cli", "augment"], ]); /** Truthy boolean-ish values — signal "an agent is present" but don't name it. */ @@ -120,25 +127,40 @@ export const ENV_VAR_AGENTS = new Map([ // Cursor ["CURSOR_TRACE_ID", "cursor"], ["CURSOR_AGENT", "cursor"], + // Kimi Code plugin hooks — KIMI_CODE_HOME can be set outside a session + ["KIMI_PLUGIN_ROOT", "kimi"], + // Grok plugin hooks must win over Claude compatibility markers + ["GROK_PLUGIN_ROOT", "grok"], + ["GROK_PLUGIN_DATA", "grok"], // Gemini CLI ["GEMINI_CLI", "gemini"], + // Cline + ["CLINE_ACTIVE", "cline"], // OpenAI Codex ["CODEX_SANDBOX", "codex"], ["CODEX_CI", "codex"], ["CODEX_THREAD_ID", "codex"], + ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"], // Antigravity ["ANTIGRAVITY_AGENT", "antigravity"], + ["ANTIGRAVITY_CLI_ALIAS", "antigravity"], // Augment ["AUGMENT_AGENT", "augment"], // OpenCode ["OPENCODE_CLIENT", "opencode"], + ["OPENCODE", "opencode"], + // Junie + ["JUNIE_DATA", "junie"], + ["JUNIE_SHIM_PATH", "junie"], + // OpenClaw + ["OPENCLAW_SHELL", "openclaw"], // Replit — REPL_ID intentionally excluded because it's set in ALL Replit // workspaces, not just when the AI agent is driving the CLI // GitHub Copilot — COPILOT_GITHUB_TOKEN intentionally excluded because // users may export it persistently for auth, causing false positives ["COPILOT_MODEL", "github-copilot"], ["COPILOT_ALLOW_ALL", "github-copilot"], - // Goose + // Goose — GOOSE_PROVIDER can be persistent configuration in a human shell ["GOOSE_TERMINAL", "goose"], // Amp ["AMP_THREAD_ID", "amp"], @@ -203,7 +225,7 @@ export function setProcessInfoProvider(provider: ProcessInfoProvider): void { * * Priority: * 1. `AI_AGENT` env var — explicit override, any agent can self-identify - * 2. Agent-specific env vars from {@link ENV_VAR_AGENTS} + * 2. Cursor's exact agent-exec role, then env vars from {@link ENV_VAR_AGENTS} * 3. Claude Code with Cowork variant (conditional, can't be in the map) * 4. `AGENT` env var — generic fallback set by Goose, Amp, and others * @@ -225,6 +247,12 @@ export function detectAgent(): AgentInfo | undefined { } } + // The role marker also exists outside agent execution; only this value + // identifies an agent. Check before the map to retain Cursor's priority. + if (env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec") { + return normalizeAgent("cursor"); + } + // 2. Table-driven env var check (Map iteration preserves insertion order). // These values are our own clean strings — normalizeAgent won't return undefined. for (const [envVar, agent] of ENV_VAR_AGENTS) { diff --git a/packages/cli/test/lib/detect-agent.test.ts b/packages/cli/test/lib/detect-agent.test.ts index 65c3286f0a..0d866f3d1b 100644 --- a/packages/cli/test/lib/detect-agent.test.ts +++ b/packages/cli/test/lib/detect-agent.test.ts @@ -36,7 +36,13 @@ describe("detectAgent", () => { // ── AI_AGENT override ────────────────────────────────────────────── test("AI_AGENT takes highest priority", () => { - withEnv({ AI_AGENT: "custom-agent", CLAUDE_CODE: "1", CI: "true" }); + withEnv({ + AI_AGENT: "custom-agent", + CURSOR_EXTENSION_HOST_ROLE: "agent-exec", + GROK_PLUGIN_DATA: "/tmp/grok", + CLAUDE_CODE: "1", + CI: "true", + }); expect(detectAgent()).toEqual(named("custom-agent")); }); @@ -94,6 +100,38 @@ describe("detectAgent", () => { expect(detectAgent()).toEqual(named("claude")); }); + test.each([ + ["CLINE_ACTIVE", "cline"], + ["OPENCLAW_SHELL", "openclaw"], + ["KIMI_PLUGIN_ROOT", "kimi"], + ["GROK_PLUGIN_ROOT", "grok"], + ["GROK_PLUGIN_DATA", "grok"], + ["JUNIE_DATA", "junie"], + ["JUNIE_SHIM_PATH", "junie"], + ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"], + ["ANTIGRAVITY_CLI_ALIAS", "antigravity"], + ["OPENCODE", "opencode"], + ])("%s detects %s before the AGENT fallback", (envVar, agent) => { + withEnv({ [envVar]: "1", AGENT: "other-agent" }); + expect(detectAgent()).toEqual(named(agent)); + }); + + test("empty agent-specific signals are ignored", () => { + withEnv({ + CLINE_ACTIVE: "", + OPENCLAW_SHELL: "", + KIMI_PLUGIN_ROOT: "", + GROK_PLUGIN_ROOT: "", + GROK_PLUGIN_DATA: "", + JUNIE_DATA: "", + JUNIE_SHIM_PATH: "", + CODEX_SANDBOX_NETWORK_DISABLED: "", + ANTIGRAVITY_CLI_ALIAS: "", + OPENCODE: "", + }); + expect(detectAgent()).toBeUndefined(); + }); + // ── Cursor ───────────────────────────────────────────────────────── test("CURSOR_TRACE_ID → cursor", () => { @@ -111,6 +149,37 @@ describe("detectAgent", () => { expect(detectAgent()).toEqual(named("cursor")); }); + test("CURSOR_EXTENSION_HOST_ROLE=agent-exec → cursor", () => { + withEnv({ CURSOR_EXTENSION_HOST_ROLE: "agent-exec" }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test("Cursor agent-exec takes priority over other agent markers", () => { + withEnv({ + CURSOR_EXTENSION_HOST_ROLE: "agent-exec", + GEMINI_CLI: "1", + GROK_PLUGIN_ROOT: "/tmp/grok", + CLAUDE_CODE: "1", + AGENT: "other-agent", + }); + expect(detectAgent()).toEqual(named("cursor")); + }); + + test.each([ + "", + "terminal", + "agent-exec-helper", + "AGENT-EXEC", + ])("CURSOR_EXTENSION_HOST_ROLE=%j does not trigger detection", (role) => { + withEnv({ CURSOR_EXTENSION_HOST_ROLE: role }); + expect(detectAgent()).toBeUndefined(); + }); + + test("a different Cursor role falls through to other agent markers", () => { + withEnv({ CURSOR_EXTENSION_HOST_ROLE: "terminal", CLINE_ACTIVE: "1" }); + expect(detectAgent()).toEqual(named("cline")); + }); + // ── Gemini ───────────────────────────────────────────────────────── test("GEMINI_CLI → gemini", () => { @@ -183,6 +252,20 @@ describe("detectAgent", () => { expect(detectAgent()).toBeUndefined(); }); + test.each([ + ["GROK_PLUGIN_ROOT", "CLAUDE_CODE", ""], + ["GROK_PLUGIN_DATA", "CLAUDECODE", ""], + ["GROK_PLUGIN_ROOT", "CLAUDECODE", "1"], + ["GROK_PLUGIN_DATA", "CLAUDE_CODE", "1"], + ])("%s takes priority over %s with CLAUDE_CODE_IS_COWORK=%j", (grokVar, claudeVar, cowork) => { + withEnv({ + [grokVar]: "/tmp/grok", + [claudeVar]: "1", + CLAUDE_CODE_IS_COWORK: cowork, + }); + expect(detectAgent()).toEqual(named("grok")); + }); + // ── Excluded env vars (false positive risks) ────────────────────── test("REPL_ID alone does not trigger detection (platform env, not agent signal)", () => { @@ -195,6 +278,14 @@ describe("detectAgent", () => { expect(detectAgent()).toBeUndefined(); }); + test.each([ + ["GOOSE_PROVIDER", "openai"], + ["KIMI_CODE_HOME", "/tmp/kimi"], + ])("%s configuration alone does not trigger detection", (envVar, value) => { + withEnv({ [envVar]: value }); + expect(detectAgent()).toBeUndefined(); + }); + // ── GitHub Copilot ───────────────────────────────────────────────── test("COPILOT_MODEL → github-copilot", () => { @@ -258,6 +349,20 @@ describe("detectAgent", () => { expect(detectAgent()).toEqual({ name: "my-agent", version: "1.0.0" }); }); + test.each([ + ["claude_code", "claude"], + ["codex_cli", "codex"], + ["gemini_cli", "gemini"], + ["open_code", "opencode"], + ["cursor-cli", "cursor"], + ["augment-cli", "augment"], + ])("%s identifies %s through AI_AGENT and AGENT", (alias, name) => { + for (const envVar of ["AI_AGENT", "AGENT"]) { + withEnv({ [envVar]: `${alias}/1.2.3/agent` }); + expect(detectAgent()).toEqual({ name, version: "1.2.3", role: "agent" }); + } + }); + // ── No agent ─────────────────────────────────────────────────────── test("no env vars → undefined", () => { diff --git a/packages/cli/test/lib/init/wizard-runner.test.ts b/packages/cli/test/lib/init/wizard-runner.test.ts index 9d34f52c4c..3949551cfc 100644 --- a/packages/cli/test/lib/init/wizard-runner.test.ts +++ b/packages/cli/test/lib/init/wizard-runner.test.ts @@ -641,6 +641,7 @@ describe("runWizard", () => { "AGENT", "CLAUDECODE", "CLAUDE_CODE", + "CURSOR_EXTENSION_HOST_ROLE", ...ENV_VAR_AGENTS.keys(), ]); const cleanEnv = Object.fromEntries(