Skip to content

Commit bd57e1b

Browse files
feat: add named profile support
Add profile management to the CLI, allowing users to store and switch between multiple LangSmith configurations (API key, endpoint, workspace) via ~/.langsmith/config.toml. New subcommands: langsmith profile create/list/show/delete/use New flags: --profile, LANGSMITH_PROFILE env var Resolution priority: flags > env vars > named profile > default profile. Includes internal/config package for TOML config read/write and comprehensive test coverage across config, profile commands, and resolution logic.
1 parent c1317ba commit bd57e1b

11 files changed

Lines changed: 1418 additions & 46 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/langchain-ai/langsmith-cli
33
go 1.25.0
44

55
require (
6+
github.com/BurntSushi/toml v1.6.0
67
github.com/google/uuid v1.6.0
78
github.com/gorilla/websocket v1.5.3
89
github.com/hashicorp/yamux v0.1.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2+
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
13
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
24
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
35
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

internal/client/client.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10-
"os"
1110
"strings"
1211
"time"
1312

@@ -17,9 +16,10 @@ import (
1716

1817
// Client wraps the LangSmith Go SDK and provides helpers for raw HTTP calls.
1918
type Client struct {
20-
SDK *langsmith.Client
21-
apiKey string
22-
apiURL string
19+
SDK *langsmith.Client
20+
apiKey string
21+
apiURL string
22+
workspaceID string
2323

2424
// Cached session name → ID mappings (per invocation).
2525
sessionCache map[string]string
@@ -34,7 +34,7 @@ func NormalizeURL(apiURL string) string {
3434
}
3535

3636
// New creates a new Client.
37-
func New(apiKey, apiURL string) *Client {
37+
func New(apiKey, apiURL, workspaceID string) *Client {
3838
normalized := NormalizeURL(apiURL)
3939

4040
opts := []option.RequestOption{
@@ -44,17 +44,18 @@ func New(apiKey, apiURL string) *Client {
4444
if normalized != "" {
4545
opts = append(opts, option.WithBaseURL(normalized))
4646
}
47-
// Forward LANGSMITH_WORKSPACE_ID to the SDK as the tenant ID.
47+
// Forward workspaceID to the SDK as the tenant ID.
4848
// The SDK already reads LANGSMITH_TENANT_ID, but LANGSMITH_WORKSPACE_ID
4949
// is the documented env var for the CLI and MCP server.
50-
if wsID := os.Getenv("LANGSMITH_WORKSPACE_ID"); wsID != "" {
51-
opts = append(opts, option.WithTenantID(wsID))
50+
if workspaceID != "" {
51+
opts = append(opts, option.WithTenantID(workspaceID))
5252
}
5353

5454
return &Client{
5555
SDK: langsmith.NewClient(opts...),
5656
apiKey: apiKey,
5757
apiURL: normalized,
58+
workspaceID: workspaceID,
5859
sessionCache: make(map[string]string),
5960
}
6061
}
@@ -110,8 +111,8 @@ func (c *Client) RawDo(ctx context.Context, method, path string, body io.Reader,
110111

111112
req.Header.Set("x-api-key", c.apiKey)
112113
req.Header.Set("Content-Type", "application/json")
113-
if wsID := os.Getenv("LANGSMITH_WORKSPACE_ID"); wsID != "" {
114-
req.Header.Set("x-tenant-id", wsID)
114+
if c.workspaceID != "" {
115+
req.Header.Set("x-tenant-id", c.workspaceID)
115116
}
116117
for k, vals := range extraHeaders {
117118
for _, v := range vals {
@@ -159,8 +160,8 @@ func (c *Client) rawRequest(ctx context.Context, method, path string, body any,
159160

160161
req.Header.Set("x-api-key", c.apiKey)
161162
req.Header.Set("Content-Type", "application/json")
162-
if wsID := os.Getenv("LANGSMITH_WORKSPACE_ID"); wsID != "" {
163-
req.Header.Set("x-tenant-id", wsID)
163+
if c.workspaceID != "" {
164+
req.Header.Set("x-tenant-id", c.workspaceID)
164165
}
165166

166167
httpClient := &http.Client{Timeout: 30 * time.Second}

internal/client/client_test.go

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestNormalizeURL(t *testing.T) {
3535
// ---------- New ----------
3636

3737
func TestNew_CreatesClient(t *testing.T) {
38-
c := New("test-key", "http://localhost:1234")
38+
c := New("test-key", "http://localhost:1234", "")
3939
if c == nil {
4040
t.Fatal("expected non-nil client")
4141
}
@@ -54,14 +54,14 @@ func TestNew_CreatesClient(t *testing.T) {
5454
}
5555

5656
func TestNew_TrimsTrailingSlash(t *testing.T) {
57-
c := New("key", "http://example.com/")
57+
c := New("key", "http://example.com/", "")
5858
if c.apiURL != "http://example.com" {
5959
t.Errorf("expected trailing slash trimmed, got %q", c.apiURL)
6060
}
6161
}
6262

6363
func TestNew_EmptyURL(t *testing.T) {
64-
c := New("key", "")
64+
c := New("key", "", "")
6565
if c.apiURL != "" {
6666
t.Errorf("expected empty apiURL, got %q", c.apiURL)
6767
}
@@ -87,7 +87,7 @@ func TestRawGet_Success(t *testing.T) {
8787
}))
8888
defer ts.Close()
8989

90-
c := New("my-key", ts.URL)
90+
c := New("my-key", ts.URL, "")
9191
var result map[string]string
9292
err := c.RawGet(context.Background(), "/test/path", &result)
9393
if err != nil {
@@ -104,7 +104,7 @@ func TestRawGet_HTTPError(t *testing.T) {
104104
}))
105105
defer ts.Close()
106106

107-
c := New("key", ts.URL)
107+
c := New("key", ts.URL, "")
108108
var result map[string]any
109109
err := c.RawGet(context.Background(), "/fail", &result)
110110
if err == nil {
@@ -122,7 +122,7 @@ func TestRawGet_NilResult(t *testing.T) {
122122
}))
123123
defer ts.Close()
124124

125-
c := New("key", ts.URL)
125+
c := New("key", ts.URL, "")
126126
err := c.RawGet(context.Background(), "/path", nil)
127127
if err != nil {
128128
t.Fatalf("unexpected error: %v", err)
@@ -147,7 +147,7 @@ func TestRawPost_Success(t *testing.T) {
147147
}))
148148
defer ts.Close()
149149

150-
c := New("key", ts.URL)
150+
c := New("key", ts.URL, "")
151151
var result map[string]string
152152
err := c.RawPost(context.Background(), "/create", map[string]any{"name": "test"}, &result)
153153
if err != nil {
@@ -165,7 +165,7 @@ func TestRawPost_NilBody(t *testing.T) {
165165
}))
166166
defer ts.Close()
167167

168-
c := New("key", ts.URL)
168+
c := New("key", ts.URL, "")
169169
err := c.RawPost(context.Background(), "/path", nil, nil)
170170
if err != nil {
171171
t.Fatalf("unexpected error: %v", err)
@@ -187,7 +187,7 @@ func TestRawDelete_Success(t *testing.T) {
187187
}))
188188
defer ts.Close()
189189

190-
c := New("key", ts.URL)
190+
c := New("key", ts.URL, "")
191191
err := c.RawDelete(context.Background(), "/items/abc", nil)
192192
if err != nil {
193193
t.Fatalf("unexpected error: %v", err)
@@ -200,7 +200,7 @@ func TestRawDelete_HTTPError(t *testing.T) {
200200
}))
201201
defer ts.Close()
202202

203-
c := New("key", ts.URL)
203+
c := New("key", ts.URL, "")
204204
err := c.RawDelete(context.Background(), "/missing", nil)
205205
if err == nil {
206206
t.Fatal("expected error for 404")
@@ -219,13 +219,11 @@ func TestRawRequest_SetsAPIKeyHeader(t *testing.T) {
219219
}))
220220
defer ts.Close()
221221

222-
c := New("secret-key", ts.URL)
222+
c := New("secret-key", ts.URL, "")
223223
_ = c.RawGet(context.Background(), "/test", nil)
224224
}
225225

226226
func TestRawRequest_SetsWorkspaceHeader(t *testing.T) {
227-
t.Setenv("LANGSMITH_WORKSPACE_ID", "ws-123")
228-
229227
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
230228
if got := r.Header.Get("x-tenant-id"); got != "ws-123" {
231229
t.Errorf("expected x-tenant-id=ws-123, got %q", got)
@@ -235,13 +233,11 @@ func TestRawRequest_SetsWorkspaceHeader(t *testing.T) {
235233
}))
236234
defer ts.Close()
237235

238-
c := New("key", ts.URL)
236+
c := New("key", ts.URL, "ws-123")
239237
_ = c.RawGet(context.Background(), "/test", nil)
240238
}
241239

242240
func TestRawRequest_NoWorkspaceHeaderWhenUnset(t *testing.T) {
243-
t.Setenv("LANGSMITH_WORKSPACE_ID", "")
244-
245241
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
246242
if got := r.Header.Get("x-tenant-id"); got != "" {
247243
t.Errorf("expected empty x-tenant-id, got %q", got)
@@ -251,14 +247,14 @@ func TestRawRequest_NoWorkspaceHeaderWhenUnset(t *testing.T) {
251247
}))
252248
defer ts.Close()
253249

254-
c := New("key", ts.URL)
250+
c := New("key", ts.URL, "")
255251
_ = c.RawGet(context.Background(), "/test", nil)
256252
}
257253

258254
// ---------- Error cases ----------
259255

260256
func TestRawGet_InvalidURL(t *testing.T) {
261-
c := New("key", "http://127.0.0.1:1") // unlikely to be listening
257+
c := New("key", "http://127.0.0.1:1", "") // unlikely to be listening
262258
err := c.RawGet(context.Background(), "/test", nil)
263259
if err == nil {
264260
t.Fatal("expected error for unreachable server")
@@ -272,7 +268,7 @@ func TestRawGet_InvalidJSON(t *testing.T) {
272268
}))
273269
defer ts.Close()
274270

275-
c := New("key", ts.URL)
271+
c := New("key", ts.URL, "")
276272
var result map[string]any
277273
err := c.RawGet(context.Background(), "/test", &result)
278274
if err == nil {
@@ -291,7 +287,7 @@ func TestRawRequest_400Error(t *testing.T) {
291287
}))
292288
defer ts.Close()
293289

294-
c := New("key", ts.URL)
290+
c := New("key", ts.URL, "")
295291
err := c.RawGet(context.Background(), "/test", nil)
296292
if err == nil {
297293
t.Fatal("expected error for 400")
@@ -307,7 +303,7 @@ func TestRawRequest_500Error(t *testing.T) {
307303
}))
308304
defer ts.Close()
309305

310-
c := New("key", ts.URL)
306+
c := New("key", ts.URL, "")
311307
err := c.RawPost(context.Background(), "/test", map[string]string{"a": "b"}, nil)
312308
if err == nil {
313309
t.Fatal("expected error for 500")
@@ -336,7 +332,7 @@ func TestRawDo_ReturnsStatusAndBody(t *testing.T) {
336332
}))
337333
defer ts.Close()
338334

339-
c := New("my-key", ts.URL)
335+
c := New("my-key", ts.URL, "")
340336
status, hdr, body, err := c.RawDo(context.Background(), "PATCH", "/api/v1/sessions", nil, nil)
341337
if err != nil {
342338
t.Fatalf("unexpected error: %v", err)
@@ -360,7 +356,7 @@ func TestRawDo_WithBodyReader(t *testing.T) {
360356
}))
361357
defer ts.Close()
362358

