Skip to content

Commit 950abfa

Browse files
seancdavisclaude
andauthored
feat: add the driving agent to the CLI's API User-Agent (#8505)
## Summary Adds `agent/<name>` to the User-Agent of the CLI's API client when `getDrivingAgent()` detects an agent, so the API can attribute deploys, site creation, and other command calls to agents without relying on telemetry. Also fires a telemetry event when `netlify recipes ai-context` installs context. ## What changed - `getRequestUserAgent()` in `command-helpers.ts` returns `USER_AGENT`, plus ` agent/<name>` when an agent is detected (name only, no version or source). `base-command.ts` passes it to the API client, so anonymous deploys and the commands that reuse `apiOpts.userAgent` get it too. The `USER_AGENT` text printed by `--version` and help is unchanged. - `ai-context` recipe fires `sites_aiContextInstalled` with `{ consumer }` when it creates or updates a context file. `downloadAndWriteContextFiles` now returns whether it wrote anything and rejects on a failed download or write, so failures and no-ops don't count. The ticket's `ai_context_installed` fails the CLI's event-name validation, so this uses the `sites` object like `sites_initStarted`. `agent` comes from `track()` (#8504). Linear: https://linear.app/netlify/issue/EX-3044/augment-cli-calls-to-identify-if-an-agent-is-performing-the-call 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Nq1RjpFnzzsfZf5qHLCH36 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b08eb97 commit 950abfa

7 files changed

Lines changed: 134 additions & 14 deletions

File tree

src/commands/base-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
logAndThrowError,
2525
logJson,
2626
exit,
27+
getRequestUserAgent,
2728
getToken,
2829
log,
2930
version,
@@ -670,7 +671,7 @@ export default class BaseCommand extends Command {
670671
host?: string
671672
pathPrefix?: string
672673
} = {
673-
userAgent: USER_AGENT,
674+
userAgent: getRequestUserAgent(),
674675
}
675676

676677
if (process.env.NETLIFY_API_URL) {

src/recipes/ai-context/context.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,11 @@ export const deleteFile = async (path: string) => {
221221
}
222222
}
223223

224-
export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => {
225-
await Promise.allSettled(
224+
export const downloadAndWriteContextFiles = async (
225+
consumer: ConsumerConfig,
226+
{ command }: RunRecipeOptions,
227+
): Promise<boolean> => {
228+
const results = await Promise.allSettled(
226229
Object.keys(consumer.contextScopes).map(async (contextKey) => {
227230
const contextConfig = consumer.contextScopes[contextKey]
228231

@@ -264,7 +267,7 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c
264267
absoluteFilePath,
265268
)} contains the latest version of the context files.`,
266269
)
267-
return
270+
return false
268271
}
269272

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

291294
log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`)
295+
return true
292296
}),
293297
)
298+
299+
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
300+
if (failure) {
301+
throw failure.reason
302+
}
303+
304+
return results.some((result) => result.status === 'fulfilled' && result.value)
294305
}

src/recipes/ai-context/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import execa from 'execa'
55

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

