@@ -39,6 +39,7 @@ const (
3939 EditorWindsurf = "windsurf"
4040 EditorTerminal = "terminal"
4141 EditorTmux = "tmux"
42+ EditorClaude = "claude"
4243)
4344
4445var (
@@ -50,6 +51,7 @@ Supported editors:
5051 windsurf - Windsurf
5152 terminal - Opens a new terminal window with SSH
5253 tmux - Opens a new terminal window with SSH + tmux session
54+ claude - Claude Code in a tmux session (auto-installs, auto-authenticates)
5355
5456Terminal support by platform:
5557 macOS: Terminal.app
@@ -96,7 +98,14 @@ You must have the editor installed in your path.`
9698 brev create my-instance | brev open terminal
9799
98100 # Open in a new terminal window with tmux (supports multiple instances)
99- brev create my-cluster --count 3 | brev open tmux`
101+ brev create my-cluster --count 3 | brev open tmux
102+
103+ # Open Claude Code on a remote instance (installs if needed, auto-authenticates with ANTHROPIC_API_KEY)
104+ brev open my-instance claude
105+
106+ # Pass flags through to Claude Code (use -- to separate brev flags from claude flags)
107+ brev open my-instance claude -- --model opus --allowedTools computer
108+ brev open my-instance claude -- -p "fix the tests"`
100109)
101110
102111type OpenStore interface {
@@ -142,11 +151,11 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto
142151
143152 // Validate editor flag if provided
144153 if editor != "" && ! isEditorType (editor ) {
145- return breverrors .NewValidationError (fmt .Sprintf ("invalid editor: %s. Must be 'code', 'cursor', 'windsurf', 'terminal', or 'tmux '" , editor ))
154+ return breverrors .NewValidationError (fmt .Sprintf ("invalid editor: %s. Must be 'code', 'cursor', 'windsurf', 'terminal', 'tmux', or 'claude '" , editor ))
146155 }
147156
148157 // Get instance names and editor type from args or stdin
149- instanceNames , editorType , err := getInstanceNamesAndEditor (args , editor )
158+ instanceNames , editorType , editorArgs , err := getInstanceNamesAndEditor (args , editor )
150159 if err != nil {
151160 return breverrors .WrapAndTrace (err )
152161 }
@@ -162,7 +171,7 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto
162171 if len (instanceNames ) > 1 {
163172 fmt .Fprintf (os .Stderr , "Opening %s...\n " , instanceName )
164173 }
165- err = runOpenCommand (t , store , instanceName , setupDoneString , directory , host , editorType )
174+ err = runOpenCommand (t , store , instanceName , setupDoneString , directory , host , editorType , editorArgs )
166175 if err != nil {
167176 if len (instanceNames ) > 1 {
168177 fmt .Fprintf (os .Stderr , "Error opening %s: %v\n " , instanceName , err )
@@ -185,15 +194,15 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto
185194 cmd .Flags ().BoolVarP (& host , "host" , "" , false , "ssh into the host machine instead of the container" )
186195 cmd .Flags ().BoolVarP (& waitForSetupToFinish , "wait" , "w" , false , "wait for setup to finish" )
187196 cmd .Flags ().StringVarP (& directory , "dir" , "d" , "" , "directory to open" )
188- cmd .Flags ().StringVar (& setDefault , "set-default" , "" , "set default editor (code, cursor, windsurf, terminal, or tmux )" )
189- cmd .Flags ().StringVarP (& editor , "editor" , "e" , "" , "editor to use (code, cursor, windsurf, terminal, or tmux )" )
197+ cmd .Flags ().StringVar (& setDefault , "set-default" , "" , "set default editor (code, cursor, windsurf, terminal, tmux, or claude )" )
198+ cmd .Flags ().StringVarP (& editor , "editor" , "e" , "" , "editor to use (code, cursor, windsurf, terminal, tmux, or claude )" )
190199
191200 return cmd
192201}
193202
194203// isEditorType checks if a string is a valid editor type
195204func isEditorType (s string ) bool {
196- return s == EditorVSCode || s == EditorCursor || s == EditorWindsurf || s == EditorTerminal || s == EditorTmux
205+ return s == EditorVSCode || s == EditorCursor || s == EditorWindsurf || s == EditorTerminal || s == EditorTmux || s == EditorClaude
197206}
198207
199208// isPiped returns true if stdout is piped to another command
@@ -202,16 +211,27 @@ func isPiped() bool {
202211 return (stat .Mode () & os .ModeCharDevice ) == 0
203212}
204213
205- // getInstanceNamesAndEditor gets instance names from args/stdin and determines editor type
206- // editorFlag takes precedence, otherwise last arg may be an editor type (code, cursor, windsurf, tmux)
207- func getInstanceNamesAndEditor (args []string , editorFlag string ) ([]string , string , error ) {
214+ // getInstanceNamesAndEditor gets instance names from args/stdin and determines editor type.
215+ // Any args that appear after the editor type are returned as editorArgs (e.g. claude flags).
216+ // editorFlag takes precedence, otherwise last arg may be an editor type (code, cursor, windsurf, tmux, claude)
217+ func getInstanceNamesAndEditor (args []string , editorFlag string ) ([]string , string , []string , error ) {
208218 var names []string
219+ var editorArgs []string
209220 editorType := editorFlag
210221
211- // If no editor flag, check if last arg is an editor type
212- if editorType == "" && len (args ) > 0 && isEditorType (args [len (args )- 1 ]) {
213- editorType = args [len (args )- 1 ]
214- args = args [:len (args )- 1 ]
222+ // Find the editor type in the args list; everything after it becomes editorArgs
223+ if editorType == "" {
224+ for i , arg := range args {
225+ if isEditorType (arg ) {
226+ editorType = arg
227+ editorArgs = args [i + 1 :]
228+ args = args [:i ]
229+ break
230+ }
231+ }
232+ } else {
233+ // Editor was set via --editor flag; all positional args after instance names
234+ // that start with "-" are treated as editor args (use -- separator)
215235 }
216236
217237 // Add names from remaining args
@@ -229,12 +249,12 @@ func getInstanceNamesAndEditor(args []string, editorFlag string) ([]string, stri
229249 }
230250 }
231251 if err := scanner .Err (); err != nil {
232- return nil , "" , breverrors .WrapAndTrace (err )
252+ return nil , "" , nil , breverrors .WrapAndTrace (err )
233253 }
234254 }
235255
236256 if len (names ) == 0 {
237- return nil , "" , breverrors .NewValidationError ("instance name required: provide as argument or pipe from another command" )
257+ return nil , "" , nil , breverrors .NewValidationError ("instance name required: provide as argument or pipe from another command" )
238258 }
239259
240260 // If no editor specified, get default
@@ -252,12 +272,12 @@ func getInstanceNamesAndEditor(args []string, editorFlag string) ([]string, stri
252272 }
253273 }
254274
255- return names , editorType , nil
275+ return names , editorType , editorArgs , nil
256276}
257277
258278func handleSetDefault (t * terminal.Terminal , editorType string ) error {
259279 if ! isEditorType (editorType ) {
260- return fmt .Errorf ("invalid editor type: %s. Must be 'code', 'cursor', 'windsurf', 'terminal', or 'tmux '" , editorType )
280+ return fmt .Errorf ("invalid editor type: %s. Must be 'code', 'cursor', 'windsurf', 'terminal', 'tmux', or 'claude '" , editorType )
261281 }
262282
263283 homeDir , err := os .UserHomeDir ()
@@ -279,7 +299,7 @@ func handleSetDefault(t *terminal.Terminal, editorType string) error {
279299}
280300
281301// Fetch workspace info, then open code editor
282- func runOpenCommand (t * terminal.Terminal , tstore OpenStore , wsIDOrName string , setupDoneString string , directory string , host bool , editorType string ) error { //nolint:funlen,gocyclo // define brev command
302+ func runOpenCommand (t * terminal.Terminal , tstore OpenStore , wsIDOrName string , setupDoneString string , directory string , host bool , editorType string , editorArgs [] string ) error { //nolint:funlen,gocyclo // define brev command
283303 // todo check if workspace is stopped and start if it if it is stopped
284304 fmt .Println ("finding your instance..." )
285305 res := refresh .RunRefreshAsync (tstore )
@@ -292,7 +312,7 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
292312 if awaitErr := res .Await (); awaitErr != nil {
293313 return breverrors .WrapAndTrace (awaitErr )
294314 }
295- return openExternalNode (t , tstore , target .Node , directory , editorType )
315+ return openExternalNode (t , tstore , target .Node , directory , editorType , editorArgs )
296316 }
297317 workspace := target .Workspace
298318 if workspace .Status == "STOPPED" { // we start the env for the user
@@ -341,7 +361,7 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
341361 // legacy environments wont support this and cause errrors,
342362 // but we don't want to block the user from using vscode
343363 _ = writeconnectionevent .WriteWCEOnEnv (tstore , string (localIdentifier ))
344- err = openEditorWithSSH (t , string (localIdentifier ), projPath , tstore , setupDoneString , editorType )
364+ err = openEditorWithSSH (t , string (localIdentifier ), projPath , tstore , setupDoneString , editorType , editorArgs )
345365 if err != nil {
346366 if strings .Contains (err .Error (), `"code": executable file not found in $PATH` ) {
347367 errMsg := "code\" : executable file not found in $PATH\n \n add 'code' to your $PATH to open VS Code from the terminal\n \t export PATH=\" /Applications/Visual Studio Code.app/Contents/Resources/app/bin:$PATH\" "
@@ -359,14 +379,17 @@ func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, s
359379 errMsg := "tmux not found on remote instance. Please install it and try again."
360380 return handlePathError (tstore , workspace , errMsg )
361381 }
382+ if strings .Contains (err .Error (), "failed to install Claude Code" ) {
383+ return breverrors .WrapAndTrace (err )
384+ }
362385 return breverrors .WrapAndTrace (err )
363386 }
364387 // Call analytics for open
365388 _ = pushOpenAnalytics (tstore , workspace )
366389 return nil
367390}
368391
369- func openExternalNode (t * terminal.Terminal , tstore OpenStore , node * nodev1.ExternalNode , directory string , editorType string ) error {
392+ func openExternalNode (t * terminal.Terminal , tstore OpenStore , node * nodev1.ExternalNode , directory string , editorType string , editorArgs [] string ) error {
370393 info , err := util .ResolveExternalNodeSSH (tstore , node )
371394 if err != nil {
372395 return breverrors .WrapAndTrace (err )
@@ -393,7 +416,7 @@ func openExternalNode(t *terminal.Terminal, tstore OpenStore, node *nodev1.Exter
393416 s .Stop ()
394417 t .Vprintf ("\n " )
395418
396- return openEditorByType (t , editorType , alias , path , tstore )
419+ return openEditorByType (t , editorType , alias , path , tstore , editorArgs )
397420}
398421
399422func pushOpenAnalytics (tstore OpenStore , workspace * entity.Workspace ) error {
@@ -530,6 +553,8 @@ func getEditorName(editorType string) string {
530553 return "Terminal"
531554 case EditorTmux :
532555 return "tmux"
556+ case EditorClaude :
557+ return "Claude Code"
533558 default :
534559 return "VSCode"
535560 }
@@ -549,7 +574,7 @@ func handlePathError(tstore OpenStore, workspace *entity.Workspace, errMsg strin
549574 return errors .New (errMsg )
550575}
551576
552- func openEditorByType (t * terminal.Terminal , editorType string , sshAlias string , path string , tstore OpenStore ) error {
577+ func openEditorByType (t * terminal.Terminal , editorType string , sshAlias string , path string , tstore OpenStore , editorArgs [] string ) error {
553578 extensions := []string {"ms-vscode-remote.remote-ssh" , "ms-toolsai.jupyter-keymap" , "ms-python.python" }
554579 switch editorType {
555580 case EditorCursor :
@@ -562,6 +587,8 @@ func openEditorByType(t *terminal.Terminal, editorType string, sshAlias string,
562587 return openTerminal (sshAlias , path , tstore )
563588 case EditorTmux :
564589 return openTerminalWithTmux (sshAlias , path , tstore )
590+ case EditorClaude :
591+ return openClaude (t , sshAlias , path , editorArgs )
565592 default :
566593 tryToInstallExtensions (t , extensions )
567594 return openVsCode (sshAlias , path , tstore )
@@ -597,6 +624,7 @@ func openEditorWithSSH(
597624 tstore OpenStore ,
598625 _ string ,
599626 editorType string ,
627+ editorArgs []string ,
600628) error {
601629 res := refresh .RunRefreshAsync (tstore )
602630 err := res .Await ()
@@ -618,7 +646,7 @@ func openEditorWithSSH(
618646 s .Stop ()
619647 t .Vprintf ("\n " )
620648
621- err = openEditorByType (t , editorType , sshAlias , path , tstore )
649+ err = openEditorByType (t , editorType , sshAlias , path , tstore , editorArgs )
622650 if err != nil {
623651 return breverrors .WrapAndTrace (err )
624652 }
@@ -814,3 +842,123 @@ func ensureTmuxInstalled(sshAlias string) error {
814842 }
815843 return nil
816844}
845+
846+ func openClaude (t * terminal.Terminal , sshAlias string , path string , claudeArgs []string ) error {
847+ // Ensure tmux is available on remote
848+ err := ensureTmuxInstalled (sshAlias )
849+ if err != nil {
850+ return breverrors .WrapAndTrace (fmt .Errorf ("tmux: command not found" ))
851+ }
852+
853+ // Install Claude Code remotely if not present
854+ err = ensureClaudeInstalled (t , sshAlias )
855+ if err != nil {
856+ return breverrors .WrapAndTrace (err )
857+ }
858+
859+ // Auto-authenticate: only forward a key if the remote is not already logged in
860+ apiKey := resolveClaudeAPIKey (t , sshAlias )
861+
862+ sessionName := "claude"
863+
864+ var envExport string
865+ if apiKey != "" {
866+ envExport = fmt .Sprintf ("export ANTHROPIC_API_KEY=%s; " , shellescape .Quote (apiKey ))
867+ }
868+
869+ // Build the claude command with any extra flags
870+ claudeCmd := "claude"
871+ if len (claudeArgs ) > 0 {
872+ claudeCmd = "claude " + strings .Join (claudeArgs , " " )
873+ }
874+
875+ // Prepend installer paths, set env if needed, then attach-or-create tmux session
876+ remoteScript := fmt .Sprintf (
877+ `export PATH="$HOME/.claude/local/bin:$HOME/.local/bin:$PATH"; %stmux has-session -t %s 2>/dev/null && tmux attach-session -t %s || (cd %s && tmux new-session -s %s %s)` ,
878+ envExport , sessionName , sessionName , shellescape .Quote (path ), sessionName , shellescape .Quote (claudeCmd ),
879+ )
880+
881+ // Run SSH inline in the current terminal (interactive, with TTY)
882+ sshCmd := exec .Command ("ssh" , "-t" , sshAlias , remoteScript ) // #nosec G204
883+ sshCmd .Stdin = os .Stdin
884+ sshCmd .Stdout = os .Stdout
885+ sshCmd .Stderr = os .Stderr
886+
887+ err = sshCmd .Run ()
888+ if err != nil {
889+ return breverrors .WrapAndTrace (err )
890+ }
891+ return nil
892+ }
893+
894+ // resolveClaudeAPIKey returns an API key to forward to the remote, or "" if
895+ // the remote is already authenticated or no local key can be found.
896+ func resolveClaudeAPIKey (t * terminal.Terminal , sshAlias string ) string {
897+ // Check if remote already has auth (credentials file or ANTHROPIC_API_KEY in env)
898+ if isRemoteClaudeAuthenticated (sshAlias ) {
899+ return ""
900+ }
901+
902+ // 1. Check local ANTHROPIC_API_KEY env var
903+ if key := os .Getenv ("ANTHROPIC_API_KEY" ); key != "" {
904+ t .Vprintf ("%s" , t .Green ("Forwarding ANTHROPIC_API_KEY to remote instance\n " ))
905+ return key
906+ }
907+
908+ // 2. Try macOS Keychain
909+ if runtime .GOOS == "darwin" {
910+ key , err := getClaudeKeyFromKeychain ()
911+ if err == nil && key != "" {
912+ t .Vprintf ("%s" , t .Green ("Forwarding API key from macOS Keychain to remote instance\n " ))
913+ return key
914+ }
915+ }
916+
917+ return ""
918+ }
919+
920+ // isRemoteClaudeAuthenticated checks whether the remote already has Claude
921+ // credentials (OAuth credentials file or ANTHROPIC_API_KEY set in the shell).
922+ func isRemoteClaudeAuthenticated (sshAlias string ) bool {
923+ // Check for credentials file or env var in one SSH round-trip
924+ checkCmd := exec .Command (
925+ "ssh" , sshAlias ,
926+ `test -f "$HOME/.claude/.credentials.json" || printenv ANTHROPIC_API_KEY >/dev/null 2>&1` ,
927+ ) // #nosec G204
928+ return checkCmd .Run () == nil
929+ }
930+
931+ // getClaudeKeyFromKeychain reads the API key stored by Claude Code in the
932+ // macOS Keychain (security framework).
933+ func getClaudeKeyFromKeychain () (string , error ) {
934+ out , err := exec .Command ("security" , "find-generic-password" , "-s" , "Claude Code" , "-w" ).Output () // #nosec G204
935+ if err != nil {
936+ return "" , err
937+ }
938+ return strings .TrimSpace (string (out )), nil
939+ }
940+
941+ func ensureClaudeInstalled (t * terminal.Terminal , sshAlias string ) error {
942+ // Check PATH and common install locations
943+ checkCmd := fmt .Sprintf (
944+ "ssh %s 'export PATH=\" $HOME/.claude/local/bin:$HOME/.local/bin:$PATH\" ; which claude >/dev/null 2>&1'" ,
945+ sshAlias ,
946+ )
947+ checkExec := exec .Command ("bash" , "-c" , checkCmd ) // #nosec G204
948+ err := checkExec .Run ()
949+ if err == nil {
950+ return nil // already installed
951+ }
952+
953+ t .Vprintf ("Installing Claude Code on remote instance...\n " )
954+
955+ installCmd := fmt .Sprintf ("ssh %s 'curl -fsSL https://claude.ai/install.sh | bash'" , sshAlias )
956+ installExec := exec .Command ("bash" , "-c" , installCmd ) // #nosec G204
957+ output , err := installExec .CombinedOutput ()
958+ if err != nil {
959+ return fmt .Errorf ("failed to install Claude Code: %s\n %s" , err , string (output ))
960+ }
961+
962+ t .Vprintf ("%s" , t .Green ("Claude Code installed successfully\n " ))
963+ return nil
964+ }
0 commit comments