Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
98d1e39
feat: add getDrivingAgent agent-detection helper
seancdavis Sep 8, 2026
84c9b48
test: cover getDrivingAgent signals, nesting, and ignored markers
seancdavis Sep 8, 2026
38c708f
docs: document NETLIFY_AGENT for agent detection
seancdavis Sep 8, 2026
ba67e09
fix: guard agent-name lookup against prototype keys
seancdavis Sep 8, 2026
608b21e
test: consolidate prototype-key regression cases
seancdavis Sep 8, 2026
5154f78
feat: detect Warp runs and let NETLIFY_AGENT override unconditionally
seancdavis Sep 9, 2026
5b2eae4
fix: address CodeRabbit review on agent detection
seancdavis Sep 9, 2026
4ec9772
fix: tighten agent-detection precedence and input hardening
seancdavis Sep 9, 2026
000b3b8
fix: honor name@version announcements and let the first match win
seancdavis Sep 9, 2026
6a205b5
feat: recognize documented AI_AGENT aliases
seancdavis Sep 9, 2026
4d55c4d
Merge remote-tracking branch 'origin/main' into ex-3040-agent-detection
seancdavis Sep 10, 2026
25b3edb
feat: add the driving agent to outgoing User-Agent headers
seancdavis Sep 10, 2026
2635468
test: expect the full User-Agent on telemetry requests
seancdavis Sep 10, 2026
3862629
fix: send the agent User-Agent on every Netlify request
seancdavis Sep 10, 2026
00d059c
refactor: drop comments from netlifyFetch helpers
seancdavis Sep 10, 2026
940bee2
revert: limit the agent User-Agent to direct API calls
seancdavis Sep 11, 2026
3eb21ee
Merge origin/main and limit the agent User-Agent to the API client
seancdavis Sep 11, 2026
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
3 changes: 2 additions & 1 deletion src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
logAndThrowError,
logJson,
exit,
getRequestUserAgent,
getToken,
log,
version,
Expand Down Expand Up @@ -670,7 +671,7 @@ export default class BaseCommand extends Command {
host?: string
pathPrefix?: string
} = {
userAgent: USER_AGENT,
userAgent: getRequestUserAgent(),
}

if (process.env.NETLIFY_API_URL) {
Expand Down
17 changes: 14 additions & 3 deletions src/recipes/ai-context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,11 @@ export const deleteFile = async (path: string) => {
}
}

export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => {
await Promise.allSettled(
export const downloadAndWriteContextFiles = async (
consumer: ConsumerConfig,
{ command }: RunRecipeOptions,
): Promise<boolean> => {
const results = await Promise.allSettled(
Object.keys(consumer.contextScopes).map(async (contextKey) => {
const contextConfig = consumer.contextScopes[contextKey]

Expand Down Expand Up @@ -264,7 +267,7 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c
absoluteFilePath,
)} contains the latest version of the context files.`,
)
return
return false
}

// We must preserve any overrides found in the existing file.
Expand All @@ -289,6 +292,14 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c
await writeFile(absoluteFilePath, contents)

log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`)
return true
}),
)

const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure) {
throw failure.reason
}

return results.some((result) => result.status === 'fulfilled' && result.value)
}
8 changes: 7 additions & 1 deletion src/recipes/ai-context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import execa from 'execa'

import type { RunRecipeOptions } from '../../commands/recipes/recipes.js'
import { logAndThrowError, log, version } from '../../utils/command-helpers.js'
import { track } from '../../utils/telemetry/index.js'

