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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ This package can detect the following AI agents and development environments:
- **Antigravity** (Google DeepMind)
- **Augment**
- **OpenCode**
- **Goose** (Block)
- **Junie** (JetBrains)
- **Kiro** (AWS)
- **Pi**
- **GitHub Copilot**
- **Replit**
- **Custom agents** (via the `AI_AGENT` environment variable)
Expand Down
51 changes: 50 additions & 1 deletion agents.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,44 @@
"name": "opencode",
"match": {
"type": "anyOf",
"conditions": [{ "type": "env_set", "name": "OPENCODE_CLIENT" }]
"conditions": [
{ "type": "env_set", "name": "OPENCODE_CLIENT" },
{ "type": "env_set", "name": "OPENCODE" }
]
}
},
{
"key": "GOOSE",
"name": "goose",
"match": {
"type": "anyOf",
"conditions": [{ "type": "env_set", "name": "GOOSE_PROVIDER" }]
}
},
{
"key": "JUNIE",
"name": "junie",
"match": {
"type": "anyOf",
"conditions": [
{ "type": "env_set", "name": "JUNIE_DATA" },
{ "type": "env_set", "name": "JUNIE_SHIM_PATH" }
]
}
},
{
"key": "PI",
"name": "pi",
"description": "Matches when the PATH contains a '.pi/agent' (or '.pi\\\\agent') segment.",
"match": {
"type": "anyOf",
"conditions": [
{
"type": "env_matches",
"name": "PATH",
"pattern": "\\.pi[\\\\/]agent"
}
]
}
},
{
Expand Down Expand Up @@ -120,6 +157,18 @@
]
}
},
{
"key": "KIRO",
"name": "kiro",
"description": "AWS Kiro. TERM_PROGRAM=kiro is set by both the IDE terminal and the CLI agent, so gate on no_tty to avoid misdetecting a human at the integrated terminal.",
"match": {
"type": "allOf",
"conditions": [
{ "type": "env_matches", "name": "TERM_PROGRAM", "pattern": "kiro" },
{ "type": "no_tty" }
]
}
},
{
"key": "DEVIN",
"name": "devin",
Expand Down
24 changes: 23 additions & 1 deletion agents.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@
{ "$ref": "#/definitions/allOf" },
{ "$ref": "#/definitions/envSet" },
{ "$ref": "#/definitions/envValue" },
{ "$ref": "#/definitions/fileExists" }
{ "$ref": "#/definitions/envMatches" },
{ "$ref": "#/definitions/fileExists" },
{ "$ref": "#/definitions/noTty" }
]
},
"anyOf": {
Expand Down Expand Up @@ -106,6 +108,17 @@
"value": { "type": "string" }
}
},
"envMatches": {
"type": "object",
"description": "Leaf: matches when the named environment variable is set and its value matches `pattern`. `pattern` is a regular-expression source string (no delimiters), applied with substring semantics (not anchored) and case-sensitive. An unset variable never matches.",
"required": ["type", "name", "pattern"],
"additionalProperties": false,
"properties": {
"type": { "const": "env_matches" },
"name": { "type": "string" },
"pattern": { "type": "string" }
}
},
"fileExists": {
"type": "object",
"description": "Leaf: matches when the given filesystem path exists.",
Expand All @@ -115,6 +128,15 @@
"type": { "const": "file_exists" },
"path": { "type": "string" }
}
},
"noTty": {
"type": "object",
"description": "Leaf: matches when the process is non-interactive (standard output is not a TTY). Unlike the other leaves this reads runtime process state rather than the environment or filesystem; it exists to disambiguate terminal-based IDE agents (e.g. pairing an `env_matches` on TERM_PROGRAM with `no_tty`) so a human at the IDE's integrated terminal is not misdetected as the agent.",
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": { "const": "no_tty" }
}
}
}
}
145 changes: 145 additions & 0 deletions src/determine-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import mockFs from 'mock-fs';
vi.setConfig({ testTimeout: 6 * 60 * 1000 });