363-
c := New("key", ts.URL)
359+
c := New("key", ts.URL, "")
364360
status, _, body, err := c.RawDo(context.Background(), "POST", "/create", strings.NewReader(`{"name":"test"}`), nil)
365361
if err != nil {
366362
t.Fatalf("unexpected error: %v", err)
@@ -386,7 +382,7 @@ func TestRawDo_ExtraHeaders(t *testing.T) {
386382
}))
387383
defer ts.Close()
388384

389-
c := New("key", ts.URL)
385+
c := New("key", ts.URL, "")
390386
extra := http.Header{"X-Custom": []string{"hello"}}
391387
_, _, _, err := c.RawDo(context.Background(), "GET", "/test", nil, extra)
392388
if err != nil {
@@ -401,7 +397,7 @@ func TestRawDo_Returns4xxWithoutError(t *testing.T) {
401397
}))
402398
defer ts.Close()
403399

404-
c := New("key", ts.URL)
400+
c := New("key", ts.URL, "")
405401
status, _, body, err := c.RawDo(context.Background(), "GET", "/test", nil, nil)
406402
if err != nil {
407403
t.Fatalf("unexpected error: %v", err)
@@ -417,14 +413,14 @@ func TestRawDo_Returns4xxWithoutError(t *testing.T) {
417413
// ---------- Accessors ----------
418414

419415
func TestAPIKey(t *testing.T) {
420-
c := New("secret", "http://localhost")
416+
c := New("secret", "http://localhost", "")
421417
if c.APIKey() != "secret" {
422418
t.Errorf("expected secret, got %q", c.APIKey())
423419
}
424420
}
425421

426422
func TestAPIURL(t *testing.T) {
427-
c := New("key", "http://localhost:1234")
423+
c := New("key", "http://localhost:1234", "")
428424
if c.APIURL() != "http://localhost:1234" {
429425
t.Errorf("expected http://localhost:1234, got %q", c.APIURL())
430426
}

internal/cmd/api/request.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// runRequest executes an HTTP request and writes the response to w.
1717
// Returns the HTTP status code and any transport-level error.
1818
func runRequest(apiURL, apiKey, method, path, body string, headers []string, include bool, w io.Writer) (int, error) {
19-
c := client.New(apiKey, apiURL)
19+
c := client.New(apiKey, apiURL, "")
2020

2121
fullURL := resolveEndpoint(apiURL, path)
2222
// RawDo prepends apiURL, so compute the relative path.
@@ -28,7 +28,7 @@ func runRequest(apiURL, apiKey, method, path, body string, headers []string, inc
2828
} else if strings.HasPrefix(fullURL, "http://") || strings.HasPrefix(fullURL, "https://") {
2929
// Full URL to a different host — use empty-base client so RawDo
3030
// doesn't prepend apiURL.
31-
c = client.New(apiKey, "")
31+
c = client.New(apiKey, "", "")
3232
}
3333

3434
// Resolve body

0 commit comments

Comments
 (0)