Skip to content

Commit dbe275f

Browse files
sean-robertssmnhSean Roberts
authored
feat: support granular context file setting (#7284)
* feat: automatically detects IDE - windsurf or cursor - for AI context files. * Don't ask permission to write ai-context files if IDE was detected. Added `--skip-detection` flag to skip the automatic IDE detection and fallback to choice list. * PR review fixes * remove unused options field * remove unused recipeName field * remove unused options field * formatting * feat: support granular context file setting * handle docs site not being available * fix: nullish coalescing * chore: moving download/write function to a more testable location * adding testing for ai-context downloading * feat: updating granular context to have test coverage * chore: ensure mocks are all reset after tests * fix: formatting and parse tests * fix: fix the test for windows paths * fix: fix the test for windows paths --------- Co-authored-by: Simon Hanukaev <[email protected]> Co-authored-by: Sean Roberts <[email protected]>
1 parent 5fe35cf commit dbe275f

File tree

4 files changed

+508
-156
lines changed

4 files changed

+508
-156
lines changed

src/recipes/ai-context/context.ts

Lines changed: 164 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,94 @@
11
import { promises as fs } from 'node:fs'
2-
import { dirname } from 'node:path'
2+
import { dirname, resolve } from 'node:path'
3+
import semver from 'semver'
4+
import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js'
5+
import type { RunRecipeOptions } from '../../commands/recipes/recipes.js'
36

47
const ATTRIBUTES_REGEX = /(\S*)="([^\s"]*)"/gim
5-
const BASE_URL = 'https://docs.netlify.com/ai-context'
6-
export const FILE_NAME = 'netlify-development.mdc'
8+
// AI_CONTEXT_BASE_URL is used to help with local testing at non-production
9+
// versions of the context apis.
10+
const BASE_URL = new URL(process.env.AI_CONTEXT_BASE_URL ?? 'https://docs.netlify.com/ai-context').toString()
11+
export const NTL_DEV_MCP_FILE_NAME = 'netlify-development.mdc'
712
const MINIMUM_CLI_VERSION_HEADER = 'x-cli-min-ver'
813
export const NETLIFY_PROVIDER = 'netlify'
914
const PROVIDER_CONTEXT_REGEX = /<providercontext ([^>]*)>(.*)<\/providercontext>/ims
1015
const PROVIDER_CONTEXT_OVERRIDES_REGEX = /<providercontextoverrides([^>]*)>(.*)<\/providercontextoverrides>/ims
1116
const PROVIDER_CONTEXT_OVERRIDES_TAG = 'ProviderContextOverrides'
1217

