Skip to content

Commit 90b0a17

Browse files
committed
Merge branch 'main' into codedor/register/hardware_profiler
2 parents ce8252b + 08b1024 commit 90b0a17

32 files changed

Lines changed: 1844 additions & 130 deletions

.agents/skills/brev-cli/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
22
name: brev-cli
3-
description: Manage GPU and CPU cloud instances with the Brev CLI. Use when users want to create instances, search for GPUs or CPUs, SSH into instances, open editors, copy files, port forward, manage organizations, or work with cloud compute. Trigger keywords - brev, gpu, cpu, instance, create instance, ssh, vram, vcpu, A100, H100, cloud gpu, cloud cpu, remote machine.
3+
description: Manage GPU and CPU cloud instances with the Brev CLI for ML workloads and general compute. Use when users want to create instances, search for GPUs or CPUs, SSH into instances, open editors, copy files, port forward, manage organizations, or work with cloud compute. Supports fine-tuning, reinforcement learning, training, inference, batch processing, and other ML/AI workloads. Trigger keywords - brev, gpu, cpu, instance, create instance, ssh, vram, vcpu, A100, H100, cloud gpu, cloud cpu, remote machine, finetune, fine-tune, RL, RLHF, training, inference, deploy model, serve model, batch job.
44
allowed-tools: Bash, Read, AskUserQuestion
5-
argument-hint: [create|search|shell|exec|open|ls|delete] [instance-name]
5+
argument-hint: "[create|search|shell|exec|open|ls|delete] [instance-name]"
66
---
77
<!--
88
Token Budget:

pkg/cmd/agentskill/agentskill.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func newCmdUninstall(t *terminal.Terminal, store AgentSkillStore) *cobra.Command
116116

117117
// installDirs are the parent directories under $HOME where skills are installed.
118118
// We install to both so the skill works with Claude Code (~/.claude) and other agents (~/.agent).
119-
var installDirs = []string{".claude", ".agents"}
119+
var installDirs = []string{".claude", ".agents", ".codex"}
120120

