Skip to content

Commit 782baf9

Browse files
committed
feat: add brev open claude for remote Claude Code sessions
Add "claude" as a new editor type for `brev open`. This: - Installs Claude Code remotely via the official installer if not present - Auto-authenticates by detecting API keys from ANTHROPIC_API_KEY env var or macOS Keychain, forwarding only when the remote is not already logged in - Launches Claude Code in a tmux session for persistence across disconnects - Supports passing arbitrary claude flags via -- separator - Supports -d flag to specify remote working directory Usage: brev open my-instance claude brev open my-instance claude -d /path/to/project brev open my-instance claude -- --model opus -p "fix the tests"
1 parent 090deec commit 782baf9

2 files changed

Lines changed: 175 additions & 26 deletions

File tree

pkg/cmd/open/open.go

Lines changed: 173 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
EditorWindsurf = "windsurf"
4040
EditorTerminal = "terminal"
4141
EditorTmux = "tmux"
42+
EditorClaude = "claude"
4243
)
4344

4445
var (
@@ -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
5456
Terminal 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

102111
type 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
195204
func 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

258278
func 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\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\""
@@ -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

399422
func 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+
}

pkg/cmd/open/open_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
)
66

77
func TestIsEditorType(t *testing.T) {
8-
valid := []string{"code", "cursor", "windsurf", "terminal", "tmux"}
8+
valid := []string{"code", "cursor", "windsurf", "terminal", "tmux", "claude"}
99
for _, v := range valid {
1010
if !isEditorType(v) {
1111
t.Errorf("expected %q to be valid editor type", v)
@@ -30,6 +30,7 @@ func TestGetEditorName(t *testing.T) {
3030
{"windsurf", "Windsurf"},
3131
{"terminal", "Terminal"},
3232
{"tmux", "tmux"},
33+
{"claude", "Claude Code"},
3334
{"unknown", "VSCode"},
3435
}
3536
for _, tt := range tests {

0 commit comments

Comments
 (0)