13-
export const downloadFile = async (cliVersion: string) => {
18+
export interface ContextConfig {
19+
scope: string
20+
glob?: string
21+
shared?: string[]
22+
endpoint?: string
23+
}
24+
25+
export interface ContextFile {
26+
key: string
27+
config: ContextConfig
28+
content: string
29+
}
30+
31+
export interface ConsumerConfig {
32+
key: string
33+
presentedName: string
34+
consumerProcessCmd?: string
35+
path: string
36+
ext: string
37+
truncationLimit?: number
38+
contextScopes: Record<string, ContextConfig>
39+
hideFromCLI?: boolean
40+
consumerTrigger?: string
41+
}
42+
43+
let contextConsumers: ConsumerConfig[] = []
44+
export const getContextConsumers = async (cliVersion: string) => {
45+
if (contextConsumers.length > 0) {
46+
return contextConsumers
47+
}
1448
try {
15-
const res = await fetch(`${BASE_URL}/${FILE_NAME}`, {
49+
const res = await fetch(`${BASE_URL}/context-consumers`, {
1650
headers: {
1751
'user-agent': `NetlifyCLI ${cliVersion}`,
1852
},
1953
})
54+
55+
if (!res.ok) {
56+
return []
57+
}
58+
59+
const data = (await res.json()) as { consumers: ConsumerConfig[] } | undefined
60+
contextConsumers = data?.consumers ?? []
61+
} catch {}
62+
63+
return contextConsumers
64+
}
65+
66+
export const downloadFile = async (cliVersion: string, contextConfig: ContextConfig, consumer: ConsumerConfig) => {
67+
try {
68+
if (!contextConfig.endpoint) {
69+
return null
70+
}
71+
72+
const url = new URL(contextConfig.endpoint, BASE_URL)
73+
url.searchParams.set('consumer', consumer.key)
74+
75+
if (process.env.AI_CONTEXT_BASE_URL) {
76+
const overridingUrl = new URL(process.env.AI_CONTEXT_BASE_URL)
77+
url.host = overridingUrl.host
78+
url.port = overridingUrl.port
79+
url.protocol = overridingUrl.protocol
80+
}
81+
82+
const res = await fetch(url, {
83+
headers: {
84+
'user-agent': `NetlifyCLI ${cliVersion}`,
85+
},
86+
})
87+
88+
if (!res.ok) {
89+
return null
90+
}
91+
2092
const contents = await res.text()
2193
const minimumCLIVersion = res.headers.get(MINIMUM_CLI_VERSION_HEADER) ?? undefined
2294

@@ -99,10 +171,12 @@ export const applyOverrides = (template: string, overrides?: string) => {
99171
return template
100172
}
101173

102-
return template.replace(
103-
PROVIDER_CONTEXT_OVERRIDES_REGEX,
104-
`<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`,
105-
)
174+
return template
175+
.replace(
176+
PROVIDER_CONTEXT_OVERRIDES_REGEX,
177+
`<${PROVIDER_CONTEXT_OVERRIDES_TAG}>${overrides}</${PROVIDER_CONTEXT_OVERRIDES_TAG}>`,
178+
)
179+
.trim()
106180
}
107181

108182
/**
@@ -137,3 +211,84 @@ export const writeFile = async (path: string, contents: string) => {
137211
await fs.mkdir(directory, { recursive: true })
138212
await fs.writeFile(path, contents)
139213
}
214+
215+
export const deleteFile = async (path: string) => {
216+
try {
217+
// delete file from file system - not just unlinking it
218+
await fs.rm(path)
219+
} catch {
220+
// ignore
221+
}
222+
}
223+
224+
export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => {
225+
await Promise.allSettled(
226+
Object.keys(consumer.contextScopes).map(async (contextKey) => {
227+
const contextConfig = consumer.contextScopes[contextKey]
228+
229+
const { contents: downloadedFile, minimumCLIVersion } =
230+
(await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {}
231+
232+
if (!downloadedFile) {
233+
return logAndThrowError(
234+
`An error occurred when pulling the latest context file for scope ${contextConfig.scope}. Please try again.`,
235+
)
236+
}
237+
if (minimumCLIVersion && semver.lt(version, minimumCLIVersion)) {
238+
return logAndThrowError(
239+
`This command requires version ${minimumCLIVersion} or above of the Netlify CLI. Refer to ${chalk.underline(
240+
'https://ntl.fyi/update-cli',
241+
)} for information on how to update.`,
242+
)
243+
}
244+
245+
const absoluteFilePath = resolve(
246+
command?.workingDir ?? '',
247+
consumer.path,
248+
`netlify-${contextKey}.${consumer.ext || 'mdc'}`,
249+
)
250+
251+
const existing = await getExistingContext(absoluteFilePath)
252+
const remote = parseContextFile(downloadedFile)
253+
254+
let { contents } = remote
255+
256+
// Does a file already exist at this path?
257+
if (existing) {
258+
// If it's a file we've created, let's check the version and bail if we're
259+
// already on the latest, otherwise rewrite it with the latest version.
260+
if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) {
261+
if (remote.version === existing.version) {
262+
log(
263+
`You're all up to date! ${chalk.underline(
264+
absoluteFilePath,
265+
)} contains the latest version of the context files.`,
266+
)
267+
return
268+
}
269+
270+
// We must preserve any overrides found in the existing file.
271+
contents = applyOverrides(remote.contents, existing.overrides?.innerContents)
272+
} else {
273+
// Whatever exists in the file goes in the overrides block.
274+
contents = applyOverrides(remote.contents, existing.contents)
275+
}
276+
}
277+
278+
// we don't want to cut off content, but if we _have_ to
279+
// then we need to do so before writing or the user's
280+
// context gets in a bad state. Note, this can result in
281+
// a file that's not parsable next time. This will be
282+
// fine because the file will simply be replaced. Not ideal
283+
// but solves the issue of a truncated file in a bad state
284+
// being updated.
285+
if (consumer.truncationLimit && contents.length > consumer.truncationLimit) {
286+
contents = contents.slice(0, consumer.truncationLimit)
287+
}
288+
289+
await writeFile(absoluteFilePath, contents)
290+
291+
log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`)
292+
}),
293+
)
294+
}

0 commit comments

Comments
 (0)