From 0fbfd506c5c5f590e9dc7b6e097baf8b55ba89e3 Mon Sep 17 00:00:00 2001 From: Simon Hanukaev Date: Thu, 8 May 2025 22:41:56 +0300 Subject: [PATCH 1/7] feat: automatically detects IDE - windsurf or cursor - for AI context files. --- src/recipes/ai-context/index.ts | 83 +++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 28d813858f6..dc0675b2b4f 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -2,6 +2,7 @@ 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' @@ -18,9 +19,14 @@ import { export const description = 'Manage context files for AI tools' +const IDE_RULES_PATH_MAP = { + windsurf: '.windsurf/rules', + cursor: '.cursor/rules', +} + const presets = [ - { name: 'Windsurf rules (.windsurf/rules/)', value: '.windsurf/rules' }, - { name: 'Cursor rules (.cursor/rules/)', value: '.cursor/rules' }, + { 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: '' }, ] @@ -56,11 +62,82 @@ const promptForPath = async (): Promise => { return promptForPath() } +type IDE = { + name: string + command: string + path: string +} +const IDE: IDE[] = [ + { + name: 'Windsurf', + command: 'windsurf', + path: IDE_RULES_PATH_MAP.windsurf, + }, + { + name: 'Cursor', + command: 'cursor', + path: IDE_RULES_PATH_MAP.cursor, + }, +] + +const getPathByDetectingIDE = async (): Promise => { + const getIDEFromCommand = (command: string): IDE | null => { + const match = IDE.find((ide) => command.includes(ide.command)) + return match ?? null + } + + async function getCommandAndParentPID(pid: number): Promise<{ + parentPID: number + command: string + ide: IDE | null + }> { + const { stdout } = await execa('ps', ['-p', String(pid), '-o', 'ppid=,comm=']) + 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), + } + } + + // 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> + try { + result = await getCommandAndParentPID(ppid) + while (result.parentPID !== 1 && !result.ide) { + 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 + } + + if (result.ide) { + const { saveToPath } = await inquirer.prompt([ + { + name: 'saveToPath', + message: `We detected that you're using ${result.ide.name}. Would you like us to store the context files in ${result.ide.path}?`, + type: 'confirm', + default: true, + }, + ]) + if (saveToPath) { + return result.ide.path + } + } + 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) - const filePath = args[0] || (await promptForPath()) + const filePath = args[0] || ((await getPathByDetectingIDE()) ?? (await promptForPath())) const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} if (!downloadedFile) { From 403e4ae4beec7ca140710bcb9de510af2560c927 Mon Sep 17 00:00:00 2001 From: Simon Hanukaev Date: Thu, 8 May 2025 23:59:29 +0300 Subject: [PATCH 2/7] 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. --- src/commands/recipes/index.ts | 4 ++++ src/commands/recipes/recipes.ts | 7 ++++--- src/recipes/ai-context/index.ts | 27 +++++++-------------------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/commands/recipes/index.ts b/src/commands/recipes/index.ts index 826f9f721eb..689fefa7408 100644 --- a/src/commands/recipes/index.ts +++ b/src/commands/recipes/index.ts @@ -17,6 +17,10 @@ export const createRecipesCommand = (program: BaseCommand) => { .argument('[name]', 'name of the recipe') .description(`Create and modify files in a project using pre-defined recipes`) .option('-n, --name ', 'recipe name to use') + .option( + '--skip-detection', + 'Skips automatic IDE detection. Use this with the ai-context recipe to specify paths manually.', + ) .addExamples(['netlify recipes my-recipe', 'netlify recipes --name my-recipe']) .action(async (recipeName: string, options: OptionValues, command: BaseCommand) => { const { recipesCommand } = await import('./recipes.js') diff --git a/src/commands/recipes/recipes.ts b/src/commands/recipes/recipes.ts index 5c98aef8a33..e6c60c531ca 100644 --- a/src/commands/recipes/recipes.ts +++ b/src/commands/recipes/recipes.ts @@ -14,15 +14,16 @@ const SUGGESTION_TIMEOUT = 1e4 export interface RunRecipeOptions { args: string[] command?: BaseCommand + options?: OptionValues config: unknown recipeName: string repositoryRoot: string } -export const runRecipe = async ({ args, command, config, recipeName, repositoryRoot }: RunRecipeOptions) => { +export const runRecipe = async ({ args, command, options, config, recipeName, repositoryRoot }: RunRecipeOptions) => { const recipe = await getRecipe(recipeName) - return recipe.run({ args, command, config, repositoryRoot }) + return recipe.run({ args, command, options, config, repositoryRoot }) } export const recipesCommand = async (recipeName: string, options: OptionValues, command: BaseCommand): Promise => { @@ -36,7 +37,7 @@ export const recipesCommand = async (recipeName: string, options: OptionValues, const args = command.args.slice(1) try { - return await runRecipe({ args, command, config, recipeName: sanitizedRecipeName, repositoryRoot }) + return await runRecipe({ args, command, options, config, recipeName: sanitizedRecipeName, repositoryRoot }) } catch (error) { if ( // The ESM loader throws this instead of MODULE_NOT_FOUND diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index dc0675b2b4f..75b16c56054 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -65,18 +65,18 @@ const promptForPath = async (): Promise => { type IDE = { name: string command: string - path: string + rulesPath: string } const IDE: IDE[] = [ { name: 'Windsurf', command: 'windsurf', - path: IDE_RULES_PATH_MAP.windsurf, + rulesPath: IDE_RULES_PATH_MAP.windsurf, }, { name: 'Cursor', command: 'cursor', - path: IDE_RULES_PATH_MAP.cursor, + rulesPath: IDE_RULES_PATH_MAP.cursor, }, ] @@ -116,28 +116,15 @@ const getPathByDetectingIDE = async (): Promise => { // perhaps we are on a machine that doesn't support it. return null } - - if (result.ide) { - const { saveToPath } = await inquirer.prompt([ - { - name: 'saveToPath', - message: `We detected that you're using ${result.ide.name}. Would you like us to store the context files in ${result.ide.path}?`, - type: 'confirm', - default: true, - }, - ]) - if (saveToPath) { - return result.ide.path - } - } - return null + return result.ide ? result.ide.rulesPath : null } -export const run = async ({ args, command }: RunRecipeOptions) => { +export const run = async ({ args, command, options }: RunRecipeOptions) => { // Start the download in the background while we wait for the prompts. const download = downloadFile(version).catch(() => null) - const filePath = args[0] || ((await getPathByDetectingIDE()) ?? (await promptForPath())) + const filePath = + args[0] || ((options?.skipDetection ? null : await getPathByDetectingIDE()) ?? (await promptForPath())) const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} if (!downloadedFile) { From fa5a9907c784528617becda6f0d2902bfdf7f1c2 Mon Sep 17 00:00:00 2001 From: Simon Hanukaev Date: Fri, 9 May 2025 14:10:29 +0300 Subject: [PATCH 3/7] PR review fixes --- src/commands/recipes/index.ts | 4 --- src/recipes/ai-context/index.ts | 52 ++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/commands/recipes/index.ts b/src/commands/recipes/index.ts index 689fefa7408..826f9f721eb 100644 --- a/src/commands/recipes/index.ts +++ b/src/commands/recipes/index.ts @@ -17,10 +17,6 @@ export const createRecipesCommand = (program: BaseCommand) => { .argument('[name]', 'name of the recipe') .description(`Create and modify files in a project using pre-defined recipes`) .option('-n, --name ', 'recipe name to use') - .option( - '--skip-detection', - 'Skips automatic IDE detection. Use this with the ai-context recipe to specify paths manually.', - ) .addExamples(['netlify recipes my-recipe', 'netlify recipes --name my-recipe']) .action(async (recipeName: string, options: OptionValues, command: BaseCommand) => { const { recipesCommand } = await import('./recipes.js') diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 75b16c56054..3ce99fbc979 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -80,29 +80,39 @@ const IDE: IDE[] = [ }, ] -const getPathByDetectingIDE = async (): Promise => { - const getIDEFromCommand = (command: string): IDE | null => { - const match = IDE.find((ide) => command.includes(ide.command)) - return match ?? null - } +/** + * Checks if a command belongs to a known IDEs by checking if it includes a specific string. + * For example, the command that starts windsurf looks something like "/applications/windsurf.app/contents/...". + */ +const getIDEFromCommand = (command: string): IDE | 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 +} - async function getCommandAndParentPID(pid: number): Promise<{ - parentPID: number - command: string - ide: IDE | null - }> { - const { stdout } = await execa('ps', ['-p', String(pid), '-o', 'ppid=,comm=']) - 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), - } +/** + * Receives a process ID (pid) and returns both the command that the process was run with and its parent process ID. If the process is a known IDE, also returns information about that IDE. + */ +const getCommandAndParentPID = async ( + pid: number, +): Promise<{ + parentPID: number + command: string + ide: IDE | null +}> => { + const { stdout } = await execa('ps', ['-p', String(pid), '-o', 'ppid=,comm=']) + 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), } +} +const getPathByDetectingIDE = async (): Promise => { // 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> @@ -111,7 +121,7 @@ const getPathByDetectingIDE = async (): Promise => { while (result.parentPID !== 1 && !result.ide) { result = await getCommandAndParentPID(result.parentPID) } - } catch (_) { + } 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 From b9d28812bee9e971f2eecf5b04fafc4ba2801172 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 9 May 2025 16:18:26 -0400 Subject: [PATCH 4/7] remove unused options field --- src/commands/recipes/recipes.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/commands/recipes/recipes.ts b/src/commands/recipes/recipes.ts index e6c60c531ca..5c53c4d6738 100644 --- a/src/commands/recipes/recipes.ts +++ b/src/commands/recipes/recipes.ts @@ -14,16 +14,15 @@ const SUGGESTION_TIMEOUT = 1e4 export interface RunRecipeOptions { args: string[] command?: BaseCommand - options?: OptionValues config: unknown recipeName: string repositoryRoot: string } -export const runRecipe = async ({ args, command, options, config, recipeName, repositoryRoot }: RunRecipeOptions) => { +export const runRecipe = async ({ args, command, config, recipeName, repositoryRoot }: RunRecipeOptions) => { const recipe = await getRecipe(recipeName) - return recipe.run({ args, command, options, config, repositoryRoot }) + return recipe.run({ args, command, config, recipeName, repositoryRoot }) } export const recipesCommand = async (recipeName: string, options: OptionValues, command: BaseCommand): Promise => { @@ -37,7 +36,7 @@ export const recipesCommand = async (recipeName: string, options: OptionValues, const args = command.args.slice(1) try { - return await runRecipe({ args, command, options, config, recipeName: sanitizedRecipeName, repositoryRoot }) + return await runRecipe({ args, command, config, recipeName: sanitizedRecipeName, repositoryRoot }) } catch (error) { if ( // The ESM loader throws this instead of MODULE_NOT_FOUND From 0da295792f1b698857473a8467a4321d80c498f3 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 9 May 2025 16:19:21 -0400 Subject: [PATCH 5/7] remove unused recipeName field --- src/commands/recipes/recipes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/recipes/recipes.ts b/src/commands/recipes/recipes.ts index 5c53c4d6738..5c98aef8a33 100644 --- a/src/commands/recipes/recipes.ts +++ b/src/commands/recipes/recipes.ts @@ -22,7 +22,7 @@ export interface RunRecipeOptions { export const runRecipe = async ({ args, command, config, recipeName, repositoryRoot }: RunRecipeOptions) => { const recipe = await getRecipe(recipeName) - return recipe.run({ args, command, config, recipeName, repositoryRoot }) + return recipe.run({ args, command, config, repositoryRoot }) } export const recipesCommand = async (recipeName: string, options: OptionValues, command: BaseCommand): Promise => { From 5965c82217166c6230164efd8b7c11f056fbaca1 Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 9 May 2025 16:22:02 -0400 Subject: [PATCH 6/7] remove unused options field --- src/recipes/ai-context/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 3ce99fbc979..dc23f87721d 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -129,12 +129,12 @@ const getPathByDetectingIDE = async (): Promise => { return result.ide ? result.ide.rulesPath : null } -export const run = async ({ args, command, options }: RunRecipeOptions) => { +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) const filePath = - args[0] || ((options?.skipDetection ? null : await getPathByDetectingIDE()) ?? (await promptForPath())) + args[0] || ((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ?? (await promptForPath())) const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} if (!downloadedFile) { From cfa996744ee5323dfff25aa4025871355742042b Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Fri, 9 May 2025 16:34:19 -0400 Subject: [PATCH 7/7] formatting --- src/recipes/ai-context/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index dc23f87721d..d97ff66bf1e 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -134,7 +134,9 @@ export const run = async ({ args, command }: RunRecipeOptions) => { const download = downloadFile(version).catch(() => null) const filePath = - args[0] || ((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ?? (await promptForPath())) + args[0] || + ((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ?? + (await promptForPath())) const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} if (!downloadedFile) {