describe('determineAgent', () => {
const originalIsTTY = process.stdout.isTTY;

beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv('AI_AGENT', '');
Expand All @@ -18,17 +20,26 @@ describe('determineAgent', () => {
vi.stubEnv('ANTIGRAVITY_AGENT', '');
vi.stubEnv('AUGMENT_AGENT', '');
vi.stubEnv('OPENCODE_CLIENT', '');
vi.stubEnv('OPENCODE', '');
vi.stubEnv('GOOSE_PROVIDER', '');
vi.stubEnv('JUNIE_DATA', '');
vi.stubEnv('JUNIE_SHIM_PATH', '');
vi.stubEnv('CLAUDECODE', '');
vi.stubEnv('CLAUDE_CODE', '');
vi.stubEnv('CLAUDE_CODE_IS_COWORK', '');
vi.stubEnv('REPL_ID', '');
vi.stubEnv('COPILOT_MODEL', '');
vi.stubEnv('COPILOT_ALLOW_ALL', '');
vi.stubEnv('COPILOT_GITHUB_TOKEN', '');
// PATH and TERM_PROGRAM are neutralized so a stray `.pi/agent` segment or
// a `kiro` terminal in the real environment can't leak into detection.
vi.stubEnv('PATH', '/usr/bin');
vi.stubEnv('TERM_PROGRAM', '');
});

afterEach(() => {
vi.unstubAllEnvs();
process.stdout.isTTY = originalIsTTY;
mockFs.restore();
});

Expand Down Expand Up @@ -318,6 +329,140 @@ describe('determineAgent', () => {
});
});
});

describe('OPENCODE set', () => {
beforeEach(() => {
vi.stubEnv('OPENCODE', '1');
});

it('detects opencode', async () => {
const result = await determineAgent();
expect(result).toEqual({
isAgent: true,
agent: { name: KNOWN_AGENTS.OPENCODE },
});
});
});
});

describe('goose detection', () => {
describe('GOOSE_PROVIDER not set', () => {
it('returns no agent', async () => {
const result = await determineAgent();
expect(result).toEqual({ isAgent: false });
});
});

describe('GOOSE_PROVIDER set', () => {
beforeEach(() => {
vi.stubEnv('GOOSE_PROVIDER', 'anthropic');
});

it('detects goose', async () => {
const result = await determineAgent();
expect(result).toEqual({
isAgent: true,
agent: { name: KNOWN_AGENTS.GOOSE },
});
});
});
});

describe('junie detection', () => {
describe('neither JUNIE var set', () => {
it('returns no agent', async () => {
const result = await determineAgent();
expect(result).toEqual({ isAgent: false });
});
});

describe('JUNIE_DATA set', () => {
beforeEach(() => {
vi.stubEnv('JUNIE_DATA', '/tmp/junie');
});

it('detects junie', async () => {
const result = await determineAgent();
expect(result).toEqual({
isAgent: true,
agent: { name: KNOWN_AGENTS.JUNIE },
});
});
});

describe('JUNIE_SHIM_PATH set', () => {
beforeEach(() => {
vi.stubEnv('JUNIE_SHIM_PATH', '/tmp/junie/shim');
});

it('detects junie', async () => {
const result = await determineAgent();
expect(result).toEqual({
isAgent: true,
agent: { name: KNOWN_AGENTS.JUNIE },
});
});
});
});

describe('pi detection', () => {
describe('PATH has no .pi/agent segment', () => {
it('returns no agent', async () => {
const result = await determineAgent();
expect(result).toEqual({ isAgent: false });
});
});

describe('PATH contains a .pi/agent segment', () => {
beforeEach(() => {
vi.stubEnv('PATH', '/usr/bin:/home/me/.pi/agent/bin');
});

it('detects pi', async () => {
const result = await determineAgent();
expect(result).toEqual({
isAgent: true,
agent: { name: KNOWN_AGENTS.PI },
});
});
});
});

