Skip to content

feat: add ai command for cli to start the project #7352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 24 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions src/commands/init/ai-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { resolve } from 'node:path'
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

part of code here is duplication, but we still not setelled the command and want to see how it feels so then we can extract stuff into common utils after we work on making command fully public

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why trialing the new command should mean more code duplication? If you move the generic methods you're reusing to a separate file (which I would argue should already be the case), you're making it easier for this PR to be reviewed, you're going to have less code to maintain, and you reduce the number of places where you might need to adjust things if an issue or an improvement come up.

If you then decide that you want to keep the new command in its current shape, great β€” you already have the code in the structure you want and no further changes are needed; if you decide you want to bin it, you can just delete it and the code you abstracted away into a separate file still makes sense on its own.

import { promises as fs } from 'node:fs'
import type { NetlifyAPI } from '@netlify/api'

import {
chalk,
log,
logPadded,
logAndThrowError,
type APIError,
NETLIFY_CYAN,
NETLIFYDEVLOG,
NETLIFYDEVWARN,
NETLIFYDEVERR,
} from '../../utils/command-helpers.js'
import { normalizeRepoUrl } from '../../utils/normalize-repo-url.js'
import { runGit } from '../../utils/run-git.js'
import { startSpinner } from '../../lib/spinner.js'
import { detectIDE } from '../../recipes/ai-context/index.js'
import { type ConsumerConfig } from '../../recipes/ai-context/context.js'
import {
generateMcpConfig,
configureMcpForVSCode,
configureMcpForCursor,
configureMcpForWindsurf,
showGenericMcpConfig,
} from '../../utils/mcp-utils.js'
import type BaseCommand from '../base-command.js'
import type { SiteInfo } from '../../utils/types.js'
import inquirer from 'inquirer'

const SPARK_URL = process.env.SPARK_URL ?? 'https://spark.netlify.app'
const AI_SITE_PROMPT_GEN_URL = `${SPARK_URL}/site-prompt-gen`
const DOCS_URL = process.env.DOCS_URL ?? 'https://docs.netlify.com'

/**
* Project information interface for AI projects
*/
interface ProjectInfo {
success: boolean
projectId: string
prompt: string
}

// Trigger IDE-specific MCP configuration
const triggerMcpConfiguration = async (ide: ConsumerConfig, projectPath: string): Promise<boolean> => {
log(`\n${chalk.blue('πŸ”§ MCP Configuration for')} ${NETLIFY_CYAN(ide.presentedName)}`)

const { shouldConfigure } = await inquirer.prompt<{ shouldConfigure: boolean }>([
{
type: 'confirm',
name: 'shouldConfigure',
message: `Would you like to automatically configure the MCP server for ${ide.presentedName}?`,
default: true,
},
])

if (!shouldConfigure) {
log(` ${chalk.dim('You can configure MCP manually later for enhanced AI capabilities:')}`)
log(
` ${chalk.dim('Documentation:')} ${NETLIFY_CYAN(
'https://docs.netlify.com/welcome/build-with-ai/netlify-mcp-server/',
)}`,
)

return false
}

try {
const config = generateMcpConfig(ide)

// IDE-specific configuration actions
switch (ide.key) {
case 'vscode':
await configureMcpForVSCode(config, projectPath)
break
case 'cursor':
await configureMcpForCursor(config, projectPath)
break
case 'windsurf':
await configureMcpForWindsurf(config, projectPath)
break
default:
showGenericMcpConfig(config, ide.presentedName)
}

log(`${NETLIFYDEVLOG} MCP configuration completed for ${NETLIFY_CYAN(ide.presentedName)}`)
return true
} catch (error) {
log(`${NETLIFYDEVERR} Failed to configure MCP: ${error instanceof Error ? error.message : 'Unknown error'}`)
return false
}
}

const fetchProjectInfo = async (url: string): Promise<ProjectInfo> => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to have a comment explaining what is the URL we're hitting β€” is it the Netlify API, something else?

