Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,11 @@ func resolveAuthInfo() (authInfoResult, error) {
result.Auth = "api_key"
result.AuthSource = "flag"
result.APIKey = lsconfig.MaskSecret(flagAPIKey)
case os.Getenv("LANGSMITH_API_KEY") != "":
case lsconfig.EnvAPIKey() != "":
result.Auth = "api_key"
result.AuthSource = "env"
result.AuthNote = "LANGSMITH_API_KEY is set and takes precedence over saved profile auth."
result.APIKey = lsconfig.MaskSecret(os.Getenv("LANGSMITH_API_KEY"))
result.APIKey = lsconfig.MaskSecret(lsconfig.EnvAPIKey())
case hasProfile && (profile.AccessToken() != "" || profile.OAuth.RefreshToken != ""):
result.Auth = "oauth"
result.AuthSource = "profile"
Expand Down
34 changes: 30 additions & 4 deletions internal/cmd/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ func newProfileCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create NAME",
Short: "Create an API-key profile",
Args: cobra.ExactArgs(1),
Long: `Create an API-key profile.

To create or update a profile that uses OAuth, run:
langsmith auth login --profile NAME`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileCreate(cmd, args[0], workspaceID, setCurrent)
},
Expand Down Expand Up @@ -186,7 +190,7 @@ func profileCreateAPIKey() string {
if flagAPIKey != "" {
return flagAPIKey
}
return os.Getenv("LANGSMITH_API_KEY")
return lsconfig.EnvAPIKey()
}

