Skip to content

Commit 53896a9

Browse files
Add support for brev open code/cursor commands and default editor setting (#238)
* Add support for brev open code/cursor commands and default editor setting - Add cursor utilities in pkg/util/util.go with TryRunCursorCommand, InstallCursorExtension, etc. - Add personal settings management in pkg/files/files.go for storing default editor preference - Update open command to support 'brev open <workspace> <editor>' syntax - Add --set-default flag to configure default editor (code or cursor) - Remove broken --cursor flag in favor of argument-based approach - Maintain backward compatibility with existing 'brev open <workspace>' usage - Add proper error handling for invalid editor types and missing executables Co-Authored-By: Alec Fong <[email protected]> * Fix lint issues: remove unused store parameters - Remove unused store parameter from handleSetDefault function - Remove unused store parameter from determineEditorType function - Update function calls to match new signatures Co-Authored-By: Alec Fong <[email protected]> * Fix argument validation for --set-default flag - Replace static cobra.RangeArgs(1, 2) with custom validation function - Allow 0 arguments when --set-default flag is provided - Maintain 1-2 argument requirement for normal workspace operations - Resolves issue where 'brev open --set-default cursor' failed with arg validation error Co-Authored-By: Alec Fong <[email protected]> * Fix printf formatting in success message for --set-default - Change t.Vprintf to t.Printf to fix format string mismatch - Resolves malformed success message '%!!(string=cursor)s(MISSING)' - Success message now displays correctly as 'Default editor set to cursor' Co-Authored-By: Alec Fong <[email protected]> * Fix Terminal method call - use t.Vprint instead of t.Printf - Terminal type doesn't have Printf method, only Print/Vprint/Vprintf - Use t.Vprint with string concatenation following codebase patterns - Resolves build error: 't.Printf undefined' Co-Authored-By: Alec Fong <[email protected]> * Fix gofumpt formatting issues - remove extra blank lines - Remove extra blank lines on lines 80 and 85 that violated gofumpt formatting rules - Resolves CI lint failure in 'ci (ubuntu-22.04)' check - All formatting now complies with gofumpt standards Co-Authored-By: Alec Fong <[email protected]> * Apply gofumpt formatting to fix remaining lint issues - Run gofumpt on pkg/cmd/open/open.go to fix all formatting violations - Remove trailing spaces and fix indentation issues - Resolves remaining CI lint failure in 'ci (ubuntu-22.04)' check Co-Authored-By: Alec Fong <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alec Fong <[email protected]>
1 parent 734cf8d commit 53896a9

File tree

3 files changed

+236
-15
lines changed

3 files changed

+236
-15
lines changed

pkg/cmd/open/open.go

Lines changed: 151 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package open
33
import (
44
"errors"
55
"fmt"
6+
"os"
67
"os/exec"
78
"strings"
89
"time"
@@ -16,6 +17,7 @@ import (
1617
"github.com/brevdev/brev-cli/pkg/cmd/util"
1718
"github.com/brevdev/brev-cli/pkg/entity"
1819
breverrors "github.com/brevdev/brev-cli/pkg/errors"
20+
"github.com/brevdev/brev-cli/pkg/files"
1921
"github.com/brevdev/brev-cli/pkg/store"
2022
"github.com/brevdev/brev-cli/pkg/terminal"
2123
uutil "github.com/brevdev/brev-cli/pkg/util"
@@ -27,9 +29,14 @@ import (
2729
"github.com/spf13/cobra"
2830
)
2931

32+
const (
33+
EditorVSCode = "code"
34+
EditorCursor = "cursor"
35+
)
36+
3037
var (
31-
openLong = "[command in beta] This will open VS Code SSH-ed in to your workspace. You must have 'code' installed in your path."
32-
openExample = "brev open workspace_id_or_name\nbrev open my-app\nbrev open h9fp5vxwe"
38+
openLong = "[command in beta] This will open VS Code or Cursor SSH-ed in to your workspace. You must have the editor installed in your path."
39+
openExample = "brev open workspace_id_or_name\nbrev open my-app\nbrev open my-app code\nbrev open my-app cursor\nbrev open --set-default cursor"
3340
)
3441

3542
type OpenStore interface {
@@ -46,10 +53,10 @@ type OpenStore interface {
4653
}
4754

4855
func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenStore) *cobra.Command {
49-
var openWithCursor bool
5056
var waitForSetupToFinish bool
5157
var directory string
5258
var host bool
59+
var setDefault string
5360

5461
cmd := &cobra.Command{
5562
Annotations: map[string]string{"ssh": ""},
@@ -58,30 +65,91 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto
5865
Short: "[beta] open VSCode or Cursor to your workspace",
5966
Long: openLong,
6067
Example: openExample,
61-
Args: cmderrors.TransformToValidationError(cobra.ExactArgs(1)),
62-
ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t),
68+
Args: cmderrors.TransformToValidationError(func(cmd *cobra.Command, args []string) error {
69+
setDefaultFlag, _ := cmd.Flags().GetString("set-default")
70+
if setDefaultFlag != "" {
71+
return cobra.NoArgs(cmd, args)
72+
}
73+
return cobra.RangeArgs(1, 2)(cmd, args)
74+
}),
75+
ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t),
6376
RunE: func(cmd *cobra.Command, args []string) error {
77+
if setDefault != "" {
78+
return handleSetDefault(t, setDefault)
79+
}
80+
6481
setupDoneString := "------ Git repo cloned ------"
6582
if waitForSetupToFinish {
6683
setupDoneString = "------ Done running execs ------"
6784
}
68-
err := runOpenCommand(t, store, args[0], setupDoneString, directory, host)
85+
86+
editorType, err := determineEditorType(args)
87+
if err != nil {
88+
return breverrors.WrapAndTrace(err)
89+
}
90+
91+
err = runOpenCommand(t, store, args[0], setupDoneString, directory, host, editorType)
6992
if err != nil {
7093
return breverrors.WrapAndTrace(err)
7194
}
7295
return nil
7396
},
7497
}
75-
cmd.Flags().BoolVarP(&openWithCursor, "cursor", "c", false, "open cursor instead of VS Code")
7698
cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container")
7799
cmd.Flags().BoolVarP(&waitForSetupToFinish, "wait", "w", false, "wait for setup to finish")
78100
cmd.Flags().StringVarP(&directory, "dir", "d", "", "directory to open")
101+
cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code or cursor)")
79102

80103
return cmd
81104
}
82105