import {
getExistingContext,
Expand Down Expand Up @@ -156,8 +157,9 @@ export const run = async (runOptions: RunRecipeOptions) => {
return
}

let wroteFiles = false
try {
await downloadAndWriteContextFiles(consumer, runOptions)
wroteFiles = await downloadAndWriteContextFiles(consumer, runOptions)

// the deprecated MCP file path
// let's remove that file if it exists.
Expand All @@ -171,4 +173,8 @@ export const run = async (runOptions: RunRecipeOptions) => {
} catch (error) {
logAndThrowError(error)
}

if (wroteFiles) {
await track('sites_aiContextInstalled', { consumer: consumer.key })
}
}
6 changes: 6 additions & 0 deletions src/utils/command-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import terminalLink from 'terminal-link'

import { startSpinner } from '../lib/spinner.js'

import { getDrivingAgent } from './agent-detection.js'
import getCLIPackageJson from './get-cli-package-json.js'
import { reportError } from './telemetry/report-error.js'
import type { TokenLocation } from './types.js'
Expand Down Expand Up @@ -54,6 +55,11 @@ const { name, version: packageVersion } = await getCLIPackageJson()
export const version = packageVersion
export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}`

export const getRequestUserAgent = (env: NodeJS.ProcessEnv = process.env): string => {
const agent = getDrivingAgent(env)
return agent ? `${USER_AGENT} agent/${agent.name}` : USER_AGENT
}

/** A list of base command flags that needs to be sorted down on documentation and on help pages */
const BASE_FLAGS = new Set(['--debug', '--http-proxy', '--http-proxy-certificate-filename'])

Expand Down
28 changes: 20 additions & 8 deletions tests/unit/recipes/ai-context/download-context-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('downloadAndWriteContextFiles', () => {

test('downloads and writes context files for all scopes', async () => {
// Execute the actual function
await downloadAndWriteContextFiles(mockConsumer, mockRunOptions)
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(true)

// Verify expected calls
expect(mockFetch).toHaveBeenCalledTimes(2) // Once for each scope
Expand All @@ -124,12 +124,19 @@ describe('downloadAndWriteContextFiles', () => {
fs.readFile.mockResolvedValue(mockProviderContent)

// Execute the actual function
await downloadAndWriteContextFiles(mockConsumer, mockRunOptions)
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(false)

// Verify expected behavior - no writes when versions match
expect(fs.writeFile).not.toHaveBeenCalled()
})

test('reports no writes when the consumer has no context scopes', async () => {
await expect(downloadAndWriteContextFiles({ ...mockConsumer, contextScopes: {} }, mockRunOptions)).resolves.toBe(
false,
)
expect(fs.writeFile).not.toHaveBeenCalled()
})

test('applies overrides when updating existing Netlify files', async () => {
// Mock existing file with different version
const existingContent =
Expand Down Expand Up @@ -199,22 +206,25 @@ describe('downloadAndWriteContextFiles', () => {
)
})

test('handles download errors gracefully', async () => {
test('rejects when a context file cannot be downloaded', async () => {
// Mock fetch to return not ok
// @ts-expect-error mocking is not 100% consistent with full API and types for
fetch.mockResolvedValue({
ok: false,
})

// Execute the actual function and expect error
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined()
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow(
'An error occurred when pulling the latest context file',
)
expect(fs.writeFile).not.toHaveBeenCalled()
})

test('checks CLI version compatibility', async () => {
test('rejects when the CLI is older than the minimum version', async () => {
// Set higher minimum CLI version
// @ts-expect-error mocking is not 100% consistent with full API and types for
fetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve(mockProviderContent),
headers: {
get: (header: string) => {
if (header === 'x-cli-min-ver') return '2.0.0' // Higher than the mocked current version
Expand All @@ -223,7 +233,9 @@ describe('downloadAndWriteContextFiles', () => {
},
})

// Execute the actual function and expect error
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined()
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow(
'This command requires version 2.0.0',
)
expect(fs.writeFile).not.toHaveBeenCalled()
})
})
74 changes: 74 additions & 0 deletions tests/unit/recipes/ai-context/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'

import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js'

const { cursorConsumer } = vi.hoisted(() => ({
cursorConsumer: {
key: 'cursor',
presentedName: 'Cursor',
consumerProcessCmd: 'cursor',
path: './.cursor/rules',
ext: 'mdc',
contextScopes: { serverless: { scope: 'Serverless functions' } },
},
}))

vi.mock('../../../../src/recipes/ai-context/context.js', () => ({
NTL_DEV_MCP_FILE_NAME: 'netlify-development.mdc',
getContextConsumers: vi.fn().mockResolvedValue([cursorConsumer]),
downloadAndWriteContextFiles: vi.fn().mockResolvedValue(true),
getExistingContext: vi.fn().mockResolvedValue(null),
deleteFile: vi.fn(),
}))

vi.mock('../../../../src/utils/command-helpers.js', () => ({
log: vi.fn(),
logAndThrowError: vi.fn((error: unknown) => {
throw error
}),
version: '1.0.0',
}))

vi.mock('../../../../src/utils/telemetry/index.js', () => ({
track: vi.fn(),
}))

vi.mock('inquirer', () => ({
default: { prompt: vi.fn().mockResolvedValue({ consumerKey: 'cursor' }) },
}))

import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js'
import { run } from '../../../../src/recipes/ai-context/index.js'
import { track } from '../../../../src/utils/telemetry/index.js'

const runRecipe = () => run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions)

beforeEach(() => {
vi.mocked(track).mockClear()
vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true')
})

afterEach(() => {
vi.unstubAllEnvs()
})

test('tracks sites_aiContextInstalled with the consumer the context was installed for', async () => {
await runRecipe()

expect(track).toHaveBeenCalledWith('sites_aiContextInstalled', { consumer: 'cursor' })
})

test('does not track an install when every context file was already current', async () => {
vi.mocked(downloadAndWriteContextFiles).mockResolvedValueOnce(false)

await runRecipe()

expect(track).not.toHaveBeenCalled()
})

test('does not track an install when writing the context files fails', async () => {
vi.mocked(downloadAndWriteContextFiles).mockRejectedValueOnce(new Error('download failed'))

await expect(runRecipe()).rejects.toThrow('download failed')
expect(track).not.toHaveBeenCalled()
})
12 changes: 11 additions & 1 deletion tests/unit/utils/command-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { describe, expect, test } from 'vitest'

import { normalizeConfig } from '../../../src/utils/command-helpers.js'
import { USER_AGENT, getRequestUserAgent, normalizeConfig } from '../../../src/utils/command-helpers.js'

describe('getRequestUserAgent', () => {
test('appends only the agent name, without its version or source', () => {
expect(getRequestUserAgent({ AI_AGENT: '[email protected]' })).toBe(`${USER_AGENT} agent/claude`)
})

test('returns the User-Agent unchanged when no agent is detected', () => {
expect(getRequestUserAgent({})).toBe(USER_AGENT)
})
})

describe('normalizeConfig', () => {
test('should remove publish and publishOrigin property if publishOrigin is "default"', () => {
Expand Down
Loading