func profileCreateAPIURL() string {
Expand All @@ -210,7 +214,7 @@ func runProfileShow(cmd *cobra.Command, profileName string) error {
return fmt.Errorf("profile %q not found", profileName)
}

activeName := cfg.ResolveProfileName(flagProfile, profileEnvName())
activeName := activeProfileName(cfg)
item := profileShowItem{
Name: profileName,
Active: profileName == activeName,
Expand All @@ -225,6 +229,7 @@ func runProfileShow(cmd *cobra.Command, profileName string) error {

if GetFormat() == "pretty" {
renderProfileShowTable(cmd, item)
printProfileAuthNote(cmd)
return nil
}
enc := json.NewEncoder(cmd.OutOrStdout())
Expand Down Expand Up @@ -291,7 +296,7 @@ func runProfileList(cmd *cobra.Command) error {
return err
}

activeName := cfg.ResolveProfileName(flagProfile, profileEnvName())
activeName := activeProfileName(cfg)
items := make([]profileListItem, 0, len(cfg.Profiles))
for name, profile := range cfg.Profiles {
items = append(items, profileListItem{
Expand All @@ -312,6 +317,7 @@ func runProfileList(cmd *cobra.Command) error {

if GetFormat() == "pretty" {
renderProfileTable(cmd, items)
printProfileAuthNote(cmd)
return nil
}
enc := json.NewEncoder(cmd.OutOrStdout())
Expand All @@ -330,6 +336,26 @@ func profileAuthType(profile lsconfig.Profile) string {
}
}

func activeProfileName(cfg *lsconfig.Config) string {
if flagAPIKey != "" || lsconfig.EnvAPIKey() != "" {
return ""
}
return cfg.ResolveProfileName(flagProfile, profileEnvName())
}

func profileAuthNote() string {
if lsconfig.EnvAPIKey() != "" {
return "LANGSMITH_API_KEY is set and takes precedence over saved profiles."
}
return ""
}

func printProfileAuthNote(cmd *cobra.Command) {
if note := profileAuthNote(); note != "" {
fmt.Fprintln(cmd.OutOrStdout(), note)
}
}

func profileEnvName() string {
return strings.TrimSpace(os.Getenv("LANGSMITH_PROFILE"))
}
Expand Down
86 changes: 86 additions & 0 deletions internal/cmd/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ func TestProfileShowDoesNotExposeSecrets(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("LANGSMITH_CONFIG_FILE", configPath)
t.Setenv("LANGSMITH_PROFILE", "")
t.Setenv("LANGSMITH_API_KEY", "")
apiKey := "test-profile-api-key"
accessToken := "test-access-token"
refreshToken := "test-refresh-token"
Expand Down Expand Up @@ -296,6 +297,7 @@ func TestProfileDisplayTrimsEnvProfile(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("LANGSMITH_CONFIG_FILE", configPath)
t.Setenv("LANGSMITH_PROFILE", " prod ")
t.Setenv("LANGSMITH_API_KEY", "")
if err := os.WriteFile(configPath, []byte(`{
"current_profile": "dev",
"profiles": {
Expand Down Expand Up @@ -336,6 +338,88 @@ func TestProfileDisplayTrimsEnvProfile(t *testing.T) {
}
}

func TestProfileDisplayShowsNoActiveProfileWhenEnvAPIKeyWins(t *testing.T) {
oldProfile := flagProfile
oldFormat := flagOutputFormat
oldAPIKey := flagAPIKey
defer func() {
flagProfile = oldProfile
flagOutputFormat = oldFormat
flagAPIKey = oldAPIKey
}()
flagProfile = ""
flagOutputFormat = "json"
flagAPIKey = ""

configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("LANGSMITH_CONFIG_FILE", configPath)
t.Setenv("LANGSMITH_PROFILE", "dev")
t.Setenv("LANGSMITH_API_KEY", "env-api-key")
if err := os.WriteFile(configPath, []byte(`{
"current_profile": "dev",
"profiles": {
"dev": {
"api_key": "dev-api-key"
}
}
}
`), 0600); err != nil {
t.Fatal(err)
}

showStdout, err := executeCommand(t, "--format=json", "profile", "show", "dev")
if err != nil {
t.Fatalf("profile show returned error: %v\nstdout: %s", err, showStdout)
}
var showResult profileShowItem
if err := json.Unmarshal([]byte(showStdout), &showResult); err != nil {
t.Fatalf("profile show stdout was not JSON: %v\n%s", err, showStdout)
}
if showResult.Active {
t.Fatalf("expected no active profile when env API key wins: %+v", showResult)
}
if strings.Contains(showStdout, "auth_note") {
t.Fatalf("profile show JSON should not include output-only auth note: %s", showStdout)
}

listStdout, err := executeCommand(t, "--format=json", "profile", "list")
if err != nil {
t.Fatalf("profile list returned error: %v\nstdout: %s", err, listStdout)
}
var listResult []profileListItem
if err := json.Unmarshal([]byte(listStdout), &listResult); err != nil {
t.Fatalf("profile list stdout was not JSON: %v\n%s", err, listStdout)
}
if len(listResult) != 1 || listResult[0].Active {
t.Fatalf("expected list to show no active profile when env API key wins: %+v", listResult)
}
if strings.Contains(listStdout, "auth_note") {
t.Fatalf("profile list JSON should not include output-only auth note: %s", listStdout)
}

showPretty, err := executeCommand(t, "profile", "show", "dev")
if err != nil {
t.Fatalf("profile show returned error: %v\nstdout: %s", err, showPretty)
}
if !strings.Contains(showPretty, "LANGSMITH_API_KEY is set and takes precedence over saved profiles.") {
t.Fatalf("expected env API key note in pretty show output: %s", showPretty)
}
if strings.Contains(showPretty, "NOTE") {
t.Fatalf("did not expect auth note as a table column: %s", showPretty)
}

listPretty, err := executeCommand(t, "profile", "list")
if err != nil {
t.Fatalf("profile list returned error: %v\nstdout: %s", err, listPretty)
}
if !strings.Contains(listPretty, "LANGSMITH_API_KEY is set and takes precedence over saved profiles.") {
t.Fatalf("expected env API key note in pretty list output: %s", listPretty)
}
if strings.Contains(listPretty, "NOTE") {
t.Fatalf("did not expect auth note as a table column: %s", listPretty)
}
}

func TestProfileShowNotFound(t *testing.T) {
t.Setenv("LANGSMITH_CONFIG_FILE", filepath.Join(t.TempDir(), "config.json"))

Expand Down Expand Up @@ -498,6 +582,7 @@ func TestProfileSetWorkspace(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("LANGSMITH_CONFIG_FILE", configPath)
t.Setenv("LANGSMITH_PROFILE", "")
t.Setenv("LANGSMITH_API_KEY", "")
accessToken := "test-access-token"
if err := os.WriteFile(configPath, []byte(`{
"current_profile": "local",
Expand Down Expand Up @@ -568,6 +653,7 @@ func TestProfileListDoesNotExposeSecrets(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv("LANGSMITH_CONFIG_FILE", configPath)
t.Setenv("LANGSMITH_PROFILE", "")
t.Setenv("LANGSMITH_API_KEY", "")
accessToken := "test-access-token"
refreshToken := "test-refresh-token"
apiKey := "test-api-key"
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ func resolveClientOptions(refreshOAuth bool) (client.Options, error) {
switch {
case flagAPIKey != "":
opts.APIKey = flagAPIKey
case os.Getenv("LANGSMITH_API_KEY") != "":
opts.APIKey = os.Getenv("LANGSMITH_API_KEY")
case lsconfig.EnvAPIKey() != "":
opts.APIKey = lsconfig.EnvAPIKey()
case hasProfile && (profile.AccessToken() != "" || (refreshOAuth && profile.OAuth.RefreshToken != "")):
if refreshOAuth && profile.OAuth.RefreshToken != "" &&
(profile.AccessToken() == "" || profile.TokenExpiresSoon(time.Now(), time.Minute)) {
Expand Down
6 changes: 3 additions & 3 deletions internal/cmdutil/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func ResolveAPIKey(cmd *cobra.Command) string {
if v := getFlagString(cmd, "api-key"); v != "" {
return v
}
return os.Getenv("LANGSMITH_API_KEY")
return lsconfig.EnvAPIKey()
}

// ResolveAPIURL reads the API URL from cobra's flag tree → env → default.
Expand Down Expand Up @@ -146,8 +146,8 @@ func ResolveClientOptions(cmd *cobra.Command, refreshOAuth bool) (client.Options
switch {
case getFlagString(cmd, "api-key") != "":
opts.APIKey = getFlagString(cmd, "api-key")
case os.Getenv("LANGSMITH_API_KEY") != "":
opts.APIKey = os.Getenv("LANGSMITH_API_KEY")
case lsconfig.EnvAPIKey() != "":
opts.APIKey = lsconfig.EnvAPIKey()
case hasProfile && (profile.AccessToken() != "" || (refreshOAuth && profile.OAuth.RefreshToken != "")):
if refreshOAuth && profile.OAuth.RefreshToken != "" &&
(profile.AccessToken() == "" || profile.TokenExpiresSoon(time.Now(), time.Minute)) {
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const (
DefaultAPIURL = "https://api.smith.langchain.com"
)

func EnvAPIKey() string {
return os.Getenv("LANGSMITH_API_KEY")
}

// Profile represents one named LangSmith CLI profile.
type Profile struct {
APIKey string `json:"api_key,omitempty"`
Expand Down
Loading