106+
func handleSetDefault(t *terminal.Terminal, editorType string) error {
107+
if editorType != EditorVSCode && editorType != EditorCursor {
108+
return fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType)
109+
}
110+
111+
homeDir, err := os.UserHomeDir()
112+
if err != nil {
113+
return breverrors.WrapAndTrace(err)
114+
}
115+
116+
settings := &files.PersonalSettings{
117+
DefaultEditor: editorType,
118+
}
119+
120+
err = files.WritePersonalSettings(files.AppFs, homeDir, settings)
121+
if err != nil {
122+
return breverrors.WrapAndTrace(err)
123+
}
124+
125+
t.Vprint(t.Green("Default editor set to " + editorType + "\n"))
126+
return nil
127+
}
128+
129+
func determineEditorType(args []string) (string, error) {
130+
if len(args) == 2 {
131+
editorType := args[1]
132+
if editorType != EditorVSCode && editorType != EditorCursor {
133+
return "", fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType)
134+
}
135+
return editorType, nil
136+
}
137+
138+
homeDir, err := os.UserHomeDir()
139+
if err != nil {
140+
return EditorVSCode, nil
141+
}
142+
143+
settings, err := files.ReadPersonalSettings(files.AppFs, homeDir)
144+
if err != nil {
145+
return EditorVSCode, nil
146+
}
147+
148+
return settings.DefaultEditor, nil
149+
}
150+
83151
// Fetch workspace info, then open code editor
84-
func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, setupDoneString string, directory string, host bool) error { //nolint:funlen // define brev command
152+
func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, setupDoneString string, directory string, host bool, editorType string) error { //nolint:funlen // define brev command
85153
// todo check if workspace is stopped and start if it if it is stopped
86154
fmt.Println("finding your instance...")
87155
res := refresh.RunRefreshAsync(tstore)
@@ -132,7 +200,7 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
132200
// legacy environments wont support this and cause errrors,
133201
// but we don't want to block the user from using vscode
134202
_ = writeconnectionevent.WriteWCEOnEnv(tstore, string(localIdentifier))
135-
err = openVsCodeWithSSH(t, string(localIdentifier), projPath, tstore, setupDoneString)
203+
err = openEditorWithSSH(t, string(localIdentifier), projPath, tstore, setupDoneString, editorType)
136204
if err != nil {
137205
if strings.Contains(err.Error(), `"code": executable file not found in $PATH`) {
138206
errMsg := "code\": executable file not found in $PATH\n\nadd 'code' to your $PATH to open VS Code from the terminal\n\texport PATH=\"/Applications/Visual Studio Code.app/Contents/Resources/app/bin:$PATH\""
@@ -148,6 +216,20 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
148216
}
149217
return errors.New(errMsg)
150218
}
219+
if strings.Contains(err.Error(), `"cursor": executable file not found in $PATH`) {
220+
errMsg := "cursor\": executable file not found in $PATH\n\nadd 'cursor' to your $PATH to open Cursor from the terminal"
221+
_, errStore := tstore.UpdateUser(
222+
workspace.CreatedByUserID,
223+
&entity.UpdateUser{
224+
OnboardingData: map[string]interface{}{
225+
"pathErrorTS": time.Now().UTC().Unix(),
226+
},
227+
})
228+
if errStore != nil {
229+
return errors.New(errMsg + "\n" + errStore.Error())
230+
}
231+
return errors.New(errMsg)
232+
}
151233
return breverrors.WrapAndTrace(err)
152234
}
153235
// Call analytics for open
@@ -232,13 +314,37 @@ func tryToInstallExtensions(
232314
}
233315
}
234316

