diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 28d813858f6..d97ff66bf1e 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,81 @@ const promptForPath = async (): Promise => { return promptForPath() } +type IDE = { + name: string + command: string + rulesPath: string +} +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. + * 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 +} + +/** + * 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> + 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 + } + return result.ide ? result.ide.rulesPath : 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] || + ((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ?? + (await promptForPath())) const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {} if (!downloadedFile) {