Skip to content

Commit 43c297d

Browse files
authoredFeb 13, 2025··
feat: add ai-context recipe (#7035)
* feat: add `ai-context` recipe * feat: update command
1 parent a87fd59 commit 43c297d

File tree

5 files changed

+370
-1
lines changed

5 files changed

+370
-1
lines changed
 

‎src/commands/recipes/recipes.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getRecipe, listRecipes } from './common.js'
1111

1212
const SUGGESTION_TIMEOUT = 1e4
1313

14-
interface RunRecipeOptions {
14+
export interface RunRecipeOptions {
1515
args: string[]
1616
command?: BaseCommand
1717
config: unknown

‎src/recipes/ai-context/context.ts

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { promises as fs } from 'node:fs'
2+
import { dirname } from 'node:path'
3+
4+
const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim
5+
const BASE_URL = 'https://docs.netlify.com/ai-context'
6+
export const FILE_NAME = 'netlify-development.mdc'
7+
const MINIMUM_CLI_VERSION_HEADER = 'x-cli-min-ver'
8+
export const NETLIFY_PROVIDER = 'netlify'
9+
const PROVIDER_CONTEXT_REGEX = /<providercontext ([^>]*)>(.*)<\/providercontext>/ims
10+
const PROVIDER_CONTEXT_OVERRIDES_REGEX = /<providercontextoverrides([^>]*)>(.*)<\/providercontextoverrides>/ims
11+
const PROVIDER_CONTEXT_OVERRIDES_TAG = 'ProviderContextOverrides'
12+
13+
export const downloadFile = async (cliVersion: string) => {
14+
try {
15+
const res = await fetch(`${BASE_URL}/${FILE_NAME}`, {
16+
headers: {
17+
'user-agent': `NetlifyCLI ${cliVersion}`,
18+
},
19+
})
20+
const contents = await res.text()
21+
const minimumCLIVersion = res.headers.get(MINIMUM_CLI_VERSION_HEADER) ?? undefined
22+
23+
return {
24+
contents,
25+
minimumCLIVersion,
26+
}
27+
} catch {
28+
// no-op
29+
}
30+
31+
return null
32+
}
33+
34+
interface ParsedContextFile {
35+
contents: string
36+
innerContents?: string
37+
overrides?: {
38+
contents?: string
39+
innerContents?: string
40+
}
41+
provider?: string
42+
version?: string
43+
}
44+
45+
/**
46+
* Parses the `<ProviderContext>` and `<ProviderContextOverrides>` blocks in
47+
* a context file.
48+
*/
49+
export const parseContextFile = (contents: string) => {
50+
const result: ParsedContextFile = {
51+
contents,
52+
}
53+
54+
const providerContext = contents.match(PROVIDER_CONTEXT_REGEX)
55+
56+
if (providerContext) {
57+
const [, attributes, innerContents] = providerContext
58+
59+
result.innerContents = innerContents
60+
61+
for (const [, name, value] of attributes.matchAll(ATTRIBUTES_REGEX)) {
62+
switch (name.toLowerCase()) {
63+
case 'provider':
64+
result.provider = value
65+
66+
break
67+
68+
case 'version':
69+
result.version = value
70+
71+
break
72+
73+
default:
74+
continue
75+
}
76+
}
77+
}
78+
79+
const contextOverrides = contents.match(PROVIDER_CONTEXT_OVERRIDES_REGEX)
80+
81+
if (contextOverrides) {
82+
const [overrideContents, , innerContents] = contextOverrides
83+
84+
result.overrides = {
85+
contents: overrideContents,
86+
innerContents,
87+
}
88+
}
89+
90+
return result
91+
}
92+
93+
/**
94+
* Takes a context file (a template) and injects a string in an overrides block
95+
* if one is found. Returns the resulting context file.
96+
*/
97+
export const applyOverrides = (template: string, overrides?: string) => {
98+
if (!overrides) {
99+
return template
100+
}
101+
102+
return template.replace(
103+
PROVIDER_CONTEXT_OVERRIDES_REGEX,
104+
`<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`,
105+
)
106+
}
107+
108+
/**
109+
* Reads a file on disk and tries to parse it as a context file.
110+
*/
111+
export const getExistingContext = async (path: string) => {
112+
try {
113+
const stats = await fs.stat(path)
114+
115+
if (!stats.isFile()) {
116+
throw new Error(`${path} already exists but is not a file. Please remove it or rename it and try again.`)
117+
}
118+
119+
const file = await fs.readFile(path, 'utf8')
120+
const parsedFile = parseContextFile(file)
121+
122+
return parsedFile
123+
} catch (error) {
124+
const exception = error as NodeJS.ErrnoException
125+
126+
if (exception.code !== 'ENOENT') {
127+
throw new Error(`Could not open context file at ${path}: ${exception.message}`)
128+
}
129+
130+
return null
131+
}
132+
}
133+
134+
export const writeFile = async (path: string, contents: string) => {
135+
const directory = dirname(path)
136+
137+
await fs.mkdir(directory, { recursive: true })
138+
await fs.writeFile(path, contents)
139+
}

‎src/recipes/ai-context/index.ts

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { resolve } from 'node:path'
2+
3+
import inquirer from 'inquirer'
4+
import semver from 'semver'
5+
6+
import type { RunRecipeOptions } from '../../commands/recipes/recipes.js'
7+
import { chalk, error, log, version } from '../../utils/command-helpers.js'
8+
9+
import {
10+
applyOverrides,
11+
downloadFile,
12+
getExistingContext,
13+
parseContextFile,
14+
writeFile,
15+
FILE_NAME,
16+
NETLIFY_PROVIDER,
17+
} from './context.js'
18+
19+
export const description = 'Manage context files for AI tools'
20+
21+
const presets = [
22+
{ name: 'Cursor rules (.cursor/rules/)', value: '.cursor/rules' },
23+
{ name: 'Custom location', value: '' },
24+
]
25+
26+
const promptForPath = async (): Promise<string> => {
27+
const { presetPath } = await inquirer.prompt([
28+
{
29+
name: 'presetPath',
30+
message: 'Where should we put the context files?',
31+
type: 'list',
32+
choices: presets,
33+
},
34+
])
35+
36+
if (presetPath) {
37+
return presetPath
38+
}
39+
40+
const { customPath } = await inquirer.prompt([
41+
{
42+
type: 'input',
43+
name: 'customPath',
44+
message: 'Enter the path, relative to the project root, where the context files should be placed',
45+
default: './ai-context',
46+
},
47+
])
48+
49+
if (customPath) {
50+
return customPath
51+
}
52+
53+
log('You must select a path.')
54+
55+
return promptForPath()
56+
}
57+
58+
export const run = async ({ args, command }: RunRecipeOptions) => {
59+
// Start the download in the background while we wait for the prompts.
60+
// eslint-disable-next-line promise/prefer-await-to-then
61+
const download = downloadFile(version).catch(() => null)
62+
63+
const filePath = args[0] || (await promptForPath())
64+
const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {}
65+
66+
if (!downloadedFile) {
67+
error('An error occurred when pulling the latest context files. Please try again.')
68+
69+
return
70+
}
71+
72+
if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) {
73+
error(
74+
`This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline(
75+
'https://ntl.fyi/update-cli',
76+
)} for information on how to update.`,
77+
)
78+
79+
return
80+
}
81+
82+
const absoluteFilePath = resolve(command?.workingDir ?? '', filePath, FILE_NAME)
83+
const existing = await getExistingContext(absoluteFilePath)
84+
const remote = parseContextFile(downloadedFile)
85+
86+
let { contents } = remote
87+
88+
// Does a file already exist at this path?
89+
if (existing) {
90+
// If it's a file we've created, let's check the version and bail if we're
91+
// already on the latest, otherwise rewrite it with the latest version.
92+
if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) {
93+
if (remote?.version === existing.version) {
94+
log(
95+
`You're all up to date! ${chalk.underline(
96+
absoluteFilePath,
97+
)} contains the latest version of the context files.`,
98+
)
99+
100+
return
101+
}
102+
103+
// We must preserve any overrides found in the existing file.
104+
contents = applyOverrides(remote.contents, existing.overrides?.innerContents)
105+
} else {
106+
// If this is not a file we've created, we can offer to overwrite it and
107+
// preserve the existing contents by moving it to the overrides slot.
108+
const { confirm } = await inquirer.prompt({
109+
type: 'confirm',
110+
name: 'confirm',
111+
message: `A context file already exists at ${chalk.underline(
112+
absoluteFilePath,
113+
)}. It has not been created by the Netlify CLI, but we can update it while preserving its existing content. Can we proceed?`,
114+
default: true,
115+
})
116+
117+
if (!confirm) {
118+
return
119+
}
120+
121+
// Whatever exists in the file goes in the overrides block.
122+
contents = applyOverrides(remote.contents, existing.contents)
123+
}
124+
}
125+
126+
await writeFile(absoluteFilePath, contents)
127+
128+
log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`)
129+
}

‎tests/integration/commands/recipes/__snapshots__/recipes.test.js.snap

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ exports[`commands/recipes > Shows a list of all the available recipes 1`] = `
66
|-----------------------------------------------------------------------------------------|
77
| Name | Description |
88
|---------------|-------------------------------------------------------------------------|
9+
| ai-context | Manage context files for AI tools |
910
| blobs-migrate | Migrate legacy Netlify Blobs stores |
1011
| vscode | Create VS Code settings for an optimal experience with Netlify projects |
1112
'-----------------------------------------------------------------------------------------'"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { applyOverrides, parseContextFile } from '../../../../dist/recipes/ai-context/context.js'
4+
5+
describe('applyOverrides', () => {
6+
test('applies overrides to a context file', () => {
7+
const file = `
8+
<ProviderContextOverrides>sdf</ProviderContextOverrides>
9+
<ProviderContext version="1.0" provider="Netlify">This is the contents
10+
Something here
11+
Something there
12+
</ProviderContext>`
13+
const expected = `
14+
<ProviderContextOverrides>Here come the overrides</ProviderContextOverrides>
15+
<ProviderContext version="1.0" provider="Netlify">This is the contents
16+
Something here
17+
Something there
18+
</ProviderContext>`
19+
20+
expect(applyOverrides(file, 'Here come the overrides')).toBe(expected)
21+
})
22+
23+
test('supports a multiline overrides slot', () => {
24+
const file = `
25+
<ProviderContextOverrides>
26+
This is where overrides go
27+
</ProviderContextOverrides>
28+
<ProviderContext version="1.0" provider="Netlify">This is the contents
29+
Something here
30+
Something there
31+
</ProviderContext>`
32+
const expected = `
33+
<ProviderContextOverrides>Here come the overrides</ProviderContextOverrides>
34+
<ProviderContext version="1.0" provider="Netlify">This is the contents
35+
Something here
36+
Something there
37+
</ProviderContext>`
38+
39+
expect(applyOverrides(file, 'Here come the overrides')).toBe(expected)
40+
})
41+
})
42+
43+
describe('parseContextFile', () => {
44+
test('extracts the provider, version and contents', () => {
45+
const file = `<ProviderContext provider="Netlify" version="1.0">This is the contents</ProviderContext>`
46+
47+
expect(parseContextFile(file)).toStrictEqual({
48+
provider: 'Netlify',
49+
version: '1.0',
50+
contents: file,
51+
innerContents: 'This is the contents',
52+
})
53+
})
54+
55+
test('ignores unknown attributes', () => {
56+
const file = `<ProviderContext foo="bar" provider="Netlify" version="1.0">This is the contents</ProviderContext>`
57+
58+
expect(parseContextFile(file)).toStrictEqual({
59+
provider: 'Netlify',
60+
version: '1.0',
61+
contents: file,
62+
innerContents: 'This is the contents',
63+
})
64+
})
65+
66+
test('ignores the order of attributes', () => {
67+
const file = `<ProviderContext version="1.0" provider="Netlify">This is the contents</ProviderContext>`
68+
69+
expect(parseContextFile(file)).toStrictEqual({
70+
provider: 'Netlify',
71+
version: '1.0',
72+
contents: file,
73+
innerContents: 'This is the contents',
74+
})
75+
})
76+
77+
test('extracts overrides', () => {
78+
const overrides = `<ProviderContextOverrides>This will be kept</ProviderContextOverrides>`
79+
const file = `
80+
${overrides}
81+
<ProviderContext version="1.0" provider="Netlify">This is the contents
82+
Something here
83+
Something there
84+
</ProviderContext>`
85+
86+
expect(parseContextFile(file)).toStrictEqual({
87+
provider: 'Netlify',
88+
version: '1.0',
89+
contents: file,
90+
innerContents: `This is the contents
91+
Something here
92+
Something there
93+
`,
94+
overrides: {
95+
contents: overrides,
96+
innerContents: 'This will be kept',
97+
},
98+
})
99+
})
100+
})

2 commit comments

Comments
 (2)

github-actions[bot] commented on Feb 13, 2025

@github-actions[bot]

📊 Benchmark results

  • Dependency count: 1,192
  • Package size: 306 MB
  • Number of ts-expect-error directives: 801

github-actions[bot] commented on Feb 13, 2025

@github-actions[bot]

📊 Benchmark results

  • Dependency count: 1,192
  • Package size: 306 MB
  • Number of ts-expect-error directives: 801
Please sign in to comment.