Skip to content
Closed
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
37 changes: 25 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# detect_agent

> This is a Python port of Vercel's `@vercel/detect-agent` npm package.
> This is a Python port of Vercel's [`detect-agent`](https://github.com/vercel/detect-agent) package.

A lightweight utility for detecting if code is being executed by an AI agent or automated development environment.

Expand All @@ -23,20 +23,34 @@ if result["is_agent"]:
# Adapt behavior for AI agent context
```

## Agent Definitions

Detection rules are vendored from upstream [`agents.json`](https://raw.githubusercontent.com/vercel/detect-agent/main/agents.json) (validated against [`agents.schema.json`](https://raw.githubusercontent.com/vercel/detect-agent/main/agents.schema.json)) and evaluated in array order; the first match wins.

## Supported Agents

This package can detect the following AI agents and development environments:

- **Custom agents** via `AI_AGENT` environment variable
- **Cursor** (cursor editor and cursor-cli)
- **Claude Code** (Anthropic's Claude)
- **Cursor** (Anysphere)
- **Claude Code** (Anthropic)
- **Claude Cowork** (Anthropic)
- **Devin** (Cognition Labs)
- **Gemini CLI** (Google)
- **Codex** (OpenAI)
- **Antigravity** (Google DeepMind)
- **GitHub Copilot** (via `AI_AGENT=github-copilot|github-copilot-cli`, `COPILOT_MODEL`, `COPILOT_ALLOW_ALL`, or `COPILOT_GITHUB_TOKEN`)
- **Replit** (online IDE)
- **v0** (Vercel's AI assistant, via `AI_AGENT=v0`)
- **Augment**
- **Cline**
- **OpenCode**
- **OpenClaw**
- **Goose** (Block)
- **Junie** (JetBrains)
- **Kiro** (AWS)
- **Pi**
- **GitHub Copilot**
- **Replit**
- **Custom agents** (via the `AI_AGENT` environment variable)

See [`agents.json`](https://raw.githubusercontent.com/vercel/detect-agent/main/agents.json) for the exact environment variables and conditions used to detect each one.

## The AI_AGENT Standard

Expand Down Expand Up @@ -131,12 +145,11 @@ def should_enable_feature(feature: str) -> bool:

To add support for a new AI agent:

1. Add detection logic to `detect_agent/__init__.py`
2. Add comprehensive test cases in `tests/test_detect_agent.py`
3. Update this README with the new agent information
4. Follow the existing priority order pattern
1. Sync detection rules from upstream [`agents.json`](https://github.com/vercel/detect-agent/blob/main/agents.json) into `detect_agent/agents.json`. Agents are evaluated in array order — the first match wins.
2. Sync or extend test cases in `tests/testcases.json`.
3. Update this README with the new agent information.

## Links

- [GitHub Repository](https://github.com/togethercomputer/detect_agent)
- [Vercel upstream package](https://github.com/vercel/vercel/tree/main/packages/detect-agent)
- [Vercel upstream package](https://github.com/vercel/detect-agent)
126 changes: 48 additions & 78 deletions detect_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
"""Detect if code is running in an AI agent or automated development environment."""

from __future__ import annotations

import os
from pathlib import Path
from typing import Literal, TypedDict, Union

from detect_agent._evaluate import evaluate_condition
from detect_agent._spec import known_agents_map, load_agents_file

# Kept for backward-compatible imports/tests; Devin detection reads path from agents.json.
DEVIN_LOCAL_PATH = "/opt/.devin"

KNOWN_AGENTS: dict[str, str] = known_agents_map()

CURSOR: Literal["cursor"] = "cursor"
CURSOR_CLI: Literal["cursor-cli"] = "cursor-cli"
CLAUDE: Literal["claude"] = "claude"
Expand All @@ -16,9 +25,12 @@
AUGMENT_CLI: Literal["augment-cli"] = "augment-cli"
OPENCODE: Literal["opencode"] = "opencode"
GITHUB_COPILOT: Literal["github-copilot"] = "github-copilot"
GITHUB_COPILOT_CLI: Literal["github-copilot-cli"] = "github-copilot-cli"
V0: Literal["v0"] = "v0"
CLINE: Literal["cline"] = "cline"
GOOSE: Literal["goose"] = "goose"
JUNIE: Literal["junie"] = "junie"
PI: Literal["pi"] = "pi"
KIRO: Literal["kiro"] = "kiro"
OPENCLAW: Literal["openclaw"] = "openclaw"

KnownAgentNames = Literal[
"cursor",
Expand All @@ -33,13 +45,17 @@
"augment-cli",
"opencode",
"github-copilot",
"v0",
"cline",
"goose",
"junie",
"pi",
"kiro",
"openclaw",
]


class KnownAgentDetails(TypedDict):
name: KnownAgentNames
name: str


class AgentResultAgent(TypedDict):
Expand All @@ -54,84 +70,38 @@ class AgentResultNone(TypedDict):

AgentResult = Union[AgentResultAgent, AgentResultNone]

KNOWN_AGENTS = {
"PI": PI,
"CURSOR": CURSOR,
"CURSOR_CLI": CURSOR_CLI,
"CLAUDE": CLAUDE,
"COWORK": COWORK,
"DEVIN": DEVIN,
"REPLIT": REPLIT,
"GEMINI": GEMINI,
"CODEX": CODEX,
"ANTIGRAVITY": ANTIGRAVITY,
"AUGMENT_CLI": AUGMENT_CLI,
"OPENCODE": OPENCODE,
"GITHUB_COPILOT": GITHUB_COPILOT,
"V0": V0,
}

def _resolve_ai_agent_standard(ai_agent_var: str) -> str | None:
raw = os.environ.get(ai_agent_var)
if not raw:
return None
value = raw.strip()
if not value:
return None
return value


def determine_agent() -> AgentResult:
ai_agent = os.environ.get("AI_AGENT")
"""Inspect the environment and return which AI agent is running, if any.

``AI_AGENT`` takes highest priority. After that, agents from ``agents.json``
are evaluated in order and the first match wins.

Python-port extension: ``PI_CODING_AGENT`` also detects Pi for backward
compatibility with earlier releases of this package.
"""
spec = load_agents_file()

ai_agent = _resolve_ai_agent_standard(spec["aiAgentVar"])
if ai_agent:
name = ai_agent.strip()
if name:
if name in (GITHUB_COPILOT, GITHUB_COPILOT_CLI):
return {"is_agent": True, "agent": {"name": GITHUB_COPILOT}}
if name == V0:
return {"is_agent": True, "agent": {"name": V0}}
return {"is_agent": True, "agent": {"name": name}} # type: ignore[return-value, misc]
return {"is_agent": True, "agent": {"name": ai_agent}}

# Backward-compatible Pi marker from earlier Python port releases.
if os.environ.get("PI_CODING_AGENT"):
return {"is_agent": True, "agent": {"name": PI}}

# Cursor IDE agent-terminal sessions expose CURSOR_TRACE_ID; the
# cursor-agent CLI sets CURSOR_AGENT for commands it executes.
if os.environ.get("CURSOR_TRACE_ID"):
return {"is_agent": True, "agent": {"name": CURSOR}}

if (
os.environ.get("CURSOR_AGENT")
or os.environ.get("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec"
):
return {"is_agent": True, "agent": {"name": CURSOR_CLI}}

if os.environ.get("GEMINI_CLI"):
return {"is_agent": True, "agent": {"name": GEMINI}}

if (
os.environ.get("CODEX_SANDBOX")
or os.environ.get("CODEX_CI")
or os.environ.get("CODEX_THREAD_ID")
):
return {"is_agent": True, "agent": {"name": CODEX}}

if os.environ.get("ANTIGRAVITY_AGENT"):
return {"is_agent": True, "agent": {"name": ANTIGRAVITY}}

if os.environ.get("AUGMENT_AGENT"):
return {"is_agent": True, "agent": {"name": AUGMENT_CLI}}

if os.environ.get("OPENCODE_CLIENT"):
return {"is_agent": True, "agent": {"name": OPENCODE}}

if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_CODE"):
if os.environ.get("CLAUDE_CODE_IS_COWORK"):
return {"is_agent": True, "agent": {"name": COWORK}}
return {"is_agent": True, "agent": {"name": CLAUDE}}

if os.environ.get("REPL_ID"):
return {"is_agent": True, "agent": {"name": REPLIT}}

if (
os.environ.get("COPILOT_MODEL")
or os.environ.get("COPILOT_ALLOW_ALL")
or os.environ.get("COPILOT_GITHUB_TOKEN")
):
return {"is_agent": True, "agent": {"name": GITHUB_COPILOT}}

if Path(DEVIN_LOCAL_PATH).exists():
return {"is_agent": True, "agent": {"name": DEVIN}}
return {"is_agent": True, "agent": {"name": KNOWN_AGENTS["PI"]}}

for agent in spec["agents"]:
if evaluate_condition(agent["match"]):
return {"is_agent": True, "agent": {"name": agent["name"]}}

return {"is_agent": False, "agent": None}
81 changes: 81 additions & 0 deletions detect_agent/_evaluate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Evaluate agent detection condition trees from agents.json."""

from __future__ import annotations

import os
import re
import sys
from pathlib import Path
from typing import Any, Callable


def _env_set(name: str) -> bool:
return bool(os.environ.get(name))


def _env_value(name: str, value: str) -> bool:
return name in os.environ and os.environ[name] == value


def _env_matches(name: str, pattern: str) -> bool:
value = os.environ.get(name)
if not value:
return False
try:
return re.search(pattern, value) is not None
except re.error:
# A malformed pattern in the spec should never throw at detection time.
return False


def _path_exists(path: str) -> bool:
try:
return Path(path).exists()
except OSError:
return False


def _is_tty() -> bool:
try:
return bool(sys.stdout.isatty())
except Exception:
return False


# Swappable in tests (mirrors upstream Go isTTYFn / path checks).
path_exists_fn: Callable[[str], bool] = _path_exists
is_tty_fn: Callable[[], bool] = _is_tty


def evaluate_condition(condition: dict[str, Any]) -> bool:
"""Evaluate a condition tree. ``anyOf``/``allOf`` are combinators; the rest are leaves."""
ctype = condition.get("type")

if ctype == "env_set":
return _env_set(condition["name"])

if ctype == "env_value":
return _env_value(condition["name"], condition["value"])

if ctype == "env_matches":
return _env_matches(condition["name"], condition["pattern"])

if ctype == "no_tty":
return not is_tty_fn()

if ctype == "file_exists":
return path_exists_fn(condition["path"])

if ctype == "anyOf":
for sub in condition.get("conditions", []):
if evaluate_condition(sub):
return True
return False

if ctype == "allOf":
for sub in condition.get("conditions", []):
if not evaluate_condition(sub):
return False
return True

return False
50 changes: 50 additions & 0 deletions detect_agent/_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Load the language-agnostic agents.json specification."""

from __future__ import annotations

import json
from functools import lru_cache
from importlib import resources
from pathlib import Path
from typing import Any, TypedDict


class AgentSpec(TypedDict):
key: str
name: str
match: dict[str, Any]


class AgentsFile(TypedDict):
version: int
aiAgentVar: str
agents: list[AgentSpec]


def _read_agents_json() -> str:
# Prefer importlib.resources (installed wheel); fall back to source tree path.
try:
return (resources.files("detect_agent") / "agents.json").read_text(encoding="utf-8")
except (FileNotFoundError, TypeError, ModuleNotFoundError, AttributeError):
return (Path(__file__).with_name("agents.json")).read_text(encoding="utf-8")


@lru_cache(maxsize=1)
def load_agents_file() -> AgentsFile:
data = json.loads(_read_agents_json())
return {
"version": int(data["version"]),
"aiAgentVar": str(data["aiAgentVar"]),
"agents": [
{
"key": str(agent["key"]),
"name": str(agent["name"]),
"match": agent["match"],
}
for agent in data["agents"]
],
}


def known_agents_map() -> dict[str, str]:
return {agent["key"]: agent["name"] for agent in load_agents_file()["agents"]}
Loading
Loading