Compare commits

...
Author SHA1 Message Date
David Mokos d2f8b90f53 adds Grok Bot environment detection (#21)
Grok Bot sets CURSOR_AGENT alongside SAND_BOX_BOOT_ID and SAND_BOX_STORE_ID, so it is matched before Cursor. Session ids still come from CURSOR_CONVERSATION_ID.
2026-10-24 03:10:00 -07:00
David Mokos e308d262b1 adds Factory Droid detection (#20) 2026-09-24 04:58:40 -07:00
David Mokos 09f4c420ad adds Grok environment detection (#19) 2026-08-24 23:30:14 +02:00
David Mokos 9205525ad4 adds Muse Code environment detection (#18) 2026-08-17 18:02:47 +02:00
David Mokos cf68eb6e0c updates repository metadata for npm provenance (#16) 2026-08-04 16:15:59 +02:00
David Mokos b7c8ccd0aa documents opencode detection and bumps version to 0.1.5 (#15) 2026-08-04 16:11:59 +02:00
Din 1cad0bda77 [lib] detect OpenCode from environment variables (#14)
OpenCode was only detectable through the process-tree strategy, which is
opt-in via experimentalProcessTree. With the default strategies it was
never detected.

The OpenCode CLI unconditionally sets OPENCODE=1 and OPENCODE_PID in its
yargs middleware, so both are inherited by any process it spawns.
2026-08-04 07:01:07 -07:00
David Mokos 8e4fdd3c6f add rork environment detection 2026-07-18 16:42:01 +02:00
David Mokos 238a06ac5f adds repository metadata and normalizes bin path for npm provenance 2026-07-17 17:50:47 +02:00
David Mokos 2369c9194f splits exports types per condition and bumps to 0.1.3 2026-07-17 17:49:19 +02:00
David Mokos eecb49a358 [pkg] add Replit detection (#12)
* add replit agent detection

* add bolt agent detection
2026-07-17 17:17:55 +02:00
David Mokos 9010110dea restructures readme to match ci-info style (#11) 2026-07-17 13:37:47 +02:00
David Mokos 8738a0aa09 adds npm trusted publishing workflow (#10) 2026-07-03 16:41:46 +02:00
David Mokos 0855546c89 dual publish esm and commonjs builds 2026-06-26 17:09:39 +02:00
David Mokos a9720cb533 fixes publish prepack lifecycle 2026-06-26 12:39:35 +02:00
David Mokos b742350c0b fixes cross-platform ci test execution 2026-06-25 20:40:21 +02:00
David Mokos 4784e73f5a adds cross-platform package tests 2026-06-25 19:40:03 +02:00
David Mokos 5c2c67042d simplify agent detection defaults (#5) 2026-06-25 19:32:11 +02:00
David Mokos a49b298948 [pkg] document npm install flow (#4)
* document npm install and normalize bin path

* fix npx cli package execution
2026-06-25 13:11:13 +02:00
David Mokos 3f015f76f2 list agent detection strategies in readme (#3) 2026-06-23 22:12:53 +02:00
David Mokos 5b25e7ecc7 add MIT license (#2) 2026-06-23 22:09:56 +02:00
David Mokos fc3e4c23f0 [pkg] add agent cli detector package (#1)
* adds agent cli detector package

* improves detection result interface

* removes detection evidence from result

* adds devin process tree detection

* adds kiro detection
2026-06-23 22:06:22 +02:00
18 changed files with 2938 additions and 0 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
cache: npm
- name: Update npm
run: npm install -g npm@latest
- name: Install dependencies
run: npm ci
- name: Verify release tag matches package.json version
run: |
PKG_VERSION="v$(node -p "require('./package.json').version")"
if [ "$GITHUB_REF_NAME" != "$PKG_VERSION" ]; then
echo "Release tag $GITHUB_REF_NAME does not match package.json version $PKG_VERSION"
exit 1
fi
- name: Publish
run: npm publish
+43
View File
@@ -0,0 +1,43 @@
name: test
on:
workflow_dispatch:
pull_request:
push:
permissions:
contents: read
jobs:
test:
name: ${{ matrix.os }} / Node ${{ matrix.node-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
node-version:
- 18.18.0
- 20.x
- 22.x
- 24.x
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tgz
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 David Mokos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+188
View File
@@ -0,0 +1,188 @@
# agent-cli-detector
Detect whether a JavaScript CLI is running inside a coding agent.
Please [open an issue](https://github.com/expo/agent-cli-detector/issues/new)
if your coding agent isn't properly detected :)
[![npm](https://img.shields.io/npm/v/agent-cli-detector.svg)](https://www.npmjs.com/package/agent-cli-detector)
[![Tests](https://github.com/expo/agent-cli-detector/actions/workflows/test.yml/badge.svg)](https://github.com/expo/agent-cli-detector/actions)
## Installation
```bash
npm install agent-cli-detector
```
## Usage
```js
import { detectAgent } from "agent-cli-detector";
const result = detectAgent();
if (result.detected) {
console.log("The name of the coding agent is:", result.agent.name);
} else {
console.log("This program is not running inside a coding agent");
}
```
## Supported agents
Officially supported coding agents:
| Name | ID | Session ID |
| ----------------------------------------------------------------- | ------------- | ---------- |
| [Antigravity](https://antigravity.google/) | `antigravity` | ✅ |
| [Bolt](https://bolt.new/) | `bolt` | 🚫 |
| [Claude Code](https://claude.com/claude-code) | `claude-code` | ✅ |
| [Cline](https://cline.bot/) | `cline` | 🚫 |
| [Codex](https://developers.openai.com/codex/) | `codex` | ✅ |
| [Cursor](https://cursor.com/) | `cursor` | ✅ |
| [Devin](https://devin.ai/) | `devin` | 🚫 |
| [Factory Droid](https://docs.factory.ai/droid-cli/overview) | `droid` | 🚫 |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | 🚫 |
| [GitHub Copilot CLI](https://github.com/github/copilot-cli) | `copilot` | ✅ |
| [Grok](https://x.ai/build) | `grok` | ✅ |
| [Grok Bot](https://x.ai/bot) | `grok-bot` | ✅ |
| [Kilo Code](https://kilocode.ai/) | `kilocode` | ✅ |
| [Kiro](https://kiro.dev/) | `kiro` | ✅ |
| [Muse Code][muse-code] | `muse` | 🚫 |
| [OpenCode](https://opencode.ai/) | `opencode` | 🚫 |
| [Pi](https://github.com/badlogic/pi-mono) | `pi` | 🚫 |
| [Replit](https://replit.com/) | `replit` | ✅ |
| [Rork](https://rork.com/) | `rork` | 🚫 |
[muse-code]: https://developer.meta.com/ai/products/muse-code/
Detection is data-driven: the exact environment variables and process
patterns for each agent live in [`src/agents.ts`](src/agents.ts).
OpenCode 1.0.19 and later is detected from environment variables by default.
OpenCode 0.x requires experimental process-tree detection.
## API
### `detectAgent([options])`
Runs detection and returns a `DetectionResult`:
```js
{
detected: true,
agent: {
id: "cursor",
name: "Cursor",
sessionId: "d9e9cd60-2e1c-487c-9bc7-fceee5e9c3a2"
}
}
```
### `result.detected`
A boolean. Will be `true` if the code is running inside a coding agent,
otherwise `false`.
### `result.agent.id`
A stable identifier for the detected agent (see the ID column in the
support table above). Prefer comparing against this instead of
`result.agent.name`.
### `result.agent.name`
A string containing the display name of the detected coding agent.
Don't depend on the value of this string not to change for a specific
agent. If you find yourself writing `result.agent.name === "Claude Code"`,
you most likely want to use `result.agent.id === "claude-code"` instead.
### `result.agent.sessionId`
A string containing the agent's session identifier, normalized from
agent-specific environment variables such as `CODEX_THREAD_ID`,
`CURSOR_CONVERSATION_ID`, `CLAUDE_CODE_SESSION_ID`,
`ANTIGRAVITY_TRAJECTORY_ID`, `KIRO_SESSION_ID`, `KILO_RUN_ID`, and
`COPILOT_AGENT_SESSION_ID`, `REPLIT_SESSION`, and `GROK_SESSION_ID`.
Omitted when the agent doesn't expose one
(see the Session ID column in the support table above).
### `isRunningFromAgent([options])`
A convenience shortcut that returns `result.detected` as a boolean:
```js
import { isRunningFromAgent } from "agent-cli-detector";
if (isRunningFromAgent()) {
// Adjust CLI behavior for agent-driven execution.
}
```
## CLI
The package also ships a CLI:
```bash
npx agent-cli-detector
npx agent-cli-detector --json
```
Exit codes:
- `0`: a coding agent was detected
- `1`: no coding agent was detected
## Process-tree detection (experimental)
Detection uses environment variables by default. Parent-process
inspection is available as an opt-in for callers that can tolerate
best-effort process-tree matching:
```js
const result = detectAgent({ experimentalProcessTree: true });
```
Some sandboxes block `ps`, and process names can vary by launcher, so
process-tree detection should be treated as experimental. It has only
been tested on macOS and does not work on Windows.
## Custom agents
Add an agent by supplying an `AgentDefinition`:
```js
import { detectAgent, defaultAgents } from "agent-cli-detector";
const result = detectAgent({
agents: [
...defaultAgents,
{
id: "my-agent",
name: "My Agent",
env: [{ name: "MY_AGENT", value: "1" }],
process: [{ pattern: /^my-agent$/i }]
}
]
});
```
## Custom strategies
Add a detection strategy by implementing `DetectionStrategy`:
```ts
import type { DetectionStrategy } from "agent-cli-detector";
const strategy: DetectionStrategy = {
name: "my-strategy",
detect(context) {
return [];
}
};
```
## License
[MIT](LICENSE)
+1500
View File
File diff suppressed because it is too large. Load diff
+61
View File
@@ -0,0 +1,61 @@
{
"name": "agent-cli-detector",
"version": "0.1.8",
"description": "Detect whether a JavaScript CLI is running inside a coding agent.",
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"agent-cli-detector": "dist/cli.js"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18.18"
},
"scripts": {
"build": "tsup && node -e \"require('node:fs').chmodSync('dist/cli.js', 0o755)\"",
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"test": "npm run build && node --test test/detector.test.mjs test/package.test.mjs",
"prepack": "npm run clean && npm run build && node --test test/detector.test.mjs"
},
"keywords": [
"agent",
"cli",
"detection",
"codex",
"claude",
"opencode"
],
"license": "MIT",
"devDependencies": {
"@types/node": "^24.0.4",
"tsup": "^8.5.0",
"typescript": "^5.8.3"
},
"repository": {
"type": "git",
"url": "git+https://github.com/expo/agent-cli-detector.git"
},
"bugs": {
"url": "https://github.com/expo/agent-cli-detector/issues"
},
"homepage": "https://github.com/expo/agent-cli-detector#readme"
}
+153
View File
@@ -0,0 +1,153 @@
import type { AgentDefinition } from "./types.js";
export const defaultAgents = [
{
id: "codex",
name: "Codex",
env: [
{ name: "CODEX_CI", value: "1" },
{ name: "CODEX_SHELL", value: "1" },
{ name: "CODEX_THREAD_ID" }
],
process: [{ pattern: /^codex$/i }],
sessionEnv: ["CODEX_THREAD_ID"]
},
{
id: "grok-bot",
name: "Grok Bot",
env: [
{ name: "SAND_BOX_BOOT_ID" },
{ name: "SAND_BOX_STORE_ID" }
],
sessionEnv: ["CURSOR_CONVERSATION_ID"]
},
{
id: "cursor",
name: "Cursor",
env: [
{ name: "CURSOR_AGENT", value: "1" },
{ name: "CURSOR_CONVERSATION_ID" }
],
process: [{ pattern: /^cursor$/i }],
sessionEnv: ["CURSOR_CONVERSATION_ID"]
},
{
id: "claude-code",
name: "Claude Code",
env: [
{ name: "CLAUDECODE", value: "1" },
{ name: "CLAUDE_CODE_SESSION_ID" }
],
process: [{ pattern: /^claude$/i }],
sessionEnv: ["CLAUDE_CODE_SESSION_ID"]
},
{
id: "opencode",
name: "OpenCode",
env: [
{ name: "OPENCODE", value: "1" },
{ name: "OPENCODE_PID" }
],
process: [{ pattern: /^opencode$/i }]
},
{
id: "devin",
name: "Devin",
process: [{ pattern: /devin/i }]
},
{
id: "droid",
name: "Factory Droid",
env: [{ name: "FACTORY_ENV", value: "production" }]
},
{
id: "gemini",
name: "Gemini CLI",
env: [{ name: "GEMINI_CLI", value: "1" }],
process: [{ pattern: /^gemini$/i }]
},
{
id: "grok",
name: "Grok",
env: [
{ name: "GROK_AGENT", value: "1" },
{ name: "GROK_SESSION_ID" }
],
sessionEnv: ["GROK_SESSION_ID"]
},
{
id: "antigravity",
name: "Antigravity",
env: [
{ name: "ANTIGRAVITY_AGENT", value: "1" },
{ name: "ANTIGRAVITY_TRAJECTORY_ID" }
],
process: [{ pattern: /^antigravity$/i }],
sessionEnv: ["ANTIGRAVITY_TRAJECTORY_ID"]
},
{
id: "bolt",
name: "Bolt",
env: [
{ name: "BOLT_ENV" },
{ name: "BOLT_ORIGIN" },
{ name: "BOLT_SERVER_URL" }
]
},
{
id: "pi",
name: "Pi",
env: [{ name: "PI_CODING_AGENT", value: "true" }]
},
{
id: "replit",
name: "Replit",
env: [{ name: "REPLIT_SESSION" }],
sessionEnv: ["REPLIT_SESSION"]
},
{
id: "rork",
name: "Rork",
env: [{ name: "RORK_API_URL" }]
},
{
id: "kiro",
name: "Kiro",
env: [{ name: "KIRO_SESSION_ID" }],
process: [{ pattern: /kiro/i }],
sessionEnv: ["KIRO_SESSION_ID"]
},
{
id: "muse",
name: "Muse Code",
env: [{ name: "MUSE_RELEASE_INFO" }]
},
{
id: "cline",
name: "Cline",
env: [{ name: "CLINE_WRAPPER_PATH" }],
process: [{ pattern: /^cline$/i }]
},
{
id: "kilocode",
name: "Kilo Code",
env: [
{ name: "KILO", value: "1" },
{ name: "KILOCODE_VERSION" },
{ name: "KILO_RUN_ID" }
],
process: [{ pattern: /^(kilo|kilocode)$/i }],
sessionEnv: ["KILO_RUN_ID"]
},
{
id: "copilot",
name: "GitHub Copilot CLI",
env: [
{ name: "COPILOT_CLI", value: "1" },
{ name: "COPILOT_AGENT_SESSION_ID" },
{ name: "COPILOT_RUN_APP", value: "1" }
],
process: [{ pattern: /^(github-copilot-cli|copilot)$/i }],
sessionEnv: ["COPILOT_AGENT_SESSION_ID"]
}
] satisfies readonly AgentDefinition[];
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import { detectAgent } from "./index.js";
import type { DetectionResult } from "./types.js";
const args = new Set(process.argv.slice(2));
const json = args.has("--json");
const quiet = args.has("--quiet");
const help = args.has("--help") || args.has("-h");
if (help) {
printHelp();
process.exit(0);
}
const result = detectAgent();
if (json) {
console.log(JSON.stringify(toJsonResult(result), null, 2));
} else if (!quiet) {
if (result.detected && result.agent !== undefined) {
console.log(`detected: ${result.agent.name}`);
} else {
console.log("no coding agent detected");
}
}
process.exit(result.detected ? 0 : 1);
function printHelp(): void {
console.log(`Usage: detect-agent [--json] [--quiet]
Detect whether the current process is running inside a coding agent.
Options:
--json Print the full detection result as JSON
--quiet Print nothing and use only the exit code
-h, --help
Show this help message
`);
}
function toJsonResult(result: DetectionResult): object {
return {
detected: result.detected,
agent: result.agent === undefined
? undefined
: {
id: result.agent.id,
name: result.agent.name,
sessionId: result.agent.sessionId
}
};
}
+92
View File
@@ -0,0 +1,92 @@
import { defaultAgents } from "./agents.js";
import { EnvironmentDetectionStrategy } from "./strategies/environment.js";
import { ProcessTreeDetectionStrategy } from "./strategies/process-tree.js";
import type {
AgentDefinition,
DetectAgentOptions,
DetectedAgent,
DetectionContext,
DetectionEvidence,
DetectionResult,
DetectionStrategy
} from "./types.js";
export function detectAgent(options: DetectAgentOptions = {}): DetectionResult {
const agents = options.agents ?? defaultAgents;
const strategies = options.strategies ?? defaultStrategies(options.experimentalProcessTree);
const context: DetectionContext = {
env: options.env ?? process.env,
pid: options.pid ?? process.pid,
agents,
maxProcessDepth: options.maxProcessDepth ?? 20
};
const matches = strategies.flatMap((strategy) => strategy.detect(context));
const bestMatch = selectBestAgent(agents, matches, context.env);
if (bestMatch === undefined) {
return {
detected: false
};
}
return {
detected: true,
agent: bestMatch
};
}
export function isRunningFromAgent(options: DetectAgentOptions = {}): boolean {
return detectAgent(options).detected;
}
export function createDefaultStrategies(experimentalProcessTree = false): readonly DetectionStrategy[] {
return defaultStrategies(experimentalProcessTree);
}
function defaultStrategies(experimentalProcessTree = false): readonly DetectionStrategy[] {
const strategies: DetectionStrategy[] = [new EnvironmentDetectionStrategy()];
if (experimentalProcessTree) {
strategies.push(new ProcessTreeDetectionStrategy());
}
return strategies;
}
function selectBestAgent(
agents: readonly AgentDefinition[],
matches: readonly DetectionEvidence[],
env: NodeJS.ProcessEnv
): DetectedAgent | undefined {
const matchedAgentIds = new Set(matches.map((match) => match.agent.id));
for (const agent of agents) {
if (!matchedAgentIds.has(agent.id)) {
continue;
}
return {
id: agent.id,
name: agent.name,
...sessionIdProperties(agent, env)
};
}
return undefined;
}
function sessionIdProperties(
agent: AgentDefinition,
env: NodeJS.ProcessEnv
): { readonly sessionId?: string } {
for (const envName of agent.sessionEnv ?? []) {
const value = env[envName];
if (value !== undefined && value.length > 0) {
return { sessionId: value };
}
}
return {};
}
+28
View File
@@ -0,0 +1,28 @@
export { defaultAgents } from "./agents.js";
export {
createDefaultStrategies,
detectAgent,
isRunningFromAgent
} from "./detector.js";
export { EnvironmentDetectionStrategy } from "./strategies/environment.js";
export {
ProcessTreeDetectionStrategy,
PsProcessReader,
type ProcessReader
} from "./strategies/process-tree.js";
export type {
AgentDefinition,
AgentId,
AgentIdentity,
DetectAgentOptions,
DetectedAgent,
DetectionContext,
DetectionEvidence,
DetectionResult,
DetectionStrategy,
DetectionStrategyName,
EnvSignal,
EnvValueMatcher,
ProcessInfo,
ProcessSignal
} from "./types.js";
+58
View File
@@ -0,0 +1,58 @@
import type {
AgentDefinition,
DetectionContext,
DetectionEvidence,
DetectionStrategy,
EnvSignal,
EnvValueMatcher
} from "../types.js";
export class EnvironmentDetectionStrategy implements DetectionStrategy {
readonly name = "environment";
detect(context: DetectionContext): readonly DetectionEvidence[] {
const matches: DetectionEvidence[] = [];
for (const agent of context.agents) {
for (const signal of agent.env ?? []) {
const value = context.env[signal.name];
if (value === undefined || !matchesEnvValue(value, signal.value)) {
continue;
}
matches.push(toEvidence(agent, signal, value));
}
}
return matches;
}
}
function matchesEnvValue(value: string, matcher: EnvValueMatcher | undefined): boolean {
if (matcher === undefined) {
return true;
}
if (typeof matcher === "string") {
return value === matcher;
}
if (matcher instanceof RegExp) {
return matcher.test(value);
}
return matcher(value);
}
function toEvidence(agent: AgentDefinition, signal: EnvSignal, value: string): DetectionEvidence {
return {
agent: {
id: agent.id,
name: agent.name
},
strategy: "environment",
signal: signal.name,
value
};
}
+112
View File
@@ -0,0 +1,112 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import type {
AgentDefinition,
DetectionContext,
DetectionEvidence,
DetectionStrategy,
ProcessInfo,
ProcessSignal
} from "../types.js";
export interface ProcessReader {
read(pid: number): ProcessInfo | undefined;
}
export class PsProcessReader implements ProcessReader {
read(pid: number): ProcessInfo | undefined {
const command = readPsField(pid, "comm");
const ppid = Number.parseInt(readPsField(pid, "ppid"), 10);
if (!command || !Number.isInteger(ppid)) {
return undefined;
}
return {
pid,
ppid,
command: normalizeCommand(command)
};
}
}
export class ProcessTreeDetectionStrategy implements DetectionStrategy {
readonly name = "process-tree";
constructor(private readonly reader: ProcessReader = new PsProcessReader()) {}
detect(context: DetectionContext): readonly DetectionEvidence[] {
const matches: DetectionEvidence[] = [];
let currentPid = context.pid;
for (let depth = 0; depth < context.maxProcessDepth; depth += 1) {
const processInfo = this.reader.read(currentPid);
if (processInfo === undefined) {
break;
}
matches.push(...matchProcess(context.agents, processInfo));
if (processInfo.ppid === undefined || processInfo.ppid <= 1 || processInfo.ppid === currentPid) {
break;
}
currentPid = processInfo.ppid;
}
return matches;
}
}
function matchProcess(
agents: readonly AgentDefinition[],
processInfo: ProcessInfo
): readonly DetectionEvidence[] {
const matches: DetectionEvidence[] = [];
const command = normalizeCommand(processInfo.command);
for (const agent of agents) {
for (const signal of agent.process ?? []) {
if (!signal.pattern.test(command)) {
continue;
}
matches.push(toEvidence(agent, signal, { ...processInfo, command }));
}
}
return matches;
}
function toEvidence(
agent: AgentDefinition,
signal: ProcessSignal,
processInfo: ProcessInfo
): DetectionEvidence {
return {
agent: {
id: agent.id,
name: agent.name
},
strategy: "process-tree",
signal: signal.pattern.toString(),
value: `${processInfo.pid}:${processInfo.command}`
};
}
function readPsField(pid: number, field: "comm" | "ppid"): string {
try {
return execFileSync("ps", ["-o", `${field}=`, "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
} catch {
return "";
}
}
function normalizeCommand(command: string): string {
const baseName = path.basename(command.trim());
return baseName.replace(/^-+/, "");
}
+92
View File
@@ -0,0 +1,92 @@
export type AgentId =
| "antigravity"
| "bolt"
| "cline"
| "codex"
| "copilot"
| "cursor"
| "devin"
| "droid"
| "gemini"
| "grok"
| "grok-bot"
| "kiro"
| "kilocode"
| "claude-code"
| "muse"
| "opencode"
| "pi"
| "replit"
| "rork";
export type DetectionStrategyName = "environment" | "process-tree";
export type EnvValueMatcher =
| string
| RegExp
| ((value: string) => boolean);
export interface EnvSignal {
readonly name: string;
readonly value?: EnvValueMatcher;
readonly description?: string;
}
export interface ProcessSignal {
readonly pattern: RegExp;
readonly description?: string;
}
export interface AgentIdentity {
readonly id: AgentId | string;
readonly name: string;
}
export interface DetectedAgent extends AgentIdentity {
readonly sessionId?: string;
}
export interface AgentDefinition extends AgentIdentity {
readonly env?: readonly EnvSignal[];
readonly process?: readonly ProcessSignal[];
readonly sessionEnv?: readonly string[];
}
export interface ProcessInfo {
readonly pid: number;
readonly ppid?: number;
readonly command: string;
}
export interface DetectionEvidence {
readonly agent: AgentIdentity;
readonly strategy: DetectionStrategyName | string;
readonly signal: string;
readonly value?: string;
}
export interface DetectionResult {
readonly detected: boolean;
readonly agent?: DetectedAgent;
}
export interface DetectionContext {
readonly env: NodeJS.ProcessEnv;
readonly pid: number;
readonly agents: readonly AgentDefinition[];
readonly maxProcessDepth: number;
}
export interface DetectionStrategy {
readonly name: DetectionStrategyName | string;
detect(context: DetectionContext): readonly DetectionEvidence[];
}
export interface DetectAgentOptions {
readonly env?: NodeJS.ProcessEnv;
readonly pid?: number;
readonly agents?: readonly AgentDefinition[];
readonly strategies?: readonly DetectionStrategy[];
readonly experimentalProcessTree?: boolean;
readonly maxProcessDepth?: number;
}
+314
View File
@@ -0,0 +1,314 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createDefaultStrategies,
detectAgent,
EnvironmentDetectionStrategy,
ProcessTreeDetectionStrategy
} from "../dist/index.js";
test("detects an agent from a strong environment variable", () => {
const result = detectAgent({
env: {
CLAUDECODE: "1"
}
});
assert.equal(result.detected, true);
assert.equal(result.agent.id, "claude-code");
});
test("normalizes session ids across agent-specific environment variables", () => {
const result = detectAgent({
env: {
CURSOR_AGENT: "1",
CURSOR_CONVERSATION_ID: "conversation-123"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "cursor",
name: "Cursor",
sessionId: "conversation-123"
});
});
test("detects kiro and normalizes its session id", () => {
const result = detectAgent({
env: {
KIRO_SESSION_ID: "a537a99b-b87d-450b-ace9-74231e3f4fe8"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "kiro",
name: "Kiro",
sessionId: "a537a99b-b87d-450b-ace9-74231e3f4fe8"
});
});
test("detects replit and normalizes its session id", () => {
const result = detectAgent({
env: {
REPLIT_SESSION: "session-123"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "replit",
name: "Replit",
sessionId: "session-123"
});
});
test("detects grok and normalizes its session id", () => {
const result = detectAgent({
env: {
GROK_AGENT: "1",
GROK_SESSION_ID: "01a035a8-166c-7771-8e5e-f67e0dd98c83"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "grok",
name: "Grok",
sessionId: "01a035a8-166c-7771-8e5e-f67e0dd98c83"
});
});
test("detects Grok Bot from SAND_BOX_BOOT_ID and normalizes the conversation id", () => {
const result = detectAgent({
env: {
CURSOR_AGENT: "1",
CURSOR_CONVERSATION_ID: "conversation-123",
SAND_BOX_BOOT_ID: "boot-456"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "grok-bot",
name: "Grok Bot",
sessionId: "conversation-123"
});
});
test("detects cursor from CURSOR_AGENT without Grok Bot signals", () => {
const result = detectAgent({
env: {
CURSOR_AGENT: "1",
CURSOR_CONVERSATION_ID: "conversation-123"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "cursor",
name: "Cursor",
sessionId: "conversation-123"
});
});
test("detects Grok Bot from SAND_BOX_STORE_ID alone", () => {
const result = detectAgent({
env: {
SAND_BOX_STORE_ID: "store-789"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "grok-bot",
name: "Grok Bot"
});
});
test("detects Factory Droid from FACTORY_ENV", () => {
const result = detectAgent({
env: {
FACTORY_ENV: "production"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "droid",
name: "Factory Droid"
});
});
test("detects rork from RORK_API_URL", () => {
const result = detectAgent({
env: {
RORK_API_URL: "https://api.rork.com"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "rork",
name: "Rork"
});
});
test("detects Muse Code from MUSE_RELEASE_INFO", () => {
const result = detectAgent({
env: {
MUSE_RELEASE_INFO: JSON.stringify({
channel: "muse-stable",
version: "0.1.0-R708.1"
})
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "muse",
name: "Muse Code"
});
});
test("detects bolt from any known environment variable", () => {
for (const envName of ["BOLT_ENV", "BOLT_ORIGIN", "BOLT_SERVER_URL"]) {
const result = detectAgent({
env: {
[envName]: "1"
}
});
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "bolt",
name: "Bolt"
});
}
});
test("detects opencode from any known environment variable", () => {
for (const env of [{ OPENCODE: "1" }, { OPENCODE_PID: "12345" }]) {
const result = detectAgent({ env });
assert.equal(result.detected, true);
assert.deepEqual(result.agent, {
id: "opencode",
name: "OpenCode"
});
}
});
test("default strategies only use environment variables", () => {
assert.deepEqual(
createDefaultStrategies().map((strategy) => strategy.name),
["environment"]
);
});
test("process tree strategy is experimental and opt-in", () => {
assert.deepEqual(
createDefaultStrategies(true).map((strategy) => strategy.name),
["environment", "process-tree"]
);
});
test("process tree detection can detect kiro in the hierarchy", () => {
const processes = new Map([
[62926, { pid: 62926, ppid: 53313, command: "sh" }],
[53313, { pid: 53313, ppid: 53303, command: "kiro-cli-chat" }],
[53303, { pid: 53303, ppid: 52756, command: "bun" }],
[52756, { pid: 52756, ppid: 51504, command: "kiro-cli-chat" }],
[51504, { pid: 51504, ppid: 47657, command: "kiro-cli" }],
[47657, { pid: 47657, ppid: 1, command: "zsh" }]
]);
const strategy = new ProcessTreeDetectionStrategy({
read(pid) {
return processes.get(pid);
}
});
const result = detectAgent({
env: {},
pid: 62926,
strategies: [strategy]
});
assert.equal(result.detected, true);
assert.equal(result.agent.id, "kiro");
});
test("does not detect unrelated environment variables", () => {
const result = detectAgent({
env: {
PATH: "/usr/bin"
}
});
assert.equal(result.detected, false);
assert.equal(result.agent, undefined);
});
test("process tree detection can detect opencode without env vars", () => {
const processes = new Map([
[100, { pid: 100, ppid: 90, command: "node" }],
[90, { pid: 90, ppid: 80, command: "/opt/homebrew/bin/opencode" }],
[80, { pid: 80, ppid: 1, command: "zsh" }]
]);
const strategy = new ProcessTreeDetectionStrategy({
read(pid) {
return processes.get(pid);
}
});
const result = detectAgent({
env: {},
pid: 100,
strategies: [strategy]
});
assert.equal(result.detected, true);
assert.equal(result.agent.id, "opencode");
});
test("process tree detection can detect devin in the hierarchy", () => {
const processes = new Map([
[200, { pid: 200, ppid: 190, command: "node" }],
[190, { pid: 190, ppid: 180, command: "/usr/local/bin/devin" }],
[180, { pid: 180, ppid: 1, command: "zsh" }]
]);
const strategy = new ProcessTreeDetectionStrategy({
read(pid) {
return processes.get(pid);
}
});
const result = detectAgent({
env: {},
pid: 200,
strategies: [strategy]
});
assert.equal(result.detected, true);
assert.equal(result.agent.id, "devin");
});
test("custom agents can be added without custom detector code", () => {
const result = detectAgent({
agents: [
{
id: "example-agent",
name: "Example Agent",
env: [{ name: "EXAMPLE_AGENT", value: "yes" }]
}
],
env: {
EXAMPLE_AGENT: "yes"
},
strategies: [new EnvironmentDetectionStrategy()]
});
assert.equal(result.detected, true);
assert.equal(result.agent.id, "example-agent");
});
+138
View File
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const projectRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
test("packed package can be installed, imported, and run as a CLI", () => {
const workspace = mkdtempSync(path.join(tmpdir(), "agent-cli-detector-"));
try {
const packOutput = execNpm(
["pack", "--ignore-scripts", "--json", "--pack-destination", workspace],
{
cwd: projectRoot,
encoding: "utf8"
}
);
const [packResult] = JSON.parse(packOutput);
const tarball = path.join(workspace, packResult.filename);
const packageContents = packResult.files.map((file) => file.path).sort();
assert.deepEqual(packageContents, [
"LICENSE",
"README.md",
"dist/cli.cjs",
"dist/cli.cjs.map",
"dist/cli.d.cts",
"dist/cli.d.ts",
"dist/cli.js",
"dist/cli.js.map",
"dist/index.cjs",
"dist/index.cjs.map",
"dist/index.d.cts",
"dist/index.d.ts",
"dist/index.js",
"dist/index.js.map",
"package.json"
]);
writeFileSync(path.join(workspace, "package.json"), '{"type":"module"}\n');
execNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], {
cwd: workspace,
stdio: "pipe"
});
const importOutput = execFileSync(
process.execPath,
[
"--input-type=module",
"--eval",
[
"import { detectAgent, isRunningFromAgent } from 'agent-cli-detector';",
"const result = detectAgent({ env: { CODEX_THREAD_ID: 'thread-123' } });",
"console.log(JSON.stringify({ result, running: isRunningFromAgent({ env: { CODEX_CI: '1' } }) }));"
].join("")
],
{
cwd: workspace,
encoding: "utf8"
}
);
assert.deepEqual(JSON.parse(importOutput), {
result: {
detected: true,
agent: {
id: "codex",
name: "Codex",
sessionId: "thread-123"
}
},
running: true
});
const requireOutput = execFileSync(
process.execPath,
[
"--eval",
[
"const { detectAgent, isRunningFromAgent } = require('agent-cli-detector');",
"const result = detectAgent({ env: { CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'session-123' } });",
"console.log(JSON.stringify({ result, running: isRunningFromAgent({ env: { CLAUDECODE: '1' } }) }));"
].join("")
],
{
cwd: workspace,
encoding: "utf8"
}
);
assert.deepEqual(JSON.parse(requireOutput), {
result: {
detected: true,
agent: {
id: "claude-code",
name: "Claude Code",
sessionId: "session-123"
}
},
running: true
});
const installedPackage = JSON.parse(
readFileSync(path.join(workspace, "node_modules/agent-cli-detector/package.json"), "utf8")
);
const binPath = path.join(workspace, "node_modules/agent-cli-detector", installedPackage.bin["agent-cli-detector"]);
const cliOutput = execFileSync(process.execPath, [binPath, "--json"], {
cwd: workspace,
env: {
...process.env,
CODEX_CI: "1",
CODEX_THREAD_ID: ""
},
encoding: "utf8"
});
assert.deepEqual(JSON.parse(cliOutput), {
detected: true,
agent: {
id: "codex",
name: "Codex"
}
});
} finally {
rmSync(workspace, { recursive: true, force: true });
}
});
function execNpm(args, options) {
if (process.env.npm_execpath !== undefined) {
return execFileSync(process.execPath, [process.env.npm_execpath, ...args], options);
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
return execFileSync(npm, args, options);
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
cli: "src/cli.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
splitting: false,
target: "node18",
outExtension({ format }) {
return {
js: format === "cjs" ? ".cjs" : ".js"
};
}
});