Skip to content

Commit 96662b2

Browse files
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 <alecsanf@usc.edu>
1 parent 2fb523a commit 96662b2

3 files changed

Lines changed: 229 additions & 14 deletions

File tree

pkg/cmd/open/open.go

Lines changed: 144 additions & 14 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,85 @@ 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)),
68+
Args: cmderrors.TransformToValidationError(cobra.RangeArgs(1, 2)),
6269
ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t),
6370
RunE: func(cmd *cobra.Command, args []string) error {
71+
if setDefault != "" {
72+
return handleSetDefault(t, store, setDefault)
73+
}
74+
6475
setupDoneString := "------ Git repo cloned ------"
6576
if waitForSetupToFinish {
6677
setupDoneString = "------ Done running execs ------"
6778
}
68-
err := runOpenCommand(t, store, args[0], setupDoneString, directory, host)
79+
80+
editorType, err := determineEditorType(args, store)
81+
if err != nil {
82+
return breverrors.WrapAndTrace(err)
83+
}
84+
85+
err = runOpenCommand(t, store, args[0], setupDoneString, directory, host, editorType)
6986
if err != nil {
7087
return breverrors.WrapAndTrace(err)
7188
}
7289
return nil
7390
},
7491
}
75-
cmd.Flags().BoolVarP(&openWithCursor, "cursor", "c", false, "open cursor instead of VS Code")
7692
cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container")
7793
cmd.Flags().BoolVarP(&waitForSetupToFinish, "wait", "w", false, "wait for setup to finish")
7894
cmd.Flags().StringVarP(&directory, "dir", "d", "", "directory to open")
95+
cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code or cursor)")
7996

8097
return cmd
8198
}
8299

100+
func handleSetDefault(t *terminal.Terminal, store OpenStore, editorType string) error {
101+
if editorType != EditorVSCode && editorType != EditorCursor {
102+
return fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType)
103+
}
104+
105+
homeDir, err := os.UserHomeDir()
106+
if err != nil {
107+
return breverrors.WrapAndTrace(err)
108+
}
109+
110+
settings := &files.PersonalSettings{
111+
DefaultEditor: editorType,
112+
}
113+
114+
err = files.WritePersonalSettings(files.AppFs, homeDir, settings)
115+
if err != nil {
116+
return breverrors.WrapAndTrace(err)
117+
}
118+
119+
t.Vprintf(t.Green("Default editor set to %s\n"), editorType)
120+
return nil
121+
}
122+
123+
func determineEditorType(args []string, store OpenStore) (string, error) {
124+
if len(args) == 2 {
125+
editorType := args[1]
126+
if editorType != EditorVSCode && editorType != EditorCursor {
127+
return "", fmt.Errorf("invalid editor type: %s. Must be 'code' or 'cursor'", editorType)
128+
}
129+
return editorType, nil
130+
}
131+
132+
homeDir, err := os.UserHomeDir()
133+
if err != nil {
134+
return EditorVSCode, nil
135+
}
136+
137+
settings, err := files.ReadPersonalSettings(files.AppFs, homeDir)
138+
if err != nil {
139+
return EditorVSCode, nil
140+
}
141+
142+
return settings.DefaultEditor, nil
143+
}
144+
83145
// 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
146+
func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, setupDoneString string, directory string, host bool, editorType string) error { //nolint:funlen // define brev command
85147
// todo check if workspace is stopped and start if it if it is stopped
86148
fmt.Println("finding your instance...")
87149
res := refresh.RunRefreshAsync(tstore)
@@ -132,7 +194,7 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
132194
// legacy environments wont support this and cause errrors,
133195
// but we don't want to block the user from using vscode
134196
_ = writeconnectionevent.WriteWCEOnEnv(tstore, string(localIdentifier))
135-
err = openVsCodeWithSSH(t, string(localIdentifier), projPath, tstore, setupDoneString)
197+
err = openEditorWithSSH(t, string(localIdentifier), projPath, tstore, setupDoneString, editorType)
136198
if err != nil {
137199
if strings.Contains(err.Error(), `"code": executable file not found in $PATH`) {
138200
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 +210,20 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
148210
}
149211
return errors.New(errMsg)
150212
}
213+
if strings.Contains(err.Error(), `"cursor": executable file not found in $PATH`) {
214+
errMsg := "cursor\": executable file not found in $PATH\n\nadd 'cursor' to your $PATH to open Cursor from the terminal"
215+
_, errStore := tstore.UpdateUser(
216+
workspace.CreatedByUserID,
217+
&entity.UpdateUser{
218+
OnboardingData: map[string]interface{}{
219+
"pathErrorTS": time.Now().UTC().Unix(),
220+
},
221+
})
222+
if errStore != nil {
223+
return errors.New(errMsg + "\n" + errStore.Error())
224+
}
225+
return errors.New(errMsg)
226+
}
151227
return breverrors.WrapAndTrace(err)
152228
}
153229
// Call analytics for open
@@ -232,13 +308,37 @@ func tryToInstallExtensions(
232308
}
233309
}
234310

