diff --git a/README.md b/README.md index b0aa618..f87386d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/agents.json b/agents.json index e70550d..ffde92f 100644 --- a/agents.json +++ b/agents.json @@ -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" + } + ] } }, { @@ -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", diff --git a/agents.schema.json b/agents.schema.json index 7810829..2c63857 100644 --- a/agents.schema.json +++ b/agents.schema.json @@ -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": { @@ -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.", @@ -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" } + } } } } diff --git a/src/determine-agent.test.ts b/src/determine-agent.test.ts index 247e071..da6ea81 100644 --- a/src/determine-agent.test.ts +++ b/src/determine-agent.test.ts @@ -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', ''); @@ -18,6 +20,10 @@ 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', ''); @@ -25,10 +31,15 @@ describe('determineAgent', () => { 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(); }); @@ -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', () => { diff --git a/src/evaluate-condition.test.ts b/src/evaluate-condition.test.ts index 45cbd71..9e77356 100644 --- a/src/evaluate-condition.test.ts +++ b/src/evaluate-condition.test.ts @@ -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'); diff --git a/src/evaluate-condition.ts b/src/evaluate-condition.ts index 4cbff4b..cced26f 100644 --- a/src/evaluate-condition.ts +++ b/src/evaluate-condition.ts @@ -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); diff --git a/src/types.ts b/src/types.ts index bb642b7..fb22159 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,10 +44,18 @@ interface EnvValueCondition { name: string; value: string; } +interface EnvMatchesCondition { + type: 'env_matches'; + name: string; + pattern: string; +} interface FileExistsCondition { type: 'file_exists'; path: string; } +interface NoTtyCondition { + type: 'no_tty'; +} interface AnyOfCondition { type: 'anyOf'; conditions: Condition[]; @@ -59,7 +67,9 @@ interface AllOfCondition { export type Condition = | EnvSetCondition | EnvValueCondition + | EnvMatchesCondition | FileExistsCondition + | NoTtyCondition | AnyOfCondition | AllOfCondition;