Skip to content

Commit fd02e5d

Browse files
smnhSean Roberts
andauthored
feat: automatically detects IDE - windsurf or cursor - for AI context files. (#7280)
* 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 --------- Co-authored-by: Sean Roberts <[email protected]>
1 parent aaa87aa commit fd02e5d

File tree

1 file changed

+79
-3
lines changed

1 file changed

+79
-3
lines changed

src/recipes/ai-context/index.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolve } from 'node:path'
22

33
import inquirer from 'inquirer'
44
import semver from 'semver'
5+
import execa from 'execa'
56

67
import type { RunRecipeOptions } from '../../commands/recipes/recipes.js'
78
import { chalk, logAndThrowError, log, version } from '../../utils/command-helpers.js'
@@ -18,9 +19,14 @@ import {
1819

1920
export const description = 'Manage context files for AI tools'
2021

22+
const IDE_RULES_PATH_MAP = {
23+
windsurf: '.windsurf/rules',
24+
cursor: '.cursor/rules',
25+
}
26+
2127
const presets = [
22-
{ name: 'Windsurf rules (.windsurf/rules/)', value: '.windsurf/rules' },
23-
{ name: 'Cursor rules (.cursor/rules/)', value: '.cursor/rules' },
28+
{ name: 'Windsurf rules (.windsurf/rules/)', value: IDE_RULES_PATH_MAP.windsurf },
29+
{ name: 'Cursor rules (.cursor/rules/)', value: IDE_RULES_PATH_MAP.cursor },
2430
{ name: 'Custom location', value: '' },
2531
]
2632

@@ -56,11 +62,81 @@ const promptForPath = async (): Promise<string> => {
5662
return promptForPath()
5763
}
5864

65+
type IDE = {
66+
name: string
67+
command: string
68+
rulesPath: string
69+
}
70+
const IDE: IDE[] = [
71+
{
72+
name: 'Windsurf',
73+
command: 'windsurf',
74+
rulesPath: IDE_RULES_PATH_MAP.windsurf,
75+
},
76+
{
77+
name: 'Cursor',
78+
command: 'cursor',
79+
rulesPath: IDE_RULES_PATH_MAP.cursor,
80+
},
81+
]
82+
83+
/**
84+
* Checks if a command belongs to a known IDEs by checking if it includes a specific string.
85+
* For example, the command that starts windsurf looks something like "/applications/windsurf.app/contents/...".
86+
*/
87+
const getIDEFromCommand = (command: string): IDE | null => {
88+
// The actual command is something like "/applications/windsurf.app/contents/...", but we are only looking for windsurf
89+
const match = IDE.find((ide) => command.includes(ide.command))
90+
return match ?? null
91+
}
92+
93+
/**
94+
* 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.
95+
*/
96+
const getCommandAndParentPID = async (
97+
pid: number,
98+
): Promise<{
99+
parentPID: number
100+
command: string
101+
ide: IDE | null
102+
}> => {
103+
const { stdout } = await execa('ps', ['-p', String(pid), '-o', 'ppid=,comm='])
104+
const output = stdout.trim()
105+
const spaceIndex = output.indexOf(' ')
106+
const parentPID = output.substring(0, spaceIndex)
107+
const command = output.substring(spaceIndex + 1).toLowerCase()
108+
return {
109+
parentPID: parseInt(parentPID, 10),
110+
command: command,
111+
ide: getIDEFromCommand(command),
112+
}
113+
}
114+
115+
const getPathByDetectingIDE = async (): Promise<string | null> => {
116+
// Go up the chain of ancestor process IDs and find if one of their commands matches an IDE.
117+
const ppid = process.ppid
118+
let result: Awaited<ReturnType<typeof getCommandAndParentPID>>
119+
try {
120+
result = await getCommandAndParentPID(ppid)
121+
while (result.parentPID !== 1 && !result.ide) {
122+
result = await getCommandAndParentPID(result.parentPID)
123+
}
124+
} catch {
125+
// The command "ps -p {pid} -o ppid=,comm=" didn't work,
126+
// perhaps we are on a machine that doesn't support it.
127+
return null
128+
}
129+
return result.ide ? result.ide.rulesPath : null
130+
}
131+
59132
export const run = async ({ args, command }: RunRecipeOptions) => {
60133
// Start the download in the background while we wait for the prompts.
61134
const download = downloadFile(version).catch(() => null)
62135

63-
const filePath = args[0] || (await promptForPath())
136+
const filePath =
137+
args[0] ||
138+
((process.env.AI_CONTEXT_SKIP_DETECTION === 'true' ? null : await getPathByDetectingIDE()) ??
139+
(await promptForPath()))
64140
const { contents: downloadedFile, minimumCLIVersion } = (await download) ?? {}
65141

66142
if (!downloadedFile) {

0 commit comments

Comments
 (0)