try {
const response = await fetch(url, {
headers: {
'content-type': 'text/plain',
},
})

const data = (await response.text()) as unknown as string
const parsedData = JSON.parse(data) as unknown as ProjectInfo
return parsedData
} catch (error) {
throw new Error(`Failed to fetch project information: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}

const getRepoUrlFromProjectId = async (api: NetlifyAPI, projectId: string): Promise<string> => {
try {
const siteInfo = (await api.getSite({ siteId: projectId })) as SiteInfo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not already have the site information populated by the base command? Or is this a different site? If we could use that, we'd avoid making an extra API call.

const repoUrl = siteInfo.build_settings?.repo_url

if (!repoUrl) {
throw new Error(`No repository URL found for project ID: ${projectId}`)
}

return repoUrl
} catch (error) {
if ((error as APIError).status === 404) {
throw new Error(`Project with ID ${projectId} not found`)
}
throw new Error(`Failed to fetch project data: ${(error as Error).message}`)
}
}

const savePrompt = async (instructions: string, ntlContext: string | null, targetDir: string): Promise<void> => {
try {
const filePath = resolve(targetDir, 'AI-instructions.md')
await fs.writeFile(filePath, `Context: ${ntlContext ?? ''}\n\n${instructions}`, 'utf-8')
log(`${NETLIFYDEVLOG} AI instructions saved to ${NETLIFY_CYAN('AI-instructions.md')}`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
log(`${NETLIFYDEVWARN} Warning: Failed to save AI instructions: ${errorMessage}`)
}
}

const cloneRepo = async (repoUrl: string, targetDir: string, debug: boolean): Promise<void> => {
try {
await runGit(['clone', repoUrl, targetDir], !debug)
} catch (error) {
throw new Error(`Failed to clone repository: ${error instanceof Error ? error.message : error?.toString() ?? ''}`)
}
}

/**
* Handles AI rules initialization workflow
* This is the experimental --ai-rules functionality for the init command
*/
export const initWithAiRules = async (hash: string, command: BaseCommand): Promise<void> => {
// Authenticate first before any API operations
await command.authenticate()
const { api } = command.netlify

log(`${NETLIFY_CYAN('πŸ€– Initializing AI project')} with rules...`)
log(`${NETLIFY_CYAN('User:')} ${api.accessToken ? 'Authenticated βœ…' : 'Not authenticated ❌'}`)

try {
// Step 1: Decode hash and fetch project information
log('\nπŸ“‹ Extracting project details...')
const decodedUrl = `${AI_SITE_PROMPT_GEN_URL}/${hash}`
log(`${NETLIFY_CYAN('Decoded URL:')} ${decodedUrl}`)

log('\nπŸ” Fetching project information...')
const projectInfo = await fetchProjectInfo(decodedUrl)

// Step 2: Get repository URL from project ID via Netlify site API
log('\nπŸ”— Linking to Netlify project and fetching repository...')
const repositoryUrl = await getRepoUrlFromProjectId(api, projectInfo.projectId)

// Step 3: Clone repository
const { repoUrl, repoName } = normalizeRepoUrl(repositoryUrl)
const targetDir = `ai-project-${repoName}-${hash.substring(0, 8)}`

const cloneSpinner = startSpinner({ text: `Cloning repository to ${NETLIFY_CYAN(targetDir)}` })

await cloneRepo(repoUrl, targetDir, false)
cloneSpinner.success({ text: `Cloned repository to ${NETLIFY_CYAN(targetDir)}` })

// Step 4: Save AI instructions to file
if (projectInfo.prompt) {
const ntlContext = await fetch(`${DOCS_URL}/ai-context/scoped-context?scopes=serverless,blobs,forms`)
.then((res) => res.text())
.catch(() => {
return null
})
log('\nπŸ“ Saving AI instructions...')
await savePrompt(projectInfo.prompt, ntlContext, targetDir)
}

// Step 5: Detect IDE and configure MCP server
log('\nπŸ” Detecting development environment...')
const detectedIDE = await detectIDE()
let mcpConfigured = false

if (detectedIDE) {
log(`${NETLIFYDEVLOG} Detected development environment: ${NETLIFY_CYAN(detectedIDE.presentedName)}`)
mcpConfigured = await triggerMcpConfiguration(detectedIDE, targetDir)
}

// Update working directory to cloned repo
process.chdir(targetDir)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this?

command.workingDir = targetDir

// Success message with next steps
log()
log(`${NETLIFYDEVLOG} Your AI project is ready to go!`)
log(`β†’ Project cloned to: ${NETLIFY_CYAN(targetDir)}`)
if (projectInfo.prompt) {
log(`β†’ AI instructions saved: ${NETLIFY_CYAN('AI-instructions.md')}`)
}
log()
log(`${NETLIFYDEVWARN} Step 1: Enter your project directory`)
log(` ${NETLIFY_CYAN(`cd ${targetDir}`)}`)

if (detectedIDE) {
if (mcpConfigured) {
log(`${NETLIFYDEVWARN} Step 2: MCP Server Configured`)
log(` ${NETLIFYDEVLOG} ${NETLIFY_CYAN(detectedIDE.key)} is ready with Netlify MCP server`)
log(` ${chalk.dim('πŸ’‘ MCP will activate when you reload/restart your development environment')}`)
} else {
log(`${NETLIFYDEVWARN} Step 2: Manual MCP Configuration`)
log(` ${NETLIFY_CYAN(detectedIDE.key)} detected - MCP setup was skipped`)
log(` ${chalk.dim('You can configure MCP manually later for enhanced AI capabilities:')}`)
log(
` ${chalk.dim('Documentation:')} ${NETLIFY_CYAN(
`${DOCS_URL}/welcome/build-with-ai/netlify-mcp-server/`,
)}`,
)
}
log()
}

if (projectInfo.prompt) {
const stepNumber = detectedIDE ? '3' : '2'
log(`${NETLIFYDEVWARN} Step ${stepNumber}: Ask your AI assistant to process the instructions`)
log()
logPadded(NETLIFY_CYAN(`Follow ${targetDir}/AI-instructions.md and create a new site`))
log()
}
} catch (error) {
return logAndThrowError(error)
}
}
11 changes: 10 additions & 1 deletion src/commands/init/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OptionValues } from 'commander'
import { OptionValues, Option } from 'commander'
import terminalLink from 'terminal-link'

import BaseCommand from '../base-command.js'
Expand All @@ -11,13 +11,22 @@ export const createInitCommand = (program: BaseCommand) =>
)
.option('-m, --manual', 'Manually configure a git remote for CI')
.option('--git-remote-name <name>', 'Name of Git remote to use. e.g. "origin"')
.addOption(new Option('--ai-rules <hash>', 'Initialize with AI project configuration (experimental)').hideHelp())
.addHelpText('after', () => {
const docsUrl = 'https://docs.netlify.com/cli/get-started/'
return `
For more information about getting started with Netlify CLI, see ${terminalLink(docsUrl, docsUrl, { fallback: false })}
`
})
.action(async (options: OptionValues, command: BaseCommand) => {
// Check for experimental AI rules flag
if (options.aiRules) {
const { initWithAiRules } = await import('./ai-rules.js')
await initWithAiRules(options.aiRules as string, command)
return
}

// Standard init flow
const { init } = await import('./init.js')
await init(options, command)
})
3 changes: 2 additions & 1 deletion src/lib/functions/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ export const createHandler = function (options: GetFunctionsServerOptions): Requ
const rawQuery = new URL(request.originalUrl, 'http://example.com').search.slice(1)
// TODO(serhalp): Update several tests to pass realistic `config` objects and remove nullish coalescing.
const protocol = options.config?.dev?.https ? 'https' : 'http'
const url = new URL(requestPath, `${protocol}://${request.get('host') || 'localhost'}`)
const hostname = request.get('host') || 'localhost'
const url = new URL(requestPath, `${protocol}://${hostname}`)
url.search = rawQuery
const rawUrl = url.toString()
const event = {
Expand Down
11 changes: 7 additions & 4 deletions src/recipes/ai-context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const promptForContextConsumerSelection = async (): Promise<ConsumerConfig> => {
* 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 getConsumerKeyFromCommand = (command: string): string | null => {
export const getConsumerKeyFromCommand = (command: string): string | null => {
// The actual command is something like "/applications/windsurf.app/contents/...", but we are only looking for windsurf
const match = cliContextConsumers.find(
(consumer) => consumer.consumerProcessCmd && command.includes(consumer.consumerProcessCmd),
Expand All @@ -88,7 +88,7 @@ const getConsumerKeyFromCommand = (command: string): string | 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 (
export const getCommandAndParentPID = async (
pid: number,
): Promise<{
parentPID: number
Expand All @@ -107,7 +107,10 @@ const getCommandAndParentPID = async (
}
}

const getPathByDetectingIDE = async (): Promise<ConsumerConfig | null> => {
/**
* Detects the IDE by walking up the process tree and matching against known consumer processes
*/
export const detectIDE = async (): Promise<ConsumerConfig | null> => {
// 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<ReturnType<typeof getCommandAndParentPID>>
Expand Down Expand Up @@ -142,7 +145,7 @@ export const run = async (runOptions: RunRecipeOptions) => {
}

if (!consumer && process.env.AI_CONTEXT_SKIP_DETECTION !== 'true') {
consumer = await getPathByDetectingIDE()
consumer = await detectIDE()
}

if (!consumer) {
Expand Down
Loading
Loading