prjct-cli 0.36.1 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * AI Provider - Multi-agent support for prjct-cli
3
3
  *
4
- * Supports both Claude Code and Gemini CLI with a unified abstraction layer.
5
- * Both agents share similar architectures:
6
- * - Context files: CLAUDE.md / GEMINI.md
7
- * - Skills: Both use SKILL.md format (identical!)
8
- * - Commands: .md (Claude) / .toml (Gemini)
9
- * - MCP: Both support Model Context Protocol
4
+ * Supports multiple AI coding agents with a unified abstraction layer:
5
+ * - Claude Code (CLI): ~/.claude/, CLAUDE.md, .md commands
6
+ * - Gemini CLI (CLI): ~/.gemini/, GEMINI.md, .toml commands
7
+ * - Cursor IDE (GUI): .cursor/ (project-level), .mdc rules
8
+ *
9
+ * Key differences:
10
+ * - CLI providers (Claude/Gemini) have global config directories
11
+ * - Cursor has project-level config only (no ~/.cursor/)
10
12
  *
11
13
  * @see https://geminicli.com/docs/cli/gemini-md/
12
14
  * @see https://geminicli.com/docs/cli/skills/
15
+ * @see https://cursor.com/docs/context/rules
13
16
  */
14
17
 
15
18
  import { execSync } from 'child_process'
@@ -22,6 +25,7 @@ import type {
22
25
  ProviderDetectionResult,
23
26
  ProviderSelectionResult,
24
27
  ProviderBranding,
28
+ CursorProjectDetection,
25
29
  } from '../types/provider'
26
30
 
27
31
  // =============================================================================
@@ -66,12 +70,42 @@ export const GeminiProvider: AIProviderConfig = {
66
70
  docsUrl: 'https://geminicli.com/docs',
67
71
  }
68
72
 
73
+ /**
74
+ * Cursor IDE provider configuration
75
+ *
76
+ * Key differences from Claude/Gemini:
77
+ * - NOT a CLI (GUI app, VS Code fork)
78
+ * - No global config directory (~/.cursor/ doesn't exist)
79
+ * - Project-level config only (.cursor/rules/, .cursor/commands/)
80
+ * - User can select any model (GPT, Claude, Gemini, DeepSeek, etc.)
81
+ *
82
+ * @see https://cursor.com/docs/context/rules
83
+ */
84
+ export const CursorProvider: AIProviderConfig = {
85
+ name: 'cursor',
86
+ displayName: 'Cursor IDE',
87
+ cliCommand: null, // Not a CLI - GUI app
88
+ configDir: null, // No global config directory
89
+ contextFile: 'prjct.mdc', // Uses .mdc format with frontmatter
90
+ skillsDir: null, // No skills directory
91
+ commandsDir: '.cursor/commands',
92
+ rulesDir: '.cursor/rules', // Cursor-specific: rules directory
93
+ commandFormat: 'md',
94
+ settingsFile: null,
95
+ projectSettingsFile: null,
96
+ ignoreFile: '.cursorignore',
97
+ isProjectLevel: true, // Config is project-level only
98
+ websiteUrl: 'https://cursor.com',
99
+ docsUrl: 'https://cursor.com/docs',
100
+ }
101
+
69
102
  /**
70
103
  * All available providers
71
104
  */
72
105
  export const Providers: Record<AIProviderName, AIProviderConfig> = {
73
106
  claude: ClaudeProvider,
74
107
  gemini: GeminiProvider,
108
+ cursor: CursorProvider,
75
109
  }
76
110
 
77
111
  // =============================================================================
@@ -105,10 +139,17 @@ function getCliVersion(command: string): string | null {
105
139
  }
106
140
 
107
141
  /**
108
- * Detect if a specific provider is installed
142
+ * Detect if a specific CLI-based provider is installed
143
+ * Note: Cursor is NOT a CLI, use detectCursorProject() instead
109
144
  */
