Skip to content

Commit d907ae8

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 d907ae8

12 files changed

Lines changed: 1550 additions & 46 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Profiles Feature Design
2+
3+
## Overview
4+
5+
Add named profile support to the langsmith CLI, allowing users to store and switch between multiple LangSmith configurations (API endpoint, credentials, workspace). Profiles are stored in a TOML config file and selected via CLI flag, environment variable, or a persistent `current_profile` setting.
6+
7+
## Config File
8+
9+
- **Location:** `~/.langsmith/config.toml`
10+
- **Format:** TOML with a top-level `current_profile` key and one `[section]` per profile
11+
12+
```toml
13+
current_profile = "default"
14+
15+
[default]
16+
api_url = "https://api.smith.langchain.com"
17+
api_key = "lsv2_pt_abc123..."
18+
workspace_id = "optional-uuid"
19+
20+
[staging]
21+
api_url = "https://staging.api.smith.langchain.com"
22+
api_key = "lsv2_pt_def456..."
23+
```
24+
25+
### Profile Fields
26+
27+
| Field | Required | Default |
28+
|----------------|----------|----------------------------------------|
29+
| `api_key` | yes ||
30+
| `api_url` | no | `https://api.smith.langchain.com` |
31+
| `workspace_id` | no ||
32+
33+
## Resolution Priority
34+
35+
Credential and endpoint resolution follows AWS-style precedence (highest wins):
36+
37+
1. `--api-key` / `--api-url` CLI flags
38+
2. `LANGSMITH_API_KEY` / `LANGSMITH_ENDPOINT` / `LANGSMITH_WORKSPACE_ID` env vars
39+
3. Named profile selected via `--profile` flag or `LANGSMITH_PROFILE` env var
40+
4. `current_profile` value from config file
41+
5. `[default]` profile in config file
42+
6. Hardcoded default URL (`https://api.smith.langchain.com`)
43+
44+
If no config file exists and no env vars or flags are set, the CLI behaves exactly as it does today (requires `LANGSMITH_API_KEY`).
45+
46+
## Subcommands
47+
48+
All under `langsmith profile`:
49+
50+
### `langsmith profile list`
51+
52+
Lists all profiles. Marks the active profile with `*`.
53+
54+
- JSON output: array of objects with `name`, `api_url`, `active` fields (key never shown)
55+
- Pretty output: table with name, URL, active marker
56+
57+
### `langsmith profile show <name>`
58+
59+
Shows a single profile's configuration. The API key is masked (e.g., `lsv2_pt_...c123`).
60+
61+
- JSON output: object with `name`, `api_url`, `api_key` (masked), `workspace_id`
62+
- Pretty output: key-value display
63+
64+
### `langsmith profile create <name>`
65+
66+
Creates a new profile. Accepts flags for scripting; prompts interactively for missing required fields.
67+
68+
**Flags:**
69+
- `--api-key` (required, or prompted)
70+
- `--api-url` (optional, defaults to `https://api.smith.langchain.com`)
71+
- `--workspace-id` (optional)
72+
73+
If `~/.langsmith/config.toml` does not exist, it is created. If this is the first profile and no `current_profile` is set, it becomes the current profile automatically.
74+
75+
Errors if a profile with that name already exists.
76+
77+
### `langsmith profile delete <name>`
78+
79+
Removes a profile from the config file.
80+
81+
Errors if the profile does not exist. If the deleted profile was the `current_profile`, clears `current_profile` (the user must `profile use` another one or rely on env vars).
82+
83+
### `langsmith profile use <name>`
84+
85+
Sets `current_profile` in the config file to the given profile name.
86+
87+
Errors if the profile does not exist.
88+
89+
## Root Command Changes
90+
91+
- New `--profile` persistent flag on the root command
92+
- `GetAPIKey()`, `GetAPIURL()` updated to consult profile config as a fallback after checking flags and env vars
93+
- New helper to resolve workspace ID from profile
94+
95+
## New Package: `internal/config`
96+
97+
Handles reading, writing, and querying the TOML config file.
98+
99+
### Key types and functions:
100+
101+
```go
102+
// Profile represents a single named profile.
103+
type Profile struct {
104+
APIKey string `toml:"api_key"`
105+
APIURL string `toml:"api_url,omitempty"`
106+
WorkspaceID string `toml:"workspace_id,omitempty"`
107+
}
108+
109+
// Config represents the full config file.
110+
type Config struct {
111+
CurrentProfile string `toml:"current_profile,omitempty"`
112+
Profiles map[string]Profile // each TOML section is a profile
113+
}
114+
115+
func Load() (*Config, error) // reads ~/.langsmith/config.toml, returns empty Config if missing
116+
func (c *Config) Save() error // writes back to ~/.langsmith/config.toml
117+
func (c *Config) ActiveProfileName(flagProfile, envProfile string) string
118+
func (c *Config) ResolveProfile(flagProfile, envProfile string) *Profile
119+
```
120+
121+
## Behavioral Notes
122+
123+
- The config file is only created on first `profile create`, never eagerly.
124+
- All existing behavior is preserved when no config file exists.
125+
- `profile delete` of the active profile clears `current_profile` rather than picking a new one automatically.
126+
- API key masking shows first 8 and last 4 characters (e.g., `lsv2_pt_...c123`).
127+
128+
## Out of Scope
129+
130+
- `langsmith login` (OAuth/browser-based auth flow) — future work
131+
- Config file locking for concurrent access — single-user CLI
132+
- Profile import/export

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}

0 commit comments

Comments
 (0)