diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 7117982..25b0520 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -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" diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index 2f87d3c..95c2605 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -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) }, @@ -186,7 +190,7 @@ func profileCreateAPIKey() string { if flagAPIKey != "" { return flagAPIKey } - return os.Getenv("LANGSMITH_API_KEY") + return lsconfig.EnvAPIKey() } func profileCreateAPIURL() string { @@ -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, @@ -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()) @@ -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{ @@ -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()) @@ -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")) } diff --git a/internal/cmd/profile_test.go b/internal/cmd/profile_test.go index 4547291..df44b91 100644 --- a/internal/cmd/profile_test.go +++ b/internal/cmd/profile_test.go @@ -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" @@ -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": { @@ -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")) @@ -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", @@ -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" diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f6a1993..24c9cdc 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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)) { diff --git a/internal/cmdutil/resolve.go b/internal/cmdutil/resolve.go index e4909ee..abc98b7 100644 --- a/internal/cmdutil/resolve.go +++ b/internal/cmdutil/resolve.go @@ -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. @@ -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)) { diff --git a/internal/config/config.go b/internal/config/config.go index e80ee86..9a8c908 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"`