910
import {
1011
getExistingContext,
@@ -156,8 +157,9 @@ export const run = async (runOptions: RunRecipeOptions) => {
156157
return
157158
}
158159

160+
let wroteFiles = false
159161
try {
160-
await downloadAndWriteContextFiles(consumer, runOptions)
162+
wroteFiles = await downloadAndWriteContextFiles(consumer, runOptions)
161163

162164
// the deprecated MCP file path
163165
// let's remove that file if it exists.
@@ -171,4 +173,8 @@ export const run = async (runOptions: RunRecipeOptions) => {
171173
} catch (error) {
172174
logAndThrowError(error)
173175
}
176+
177+
if (wroteFiles) {
178+
await track('sites_aiContextInstalled', { consumer: consumer.key })
179+
}
174180
}

src/utils/command-helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import terminalLink from 'terminal-link'
1212

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

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

58+
export const getRequestUserAgent = (env: NodeJS.ProcessEnv = process.env): string => {
59+
const agent = getDrivingAgent(env)
60+
return agent ? `${USER_AGENT} agent/${agent.name}` : USER_AGENT
61+
}
62+
5763
/** A list of base command flags that needs to be sorted down on documentation and on help pages */
5864
const BASE_FLAGS = new Set(['--debug', '--http-proxy', '--http-proxy-certificate-filename'])
5965

tests/unit/recipes/ai-context/download-context-files.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe('downloadAndWriteContextFiles', () => {
9898

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

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

126126
// Execute the actual function
127-
await downloadAndWriteContextFiles(mockConsumer, mockRunOptions)
127+
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(false)
128128

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

133+
test('reports no writes when the consumer has no context scopes', async () => {
134+
await expect(downloadAndWriteContextFiles({ ...mockConsumer, contextScopes: {} }, mockRunOptions)).resolves.toBe(
135+
false,
136+
)
137+
expect(fs.writeFile).not.toHaveBeenCalled()
138+
})
139+
133140
test('applies overrides when updating existing Netlify files', async () => {
134141
// Mock existing file with different version
135142
const existingContent =
@@ -199,22 +206,25 @@ describe('downloadAndWriteContextFiles', () => {
199206
)
200207
})
201208

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

209-
// Execute the actual function and expect error
210-
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined()
216+
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow(
217+
'An error occurred when pulling the latest context file',
218+
)
219+
expect(fs.writeFile).not.toHaveBeenCalled()
211220
})
212221

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

226-
// Execute the actual function and expect error
227-
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined()
236+
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow(
237+
'This command requires version 2.0.0',
238+
)
239+
expect(fs.writeFile).not.toHaveBeenCalled()
228240
})
229241
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
2+
3+
import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js'
4+
5+
const { cursorConsumer } = vi.hoisted(() => ({
6+
cursorConsumer: {
7+
key: 'cursor',
8+
presentedName: 'Cursor',
9+
consumerProcessCmd: 'cursor',
10+
path: './.cursor/rules',
11+
ext: 'mdc',
12+
contextScopes: { serverless: { scope: 'Serverless functions' } },
13+
},
14+
}))
15+
16+
vi.mock('../../../../src/recipes/ai-context/context.js', () => ({
17+
NTL_DEV_MCP_FILE_NAME: 'netlify-development.mdc',
18+
getContextConsumers: vi.fn().mockResolvedValue([cursorConsumer]),
19+
downloadAndWriteContextFiles: vi.fn().mockResolvedValue(true),
20+
getExistingContext: vi.fn().mockResolvedValue(null),
21+
deleteFile: vi.fn(),
22+
}))
23+
24+
vi.mock('../../../../src/utils/command-helpers.js', () => ({
25+
log: vi.fn(),
26+
logAndThrowError: vi.fn((error: unknown) => {
27+
throw error
28+
}),
29+
version: '1.0.0',
30+
}))
31+
32+
vi.mock('../../../../src/utils/telemetry/index.js', () => ({
33+
track: vi.fn(),
34+
}))
35+
36+
vi.mock('inquirer', () => ({
37+
default: { prompt: vi.fn().mockResolvedValue({ consumerKey: 'cursor' }) },
38+
}))
39+
40+
import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js'
41+
import { run } from '../../../../src/recipes/ai-context/index.js'
42+
import { track } from '../../../../src/utils/telemetry/index.js'
43+
44+
const runRecipe = () => run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions)
45+
46+
beforeEach(() => {
47+
vi.mocked(track).mockClear()
48+
vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true')
49+
})
50+
51+
afterEach(() => {
52+
vi.unstubAllEnvs()
53+
})
54+
55+
test('tracks sites_aiContextInstalled with the consumer the context was installed for', async () => {
56+
await runRecipe()
57+
58+
expect(track).toHaveBeenCalledWith('sites_aiContextInstalled', { consumer: 'cursor' })
59+
})
60+
61+
test('does not track an install when every context file was already current', async () => {
62+
vi.mocked(downloadAndWriteContextFiles).mockResolvedValueOnce(false)
63+
64+
await runRecipe()
65+
66+
expect(track).not.toHaveBeenCalled()
67+
})
68+
69+
test('does not track an install when writing the context files fails', async () => {
70+
vi.mocked(downloadAndWriteContextFiles).mockRejectedValueOnce(new Error('download failed'))
71+
72+
await expect(runRecipe()).rejects.toThrow('download failed')
73+
expect(track).not.toHaveBeenCalled()
74+
})

tests/unit/utils/command-helpers.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { describe, expect, test } from 'vitest'
22

3-
import { normalizeConfig } from '../../../src/utils/command-helpers.js'
3+
import { USER_AGENT, getRequestUserAgent, normalizeConfig } from '../../../src/utils/command-helpers.js'
4+
5+
describe('getRequestUserAgent', () => {
6+
test('appends only the agent name, without its version or source', () => {
7+
expect(getRequestUserAgent({ AI_AGENT: 'claude-code@2.1.0' })).toBe(`${USER_AGENT} agent/claude`)
8+
})
9+
10+
test('returns the User-Agent unchanged when no agent is detected', () => {
11+
expect(getRequestUserAgent({})).toBe(USER_AGENT)
12+
})
13+
})
414

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

0 commit comments

Comments
 (0)