-
Notifications
You must be signed in to change notification settings - Fork 404
feat: support granular context file setting #7284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking βSign up for GitHubβ, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0fbfd50
403e4ae
fa5a990
b9d2881
0da2957
5965c82
cfa9967
5ebfa8f
f3a0833
01931b0
2e0a697
514a175
f5b8e44
61805f4
2c804e6
f76ade1
9ce0d89
050bbf4
a5c4cf2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,47 +1,58 @@ | ||
import { resolve } from 'node:path' | ||
|
||
import inquirer from 'inquirer' | ||
import semver from 'semver' | ||
import execa from 'execa' | ||
|
||
import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' | ||
import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js' | ||
import { logAndThrowError, log, version } from '../../utils/command-helpers.js' | ||
|
||
import { | ||
applyOverrides, | ||
downloadFile, | ||
getExistingContext, | ||
parseContextFile, | ||
writeFile, | ||
FILE_NAME, | ||
NETLIFY_PROVIDER, | ||
NTL_DEV_MCP_FILE_NAME, | ||
getContextConsumers, | ||
ConsumerConfig, | ||
deleteFile, | ||
downloadAndWriteContextFiles, | ||
} from './context.js' | ||
|
||
export const description = 'Manage context files for AI tools' | ||
|
||
const IDE_RULES_PATH_MAP = { | ||
windsurf: '.windsurf/rules', | ||
cursor: '.cursor/rules', | ||
// context consumers endpoints returns all supported IDE and other consumers | ||
// that can be used to pull context files. It also includes a catchall consumer | ||
// for outlining all context that an unspecified consumer would handle. | ||
const allContextConsumers = (await getContextConsumers(version)).filter((consumer) => !consumer.hideFromCLI) | ||
const cliContextConsumers = allContextConsumers.filter((consumer) => !consumer.hideFromCLI) | ||
|
||
const rulesForDefaultConsumer = allContextConsumers.find((consumer) => consumer.key === 'catchall-consumer') ?? { | ||
key: 'catchall-consumer', | ||
path: './ai-context', | ||
presentedName: '', | ||
ext: 'mdc', | ||
contextScopes: {}, | ||
hideFromCLI: true, | ||
} | ||
|
||
const presets = [ | ||
{ name: 'Windsurf rules (.windsurf/rules/)', value: IDE_RULES_PATH_MAP.windsurf }, | ||
{ name: 'Cursor rules (.cursor/rules/)', value: IDE_RULES_PATH_MAP.cursor }, | ||
{ name: 'Custom location', value: '' }, | ||
] | ||
const presets = cliContextConsumers.map((consumer) => ({ | ||
name: consumer.presentedName, | ||
value: consumer.key, | ||
})) | ||
|
||
const promptForPath = async (): Promise<string> => { | ||
const { presetPath } = await inquirer.prompt([ | ||
// always add the custom location option (not preset from API) | ||
presets.push({ name: 'Custom location', value: rulesForDefaultConsumer.key }) | ||
|
||
const promptForContextConsumerSelection = async (): Promise<ConsumerConfig> => { | ||
const { consumerKey } = await inquirer.prompt([ | ||
{ | ||
name: 'presetPath', | ||
name: 'consumerKey', | ||
message: 'Where should we put the context files?', | ||
type: 'list', | ||
choices: presets, | ||
}, | ||
]) | ||
|
||
if (presetPath) { | ||
return presetPath | ||
const contextConsumer = consumerKey ? cliContextConsumers.find((consumer) => consumer.key === consumerKey) : null | ||
if (contextConsumer) { | ||
return contextConsumer | ||
} | ||
|
||
const { customPath } = await inquirer.prompt([ | ||
|
@@ -54,40 +65,24 @@ const promptForPath = async (): Promise<string> => { | |
]) | ||
|
||
if (customPath) { | ||
return customPath | ||
return { ...rulesForDefaultConsumer, path: customPath || rulesForDefaultConsumer.path } | ||
} | ||
|
||
log('You must select a path.') | ||
|
||
return promptForPath() | ||
} | ||
|
||
type IDE = { | ||
name: string | ||
command: string | ||
rulesPath: string | ||
return promptForContextConsumerSelection() | ||
} | ||
const IDE: IDE[] = [ | ||
{ | ||
name: 'Windsurf', | ||
command: 'windsurf', | ||
rulesPath: IDE_RULES_PATH_MAP.windsurf, | ||
}, | ||
{ | ||
name: 'Cursor', | ||
command: 'cursor', | ||
rulesPath: IDE_RULES_PATH_MAP.cursor, | ||
}, | ||
] | ||
|
||
/** | ||
* Checks if a command belongs to a known IDEs by checking if it includes a specific string. | ||
sean-roberts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* For example, the command that starts windsurf looks something like "/applications/windsurf.app/contents/...". | ||
*/ | ||
const getIDEFromCommand = (command: string): IDE | null => { | ||
const getConsumerKeyFromCommand = (command: string): string | null => { | ||
// The actual command is something like "/applications/windsurf.app/contents/...", but we are only looking for windsurf | ||
const match = IDE.find((ide) => command.includes(ide.command)) | ||
return match ?? null | ||
const match = cliContextConsumers.find( | ||
(consumer) => consumer.consumerProcessCmd && command.includes(consumer.consumerProcessCmd), | ||
) | ||
return match ? match.key : null | ||
} | ||
|
||
/** | ||
|
@@ -98,104 +93,82 @@ const getCommandAndParentPID = async ( | |
): Promise<{ | ||
parentPID: number | ||
command: string | ||
ide: IDE | null | ||
consumerKey: string | null | ||
}> => { | ||
const { stdout } = await execa('ps', ['-p', String(pid), '-o', 'ppid=,comm=']) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens on Windows? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know, @smnh will need to verify. Currently, this work is all wrapped in try/catch and will skip if any errors are thrown and, if it simply doesn't work, then it will also continue as it does without this logic. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @eduardoboucas on Windows and on systems that do not support |
||
const output = stdout.trim() | ||
const spaceIndex = output.indexOf(' ') | ||
const parentPID = output.substring(0, spaceIndex) | ||
const command = output.substring(spaceIndex + 1).toLowerCase() | ||
return { | ||
parentPID: parseInt(parentPID, 10), | ||
command: command, | ||
ide: getIDEFromCommand(command), | ||
parentPID: Number(parentPID), | ||
command, | ||
consumerKey: getConsumerKeyFromCommand(command), | ||
} | ||
} | ||
|
||
const getPathByDetectingIDE = async (): Promise<string | null> => { | ||
const getPathByDetectingIDE = async (): Promise<ConsumerConfig | null> => { | ||
// Go up the chain of ancestor process IDs and find if one of their commands matches an IDE. | ||
const ppid = process.ppid | ||
let result: Awaited<ReturnType<typeof getCommandAndParentPID>> | ||
try { | ||
result = await getCommandAndParentPID(ppid) | ||
while (result.parentPID !== 1 && !result.ide) { | ||
while (result.parentPID !== 1 && !result.consumerKey) { | ||
result = await getCommandAndParentPID(result.parentPID) | ||
} | ||
} catch { | ||
// The command "ps -p {pid} -o ppid=,comm=" didn't work, | ||
// perhaps we are on a machine that doesn't support it. | ||
return null | ||
} | ||
return result.ide ? result.ide.rulesPath : null | ||
|
||
if (result?.consumerKey) { | ||
const contextConsumer = cliContextConsumers.find((consumer) => consumer.key === result.consumerKey) | ||
if (contextConsumer) { | ||
return contextConsumer | ||
} | ||
} | ||
|
||
return null | ||
} | ||
|
||
export const run = async ({ args, command }: RunRecipeOptions) => { | ||
// Start the download in the background while we wait for the prompts. | ||
const download = downloadFile(version).catch(() => null) | ||
export const run = async (runOptions: RunRecipeOptions) => { | ||
const { args, command } = runOptions | ||
let consumer: ConsumerConfig | null = null | ||
const filePath: string | null = args[0] | ||
|
||
const filePath = | ||
args[0] || | ||
((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ?? | ||
(await promptForPath())) | ||
const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} | ||
if (filePath) { | ||
consumer = { ...rulesForDefaultConsumer, path: filePath } | ||
} | ||
|
||
if (!downloadedFile) { | ||
return logAndThrowError('An error occurred when pulling the latest context files. Please try again.') | ||
if (!consumer && process.env.AI_CONTEXT_SKIP_DETECTION !== 'true') { | ||
consumer = await getPathByDetectingIDE() | ||
} | ||
|
||
if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) { | ||
return logAndThrowError( | ||
`This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline( | ||
'https://ntl.fyi/update-cli', | ||
)} for information on how to update.`, | ||
) | ||
if (!consumer) { | ||
consumer = await promptForContextConsumerSelection() | ||
} | ||
|
||
const absoluteFilePath = resolve(command?.workingDir ?? '', filePath, FILE_NAME) | ||
const existing = await getExistingContext(absoluteFilePath) | ||
const remote = parseContextFile(downloadedFile) | ||
|
||
let { contents } = remote | ||
|
||
// Does a file already exist at this path? | ||
if (existing) { | ||
// If it's a file we've created, let's check the version and bail if we're | ||
// already on the latest, otherwise rewrite it with the latest version. | ||
if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) { | ||
if (remote?.version === existing.version) { | ||
log( | ||
`You're all up to date! ${chalk.underline( | ||
absoluteFilePath, | ||
)} contains the latest version of the context files.`, | ||
) | ||
|
||
return | ||
} | ||
|
||
// We must preserve any overrides found in the existing file. | ||
contents = applyOverrides(remote.contents, existing.overrides?.innerContents) | ||
} else { | ||
// If this is not a file we've created, we can offer to overwrite it and | ||
// preserve the existing contents by moving it to the overrides slot. | ||
const { confirm } = await inquirer.prompt({ | ||
type: 'confirm', | ||
name: 'confirm', | ||
message: `A context file already exists at ${chalk.underline( | ||
absoluteFilePath, | ||
)}. It has not been created by the Netlify CLI, but we can update it while preserving its existing content. Can we proceed?`, | ||
default: true, | ||
}) | ||
|
||
if (!confirm) { | ||
return | ||
} | ||
|
||
// Whatever exists in the file goes in the overrides block. | ||
contents = applyOverrides(remote.contents, existing.contents) | ||
} | ||
if (!consumer?.contextScopes) { | ||
log( | ||
'No context files found for this consumer. Try again or let us know if this happens again via our support channels.', | ||
) | ||
return | ||
} | ||
|
||
await writeFile(absoluteFilePath, contents) | ||
try { | ||
await downloadAndWriteContextFiles(consumer, runOptions) | ||
|
||
// the deprecated MCP file path | ||
// let's remove that file if it exists. | ||
const priorContextFilePath = resolve(command?.workingDir ?? '', consumer.path, NTL_DEV_MCP_FILE_NAME) | ||
const priorExists = await getExistingContext(priorContextFilePath) | ||
if (priorExists) { | ||
await deleteFile(priorContextFilePath) | ||
} | ||
|
||
log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) | ||
log('All context files have been added!') | ||
} catch (error) { | ||
logAndThrowError(error) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,229 @@ | ||
/* eslint-disable @typescript-eslint/no-unsafe-call */ | ||
|
||
import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest' | ||
import { resolve } from 'node:path' | ||
import type { ConsumerConfig } from '../../../../src/recipes/ai-context/context.js' | ||
import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js' | ||
|
||
// Mock fs module | ||
vi.mock('node:fs', () => { | ||
return { | ||
promises: { | ||
mkdir: vi.fn().mockResolvedValue(undefined), | ||
writeFile: vi.fn().mockResolvedValue(undefined), | ||
stat: vi.fn().mockImplementation(() => { | ||
const err = new Error('File not found') as NodeJS.ErrnoException | ||
err.code = 'ENOENT' | ||
throw err | ||
}), | ||
readFile: vi.fn(), | ||
rm: vi.fn().mockResolvedValue(undefined), | ||
}, | ||
} | ||
}) | ||
|
||
// Set up global fetch mock | ||
const mockFetch = vi.fn() | ||
const originalFetch = globalThis.fetch | ||
|
||
// Mock command helpers | ||
vi.mock('../../../../src/utils/command-helpers.js', () => { | ||
const log = vi.fn() | ||
const logAndThrowError = vi.fn((msg) => { | ||
throw new Error(msg as string) | ||
}) | ||
|
||
return { | ||
log, | ||
logAndThrowError, | ||
chalk: { underline: (text: string) => text }, | ||
version: '1.0.0', | ||
} | ||
}) | ||
|
||
// Import modules after mocks are defined | ||
import { promises as fs } from 'node:fs' | ||
import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js' | ||
|
||
describe('downloadAndWriteContextFiles', () => { | ||
// Setup test data | ||
const mockConsumer: ConsumerConfig = { | ||
key: 'test-consumer', | ||
presentedName: 'Test Consumer', | ||
path: './test-path', | ||
ext: 'mdc', | ||
contextScopes: { | ||
serverless: { | ||
scope: 'Serverless functions', | ||
shared: ['shared/compute-globals'], | ||
endpoint: 'https://docs.netlify.com/ai-context/scoped-context?scopes=serverless', | ||
}, | ||
'edge-functions': { | ||
scope: 'Edge functions', | ||
shared: ['shared/compute-globals'], | ||
endpoint: 'https://docs.netlify.com/ai-context/scoped-context?scopes=edge-functions', | ||
}, | ||
}, | ||
} | ||
|
||
const mockRunOptions = { | ||
command: { | ||
workingDir: '/test/dir', | ||
}, | ||
} as RunRecipeOptions | ||
|
||
const mockProviderContent = '<ProviderContext version="1.0" provider="Netlify">Test content</ProviderContext>' | ||
|
||
const fetchRespImpl = { | ||
ok: true, | ||
text: () => Promise.resolve(), | ||
json: () => Promise.resolve(), | ||
headers: { get: (header: string) => (header === 'x-cli-min-ver' ? '0.5.0' : null) }, | ||
} | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks() | ||
globalThis.fetch = mockFetch | ||
mockFetch.mockResolvedValue({ | ||
...fetchRespImpl, | ||
text: () => Promise.resolve(mockProviderContent), | ||
json: () => Promise.resolve({ consumers: [] }), | ||
}) | ||
}) | ||
|
||
afterEach(() => { | ||
globalThis.fetch = originalFetch | ||
vi.clearAllMocks() | ||
}) | ||
|
||
test('downloads and writes context files for all scopes', async () => { | ||
// Execute the actual function | ||
await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) | ||
|
||
// Verify expected calls | ||
expect(mockFetch).toHaveBeenCalledTimes(2) // Once for each scope | ||
expect(fs.writeFile).toHaveBeenCalledTimes(2) // Once for each scope | ||
|
||
// Verify file paths and content | ||
expect(fs.writeFile).toHaveBeenCalledWith( | ||
resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-serverless.mdc'), | ||
mockProviderContent, | ||
) | ||
expect(fs.writeFile).toHaveBeenCalledWith( | ||
resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-edge-functions.mdc'), | ||
mockProviderContent, | ||
) | ||
}) | ||
|
||
test('handles existing files with same version', async () => { | ||
// Mock existing file with same version | ||
// | ||
// @ts-expect-error mocking is not 100% consistent with full API and types for | ||
fs.stat.mockResolvedValue({ isFile: () => true } as () => boolean) | ||
// @ts-expect-error mocking is not 100% consistent with full API and types for | ||
fs.readFile.mockResolvedValue(mockProviderContent) | ||
|
||
// Execute the actual function | ||
await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) | ||
|
||
// Verify expected behavior - no writes when versions match | ||
expect(fs.writeFile).not.toHaveBeenCalled() | ||
}) | ||
|
||
test('applies overrides when updating existing Netlify files', async () => { | ||
// Mock existing file with different version | ||
const existingContent = | ||
'<ProviderContext version="0.9" provider="Netlify">Old content<ProviderContextOverrides>Custom overrides</ProviderContextOverrides></ProviderContext>' | ||
// @ts-expect-error mocking is not 100% consistent with full API and types for | ||
fs.stat.mockResolvedValue({ isFile: () => true } as () => boolean) | ||
// @ts-expect-error mocking is not 100% consistent with full API and types for | ||
fs.readFile.mockResolvedValue(existingContent) | ||
|
||
// Execute the actual function | ||
await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) | ||
|
||
// Verify file was updated with overrides applied | ||
expect(fs.writeFile).toHaveBeenCalledTimes(2) | ||
}) | ||
|
||
test('handles truncation when content exceeds limit', async () => { | ||
// Create long content | ||
const longContent = | ||
'<ProviderContext version="1.0" provider="Netlify">' + | ||
Math.random().toString().repeat(1000) + | ||
'</ProviderContext>' | ||
mockFetch.mockResolvedValue({ | ||
...fetchRespImpl, | ||
text: () => Promise.resolve(longContent), | ||
json: () => Promise.resolve({ consumers: [] }), | ||
}) | ||
|
||
// Add truncation limit to consumer | ||
const consumerWithLimit = { | ||
...mockConsumer, | ||
truncationLimit: 100, | ||
} | ||
|
||
// Execute the actual function | ||
await downloadAndWriteContextFiles(consumerWithLimit, mockRunOptions) | ||
|
||
// Verify content was truncated | ||
const writeFileCalls = vi.mocked(fs.writeFile).mock.calls | ||
expect(writeFileCalls.length).toBeGreaterThan(0) | ||
|
||
// Check that all written content is truncated | ||
writeFileCalls.forEach((call) => { | ||
// @ts-expect-error mocking is not 100% consistent with full API and types for | ||
expect(call[1].length).toBeLessThanOrEqual(100) | ||
}) | ||
}) | ||
|
||
test('uses custom file extension when specified', async () => { | ||
// Create consumer with custom extension | ||
const consumerWithCustomExt = { | ||
...mockConsumer, | ||
ext: 'json', // Custom extension instead of default 'mdc' | ||
} | ||
|
||
// Execute the actual function | ||
await downloadAndWriteContextFiles(consumerWithCustomExt, mockRunOptions) | ||
|
||
// Verify file paths have the custom extension | ||
expect(fs.writeFile).toHaveBeenCalledWith( | ||
resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-serverless.json'), | ||
mockProviderContent, | ||
) | ||
expect(fs.writeFile).toHaveBeenCalledWith( | ||
resolve(mockRunOptions.command?.workingDir ?? '', 'test-path', 'netlify-edge-functions.json'), | ||
mockProviderContent, | ||
) | ||
}) | ||
|
||
test('handles download errors gracefully', 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, | ||
} as Response) | ||
|
||
// Execute the actual function and expect error | ||
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() | ||
}) | ||
|
||
test('checks CLI version compatibility', async () => { | ||
// Set higher minimum CLI version | ||
// @ts-expect-error mocking is not 100% consistent with full API and types for | ||
fetch.mockResolvedValue({ | ||
ok: true, | ||
headers: { | ||
get: (header: string) => { | ||
if (header === 'x-cli-min-ver') return '2.0.0' // Higher than the mocked current version | ||
return null | ||
}, | ||
}, | ||
} as Response) | ||
|
||
// Execute the actual function and expect error | ||
await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() | ||
}) | ||
}) |
Uh oh!
There was an error while loading. Please reload this page.