311+
func tryToInstallCursorExtensions(
312+
t *terminal.Terminal,
313+
extIDs []string,
314+
) {
315+
for _, extID := range extIDs {
316+
extInstalled, err0 := uutil.IsCursorExtensionInstalled(extID)
317+
if !extInstalled {
318+
err1 := uutil.InstallCursorExtension(extID)
319+
isRemoteInstalled, err2 := uutil.IsCursorExtensionInstalled(extID)
320+
if !isRemoteInstalled {
321+
err := multierror.Append(err0, err1, err2)
322+
t.Print(t.Red("Couldn't install the necessary Cursor extension automatically.\nError: " + err.Error()))
323+
t.Print("\tPlease install Cursor and the following Cursor extension: " + t.Yellow(extID) + ".\n")
324+
_ = terminal.PromptGetInput(terminal.PromptContent{
325+
Label: "Hit enter when finished:",
326+
ErrorMsg: "error",
327+
AllowEmpty: true,
328+
})
329+
}
330+
}
331+
}
332+
}
333+
235334
// Opens code editor. Attempts to install code in path if not installed already
236-
func openVsCodeWithSSH(
335+
func openEditorWithSSH(
237336
t *terminal.Terminal,
238337
sshAlias string,
239338
path string,
240339
tstore OpenStore,
241340
_ string,
341+
editorType string,
242342
) error {
243343
// infinite for loop:
244344
res := refresh.RunRefreshAsync(tstore)
@@ -255,14 +355,22 @@ func openVsCodeWithSSH(
255355
}
256356

257357
// todo: add it here
258-
s.Suffix = " Instance is ready. Opening VS Code 🤙"
358+
editorName := "VS Code"
359+
if editorType == EditorCursor {
360+
editorName = "Cursor"
361+
}
362+
s.Suffix = fmt.Sprintf(" Instance is ready. Opening %s 🤙", editorName)
259363
time.Sleep(250 * time.Millisecond)
260364
s.Stop()
261365
t.Vprintf("\n")
262366

263-
tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
264-
265-
err = openVsCode(sshAlias, path, tstore)
367+
if editorType == EditorCursor {
368+
tryToInstallCursorExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
369+
err = openCursor(sshAlias, path, tstore)
370+
} else {
371+
tryToInstallExtensions(t, []string{"ms-vscode-remote.remote-ssh", "ms-toolsai.jupyter-keymap", "ms-python.python"})
372+
err = openVsCode(sshAlias, path, tstore)
373+
}
266374
if err != nil {
267375
return breverrors.WrapAndTrace(err)
268376
}
@@ -289,7 +397,11 @@ func openVsCodeWithSSH(
289397
if strings.Contains(err.Error(), "you are in a remote brev instance;") {
290398
return breverrors.WrapAndTrace(err)
291399
}
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")))
400+
editorName := "VSCode"
401+
if editorType == EditorCursor {
402+
editorName = "Cursor"
403+
}
404+
return breverrors.WrapAndTrace(fmt.Errorf(t.Red("couldn't open %s, try adding it to PATH\n"), editorName))
293405
} else {
294406
return nil
295407
}
@@ -341,3 +453,21 @@ func openVsCode(sshAlias string, path string, store OpenStore) error {
341453
}
342454
return nil
343455
}
456+
457+
func openCursor(sshAlias string, path string, store OpenStore) error {
458+
cursorString := fmt.Sprintf("vscode-remote://ssh-remote+%s%s", sshAlias, path)
459+
cursorString = shellescape.QuoteCommand([]string{cursorString})
460+
461+
windowsPaths := getWindowsCursorPaths(store)
462+
_, err := uutil.TryRunCursorCommand([]string{"--folder-uri", cursorString}, windowsPaths...)
463+
if err != nil {
464+
return breverrors.WrapAndTrace(err)
465+
}
466+
return nil
467+
}
468+
469+
func getWindowsCursorPaths(store vscodePathStore) []string {
470+
wd, _ := store.GetWindowsDir()
471+
paths := append([]string{}, fmt.Sprintf("%s/AppData/Local/Programs/Cursor/Cursor.exe", wd), fmt.Sprintf("%s/AppData/Local/Programs/Cursor/bin/cursor", wd))
472+
return paths
473+
}

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)