describe('kiro detection', () => {
describe('TERM_PROGRAM is not kiro', () => {
it('returns no agent', async () => {
process.stdout.isTTY = false;
const result = await determineAgent();
expect(result).toEqual({ isAgent: false });
});
});

describe('TERM_PROGRAM is kiro without a TTY', () => {
beforeEach(() => {
vi.stubEnv('TERM_PROGRAM', 'kiro');
process.stdout.isTTY = false;
});

it('detects kiro', async () => {
const result = await determineAgent();
expect(result).toEqual({
isAgent: true,
agent: { name: KNOWN_AGENTS.KIRO },
});
});
});

describe('TERM_PROGRAM is kiro with a TTY (human at the IDE terminal)', () => {
beforeEach(() => {
vi.stubEnv('TERM_PROGRAM', 'kiro');
process.stdout.isTTY = true;
});

it('does not detect kiro', async () => {
const result = await determineAgent();
expect(result).toEqual({ isAgent: false });
});
});
});

describe('claude detection', () => {
Expand Down
74 changes: 74 additions & 0 deletions src/evaluate-condition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,80 @@ describe('evaluateCondition', () => {
});
});

describe('env_matches', () => {
it('is true when the value matches the pattern', async () => {
vi.stubEnv('PATH', '/usr/bin:/home/me/.pi/agent/bin');
expect(
await evaluateCondition({
type: 'env_matches',
name: 'PATH',
pattern: '\\.pi[\\\\/]agent',
})
).toBe(true);
});

it('matches a backslash path separator too', async () => {
vi.stubEnv('PATH', 'C:\\Users\\me\\.pi\\agent\\bin');
expect(
await evaluateCondition({
type: 'env_matches',
name: 'PATH',
pattern: '\\.pi[\\\\/]agent',
})
).toBe(true);
});

it('is false when the value does not match', async () => {
vi.stubEnv('PATH', '/usr/bin:/usr/local/bin');
expect(
await evaluateCondition({
type: 'env_matches',
name: 'PATH',
pattern: '\\.pi[\\\\/]agent',
})
).toBe(false);
});

it('is false when the variable is unset', async () => {
vi.stubEnv('TERM_PROGRAM', '');
expect(
await evaluateCondition({
type: 'env_matches',
name: 'TERM_PROGRAM',
pattern: 'kiro',
})
).toBe(false);
});

it('is false when the pattern is invalid rather than throwing', async () => {
vi.stubEnv('TERM_PROGRAM', 'kiro');
expect(
await evaluateCondition({
type: 'env_matches',
name: 'TERM_PROGRAM',
pattern: '(',
})
).toBe(false);
});
});

describe('no_tty', () => {
const original = process.stdout.isTTY;
afterEach(() => {
process.stdout.isTTY = original;
});

it('is true when stdout is not a TTY', async () => {
process.stdout.isTTY = false;
expect(await evaluateCondition({ type: 'no_tty' })).toBe(true);
});

it('is false when stdout is a TTY', async () => {
process.stdout.isTTY = true;
expect(await evaluateCondition({ type: 'no_tty' })).toBe(false);
});
});

describe('anyOf', () => {
it('is true when at least one child is true', async () => {
vi.stubEnv('B', '1');
Expand Down
14 changes: 14 additions & 0 deletions src/evaluate-condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ export async function evaluateCondition(
return Boolean(process.env[condition.name]);
case 'env_value':
return process.env[condition.name] === condition.value;
case 'env_matches': {
const value = process.env[condition.name];
if (!value) {
return false;
}
try {
return new RegExp(condition.pattern).test(value);
} catch {
// A malformed pattern in the spec should never throw at detection time.
return false;
}
}
case 'no_tty':
return !process.stdout?.isTTY;
case 'file_exists':
try {
await access(condition.path, constants.F_OK);
Expand Down
Loading
Loading