317+
func tryToInstallCursorExtensions(
318+
t *terminal.Terminal,
319+
extIDs []string,
320+
) {
321+
for _, extID := range extIDs {
322+
extInstalled, err0 := uutil.IsCursorExtensionInstalled(extID)
323+
if !extInstalled {
324+
err1 := uutil.InstallCursorExtension(extID)
325+
isRemoteInstalled, err2 := uutil.IsCursorExtensionInstalled(extID)
326+
if !isRemoteInstalled {
327+
err := multierror.Append(err0, err1, err2)
328+
t.Print(t.Red("Couldn't install the necessary Cursor extension automatically.\nError: " + err.Error()))
329+
t.Print("\tPlease install Cursor and the following Cursor extension: " + t.Yellow(extID) + ".\n")
330+
_ = terminal.PromptGetInput(terminal.PromptContent{
331+
Label: "Hit enter when finished:",
332+
ErrorMsg: "error",
333+
AllowEmpty: true,
334+
})
335+
}
336+
}
337+
}
338+
}
339+
235340
// Opens code editor. Attempts to install code in path if not installed already
236-
func openVsCodeWithSSH(
341+
func openEditorWithSSH(
237342
t *terminal.Terminal,
238343
sshAlias string,
239344
path string,
240345
tstore OpenStore,
241346
_ string,
347+
editorType string,
242348
) error {
243349
// infinite for loop:
244350
res := refresh.RunRefreshAsync(tstore)
@@ -255,14 +361,22 @@ func openVsCodeWithSSH(
255361
}
256362

257363
// todo: add it here
258-
s.Suffix = " Instance is ready. Opening VS Code 🤙"
364+
editorName := "VS Code"
365+
if editorType == EditorCursor {
366+
editorName = "Cursor"
367+
}
368+
s.Suffix = fmt.Sprintf(" Instance is ready. Opening %s 🤙", editorName)
259369
time.Sleep(250 * time.Millisecond)
260370
s.Stop()
261371
t.Vprintf("\n")
262372

263-
tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
264-
265-
err = openVsCode(sshAlias, path, tstore)
373+
if editorType == EditorCursor {
374+
tryToInstallCursorExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
375+
err = openCursor(sshAlias, path, tstore)
376+
} else {
377+
tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
378+
err = openVsCode(sshAlias, path, tstore)
379+
}
266380
if err != nil {
267381
return breverrors.WrapAndTrace(err)
268382
}
@@ -289,7 +403,11 @@ func openVsCodeWithSSH(
289403
if strings.Contains(err.Error(), "you are in a remote brev instance;") {
290404
return breverrors.WrapAndTrace(err)
291405
}
292-
return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open VSCode, try adding it to PATH (you can do this in VSCode by running CMD-SHIFT-P and typing 'install code in path')\n")))
406+
editorName := "VSCode"
407+
if editorType == EditorCursor {
408+
editorName = "Cursor"
409+
}
410+
return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open %s, try adding it to PATH\n"), editorName))
293411
} else {
294412
return nil
295413
}
@@ -341,3 +459,21 @@ func openVsCode(sshAlias string, path string, store OpenStore) error {
341459
}
342460
return nil
343461
}
462+
463+
func openCursor(sshAlias string, path string, store OpenStore) error {
464+
cursorString := fmt.Sprintf("vscode-remote://ssh-remote+%s%s", sshAlias, path)
465+
cursorString = shellescape.QuoteCommand([]string{cursorString})
466+
467+
windowsPaths := getWindowsCursorPaths(store)
468+
_, err := uutil.TryRunCursorCommand([]string{"--folder-uri", cursorString}, windowsPaths...)
469+
if err != nil {
470+
return breverrors.WrapAndTrace(err)
471+
}
472+
return nil
473+
}
474+
475+
func getWindowsCursorPaths(store vscodePathStore) []string {
476+
wd, _ := store.GetWindowsDir()
477+
paths := append([]string{}, fmt.Sprintf("%s/AppData/Local/Programs/Cursor/Cursor.exe", wd), fmt.Sprintf("%s/AppData/Local/Programs/Cursor/bin/cursor", wd))
478+
return paths
479+
}

pkg/files/files.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import (
1515
"github.com/spf13/afero"
1616
)
1717

18+
type PersonalSettings struct {
19+
DefaultEditor string `json:"default_editor"`
20+
}
21+
1822
const (
1923
brevDirectory = ".brev"
2024
// This might be better as a context.json??
@@ -92,6 +96,11 @@ func GetOnboardingStepPath(home string) string {
9296
return brevOnboardingFilePath
9397
}
9498

99+
func GetPersonalSettingsPath(home string) string {
100+
fpath := makeBrevFilePath(personalSettingsCache, home)
101+
return fpath
102+
}
103+
95104
func GetNewBackupSSHConfigFilePath(home string) string {
96105
fp := makeBrevFilePath(GetNewBackupSSHConfigFileName(), home)
97106

@@ -248,6 +257,24 @@ func CatFile(filePath string) (string, error) {
248257
}
249258
}
250259

260+
func ReadPersonalSettings(fs afero.Fs, home string) (*PersonalSettings, error) {
261+
settingsPath := GetPersonalSettingsPath(home)
262+
var settings PersonalSettings
263+
err := ReadJSON(fs, settingsPath, &settings)
264+
if err != nil {
265+
return &PersonalSettings{DefaultEditor: "code"}, nil
266+
}
267+
if settings.DefaultEditor == "" {
268+
settings.DefaultEditor = "code"
269+
}
270+
return &settings, nil
271+
}
272+
273+
func WritePersonalSettings(fs afero.Fs, home string, settings *PersonalSettings) error {
274+
settingsPath := GetPersonalSettingsPath(home)
275+
return OverwriteJSON(fs, settingsPath, settings)
276+
}
277+
251278
// if this doesn't work, just exit
252279

253280
// if this doesn't work, just exit

0 commit comments

Comments
 (0)