diff --git a/cli/cmd/encore/app/create.go b/cli/cmd/encore/app/create.go index ab9216d38e..f8bfd05de9 100644 --- a/cli/cmd/encore/app/create.go +++ b/cli/cmd/encore/app/create.go @@ -5,9 +5,7 @@ import ( "context" "encoding/json" "fmt" - "io" "io/fs" - "net/http" "os" "os/exec" "path/filepath" @@ -23,52 +21,37 @@ import ( "encr.dev/cli/cmd/encore/auth" "encr.dev/cli/cmd/encore/cmdutil" + "encr.dev/cli/cmd/encore/llm_rules" "encr.dev/cli/internal/platform" "encr.dev/cli/internal/telemetry" "encr.dev/internal/conf" "encr.dev/internal/env" + "encr.dev/internal/userconfig" "encr.dev/internal/version" "encr.dev/pkg/github" "encr.dev/pkg/xos" daemonpb "encr.dev/proto/encore/daemon" ) -const mcpJSON string = `{ - "mcpServers": { - "encore-mcp": { - "command": "encore", - "args": ["mcp", "run", "--app={{ENCORE_APP_ID}}"] - } - } -} -` - -const mdcTemplate string = `--- -description: Encore %s rules -globs: -alwaysApply: true ---- -%s -` - var ( createAppTemplate string createAppOnPlatform bool - createAppLang string - createAppEditor = cmdutil.Oneof{ + createAppLang = cmdutil.Oneof{ Value: "", - Allowed: []string{"cursor"}, - Flag: "editor", - FlagShort: "", // no short flag - Desc: "Initialize the app for a cursor-based editor", + Allowed: cmdutil.LanguageFlagValues(), + Flag: "lang", + FlagShort: "l", + Desc: "Programming language to use for the app.", + TypeDesc: "string", + } + createAppLLMRules = cmdutil.Oneof{ + Value: "", + Allowed: llm_rules.LLMRulesFlagValues(), + Flag: "llm-rules", + FlagShort: "r", + Desc: "Initialize the app with llm rules for a specific tool", TypeDesc: "string", } -) - -type editor string - -const ( - EditorCursor editor = "cursor" ) var createAppCmd = &cobra.Command{ @@ -82,7 +65,19 @@ var createAppCmd = &cobra.Command{ if len(args) > 0 { name = args[0] } - if err := createApp(context.Background(), name, createAppTemplate, language(createAppLang), editor(createAppEditor.Value)); err != nil { + + var tool llm_rules.Tool + if createAppLLMRules.Value == "" { + cfg, err := userconfig.Global().Get() + if err != nil { + cmdutil.Fatalf("Couldn't read user config: %s", err) + } + tool = llm_rules.Tool(cfg.LLMRules) + } else { + tool = llm_rules.Tool(createAppLLMRules.Value) + } + + if err := createApp(context.Background(), name, createAppTemplate, cmdutil.Language(createAppLang.Value), tool); err != nil { cmdutil.Fatal(err) } }, @@ -92,8 +87,8 @@ func init() { appCmd.AddCommand(createAppCmd) createAppCmd.Flags().BoolVar(&createAppOnPlatform, "platform", true, "whether to create the app with the Encore Platform") createAppCmd.Flags().StringVar(&createAppTemplate, "example", "", "URL to example code to use.") - createAppCmd.Flags().StringVar(&createAppLang, "lang", "", "Programming language to use for the app. (ts, go)") - createAppEditor.AddFlag(createAppCmd) + createAppLang.AddFlag(createAppCmd) + createAppLLMRules.AddFlag(createAppCmd) } func promptAccountCreation() { @@ -163,7 +158,7 @@ func promptRunApp() bool { } // createApp is the implementation of the "encore app create" command. -func createApp(ctx context.Context, name, template string, lang language, editor editor) (err error) { +func createApp(ctx context.Context, name, template string, lang cmdutil.Language, llmRules llm_rules.Tool) (err error) { defer func() { // We need to send the telemetry synchronously to ensure it's sent before the command exits. telemetry.SendSync("app.create", map[string]any{ @@ -177,15 +172,15 @@ func createApp(ctx context.Context, name, template string, lang language, editor promptAccountCreation() - if name == "" || template == "" { - name, template, lang = selectTemplate(name, template, lang, false) + if name == "" || template == "" || llmRules == "" { + name, template, lang, llmRules = createAppForm(name, template, lang, llmRules, false) } // Treat the special name "empty" as the empty app template // (the rest of the code assumes that's the empty string). if template == "empty" { template = "" } - if template == "" && lang == languageTS { + if template == "" && lang == cmdutil.LanguageTS { template = "ts/empty" } @@ -286,7 +281,7 @@ func createApp(ctx context.Context, name, template string, lang language, editor // Update to latest encore.dev release if _, err := os.Stat(filepath.Join(name, appRootRelpath, "go.mod")); err == nil { - lang = languageGo + lang = cmdutil.LanguageGo s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) s.Prefix = "Running go get encore.dev@latest" s.Start() @@ -295,7 +290,7 @@ func createApp(ctx context.Context, name, template string, lang language, editor } s.Stop() } else if _, err := os.Stat(filepath.Join(name, appRootRelpath, "package.json")); err == nil { - lang = languageTS + lang = cmdutil.LanguageTS s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) s.Prefix = "Running npm install encore.dev@latest" s.Start() @@ -336,23 +331,8 @@ func createApp(ctx context.Context, name, template string, lang language, editor color.Red("Failed to create app on daemon: %s\n", err) } - switch editor { - case EditorCursor: - cursorDir := filepath.Join(name, appRootRelpath, ".cursor") - rulesDir := filepath.Join(cursorDir, "rules") - err := os.MkdirAll(rulesDir, 0755) - if err != nil { - return err - } - err = os.WriteFile(filepath.Join(cursorDir, "mcp.json"), []byte(strings.ReplaceAll(mcpJSON, "{{ENCORE_APP_ID}}", appResp.AppId)), 0644) - if err != nil { - return err - } - llmInstructions, err := downloadLLMInstructions(lang) - err = os.WriteFile(filepath.Join(rulesDir, "encore.mdc"), fmt.Appendf(nil, mdcTemplate, lang, string(llmInstructions)), 0644) - if err != nil { - return err - } + if err := llm_rules.SetupLLMRules(llmRules, lang, filepath.Join(name, appRootRelpath), appResp.AppId); err != nil { + color.Red("Failed to setup LLM rules: %s\n", err) } cmdutil.ClearTerminalExceptFirstNLines(0) @@ -364,16 +344,7 @@ func createApp(ctx context.Context, name, template string, lang language, editor fmt.Printf("Web URL: %s%s", cyanf("https://app.encore.cloud/"+app.Slug), cmdutil.Newline) } fmt.Printf("App Root: %s\n", cyanf(appRoot)) - switch editor { - case EditorCursor: - fmt.Printf("MCP: %s\n", cyanf("Configured in Cursor")) - fmt.Println() - fmt.Println("Try these prompts in Cursor:") - fmt.Println("→ \"add image uploads to my hello world app\"") - fmt.Println("→ \"add a SQL database for storing user profiles\"") - fmt.Println("→ \"add a pub/sub topic for sending notifications\"") - } - fmt.Println() + llm_rules.PrintLLMRulesInfo(llmRules) greenBoldF := green.Add(color.Bold).SprintfFunc() fmt.Printf("Run your app with: %s\n", greenBoldF("cd %s && encore run", filepath.Join(name, appRootRelpath))) fmt.Println() @@ -400,7 +371,7 @@ func createApp(ctx context.Context, name, template string, lang language, editor _, _ = cyan.Printf(" encore run\n") fmt.Print(" Run your app locally\n\n") - if lang == languageGo { + if lang == cmdutil.LanguageGo { _, _ = cyan.Printf(" encore test ./...\n") } else { _, _ = cyan.Printf(" encore test\n") @@ -416,44 +387,15 @@ func createApp(ctx context.Context, name, template string, lang language, editor return nil } -func downloadLLMInstructions(lang language) (string, error) { - fmt.Println("Downloading LLM Instructions...") - var url string - switch lang { - case languageGo: - url = "https://raw.githubusercontent.com/encoredev/encore/refs/heads/main/go_llm_instructions.txt" - case languageTS: - url = "https://raw.githubusercontent.com/encoredev/encore/refs/heads/main/ts_llm_instructions.txt" - default: - return "", fmt.Errorf("unsupported language") - } - s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) - s.Prefix = "Downloading LLM instructions..." - s.Start() - defer s.Stop() - resp, err := http.Get(url) - if err != nil { - s.FinalMSG = fmt.Sprintf("failed, skipping: %v", err.Error()) - return "", err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - s.FinalMSG = fmt.Sprintf("failed, skipping: %v", err.Error()) - return "", err - } - return string(body), nil -} - // detectLang attempts to detect the application language for an Encore application // situated at appRoot. -func detectLang(appRoot string) language { +func detectLang(appRoot string) cmdutil.Language { if _, err := os.Stat(filepath.Join(appRoot, "go.mod")); err == nil { - return languageGo + return cmdutil.LanguageGo } else if _, err := os.Stat(filepath.Join(appRoot, "package.json")); err == nil { - return languageTS + return cmdutil.LanguageTS } - return languageGo + return cmdutil.LanguageGo } func validateName(name string) error { diff --git a/cli/cmd/encore/app/create_form.go b/cli/cmd/encore/app/create_form.go index 15f7befc13..5d37812b66 100644 --- a/cli/cmd/encore/app/create_form.go +++ b/cli/cmd/encore/app/create_form.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "slices" "strings" "sync" "time" @@ -20,112 +21,71 @@ import ( "golang.org/x/term" "encr.dev/cli/cmd/encore/cmdutil" -) - -const ( - codeBlue = "#6D89FF" - codePurple = "#A36C8C" - codeGreen = "#B3D77E" - validationFail = "#CB1010" -) - -var ( - inputStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: codeBlue, Light: codeBlue}) - descStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: codeGreen, Light: codePurple}) - docStyle = lipgloss.NewStyle().Padding(0, 2, 0, 2) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(validationFail)) - successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00C200")) + "encr.dev/cli/cmd/encore/llm_rules" + "encr.dev/pkg/option" ) type templateItem struct { - ItemTitle string `json:"title"` - Desc string `json:"desc"` - Template string `json:"template"` - Lang language `json:"lang"` + ItemTitle string `json:"title"` + Desc string `json:"desc"` + Template string `json:"template"` + Lang cmdutil.Language `json:"lang"` } func (i templateItem) Title() string { return i.ItemTitle } func (i templateItem) Description() string { return i.Desc } func (i templateItem) FilterValue() string { return i.ItemTitle } +type CreateStep int + +const ( + CreateStepLang CreateStep = iota + CreateStepTemplate + CreateStepAppName + CreateStepLLMRules +) + type createFormModel struct { - step int // 0, 1, 2, 3 + steps []CreateStep - lang languageSelectModel + lang langSelectModel templates templateListModel appName appNameModel + llmRules llm_rules.ToolSelectModel - skipShowingTemplate bool + initExistingApp bool + width int + height int aborted bool } -func (m createFormModel) Init() tea.Cmd { - return tea.Batch( - m.appName.Init(), - m.templates.Init(), - ) -} - -type languageSelectDone struct { - lang language -} - -type languageSelectModel struct { - predefined language - list list.Model -} - -func (m languageSelectModel) Selected() language { - if m.predefined != "" { - return m.predefined +func (m createFormModel) nextStep() option.Option[CreateStep] { + if len(m.steps) == 0 { + return option.None[CreateStep]() } - sel := m.list.SelectedItem() - if sel == nil { - return "" - } - return sel.(langItem).lang + return option.Some(m.steps[0]) } -func (m languageSelectModel) Update(msg tea.Msg) (languageSelectModel, tea.Cmd) { - var c tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - // Have we selected a language? - if idx := m.list.Index(); idx >= 0 { - return m, func() tea.Msg { - return languageSelectDone{ - lang: m.Selected(), - } - } - } - } - } +func (m createFormModel) hasStep(s CreateStep) bool { + return slices.Contains(m.steps, s) +} - m.list, c = m.list.Update(msg) - return m, c +func (m *createFormModel) removeStep(s CreateStep) { + m.steps = slices.DeleteFunc(m.steps, func(step CreateStep) bool { + return step == s + }) } -func (m *languageSelectModel) SetSize(width, height int) { - m.list.SetWidth(width) - m.list.SetHeight(max(height-1, 0)) +func (m createFormModel) Init() tea.Cmd { + return tea.Batch( + m.appName.Init(), + m.templates.Init(), + ) } const checkmark = "✔" -func (m languageSelectModel) View() string { - var b strings.Builder - b.WriteString(inputStyle.Render("Select language for your application")) - b.WriteString(descStyle.Render(" [Use arrows to move]")) - b.WriteString("\n") - b.WriteString(m.list.View()) - - return b.String() -} - type appNameDone struct{} type appNameModel struct { @@ -176,12 +136,12 @@ func (m appNameModel) Update(msg tea.Msg) (appNameModel, tea.Cmd) { func (m appNameModel) View() string { var b strings.Builder if m.text.Focused() { - b.WriteString(inputStyle.Render("App Name")) - b.WriteString(descStyle.Render(" [Use only lowercase letters, digits, and dashes]")) + b.WriteString(cmdutil.InputStyle.Render("App Name")) + b.WriteString(cmdutil.DescStyle.Render(" [Use only lowercase letters, digits, and dashes]")) b.WriteByte('\n') b.WriteString(m.text.View()) if m.dirExists { - b.WriteString(errorStyle.Render(" error: dir already exists")) + b.WriteString(cmdutil.ErrorStyle.Render(" error: dir already exists")) } } else { fmt.Fprintf(&b, "%s App Name: %s", checkmark, m.text.Value()) @@ -192,7 +152,7 @@ func (m appNameModel) View() string { type templateListModel struct { predefined string - filter language + filter cmdutil.Language all []templateItem list list.Model @@ -219,7 +179,7 @@ func (m templateListModel) Update(msg tea.Msg) (templateListModel, tea.Cmd) { case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: - // Have we selected a language? + // Have we selected a template? if idx := m.list.Index(); idx >= 0 { return m, func() tea.Msg { return templateSelectDone{} } } @@ -243,7 +203,7 @@ func (m templateListModel) Update(msg tea.Msg) (templateListModel, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *templateListModel) UpdateFilter(lang language) { +func (m *templateListModel) UpdateFilter(lang cmdutil.Language) { m.filter = lang m.refreshFilter() } @@ -260,8 +220,8 @@ func (m *templateListModel) refreshFilter() { func (m templateListModel) View() string { var b strings.Builder - b.WriteString(inputStyle.Render("Template")) - b.WriteString(descStyle.Render(" [Use arrows to move]")) + b.WriteString(cmdutil.InputStyle.Render("Template")) + b.WriteString(cmdutil.DescStyle.Render(" [Use arrows to move]")) b.WriteByte('\n') b.WriteString(m.list.View()) @@ -287,55 +247,69 @@ func (m createFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc, 'q': + switch msg.String() { + case "ctrl+c", "esc", "q": m.aborted = true return m, tea.Quit } - switch m.step { - case 0: - m.lang, c = m.lang.Update(msg) - cmds = append(cmds, c) - case 1: - m.templates, c = m.templates.Update(msg) - cmds = append(cmds, c) - case 2: - m.appName, c = m.appName.Update(msg) - cmds = append(cmds, c) + if step, ok := m.nextStep().Get(); ok { + switch step { + case CreateStepLang: + m.lang, c = m.lang.Update(msg) + cmds = append(cmds, c) + case CreateStepTemplate: + m.templates, c = m.templates.Update(msg) + cmds = append(cmds, c) + case CreateStepAppName: + m.appName, c = m.appName.Update(msg) + cmds = append(cmds, c) + case CreateStepLLMRules: + m.llmRules, c = m.llmRules.Update(msg) + cmds = append(cmds, c) + } } return m, tea.Batch(cmds...) - case languageSelectDone: - m.step = 1 - if m.skipShowingTemplate { - m.step = 2 - } - m.templates.UpdateFilter(msg.lang) + case langSelectDone: + m.removeStep(CreateStepLang) + m.templates.UpdateFilter(msg.Selected) + m.SetSize(m.width, m.height) + + case llm_rules.ToolSelectDone: + m.removeStep(CreateStepLLMRules) + m.SetSize(m.width, m.height) case templateSelectDone: + m.removeStep(CreateStepTemplate) if m.appName.predefined != "" { - // We're done. - m.step = 3 - cmds = append(cmds, tea.Quit) - } else { - m.step = 2 + m.removeStep(CreateStepAppName) } + m.SetSize(m.width, m.height) case appNameDone: - cmds = append(cmds, tea.Quit) - m.step = 3 + m.removeStep(CreateStepAppName) + m.SetSize(m.width, m.height) case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height m.SetSize(msg.Width, msg.Height) return m, nil } + // No more steps, quit + if !m.nextStep().Present() { + cmds = append(cmds, tea.Quit) + } + // Update all submodels for other messages. m.lang, c = m.lang.Update(msg) cmds = append(cmds, c) m.templates, c = m.templates.Update(msg) cmds = append(cmds, c) + m.llmRules, c = m.llmRules.Update(msg) + cmds = append(cmds, c) m.appName, c = m.appName.Update(msg) cmds = append(cmds, c) @@ -343,25 +317,24 @@ func (m createFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *createFormModel) SetSize(width, height int) { - // Step 1 doneHeight := lipgloss.Height(m.doneView()) - { - availHeight := height - doneHeight - m.lang.SetSize(width, availHeight) - } + availHeight := height - doneHeight - // Step 2 - { - availHeight := height - doneHeight - m.templates.SetSize(width, availHeight) - } + // CreateStepLang + m.lang.SetSize(width, availHeight) + + // CreateStepTemplate + m.templates.SetSize(width, availHeight) + + // CreateStepLLMRules + m.llmRules.SetSize(width, availHeight) } func (m createFormModel) doneView() string { var b strings.Builder renderDone := func(title, value string) { - b.WriteString(successStyle.Render(fmt.Sprintf("%s %s: ", checkmark, title))) + b.WriteString(cmdutil.SuccessStyle.Render(fmt.Sprintf("%s %s: ", checkmark, title))) b.WriteString(value) b.WriteByte('\n') } @@ -378,18 +351,27 @@ func (m createFormModel) doneView() string { renderDone("Template", m.templates.Selected()) } + renderLLMRulesDone := func() { + renderDone("LLM Rules", m.llmRules.Selected().Display()) + } + if m.appName.predefined != "" { renderNameDone() } - if m.templates.predefined == "" && m.step > 0 { + if m.templates.predefined == "" && !m.hasStep(CreateStepLang) { renderLangDone() } - if !m.skipShowingTemplate { - if m.templates.predefined != "" || m.step > 1 { + if !m.initExistingApp { + if m.templates.predefined != "" || !m.hasStep(CreateStepTemplate) { renderTemplateDone() } + if m.llmRules.Predefined != "" || !m.hasStep(CreateStepLLMRules) { + if m.llmRules.Selected() != llm_rules.LLMRulesToolNone { + renderLLMRulesDone() + } + } } - if m.appName.predefined == "" && m.step > 2 { + if m.appName.predefined == "" && !m.hasStep(CreateStepAppName) { renderNameDone() } @@ -406,23 +388,25 @@ func (m createFormModel) View() string { b.WriteByte('\n') } - if m.step == 0 { - b.WriteString(m.lang.View()) - } + if step, ok := m.nextStep().Get(); ok { + if step == CreateStepLang { + b.WriteString(m.lang.View()) + } - if m.step == 1 { - b.WriteString(m.templates.View()) - } + if step == CreateStepTemplate { + b.WriteString(m.templates.View()) + } - if m.step == 2 { - b.WriteString(m.appName.View()) - } + if step == CreateStepAppName { + b.WriteString(m.appName.View()) + } - return docStyle.Render(b.String()) -} + if step == CreateStepLLMRules { + b.WriteString(m.llmRules.View()) + } + } -func (m templateListModel) templatesLoading() bool { - return len(m.list.Items()) == 0 + return cmdutil.DocStyle.Render(b.String()) } func (m templateListModel) SelectedItem() (templateItem, bool) { @@ -437,10 +421,10 @@ func (m templateListModel) SelectedItem() (templateItem, bool) { return templateItem{}, false } -func selectTemplate(inputName, inputTemplate string, inputLang language, skipShowingTemplate bool) (appName, template string, selectedLang language) { - // If we have both name and template already, return them. - if inputName != "" && inputTemplate != "" { - return inputName, inputTemplate, inputLang +func createAppForm(inputName, inputTemplate string, inputLang cmdutil.Language, inputLLMRules llm_rules.Tool, initExistingApp bool) (appName, template string, selectedLang cmdutil.Language, selectedRules llm_rules.Tool) { + // If all is set, just return + if inputName != "" && inputTemplate != "" && inputLLMRules != "" { + return inputName, inputTemplate, inputLang, inputLLMRules } // If shell is non-interactive, don't prompt @@ -448,14 +432,14 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho if inputName == "" { cmdutil.Fatal("specify an app name") } - return inputName, inputTemplate, inputLang + return inputName, inputTemplate, inputLang, inputLLMRules } - var lang languageSelectModel + var langModel langSelectModel { ls := list.NewDefaultItemStyles() - ls.SelectedTitle = ls.SelectedTitle.Foreground(lipgloss.Color(codeBlue)).BorderForeground(lipgloss.Color(codeBlue)) - ls.SelectedDesc = ls.SelectedDesc.Foreground(lipgloss.Color(codeBlue)).BorderForeground(lipgloss.Color(codeBlue)) + ls.SelectedTitle = ls.SelectedTitle.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) + ls.SelectedDesc = ls.SelectedDesc.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) del := list.NewDefaultDelegate() del.Styles = ls del.ShowDescription = false @@ -463,11 +447,11 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho items := []list.Item{ langItem{ - lang: languageGo, + lang: cmdutil.LanguageGo, desc: "Build performant and scalable backends with Go", }, langItem{ - lang: languageTS, + lang: cmdutil.LanguageTS, desc: "Build backend and full-stack applications with TypeScript", }, } @@ -479,18 +463,19 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho ll.SetShowFilter(false) ll.SetFilteringEnabled(false) ll.SetShowStatusBar(false) - lang = languageSelectModel{ - list: ll, - predefined: inputLang, + ll.DisableQuitKeybindings() // quit handled by createFormModel + langModel = langSelectModel{ + List: ll, + Predefined: inputLang, } - lang.SetSize(0, 20) + langModel.SetSize(0, 20) } - var templates templateListModel + var templateModel templateListModel { ls := list.NewDefaultItemStyles() - ls.SelectedTitle = ls.SelectedTitle.Foreground(lipgloss.Color(codeBlue)).BorderForeground(lipgloss.Color(codeBlue)) - ls.SelectedDesc = ls.SelectedDesc.Foreground(lipgloss.Color(codeBlue)).BorderForeground(lipgloss.Color(codeBlue)) + ls.SelectedTitle = ls.SelectedTitle.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) + ls.SelectedDesc = ls.SelectedDesc.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) del := list.NewDefaultDelegate() del.Styles = ls @@ -501,16 +486,49 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho ll.SetShowFilter(false) ll.SetFilteringEnabled(false) ll.SetShowStatusBar(false) + ll.DisableQuitKeybindings() // quit handled by createFormModel sp := spinner.New() sp.Spinner = spinner.Dot - sp.Style = inputStyle.Copy().Inline(true) - templates = templateListModel{ + sp.Style = cmdutil.InputStyle.Copy().Inline(true) + templateModel = templateListModel{ predefined: inputTemplate, list: ll, loading: sp, } } + var llmRulesModel llm_rules.ToolSelectModel + { + ls := list.NewDefaultItemStyles() + ls.SelectedTitle = ls.SelectedTitle.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) + ls.SelectedDesc = ls.SelectedDesc.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) + del := list.NewDefaultDelegate() + del.Styles = ls + del.ShowDescription = false + del.SetSpacing(0) + + items := make([]list.Item, 0, len(llm_rules.AllLLMRules)+1) + items = append(items, llm_rules.NewLLMRulesItem(llm_rules.LLMRulesToolNone)) + for _, rule := range llm_rules.AllLLMRules { + items = append(items, llm_rules.NewLLMRulesItem(rule)) + } + + ll := list.New(items, del, 0, 0) + ll.SetShowTitle(false) + ll.SetShowHelp(false) + ll.SetShowPagination(true) + ll.SetShowFilter(false) + ll.SetFilteringEnabled(false) + ll.SetShowStatusBar(false) + ll.DisableQuitKeybindings() // quit handled by createFormModel + + llmRulesModel = llm_rules.ToolSelectModel{ + List: ll, + Predefined: inputLLMRules, + } + llmRulesModel.SetSize(0, 20) + + } var nameModel appNameModel { @@ -523,24 +541,42 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho nameModel = appNameModel{predefined: inputName, text: text} } + // Setup what steps and in what order they should be presented + var steps []CreateStep + if initExistingApp { + if langModel.Predefined == "" { + steps = append(steps, CreateStepLang) + } + } else { + if templateModel.predefined == "" { + if langModel.Predefined == "" { + steps = append(steps, CreateStepLang) + } else { + templateModel.UpdateFilter(inputLang) + } + steps = append(steps, CreateStepTemplate) + } + if llmRulesModel.Predefined == "" { + steps = append(steps, CreateStepLLMRules) + } + } + if nameModel.predefined == "" { + steps = append(steps, CreateStepAppName) + } + m := createFormModel{ - step: 0, - lang: lang, - templates: templates, - appName: nameModel, - skipShowingTemplate: skipShowingTemplate, + steps: steps, + lang: langModel, + templates: templateModel, + llmRules: llmRulesModel, + appName: nameModel, + initExistingApp: initExistingApp, } // If we have a name, start the list without any selection. if m.appName.predefined != "" { m.templates.list.Select(-1) } - if m.templates.predefined != "" { - m.step = 2 // skip to app name selection - } else if m.lang.predefined != "" { - m.templates.UpdateFilter(inputLang) - m.step = 1 // skip to template selection - } p := tea.NewProgram(m) @@ -561,7 +597,7 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho appName = res.appName.text.Value() } - if template == "" { + if template == "" && !initExistingApp { sel, ok := res.templates.SelectedItem() if !ok { cmdutil.Fatal("no template selected") @@ -569,39 +605,21 @@ func selectTemplate(inputName, inputTemplate string, inputLang language, skipSho template = sel.Template } - return appName, template, res.lang.Selected() + return appName, template, res.lang.Selected(), res.llmRules.Selected() } type langItem struct { - lang language + lang cmdutil.Language desc string } -func (i langItem) FilterValue() string { - return i.lang.Display() -} -func (i langItem) Title() string { - return i.FilterValue() -} -func (i langItem) Description() string { return "" } - -type language string +func (i langItem) FilterValue() string { return i.lang.Display() } +func (i langItem) Title() string { return i.FilterValue() } +func (i langItem) Description() string { return "" } +func (i langItem) SelectedID() cmdutil.Language { return i.lang } -const ( - languageGo language = "go" - languageTS language = "ts" -) - -func (lang language) Display() string { - switch lang { - case languageGo: - return "Go" - case languageTS: - return "TypeScript" - default: - return string(lang) - } -} +type langSelectModel = cmdutil.SimpleSelectModel[cmdutil.Language, langItem] +type langSelectDone = cmdutil.SimpleSelectDone[cmdutil.Language] type loadedTemplates []templateItem diff --git a/cli/cmd/encore/app/initialize.go b/cli/cmd/encore/app/initialize.go index 0fd35883f0..c9fe63ecc5 100644 --- a/cli/cmd/encore/app/initialize.go +++ b/cli/cmd/encore/app/initialize.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "encr.dev/cli/cmd/encore/cmdutil" + "encr.dev/cli/cmd/encore/llm_rules" "encr.dev/internal/conf" "encr.dev/pkg/xos" ) @@ -29,7 +30,14 @@ const ( ) var ( - initAppLang string + initAppLang = cmdutil.Oneof{ + Value: "", + Allowed: cmdutil.LanguageFlagValues(), + Flag: "lang", + FlagShort: "l", + Desc: "Programming language to use for the app.", + TypeDesc: "string", + } ) // Create a new app from scratch: `encore app create` @@ -54,7 +62,7 @@ func init() { } appCmd.AddCommand(initAppCmd) - initAppCmd.Flags().StringVar(&initAppLang, "lang", "", "Programming language to use for the app. (ts, go)") + initAppLang.AddFlag(initAppCmd) } func initializeApp(name string) error { @@ -64,7 +72,7 @@ func initializeApp(name string) error { // expected } else if err != nil { cmdutil.Fatal(err) - } else if err == nil { + } else { // There is already an app here or in a parent directory. cmdutil.Fatal("an encore.app file already exists (here or in a parent directory)") } @@ -72,7 +80,7 @@ func initializeApp(name string) error { cyan := color.New(color.FgCyan) promptAccountCreation() - name, _, lang := selectTemplate(name, "", language(initAppLang), true) + name, _, lang, _ := createAppForm(name, "", cmdutil.Language(initAppLang.Value), llm_rules.LLMRulesToolNone, true) if err := validateName(name); err != nil { return err @@ -106,13 +114,14 @@ func initializeApp(name string) error { `Use "encore app link" to link it.`, }, "\n\t//") } - encoreAppData := []byte(fmt.Sprintf(encoreAppTemplate, appSlugComments, appSlug)) + encoreAppData := fmt.Appendf(nil, encoreAppTemplate, appSlugComments, appSlug) if err := xos.WriteFile("encore.app", encoreAppData, 0644); err != nil { return err } - // Update to latest encore.dev release if this looks to be a go module. + // Update to latest encore.dev release if _, err := os.Stat("go.mod"); err == nil { + lang = cmdutil.LanguageGo s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) s.Prefix = "Running go get encore.dev@latest" s.Start() @@ -120,6 +129,15 @@ func initializeApp(name string) error { s.FinalMSG = fmt.Sprintf("failed, skipping: %v", err.Error()) } s.Stop() + } else if _, err := os.Stat("package.json"); err == nil { + lang = cmdutil.LanguageTS + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + s.Prefix = "Running npm install encore.dev@latest" + s.Start() + if err := npmInstallEncore("."); err != nil { + s.FinalMSG = fmt.Sprintf("failed, skipping: %v", err.Error()) + } + s.Stop() } green := color.New(color.FgGreen) diff --git a/cli/cmd/encore/cmdutil/forms.go b/cli/cmd/encore/cmdutil/forms.go new file mode 100644 index 0000000000..02d6664f26 --- /dev/null +++ b/cli/cmd/encore/cmdutil/forms.go @@ -0,0 +1,96 @@ +package cmdutil + +import ( + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + CodeBlue = "#6D89FF" + CodePurple = "#A36C8C" + CodeGreen = "#B3D77E" + ValidationFail = "#CB1010" +) + +var ( + InputStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: CodeBlue, Light: CodeBlue}) + DescStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: CodeGreen, Light: CodePurple}) + DocStyle = lipgloss.NewStyle().Padding(0, 2, 0, 2) + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ValidationFail)) + SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00C200")) +) + +type SelectedID[T any] interface { + SelectedID() T +} + +type Selectable interface { + comparable + SelectPrompt() string +} + +type SimpleSelectDone[T any] struct { + Selected T +} + +type SimpleSelectModel[T Selectable, S SelectedID[T]] struct { + Predefined T + List list.Model +} + +func (m SimpleSelectModel[T, S]) Selected() T { + var empty T + if m.Predefined != empty { + return m.Predefined + } + sel := m.List.SelectedItem() + if sel == nil { + return empty + } + return sel.(S).SelectedID() +} + +func (m SimpleSelectModel[T, I]) Update(msg tea.Msg) (SimpleSelectModel[T, I], tea.Cmd) { + var c tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + // Have we selected an item? + if idx := m.List.Index(); idx >= 0 { + return m, func() tea.Msg { + return SimpleSelectDone[T]{ + Selected: m.Selected(), + } + } + } + } + } + + m.List, c = m.List.Update(msg) + return m, c +} + +func (m *SimpleSelectModel[T, I]) SetSize(width, height int) { + m.List.SetWidth(width) + m.List.SetHeight(max(height-1, 0)) +} + +func (m SimpleSelectModel[T, I]) View() string { + var b strings.Builder + + // Get the prompt from the type T + var zero T + prompt := zero.SelectPrompt() + + b.WriteString(InputStyle.Render(prompt)) + b.WriteString(DescStyle.Render(" [Use arrows to move]")) + b.WriteString("\n") + b.WriteString(m.List.View()) + + return b.String() +} diff --git a/cli/cmd/encore/cmdutil/language.go b/cli/cmd/encore/cmdutil/language.go new file mode 100644 index 0000000000..ed38b6f0d8 --- /dev/null +++ b/cli/cmd/encore/cmdutil/language.go @@ -0,0 +1,36 @@ +package cmdutil + +type Language string + +const ( + LanguageGo Language = "go" + LanguageTS Language = "ts" +) + +var AllLanguages = []Language{ + LanguageGo, + LanguageTS, +} + +func LanguageFlagValues() []string { + result := make([]string, 0, len(AllLanguages)) + for _, r := range AllLanguages { + result = append(result, string(r)) + } + return result +} + +func (lang Language) Display() string { + switch lang { + case LanguageGo: + return "Go" + case LanguageTS: + return "TypeScript" + default: + return string(lang) + } +} + +func (lang Language) SelectPrompt() string { + return "Select language for your application" +} diff --git a/cli/cmd/encore/llm_rules/init.go b/cli/cmd/encore/llm_rules/init.go new file mode 100644 index 0000000000..684a1787c6 --- /dev/null +++ b/cli/cmd/encore/llm_rules/init.go @@ -0,0 +1,191 @@ +package llm_rules + +import ( + "os" + "path/filepath" + "strings" + + "encr.dev/cli/cmd/encore/cmdutil" + "encr.dev/internal/userconfig" + "encr.dev/pkg/appfile" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/cockroachdb/errors" + "github.com/spf13/cobra" +) + +var ( + llmRulesToolFlag = cmdutil.Oneof{ + Value: "", + Allowed: LLMRulesFlagValues(), + Flag: "llm-rules", + FlagShort: "r", + Desc: "Initialize the app with llm rules for a specific tool", + TypeDesc: "string", + } +) + +func init() { + llmRules := &cobra.Command{ + Use: "init", + Short: "Initialize llm rules for this project", + Args: cobra.ExactArgs(0), + + DisableFlagsInUseLine: true, + Run: func(cmd *cobra.Command, args []string) { + + var tool Tool + if llmRulesToolFlag.Value == "" { + cfg, err := userconfig.Global().Get() + if err != nil { + cmdutil.Fatalf("Couldn't read user config: %s", err) + } + tool = Tool(cfg.LLMRules) + } else { + tool = Tool(llmRulesToolFlag.Value) + } + + if err := initLLMRules(tool); err != nil { + cmdutil.Fatal(err) + } + }, + } + + llmRulesCmd.AddCommand(llmRules) + llmRulesToolFlag.AddFlag(llmRules) +} + +func initLLMRules(tool Tool) error { + if tool == "" { + var llmRulesModel ToolSelectModel + { + ls := list.NewDefaultItemStyles() + ls.SelectedTitle = ls.SelectedTitle.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) + ls.SelectedDesc = ls.SelectedDesc.Foreground(lipgloss.Color(cmdutil.CodeBlue)).BorderForeground(lipgloss.Color(cmdutil.CodeBlue)) + del := list.NewDefaultDelegate() + del.Styles = ls + del.ShowDescription = false + del.SetSpacing(0) + + items := make([]list.Item, 0, len(AllLLMRules)) + for _, rule := range AllLLMRules { + items = append(items, ToolItem{rule}) + } + + ll := list.New(items, del, 0, 0) + ll.SetShowTitle(false) + ll.SetShowHelp(false) + ll.SetShowPagination(true) + ll.SetShowFilter(false) + ll.SetFilteringEnabled(false) + ll.SetShowStatusBar(false) + ll.DisableQuitKeybindings() // quit handled by toolSelectModel + + llmRulesModel = ToolSelectModel{ + List: ll, + Predefined: LLMRulesToolNone, + } + llmRulesModel.SetSize(0, 20) + + } + t := toolSelectorModel{ + toolModel: llmRulesModel, + } + p := tea.NewProgram(t) + + result, err := p.Run() + if err != nil { + cmdutil.Fatal(err) + } + + res := result.(toolSelectorModel) + if res.aborted { + os.Exit(1) + } + + tool = res.toolModel.Selected() + } + + // Determine the app root. + root, _, err := cmdutil.MaybeAppRoot() + if errors.Is(err, cmdutil.ErrNoEncoreApp) { + root, err = os.Getwd() + } + if err != nil { + cmdutil.Fatal(err) + } + + // parse encore.app + filePath := filepath.Join(root, "encore.app") + encoreApp, err := appfile.ParseFile(filePath) + if err != nil { + cmdutil.Fatalf("couldn't parse encore.app: %s", err) + } + + var lang cmdutil.Language + switch encoreApp.Lang { + case appfile.LangGo: + lang = cmdutil.LanguageGo + case appfile.LangTS: + lang = cmdutil.LanguageTS + } + + if err := SetupLLMRules(tool, lang, root, encoreApp.ID); err != nil { + cmdutil.Fatal(err) + } + + PrintLLMRulesInfo(tool) + + return nil +} + +type toolSelectorModel struct { + toolModel ToolSelectModel + aborted bool +} + +func (t toolSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmds []tea.Cmd + c tea.Cmd + ) + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + t.SetSize(msg.Width, msg.Height) + return t, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + t.aborted = true + return t, tea.Quit + } + + t.toolModel, c = t.toolModel.Update(msg) + cmds = append(cmds, c) + return t, tea.Batch(cmds...) + + case ToolSelectDone: + cmds = append(cmds, tea.Quit) + } + + t.toolModel, c = t.toolModel.Update(msg) + cmds = append(cmds, c) + return t, tea.Batch(cmds...) +} + +func (t toolSelectorModel) Init() tea.Cmd { + return nil +} + +func (t toolSelectorModel) View() string { + var b strings.Builder + b.WriteString(t.toolModel.View()) + return cmdutil.DocStyle.Render(b.String()) +} + +func (t *toolSelectorModel) SetSize(width, height int) { + t.toolModel.SetSize(width, height) +} diff --git a/cli/cmd/encore/llm_rules/llm_rules.go b/cli/cmd/encore/llm_rules/llm_rules.go new file mode 100644 index 0000000000..c45fe38f25 --- /dev/null +++ b/cli/cmd/encore/llm_rules/llm_rules.go @@ -0,0 +1,16 @@ +package llm_rules + +import ( + "github.com/spf13/cobra" + + "encr.dev/cli/cmd/encore/root" +) + +var llmRulesCmd = &cobra.Command{ + Use: "llm_rules", + Short: "Commands to create llm rules for apps", +} + +func init() { + root.Cmd.AddCommand(llmRulesCmd) +} diff --git a/cli/cmd/encore/llm_rules/tool.go b/cli/cmd/encore/llm_rules/tool.go new file mode 100644 index 0000000000..3bc221e07d --- /dev/null +++ b/cli/cmd/encore/llm_rules/tool.go @@ -0,0 +1,324 @@ +package llm_rules + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "encr.dev/cli/cmd/encore/cmdutil" + "github.com/briandowns/spinner" + "github.com/fatih/color" +) + +const mdcTemplate string = `--- +description: Encore %s rules +globs: +alwaysApply: true +--- +%s +` + +type Tool string + +// NOTE: changes to these values should also be reflected in userconfig +const ( + LLMRulesToolNone Tool = "" + LLMRulesToolCursor Tool = "cursor" + LLMRulesToolClaudCode Tool = "claudecode" + LLMRulesToolVSCode Tool = "vscode" + LLMRulesToolAgentsMD Tool = "agentsmd" + LLMRulesToolZed Tool = "zed" +) + +// all available options exept for None +var AllLLMRules = []Tool{ + LLMRulesToolCursor, + LLMRulesToolClaudCode, + LLMRulesToolVSCode, + LLMRulesToolAgentsMD, + LLMRulesToolZed, +} + +func LLMRulesFlagValues() []string { + result := make([]string, 0, len(AllLLMRules)) + for _, r := range AllLLMRules { + result = append(result, string(r)) + } + return result +} + +func (e Tool) Display() string { + switch e { + case LLMRulesToolCursor: + return "Cursor" + case LLMRulesToolClaudCode: + return "Claude Code" + case LLMRulesToolVSCode: + return "VS Code" + case LLMRulesToolAgentsMD: + return "AGENTS.md" + case LLMRulesToolZed: + return "Zed" + default: + return "None" + } +} + +func (e Tool) SelectPrompt() string { + return "Select a tool to generate LLM rules for" +} + +type ToolItem struct { + tool Tool +} + +func NewLLMRulesItem(tool Tool) ToolItem { + return ToolItem{tool: tool} +} + +func (i ToolItem) FilterValue() string { return i.tool.Display() } +func (i ToolItem) Title() string { return i.FilterValue() } +func (i ToolItem) Description() string { return "" } +func (i ToolItem) SelectedID() Tool { return i.tool } + +type ToolSelectModel = cmdutil.SimpleSelectModel[Tool, ToolItem] +type ToolSelectDone = cmdutil.SimpleSelectDone[Tool] + +func SetupLLMRules(llmRules Tool, lang cmdutil.Language, appRootRelpath string, appSlug string) error { + llmInstructions, err := downloadLLMInstructions(lang) + if err != nil { + return err + } + + switch llmRules { + case LLMRulesToolCursor: + cursorDir := filepath.Join(appRootRelpath, ".cursor") + rulesDir := filepath.Join(cursorDir, "rules") + err := os.MkdirAll(rulesDir, 0755) + if err != nil { + return err + } + + if appSlug != "" { + // https://cursor.com/docs/context/mcp#using-mcpjson + mcpPath := filepath.Join(cursorDir, "mcp.json") + err = updateJsonFile(mcpPath, "mcpServers", func(mcpServers map[string]any) { + // Add encore-mcp configuration + mcpServers["encore-mcp"] = map[string]any{ + "command": "encore", + "args": []string{"mcp", "run", "--app=" + appSlug}, + } + }) + if err != nil { + return err + } + } + + // https://cursor.com/docs/context/rules + // always overwrite as we have a dedicated encore config file + err = os.WriteFile(filepath.Join(rulesDir, "encore.mdc"), fmt.Appendf(nil, mdcTemplate, lang, string(llmInstructions)), 0644) + if err != nil { + return err + } + case LLMRulesToolClaudCode: + if appSlug != "" { + // https://code.claude.com/docs/en/mcp#project-scope + mcpPath := filepath.Join(appRootRelpath, ".mcp.json") + err = updateJsonFile(mcpPath, "mcpServers", func(mcpServers map[string]any) { + // Add encore-mcp configuration + mcpServers["encore-mcp"] = map[string]any{ + "command": "encore", + "args": []string{"mcp", "run", "--app=" + appSlug}, + } + }) + if err != nil { + return err + } + } + + // https://code.claude.com/docs/en/settings#key-points-about-the-configuration-system + claudeDir := filepath.Join(appRootRelpath, ".claude") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + return err + } + err = writeNewFileOrSkip(filepath.Join(claudeDir, "CLAUDE.md"), []byte(llmInstructions)) + if err != nil { + return err + } + + case LLMRulesToolVSCode: + githubDir := filepath.Join(appRootRelpath, ".github") + if err := os.MkdirAll(githubDir, 0755); err != nil { + return err + } + + // https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions#writing-your-own-copilot-instructionsmd-file + err = writeNewFileOrSkip(filepath.Join(githubDir, "copilot-instructions.md"), []byte(llmInstructions)) + if err != nil { + return err + } + + vscodePath := filepath.Join(appRootRelpath, ".vscode") + if err := os.MkdirAll(vscodePath, 0755); err != nil { + return err + } + + // https://code.visualstudio.com/docs/copilot/customization/mcp-servers#_configuration-format + mcpPath := filepath.Join(vscodePath, "mcp.json") + err = updateJsonFile(mcpPath, "servers", func(servers map[string]any) { + // Add encore-mcp configuration + servers["encore-mcp"] = map[string]any{ + "command": "encore", + "args": []string{"mcp", "run", "--app=" + appSlug}, + } + }) + if err != nil { + return err + } + + case LLMRulesToolAgentsMD: + // https://agents.md/ + err = writeNewFileOrSkip(filepath.Join(appRootRelpath, "AGENTS.md"), []byte(llmInstructions)) + if err != nil { + return err + } + case LLMRulesToolZed: + // https://zed.dev/docs/ai/rules#rules-files + rulesPath := filepath.Join(appRootRelpath, ".rules") + err = writeNewFileOrSkip(rulesPath, []byte(llmInstructions)) + if err != nil { + return err + } + + if appSlug != "" { + zedDir := filepath.Join(appRootRelpath, ".zed") + err := os.MkdirAll(zedDir, 0755) + if err != nil { + return err + } + + // https://zed.dev/docs/ai/mcp#as-custom-servers + settingsPath := filepath.Join(zedDir, "settings.json") + err = updateJsonFile(settingsPath, "context_servers", func(contextServers map[string]any) { + // Add encore-mcp configuration + contextServers["encore-mcp"] = map[string]any{ + "command": "encore", + "args": []string{"mcp", "run", "--app=" + appSlug}, + "env": map[string]any{}, + "source": "custom", + } + }) + if err != nil { + return err + } + } + + } + + return nil +} + +func PrintLLMRulesInfo(tool Tool) { + if tool == LLMRulesToolNone { + return + } + + cyan := color.New(color.FgCyan) + cyanf := cyan.SprintfFunc() + + switch tool { + case LLMRulesToolCursor, LLMRulesToolClaudCode, LLMRulesToolVSCode, LLMRulesToolZed: + fmt.Printf("MCP: %s\n", cyanf("Configured in %s", tool.Display())) + fmt.Println() + } + + fmt.Printf("Try these prompts in %s:\n", tool.Display()) + fmt.Println("→ \"add image uploads to my hello world app\"") + fmt.Println("→ \"add a SQL database for storing user profiles\"") + fmt.Println("→ \"add a pub/sub topic for sending notifications\"") + fmt.Println() +} + +func updateJsonFile(path, parent string, updateFn func(field map[string]any)) error { + var conf map[string]any + + // Read existing mcp.json if it exists + if existingData, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(existingData, &conf); err != nil { + return fmt.Errorf("failed to parse existing %s: %w", path, err) + } + } else { + conf = make(map[string]any) + } + + // Get or create mcpServers + mcpServers, ok := conf[parent].(map[string]any) + if !ok { + mcpServers = make(map[string]any) + conf[parent] = mcpServers + } + + updateFn(mcpServers) + + // Write back the config + data, err := json.MarshalIndent(conf, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal mcp.json: %w", err) + } + + err = os.WriteFile(path, data, 0644) + if err != nil { + return err + } + + return nil +} + +// write to file if it doesnt exist, and emits a warning and skips writing if the file exist +func writeNewFileOrSkip(filePath string, data []byte) error { + if _, err := os.Stat(filePath); err == nil { + // File already exists, skip writing + yellow := color.New(color.FgYellow) + yellow.Printf("Warning: %s file already exists, skipping\n", filePath) + } else { + err = os.WriteFile(filePath, data, 0644) + if err != nil { + return err + } + } + + return nil +} + +func downloadLLMInstructions(lang cmdutil.Language) (string, error) { + fmt.Println("Downloading LLM Instructions...") + var url string + switch lang { + case cmdutil.LanguageGo: + url = "https://raw.githubusercontent.com/encoredev/encore/refs/heads/main/go_llm_instructions.txt" + case cmdutil.LanguageTS: + url = "https://raw.githubusercontent.com/encoredev/encore/refs/heads/main/ts_llm_instructions.txt" + default: + return "", fmt.Errorf("unsupported language") + } + s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + s.Prefix = "Downloading LLM instructions..." + s.Start() + defer s.Stop() + resp, err := http.Get(url) + if err != nil { + s.FinalMSG = fmt.Sprintf("failed, skipping: %v", err.Error()) + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + s.FinalMSG = fmt.Sprintf("failed, skipping: %v", err.Error()) + return "", err + } + return string(body), nil +} diff --git a/docs/go/cli/cli-reference.md b/docs/go/cli/cli-reference.md index 9912303411..42180c81ca 100644 --- a/docs/go/cli/cli-reference.md +++ b/docs/go/cli/cli-reference.md @@ -343,3 +343,15 @@ $ encore build docker `--base string` defines the base image to build from (default "scratch") `--push` pushes image to remote repository + +## LLM Rules + +Generate llm rules in an existing app + +#### Init + +Initialize the llm rules files + +```shell +$ encore llm_rules init +``` diff --git a/docs/ts/cli/cli-reference.md b/docs/ts/cli/cli-reference.md index efeddb4906..de48293d02 100644 --- a/docs/ts/cli/cli-reference.md +++ b/docs/ts/cli/cli-reference.md @@ -260,7 +260,7 @@ Note that this strips trailing newlines from the secret value. Lists secrets, optionally for a specific key -```shell +```shell $ encore secret list [keys...] ``` @@ -340,3 +340,15 @@ $ encore build docker `--base string` defines the base image to build from (default "scratch") `--push` pushes image to remote repository + +## LLM Rules + +Generate llm rules in an existing app + +#### Init + +Initialize the llm rules files + +```shell +$ encore llm_rules init +``` diff --git a/internal/userconfig/config.go b/internal/userconfig/config.go index f0b9ebd450..cc189c5e8e 100644 --- a/internal/userconfig/config.go +++ b/internal/userconfig/config.go @@ -5,4 +5,8 @@ type Config struct { // Whether to open the Local Development Dashboard in the browser on `encore run`. // If set to "auto", the browser will be opened if the dashboard is not already open. RunBrowser string `koanf:"run.browser" oneof:"always,never,auto" default:"auto"` + + // Always choose this tool when creating an app or when initializing llm tools + // for an existing app, unless overriden via --llm-rules flag on command line. + LLMRules string `koanf:"llm_rules" oneof:",cursor,claudcode,vscode,agentsmd,zed" default:""` }