121121
// GetSkillDirs returns all paths where the skill should be installed
122122
func GetSkillDirs(homeDir string) []string {

pkg/cmd/cmd.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import (
5858
"github.com/brevdev/brev-cli/pkg/cmd/workspacegroups"
5959
"github.com/brevdev/brev-cli/pkg/cmd/writeconnectionevent"
6060
"github.com/brevdev/brev-cli/pkg/config"
61+
"github.com/brevdev/brev-cli/pkg/entity"
6162
"github.com/brevdev/brev-cli/pkg/featureflag"
6263
"github.com/brevdev/brev-cli/pkg/files"
6364
"github.com/brevdev/brev-cli/pkg/remoteversion"
@@ -257,8 +258,20 @@ func NewBrevCommand() *cobra.Command { //nolint:funlen,gocognit,gocyclo // defin
257258
cmds.SetUsageTemplate(usageTemplate)
258259

259260
// In-memory auth for external node commands — never touches credentials.json.
260-
memAuthStore := store.NewMemoryAuthStore()
261-
memAuthenticator := auth.StandardLogin("", "", nil)
261+
// Pre-fill the cached email so the user sees a confirmation prompt instead of
262+
// having to type it from scratch every time.
263+
cachedEmail, _ := fsStore.GetCachedEmail()
264+
memAuthenticator := auth.StandardLogin("", cachedEmail, nil)
265+
if cachedEmail != "" {
266+
if kas, ok := memAuthenticator.(auth.KasAuthenticator); ok {
267+
kas.ShouldPromptEmail = true
268+
memAuthenticator = kas
269+
}
270+
}
271+
memAuthStore := &emailCachingAuthStore{
272+
MemoryAuthStore: store.NewMemoryAuthStore(),
273+
fileStore: fsStore,
274+
}
262275
memLoginAuth := auth.NewLoginAuth(memAuthStore, memAuthenticator)
263276
memLoginAuth.WithShouldLogin(func() (bool, error) { return true, nil })
264277

@@ -555,4 +568,22 @@ var (
555568
_ store.Auth = auth.NoLoginAuth{}
556569
_ auth.AuthStore = store.FileStore{}
557570
_ auth.AuthStore = &store.MemoryAuthStore{}
571+
_ auth.AuthStore = &emailCachingAuthStore{}
558572
)
573+
574+
// emailCachingAuthStore wraps MemoryAuthStore and persists the login email
575+
// to ~/.brev/cached-email after each successful authentication.
576+
type emailCachingAuthStore struct {
577+
*store.MemoryAuthStore
578+
fileStore *store.FileStore
579+
}
580+
581+
func (e *emailCachingAuthStore) SaveAuthTokens(tokens entity.AuthTokens) error {
582+
if err := e.MemoryAuthStore.SaveAuthTokens(tokens); err != nil {
583+
return breverrors.WrapAndTrace(err)
584+
}
585+
if email := auth.GetEmailFromToken(tokens.AccessToken); email != "" {
586+
_ = e.fileStore.SaveCachedEmail(email)
587+
}
588+
return nil
589+
}

pkg/cmd/cmd_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package cmd
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/brevdev/brev-cli/pkg/entity"
9+
"github.com/brevdev/brev-cli/pkg/store"
10+
"github.com/spf13/afero"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// fakeJWT builds an unsigned JWT with the given claims (header.payload.signature).
16+
func fakeJWT(t *testing.T, claims map[string]interface{}) string {
17+
t.Helper()
18+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
19+
payload, err := json.Marshal(claims)
20+
require.NoError(t, err)
21+
return header + "." + base64.RawURLEncoding.EncodeToString(payload) + "."
22+
}
23+
24+
func newTestFileStore(t *testing.T) *store.FileStore {
25+
t.Helper()
26+
fs := afero.NewMemMapFs()
27+
err := fs.MkdirAll("/home/testuser/.brev", 0o755)
28+
require.NoError(t, err)
29+
return store.NewBasicStore().WithFileSystem(fs).WithUserHomeDirGetter(
30+
func() (string, error) { return "/home/testuser", nil },
31+
)
32+
}
33+
34+
func TestEmailCachingAuthStore_SaveCachesEmail(t *testing.T) {
35+
fs := newTestFileStore(t)
36+
s := &emailCachingAuthStore{
37+
MemoryAuthStore: store.NewMemoryAuthStore(),
38+
fileStore: fs,
39+
}
40+
41+
token := fakeJWT(t, map[string]interface{}{"email": "user@example.com"})
42+
err := s.SaveAuthTokens(entity.AuthTokens{AccessToken: token})
43+
require.NoError(t, err)
44+
45+
cached, err := fs.GetCachedEmail()
46+
require.NoError(t, err)
47+
assert.Equal(t, "user@example.com", cached)
48+
}
49+
50+
func TestEmailCachingAuthStore_NoEmailInToken(t *testing.T) {
51+
fs := newTestFileStore(t)
52+
s := &emailCachingAuthStore{
53+
MemoryAuthStore: store.NewMemoryAuthStore(),
54+
fileStore: fs,
55+
}
56+
57+
token := fakeJWT(t, map[string]interface{}{"sub": "12345"})
58+
err := s.SaveAuthTokens(entity.AuthTokens{AccessToken: token})
59+
require.NoError(t, err)
60+
61+
cached, err := fs.GetCachedEmail()
62+
require.NoError(t, err)
63+
assert.Equal(t, "", cached)
64+
}
65+
66+
func TestEmailCachingAuthStore_EmptyAccessToken(t *testing.T) {
67+
fs := newTestFileStore(t)
68+
s := &emailCachingAuthStore{
69+
MemoryAuthStore: store.NewMemoryAuthStore(),
70+
fileStore: fs,
71+
}
72+
73+
err := s.SaveAuthTokens(entity.AuthTokens{AccessToken: ""})
74+
require.Error(t, err)
75+
}

pkg/cmd/copy/copy.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"time"
1010

11+
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
12+
1113
"github.com/brevdev/brev-cli/pkg/cmd/cmderrors"
1214
"github.com/brevdev/brev-cli/pkg/cmd/completions"
1315
"github.com/brevdev/brev-cli/pkg/cmd/refresh"
@@ -75,7 +77,15 @@ func runCopyCommand(t *terminal.Terminal, cstore CopyStore, source, dest string,
7577
}
7678
}
7779

78-
workspace, err := prepareWorkspace(t, cstore, workspaceNameOrID)
80+
target, err := util.ResolveWorkspaceOrNode(cstore, workspaceNameOrID)
81+
if err != nil {
82+
return breverrors.WrapAndTrace(err)
83+
}
84+
if target.Node != nil {
85+
return copyExternalNode(t, cstore, target.Node, localPath, remotePath, isUpload)
86+
}
87+
88+
workspace, err := prepareWorkspace(t, cstore, target.Workspace)
7989
if err != nil {
8090
return breverrors.WrapAndTrace(err)
8191
}
@@ -116,26 +126,22 @@ func parseCopyArguments(source, dest string) (workspaceNameOrID, remotePath, loc
116126
return destWorkspace, destPath, source, true, nil
117127
}
118128

119-
func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspaceNameOrID string) (*entity.Workspace, error) {
129+
func prepareWorkspace(t *terminal.Terminal, cstore CopyStore, workspace *entity.Workspace) (*entity.Workspace, error) {
120130
s := t.NewSpinner()
121-
workspace, err := util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
122-
if err != nil {
123-
return nil, breverrors.WrapAndTrace(err)
124-
}
125131

126132
if workspace.Status == "STOPPED" {
127-
err = startWorkspaceIfStopped(t, s, cstore, workspaceNameOrID, workspace)
133+
err := startWorkspaceIfStopped(t, s, cstore, workspace.Name, workspace)
128134
if err != nil {
129135
return nil, breverrors.WrapAndTrace(err)
130136
}
131137
}
132138

133-
err = pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
139+
err := pollUntil(s, workspace.ID, "RUNNING", cstore, " waiting for instance to be ready...")
134140
if err != nil {
135141
return nil, breverrors.WrapAndTrace(err)
136142
}
137143

138-
workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspaceNameOrID)
144+
workspace, err = util.GetUserWorkspaceByNameOrIDErr(cstore, workspace.Name)
139145
if err != nil {
140146
return nil, breverrors.WrapAndTrace(err)
141147
}
@@ -287,6 +293,28 @@ func startWorkspaceIfStopped(t *terminal.Terminal, s *spinner.Spinner, tstore Co
287293
return nil
288294
}
289295

296+
func copyExternalNode(t *terminal.Terminal, cstore CopyStore, node *nodev1.ExternalNode, localPath, remotePath string, isUpload bool) error {
297+
info, err := util.ResolveExternalNodeSSH(cstore, node)
298+
if err != nil {
299+
return breverrors.WrapAndTrace(err)
300+
}
301+
alias := info.SSHAlias()
302+
303+
// Ensure SSH config is up to date so the alias resolves.
304+
refreshRes := refresh.RunRefreshAsync(cstore)
305+
if err := refreshRes.Await(); err != nil {
306+
return breverrors.WrapAndTrace(err)
307+
}
308+
309+
s := t.NewSpinner()
310+
err = waitForSSHToBeAvailable(alias, s)
311+
if err != nil {
312+
return breverrors.WrapAndTrace(err)
313+
}
314+
315+
return runSCP(t, alias, localPath, remotePath, isUpload)
316+
}
317+
290318
func pollUntil(s *spinner.Spinner, wsid string, state string, copyStore CopyStore, waitMsg string) error {
291319
isReady := false
292320
s.Suffix = waitMsg

pkg/cmd/copy/copy_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package copy
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseCopyArguments_Upload(t *testing.T) {
8+
ws, remotePath, localPath, isUpload, err := parseCopyArguments("./local.txt", "my-node:/tmp/dest")
9+
if err != nil {
10+
t.Fatalf("unexpected error: %v", err)
11+
}
12+
if ws != "my-node" {
13+
t.Errorf("expected workspace my-node, got %s", ws)
14+
}
15+
if remotePath != "/tmp/dest" {
16+
t.Errorf("expected remotePath /tmp/dest, got %s", remotePath)
17+
}
18+
if localPath != "./local.txt" {
19+
t.Errorf("expected localPath ./local.txt, got %s", localPath)
20+
}
21+
if !isUpload {
22+
t.Error("expected isUpload=true")
23+
}
24+
}
25+
26+
func TestParseCopyArguments_Download(t *testing.T) {
27+
ws, remotePath, localPath, isUpload, err := parseCopyArguments("my-node:/tmp/file", "./local.txt")
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
if ws != "my-node" {
32+
t.Errorf("expected workspace my-node, got %s", ws)
33+
}
34+
if remotePath != "/tmp/file" {
35+
t.Errorf("expected remotePath /tmp/file, got %s", remotePath)
36+
}
37+
if localPath != "./local.txt" {
38+
t.Errorf("expected localPath ./local.txt, got %s", localPath)
39+
}
40+
if isUpload {
41+
t.Error("expected isUpload=false")
42+
}
43+
}
44+
45+
func TestParseCopyArguments_BothLocal(t *testing.T) {
46+
_, _, _, _, err := parseCopyArguments("./a", "./b")
47+
if err == nil {
48+
t.Fatal("expected error when both paths are local")
49+
}
50+
}
51+
52+
func TestParseCopyArguments_BothRemote(t *testing.T) {
53+
_, _, _, _, err := parseCopyArguments("ws1:/a", "ws2:/b")
54+
if err == nil {
55+
t.Fatal("expected error when both paths are remote")
56+
}
57+
}
58+
59+
func TestParseWorkspacePath_Local(t *testing.T) {
60+
ws, fp, err := parseWorkspacePath("/tmp/local/file")
61+
if err != nil {
62+
t.Fatalf("unexpected error: %v", err)
63+
}
64+
if ws != "" {
65+
t.Errorf("expected empty workspace, got %s", ws)
66+
}
67+
if fp != "/tmp/local/file" {
68+
t.Errorf("expected /tmp/local/file, got %s", fp)
69+
}
70+
}
71+
72+
func TestParseWorkspacePath_Remote(t *testing.T) {
73+
ws, fp, err := parseWorkspacePath("my-instance:/remote/path")
74+
if err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
if ws != "my-instance" {
78+
t.Errorf("expected my-instance, got %s", ws)
79+
}
80+
if fp != "/remote/path" {
81+
t.Errorf("expected /remote/path, got %s", fp)
82+
}
83+
}
84+
85+
func TestParseWorkspacePath_InvalidMultipleColons(t *testing.T) {
86+
_, _, err := parseWorkspacePath("ws:path:extra")
87+
if err == nil {
88+
t.Fatal("expected error for multiple colons")
89+
}
90+
}

pkg/cmd/deregister/deregister_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ type mockNetBirdManager struct {
9191
err error
9292
}
9393

94-
func (m *mockNetBirdManager) Install() error { return m.err }
95-
func (m *mockNetBirdManager) Uninstall() error { m.called = true; return m.err }
94+
func (m *mockNetBirdManager) Install() error { return m.err }
95+
func (m *mockNetBirdManager) Uninstall() error { m.called = true; return m.err }
96+
func (m *mockNetBirdManager) EnsureRunning() error { return m.err }
9697

9798
type mockNodeClientFactory struct {
9899
serverURL string

pkg/cmd/gpucreate/gpucreate.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/brevdev/brev-cli/pkg/entity"
1919
breverrors "github.com/brevdev/brev-cli/pkg/errors"
2020
"github.com/brevdev/brev-cli/pkg/featureflag"
21+
"github.com/brevdev/brev-cli/pkg/names"
2122
"github.com/brevdev/brev-cli/pkg/store"
2223
"github.com/brevdev/brev-cli/pkg/terminal"
2324
"github.com/spf13/cobra"
@@ -194,8 +195,8 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
194195
}
195196
}
196197

197-
if name == "" {
198-
return breverrors.NewValidationError("name is required (as argument or --name flag)")
198+
if err := names.ValidateNodeName(name); err != nil {
199+
return breverrors.WrapAndTrace(err)
199200
}
200201

201202
if count < 1 {

pkg/cmd/notebook/notebook.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type WorkspaceResult struct {
2525
Err error
2626
}
2727

28-
func NewCmdNotebook(store NotebookStore, _ *terminal.Terminal) *cobra.Command {
28+
func NewCmdNotebook(store NotebookStore, t *terminal.Terminal) *cobra.Command {
2929
cmd := &cobra.Command{
3030
Use: "notebook",
3131
Short: "Open a notebook on your Brev machine",
@@ -66,7 +66,7 @@ func NewCmdNotebook(store NotebookStore, _ *terminal.Terminal) *cobra.Command {
6666
hello.TypeItToMeUnskippable27("\nClick here to go to your Jupyter notebook:\n\t 👉" + urlType("http://localhost:8888") + "👈\n\n\n")
6767

6868
// Port forward on 8888
69-
err2 := portforward.RunPortforward(store, args[0], "8888:8888", false)
69+
err2 := portforward.RunPortforward(t, store, args[0], "8888:8888", false)
7070
if err2 != nil {
7171
return breverrors.WrapAndTrace(err2)
7272
}

0 commit comments

Comments
 (0)