110
145
  export function detectProvider(provider: AIProviderName): ProviderDetectionResult {
111
146
  const config = Providers[provider]
147
+
148
+ // Cursor is not a CLI - return not installed for CLI detection
149
+ if (!config.cliCommand) {
150
+ return { installed: false }
151
+ }
152
+
112
153
  const cliPath = whichCommand(config.cliCommand)
113
154
 
114
155
  if (!cliPath) {
@@ -125,9 +166,10 @@ export function detectProvider(provider: AIProviderName): ProviderDetectionResul
125
166
  }
126
167
 
127
168
  /**
128
- * Detect all available providers
169
+ * Detect all available CLI-based providers
170
+ * Note: Cursor detection is project-level, use detectCursorProject() separately
129
171
  */
130
- export function detectAllProviders(): Record<AIProviderName, ProviderDetectionResult> {
172
+ export function detectAllProviders(): { claude: ProviderDetectionResult; gemini: ProviderDetectionResult } {
131
173
  return {
132
174
  claude: detectProvider('claude'),
133
175
  gemini: detectProvider('gemini'),
@@ -165,9 +207,13 @@ export function getActiveProvider(projectProvider?: AIProviderName): AIProviderC
165
207
 
166
208
  /**
167
209
  * Check if config directory exists for a provider
210
+ * Returns false for project-level providers (Cursor)
168
211
  */
169
212
  export function hasProviderConfig(provider: AIProviderName): boolean {
170
213
  const config = Providers[provider]
214
+ if (!config.configDir) {
215
+ return false // Cursor has no global config directory
216
+ }
171
217
  return fs.existsSync(config.configDir)
172
218
  }
173
219
 
@@ -189,6 +235,14 @@ Designed for [Gemini](${config.websiteUrl})`,
189
235
  }
190
236
  }
191
237
 
238
+ if (provider === 'cursor') {
239
+ return {
240
+ commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
241
+ Built with [Cursor](${config.websiteUrl})`,
242
+ signature: '⚡ prjct + Cursor',
243
+ }
244
+ }
245
+
192
246
  // Default: Claude
193
247
  return {
194
248
  commitFooter: `🤖 Generated with [p/](https://www.prjct.app/)
@@ -197,30 +251,75 @@ Designed for [Claude](${config.websiteUrl})`,
197
251
  }
198
252
  }
199
253
 
254
+ // =============================================================================
255
+ // Cursor Project Detection
256
+ // =============================================================================
257
+
258
+ /**
259
+ * Detect if a project is configured for Cursor IDE
260
+ *
261
+ * Cursor has NO global config (~/.cursor/ doesn't exist).
262
+ * Detection is based on project-level .cursor/ directory.
263
+ */
264
+ export function detectCursorProject(projectRoot: string): CursorProjectDetection {
265
+ const cursorDir = path.join(projectRoot, '.cursor')
266
+ const rulesDir = path.join(cursorDir, 'rules')
267
+ const routerPath = path.join(rulesDir, 'prjct.mdc')
268
+
269
+ const detected = fs.existsSync(cursorDir)
270
+ const routerInstalled = fs.existsSync(routerPath)
271
+
272
+ return {
273
+ detected,
274
+ routerInstalled,
275
+ projectRoot: detected ? projectRoot : undefined,
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Check if Cursor routers need to be regenerated
281
+ */
282
+ export function needsCursorRouterRegeneration(projectRoot: string): boolean {
283
+ const detection = detectCursorProject(projectRoot)
284
+
285
+ // Only check if .cursor/ exists (project uses Cursor)
286
+ // and prjct router is missing
287
+ return detection.detected && !detection.routerInstalled
288
+ }
289
+
200
290
  // =============================================================================
201
291
  // Provider Paths
202
292
  // =============================================================================
203
293
 
204
294
  /**
205
295
  * Get full path to global context file
296
+ * Returns null for project-level providers (Cursor)
206
297
  */
207
- export function getGlobalContextPath(provider: AIProviderName): string {
298
+ export function getGlobalContextPath(provider: AIProviderName): string | null {
208
299
  const config = Providers[provider]
300
+ if (!config.configDir) {
301
+ return null // Cursor has no global config
302
+ }
209
303
  return path.join(config.configDir, config.contextFile)
210
304
  }
211
305
 
212
306
  /**
213
307
  * Get full path to global settings file
308
+ * Returns null for project-level providers (Cursor)
214
309
  */
215
- export function getGlobalSettingsPath(provider: AIProviderName): string {
310
+ export function getGlobalSettingsPath(provider: AIProviderName): string | null {
216
311
  const config = Providers[provider]
312
+ if (!config.configDir || !config.settingsFile) {
313
+ return null // Cursor has no global settings
314
+ }
217
315
  return path.join(config.configDir, config.settingsFile)
218
316
  }
219
317
 
220
318
  /**
221
319
  * Get full path to skills directory
320
+ * Returns null for providers without skill support (Cursor)
222
321
  */
223
- export function getSkillsPath(provider: AIProviderName): string {
322
+ export function getSkillsPath(provider: AIProviderName): string | null {
224
323
  return Providers[provider].skillsDir
225
324
  }
226
325
 
@@ -298,6 +397,7 @@ export default {
298
397
  Providers,
299
398
  ClaudeProvider,
300
399
  GeminiProvider,
400
+ CursorProvider,
301
401
  detectProvider,
302
402
  detectAllProviders,
303
403
  getActiveProvider,
@@ -309,4 +409,6 @@ export default {
309
409
  getCommandsDir,
310
410
  getProjectCommandsPath,
311
411
  selectProvider,
412
+ detectCursorProject,
413
+ needsCursorRouterRegeneration,
312
414
  }
@@ -60,32 +60,43 @@ export async function installDocs(): Promise<{ success: boolean; error?: string
60
60
  }
61
61
 
62
62
  /**
63
- * Install or update global CLAUDE.md configuration
63
+ * Install or update global AI agent configuration (CLAUDE.md / GEMINI.md)
64
64
  */
65
- export async function installGlobalConfig(
66
- claudeConfigPath: string,
67
- detectClaude: () => Promise<boolean>
68
- ): Promise<GlobalConfigResult> {
69
- const claudeDetected = await detectClaude()
70
-
71
- if (!claudeDetected) {
65
+ export async function installGlobalConfig(): Promise<GlobalConfigResult> {
66
+ const aiProvider = require('./ai-provider')
67
+ const activeProvider = aiProvider.getActiveProvider()
68
+ const providerName = activeProvider.name
69
+
70
+ // Check if provider is installed
71
+ const detection = aiProvider.detectProvider(providerName)
72
+ if (!detection.installed && !activeProvider.configDir) {
72
73
  return {
73
74
  success: false,
74
- error: 'Claude not detected',
75
+ error: `${activeProvider.displayName} not detected`,
75
76
  action: 'skipped',
76
77
  }
77
78
  }
78
79
 
79
80
  try {
80
- // Ensure ~/.claude directory exists
81
- const claudeDir = path.join(os.homedir(), '.claude')
82
- await fs.mkdir(claudeDir, { recursive: true })
81
+ // Ensure config directory exists
82
+ await fs.mkdir(activeProvider.configDir, { recursive: true })
83
83
 
84
- const globalConfigPath = path.join(claudeDir, 'CLAUDE.md')
85
- const templatePath = path.join(getPackageRoot(), 'templates/global/CLAUDE.md')
84
+ const globalConfigPath = path.join(activeProvider.configDir, activeProvider.contextFile)
85
+ const templatePath = path.join(getPackageRoot(), 'templates', 'global', activeProvider.contextFile)
86
86
 
87
87
  // Read template content
88
- const templateContent = await fs.readFile(templatePath, 'utf-8')
88
+ let templateContent = ''
89
+ try {
90
+ templateContent = await fs.readFile(templatePath, 'utf-8')
91
+ } catch (error) {
92
+ // Fallback if provider-specific template not found
93
+ const fallbackTemplatePath = path.join(getPackageRoot(), 'templates/global/CLAUDE.md')
94
+ templateContent = await fs.readFile(fallbackTemplatePath, 'utf-8')
95
+ // If it is Gemini, we should rename Claude to Gemini in the fallback content
96
+ if (providerName === 'gemini') {
97
+ templateContent = templateContent.replace(/Claude/g, 'Gemini')
98
+ }
99
+ }
89
100
 
90
101
  // Check if global config already exists
91
102
  let existingContent = ''
@@ -172,19 +183,26 @@ export class CommandInstaller {
172
183
 
173
184
  constructor() {
174
185
  this.homeDir = os.homedir()
175
- // Commands are stored in p/ subdirectory, accessed via p. trigger
176
- // Note: Claude Code bug #2422 prevents native slash command discovery
177
- // We use the p.md router in commands/ root instead
178
- this.claudeCommandsPath = path.join(this.homeDir, '.claude', 'commands', 'p')
179
- this.claudeConfigPath = path.join(this.homeDir, '.claude')
180
- // Use getPackageRoot() to find templates - works from both source and compiled
186
+
187
+ const aiProvider = require('./ai-provider')
188
+ const activeProvider = aiProvider.getActiveProvider()
189
+
190
+ // Command paths are provider-specific
191
+ if (activeProvider.name === 'gemini') {
192
+ this.claudeCommandsPath = path.join(activeProvider.configDir, 'commands')
193
+ } else {
194
+ // Claude: Commands are in p/ subdirectory to avoid cluttering commands/
195
+ this.claudeCommandsPath = path.join(activeProvider.configDir, 'commands', 'p')
196
+ }
197
+
198
+ this.claudeConfigPath = activeProvider.configDir
181
199
  this.templatesDir = path.join(getPackageRoot(), 'templates', 'commands')
182
200
  }
183
201
 
184
202
  /**
185
- * Detect if Claude is installed
203
+ * Detect if active provider is installed
186
204
  */
187
- async detectClaude(): Promise<boolean> {
205
+ async detectActiveProvider(): Promise<boolean> {
188
206
  try {
189
207
  await fs.access(this.claudeConfigPath)
190
208
  return true
@@ -196,6 +214,13 @@ export class CommandInstaller {
196
214
  }
197
215
  }
198
216
 
217
+ /**
218
+ * Detect if Claude is installed (legacy support)
219
+ */
220
+ async detectClaude(): Promise<boolean> {
221
+ return this.detectActiveProvider()
222
+ }
223
+
199
224
  /**
200
225
  * Get list of command files to install
201
226
  */
@@ -230,20 +255,22 @@ export class CommandInstaller {
230
255
  }
231
256
 
232
257
  /**
233
- * Install commands to Claude
258
+ * Install commands to active AI agent
234
259
  */
235
260
  async installCommands(): Promise<InstallResult> {
236
- const claudeDetected = await this.detectClaude()
261
+ const providerDetected = await this.detectActiveProvider()
262
+ const aiProvider = require('./ai-provider')
263
+ const activeProvider = aiProvider.getActiveProvider()
237
264
 
238
- if (!claudeDetected) {
265
+ if (!providerDetected) {
239
266
  return {
240
267
  success: false,
241
- error: 'Claude not detected. Please install Claude Code or Claude Desktop first.',
268
+ error: `${activeProvider.displayName} not detected. Please install it first.`,
242
269
  }
243
270
  }
244
271
 
245
272
  try {
246
- // Install the p.md router to enable "p. task" trigger
273
+ // Install the router to enable "p. task" trigger
247
274
  await this.installRouter()
248
275
 
249
276
  // Ensure commands directory exists
@@ -404,14 +431,20 @@ export class CommandInstaller {
404
431
  }
405
432
 
406
433
  /**
407
- * Install the p.md router to ~/.claude/commands/
434
+ * Install the router (p.md for Claude, p.toml for Gemini) to commands directory
408
435
  * This enables the "p. task" natural language trigger
409
- * Claude Code bug #2422 prevents subdirectory slash command discovery
410
436
  */
411
437
  async installRouter(): Promise<boolean> {
438
+ const aiProvider = require('./ai-provider')
439
+ const activeProvider = aiProvider.getActiveProvider()
440
+ const routerFile = activeProvider.name === 'gemini' ? 'p.toml' : 'p.md'
441
+
412
442
  try {
413
- const routerSource = path.join(this.templatesDir, 'p.md')
414
- const routerDest = path.join(this.homeDir, '.claude', 'commands', 'p.md')
443
+ const routerSource = path.join(this.templatesDir, routerFile)
444
+ const routerDest = path.join(activeProvider.configDir, 'commands', routerFile)
445
+
446
+ // Ensure commands directory exists
447
+ await fs.mkdir(path.dirname(routerDest), { recursive: true })
415
448
 
416
449
  const content = await fs.readFile(routerSource, 'utf-8')
417
450
  await fs.writeFile(routerDest, content, 'utf-8')
@@ -428,12 +461,12 @@ export class CommandInstaller {
428
461
  * Sync commands - intelligent update that detects and removes orphans
429
462
  */
430
463
  async syncCommands(): Promise<SyncResult> {
431
- const claudeDetected = await this.detectClaude()
464
+ const providerDetected = await this.detectActiveProvider()
432
465
 
433
- if (!claudeDetected) {
466
+ if (!providerDetected) {
434
467
  return {
435
468
  success: false,
436
- error: 'Claude not detected',
469
+ error: 'AI agent not detected',
437
470
  added: 0,
438
471
  updated: 0,
439
472
  removed: 0,
@@ -511,10 +544,10 @@ export class CommandInstaller {
511
544
  }
512
545
 
513
546
  /**
514
- * Install or update global CLAUDE.md configuration
547
+ * Install or update global AI agent configuration (CLAUDE.md / GEMINI.md)
515
548
  */
516
549
  async installGlobalConfig(): Promise<GlobalConfigResult> {
517
- return installGlobalConfig(this.claudeConfigPath, () => this.detectClaude())
550
+ return installGlobalConfig()
518
551
  }
519
552
 
520
553
  /**
@@ -301,23 +301,32 @@ class PathManager {
301
301
  }
302
302
 
303
303
  /**
304
- * Get the Claude directory path (~/.claude)
305
- * Contains Claude Code/Desktop configuration
304
+ * Get the Claude/Gemini directory path (~/.claude or ~/.gemini)
305
+ * Contains AI CLI configuration
306
306
  */
307
- getClaudeDir(): string {
308
- return path.join(os.homedir(), '.claude')
307
+ getAgentDir(): string {
308
+ const provider = require('./ai-provider').getActiveProvider()
309
+ return provider.configDir
310
+ }
311
+
312
+ /**
313
+ * Get the agent settings file path (~/.claude/settings.json or ~/.gemini/settings.json)
314
+ */
315
+ getAgentSettingsPath(): string {
316
+ const provider = require('./ai-provider').getActiveProvider()
317
+ const aiProvider = require('./ai-provider')
318
+ return aiProvider.getGlobalSettingsPath(provider.name)
309
319
  }
310
320
 
311
321
  /**
312
- * Get the Claude commands directory path (~/.claude/commands)
313
- * Contains custom slash commands for Claude
322
+ * Get the Claude directory path (~/.claude) - Legacy support
314
323
  */
315
- getClaudeCommandsDir(): string {
316
- return path.join(this.getClaudeDir(), 'commands')
324
+ getClaudeDir(): string {
325
+ return path.join(os.homedir(), '.claude')
317
326
  }
318
327
 
319
328
  /**
320
- * Get the Claude settings file path (~/.claude/settings.json)
329
+ * Get the Claude settings file path (~/.claude/settings.json) - Legacy support
321
330
  */
322
331
  getClaudeSettingsPath(): string {
323
332
  return path.join(this.getClaudeDir(), 'settings.json')
@@ -106,10 +106,11 @@ export async function run(): Promise<SetupResults> {
106
106
  configAction: null,
107
107
  }
108
108
 
109
- // Step 1: Install for each detected provider
110
- const providerNames: AIProviderName[] = ['claude', 'gemini']
109
+ // Step 1: Install for each CLI-based provider (Claude, Gemini)
110
+ // Note: Cursor is project-level and handled separately via installCursorProject()
111
+ const cliProviderNames: ('claude' | 'gemini')[] = ['claude', 'gemini']
111
112
 
112
- for (const providerName of providerNames) {
113
+ for (const providerName of cliProviderNames) {
113
114
  const providerConfig = Providers[providerName]
114
115
  const providerDetection = detection[providerName]
115
116
 
@@ -295,6 +296,148 @@ async function installGeminiGlobalConfig(): Promise<{ success: boolean; action:
295
296
  }
296
297
  }
297
298
 
299
+ // =============================================================================
300
+ // Cursor IDE Installation (Project-Level)
301
+ // =============================================================================
302
+
303
+ /**
304
+ * Install prjct routers for Cursor IDE in a project
305
+ *
306
+ * Unlike Claude/Gemini which have global config, Cursor uses project-level
307
+ * configuration in .cursor/rules/ and .cursor/commands/.
308
+ *
309
+ * Creates minimal routers that point to the npm package for real instructions.
310
+ * Installs individual command files for better Cursor UX (/sync, /task, etc.)
311
+ *
312
+ * @param projectRoot - The project root directory
313
+ * @returns Object with success status and files created
314
+ */
315
+ export async function installCursorProject(projectRoot: string): Promise<{
316
+ success: boolean
317
+ rulesCreated: boolean
318
+ commandsCreated: boolean
319
+ gitignoreUpdated: boolean
320
+ }> {
321
+ const result = {
322
+ success: false,
323
+ rulesCreated: false,
324
+ commandsCreated: false,
325
+ gitignoreUpdated: false,
326
+ }
327
+
328
+ try {
329
+ const cursorDir = path.join(projectRoot, '.cursor')
330
+ const rulesDir = path.join(cursorDir, 'rules')
331
+ const commandsDir = path.join(cursorDir, 'commands')
332
+
333
+ const routerMdcDest = path.join(rulesDir, 'prjct.mdc')
334
+
335
+ const routerMdcSource = path.join(getPackageRoot(), 'templates', 'cursor', 'router.mdc')
336
+ const cursorCommandsSource = path.join(getPackageRoot(), 'templates', 'cursor', 'commands')
337
+
338
+ // Ensure directories exist
339
+ fs.mkdirSync(rulesDir, { recursive: true })
340
+ fs.mkdirSync(commandsDir, { recursive: true })
341
+
342
+ // Copy router.mdc → .cursor/rules/prjct.mdc
343
+ if (fs.existsSync(routerMdcSource)) {
344
+ fs.copyFileSync(routerMdcSource, routerMdcDest)
345
+ result.rulesCreated = true
346
+ }
347
+
348
+ // Copy individual command files → .cursor/commands/
349
+ // This enables /sync, /task, /done, /ship, etc. syntax in Cursor
350
+ if (fs.existsSync(cursorCommandsSource)) {
351
+ const commandFiles = fs.readdirSync(cursorCommandsSource)
352
+ .filter(f => f.endsWith('.md'))
353
+
354
+ for (const file of commandFiles) {
355
+ const src = path.join(cursorCommandsSource, file)
356
+ const dest = path.join(commandsDir, file)
357
+ fs.copyFileSync(src, dest)
358
+ }
359
+ result.commandsCreated = commandFiles.length > 0
360
+ }
361
+
362
+ // Update .gitignore to exclude prjct Cursor routers
363
+ result.gitignoreUpdated = await addCursorToGitignore(projectRoot)
364
+
365
+ result.success = result.rulesCreated || result.commandsCreated
366
+ return result
367
+ } catch (error) {
368
+ console.error(`Cursor installation warning: ${(error as Error).message}`)
369
+ return result
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Add Cursor prjct routers to .gitignore
375
+ *
376
+ * These files are per-developer and regenerated automatically.
377
+ */
378
+ async function addCursorToGitignore(projectRoot: string): Promise<boolean> {
379
+ try {
380
+ const gitignorePath = path.join(projectRoot, '.gitignore')
381
+ const entriesToAdd = [
382
+ '# prjct Cursor routers (regenerated per-developer)',
383
+ '.cursor/rules/prjct.mdc',
384
+ '.cursor/commands/sync.md',
385
+ '.cursor/commands/task.md',
386
+ '.cursor/commands/done.md',
387
+ '.cursor/commands/ship.md',
388
+ '.cursor/commands/bug.md',
389
+ '.cursor/commands/pause.md',
390
+ '.cursor/commands/resume.md',
391
+ ]
392
+
393
+ let content = ''
394
+ let fileExists = false
395
+
396
+ try {
397
+ content = fs.readFileSync(gitignorePath, 'utf-8')
398
+ fileExists = true
399
+ } catch (error) {
400
+ if (!isNotFoundError(error)) {
401
+ throw error
402
+ }
403
+ }
404
+
405
+ // Check if already added
406
+ if (content.includes('.cursor/rules/prjct.mdc')) {
407
+ return false // Already added
408
+ }
409
+
410
+ // Append to .gitignore
411
+ const newContent = fileExists
412
+ ? content.trimEnd() + '\n\n' + entriesToAdd.join('\n') + '\n'
413
+ : entriesToAdd.join('\n') + '\n'
414
+
415
+ fs.writeFileSync(gitignorePath, newContent, 'utf-8')
416
+ return true
417
+ } catch (error) {
418
+ console.error(`Gitignore update warning: ${(error as Error).message}`)
419
+ return false
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Check if a project has Cursor configured (has .cursor/ directory)
425
+ */
426
+ export function hasCursorProject(projectRoot: string): boolean {
427
+ return fs.existsSync(path.join(projectRoot, '.cursor'))
428
+ }
429
+
430
+ /**
431
+ * Check if Cursor routers need regeneration
432
+ */
433
+ export function needsCursorRegeneration(projectRoot: string): boolean {
434
+ const cursorDir = path.join(projectRoot, '.cursor')
435
+ const routerPath = path.join(cursorDir, 'rules', 'prjct.mdc')
436
+
437
+ // Only check if .cursor/ exists (project uses Cursor)
438
+ return fs.existsSync(cursorDir) && !fs.existsSync(routerPath)
439
+ }
440
+
298
441
  /**
299
442
  * Migrate existing projects to add cliVersion field
300
443
  * This clears the status line warning after npm update