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 docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ Recognized values are `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencod

The CLI also recognizes markers that agent products set on their own, such as `AI_AGENT`, `CODEX_CI`, and `GEMINI_CLI`. `NETLIFY_AGENT` takes precedence over all of them, even when its value isn't recognized.

### Login source

A program that runs `netlify login` on a user’s behalf can set `NETLIFY_LOGIN_SOURCE` so a sign-up is credited to it rather than to the CLI. The only recognized value is `mcp`, which the Netlify MCP server sets. Any other value is ignored and the login URL keeps `utm_source=cli`.

### Telemetry

When telemetry is enabled, each CLI telemetry event includes the detected agent:
Expand Down
9 changes: 8 additions & 1 deletion src/utils/login-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import { getDrivingAgent } from './agent-detection.js'
// a session or run id, or a path, none of which may reach a URL.
const SOURCES_WITH_ANNOUNCED_VALUE = new Set(['NETLIFY_AGENT', 'AI_AGENT'])

const LOGIN_SOURCES = new Set(['mcp'])

const sanitizeUtmTerm = (raw: string): string => raw.replace(/[^A-Za-z0-9_.:@-]/g, '').slice(0, 64)

const getUtmSource = (env: NodeJS.ProcessEnv): string => {
const source = env.NETLIFY_LOGIN_SOURCE
return source !== undefined && LOGIN_SOURCES.has(source) ? source : 'cli'
}

const getUtmTerm = (source: string, env: NodeJS.ProcessEnv): string => {
const value = SOURCES_WITH_ANNOUNCED_VALUE.has(source) ? env[source] : undefined
return sanitizeUtmTerm(value ? `${source}:${value}` : source)
Expand All @@ -16,7 +23,7 @@ export const buildAuthorizeUrl = (ticketId: string, env: NodeJS.ProcessEnv = pro
const params = new URLSearchParams({
response_type: 'ticket',
ticket: ticketId,
utm_source: 'cli',
utm_source: getUtmSource(env),
utm_campaign: 'integrations',
})

Expand Down
20 changes: 20 additions & 0 deletions tests/unit/utils/login-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ test('tags the URL with only the CLI source and campaign when no agent is detect
)
})

test('credits the MCP server as the source when it announces the login', () => {
const params = paramsFor({ NETLIFY_LOGIN_SOURCE: 'mcp' })

expect(params.get('utm_source')).toBe('mcp')
expect(params.get('utm_campaign')).toBe('integrations')
})

test('keeps the CLI as the source for a login source that is not on the allow-list', () => {
expect(paramsFor({ NETLIFY_LOGIN_SOURCE: 'https://evil.example' }).get('utm_source')).toBe('cli')
expect(paramsFor({ NETLIFY_LOGIN_SOURCE: '' }).get('utm_source')).toBe('cli')
})

test('combines the MCP source with the announced agent', () => {
const params = paramsFor({ NETLIFY_LOGIN_SOURCE: 'mcp', NETLIFY_AGENT: 'claude-code' })

expect(params.get('utm_source')).toBe('mcp')
expect(params.get('utm_content')).toBe('claude')
expect(params.get('utm_term')).toBe('NETLIFY_AGENT:claude-code')
})

test('adds the agent name and the deciding variable with its value', () => {
const params = paramsFor({ AI_AGENT: 'claude-code_2-1-259_agent' })

Expand Down
Loading