diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f34b97bd..4ede0594 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,12 @@
 name: Ubuntu CI
 
-on: [push, pull_request]
+on:
+  pull_request:
+  push:
+    branches:
+      - 'ign-fuel-tools[0-9]'
+      - 'gz-fuel-tools[0-9]?'
+      - 'main'
 
 jobs:
   jammy-ci:
@@ -8,7 +14,7 @@ jobs:
     name: Ubuntu Jammy CI
     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
       - name: Compile and test
         id: ci
         uses: gazebo-tooling/action-gz-ci@jammy
diff --git a/.github/workflows/package_xml.yml b/.github/workflows/package_xml.yml
new file mode 100644
index 00000000..4bd4a9aa
--- /dev/null
+++ b/.github/workflows/package_xml.yml
@@ -0,0 +1,11 @@
+name: Validate package.xml
+
+on:
+  pull_request:
+
+jobs:
+  package-xml:
+    runs-on: ubuntu-latest
+    name: Validate package.xml
+    steps:
+      - uses: gazebo-tooling/action-gz-ci/validate_package_xml@jammy
diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml
index 2c94852d..2332244b 100644
--- a/.github/workflows/triage.yml
+++ b/.github/workflows/triage.yml
@@ -14,4 +14,3 @@ jobs:
         with:
           project-url: https://github.com/orgs/gazebosim/projects/7
           github-token: ${{ secrets.TRIAGE_TOKEN }}
-
diff --git a/Changelog.md b/Changelog.md
index ec6d7fe2..34ab4c54 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -4,6 +4,37 @@
 
 ## Gazebo Fuel Tools 9.x
 
+### Gazebo Fuel Tools 9.0.3 (2024-04-09)
+
+1. Use relative install path for gz tool data
+    * [Pull request #409](https://github.com/gazebosim/gz-fuel-tools/pull/409)
+
+### Gazebo Fuel Tools 9.0.2 (2024-03-18)
+
+1. Fix `LocalCache` so that models/worlds downloaded via fuel.ignitionrobotics.org can be found in the cache
+    * [Pull request #406](https://github.com/gazebosim/gz-fuel-tools/pull/406)
+
+### Gazebo Fuel Tools 9.0.1 (2024-03-14)
+
+1. Tidy nested namespaces
+    * [Pull request #396](https://github.com/gazebosim/gz-fuel-tools/pull/396)
+
+1. Update CI badges in README
+    * [Pull request #393](https://github.com/gazebosim/gz-fuel-tools/pull/393)
+
+1. Create directories and more output on fail
+    * [Pull request #392](https://github.com/gazebosim/gz-fuel-tools/pull/392)
+
+1. Disable tests that are known to fail on Windows
+    * [Pull request #387](https://github.com/gazebosim/gz-fuel-tools/pull/387)
+
+1. Update github action workflows
+    * [Pull request #388](https://github.com/gazebosim/gz-fuel-tools/pull/388)
+    * [Pull request #390](https://github.com/gazebosim/gz-fuel-tools/pull/390)
+
+1. Re-enabling Windows tests
+    * [Pull request #376](https://github.com/gazebosim/gz-fuel-tools/pull/376)
+
 ### Gazebo Fuel Tools 9.0.0 (2023-09-29)
 
 1. Added script to update assets to gz
@@ -374,6 +405,17 @@
 1. Support link referral download
     * [Pull request #333](https://github.com/gazebosim/gz-fuel-tools/pull/333)
 
+### Gazebo Fuel Tools 4.9.1 (2024-01-05)
+
+1. Create directories and more output on fail
+    * [Pull request #392](https://github.com/gazebosim/gz-fuel-tools/pull/392)
+
+1. Update github action workflows
+    * [Pull request #388](https://github.com/gazebosim/gz-fuel-tools/pull/388)
+
+1. Zip: use non-deprecated methods
+    * [Pull request #360](https://github.com/gazebosim/gz-fuel-tools/pull/360)
+
 ### Gazebo Fuel Tools 4.9.0 (2023-05-03)
 
 1. Add bash completion
diff --git a/README.md b/README.md
index 9cef36db..fe40bb12 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,25 @@ Downloading model:
 Download succeeded.
 ```
 
+**Private access tokens**
+Private models and worlds can be downloaded using access tokens.
+Access tokens are generated on `app.gazebosim.org`. After logging in,
+go to `Settings->Access Tokens`.
+
+An access token can be used with CLI commands via the `--header` option:
+
+```bash
+$ gz fuel download -u https://fuel.gazebosim.org/1.0/openrobotics/models/ambulance --header 'Private-Token: <access_token>'
+```
+
+Or, an access token can be stored in a `~/.gz/fuel/config.yaml` file. The token is then
+automatically used by the command line tool and API calls. Use the `configure` helper
+tool create your `~/.gz/fuel/config.yaml` file.
+
+```bash
+$ gz fuel configure
+```
+
 **C++ Get List models**
 ```cpp
   // Create a client (uses https://fuel.gazebosim.org by default)
diff --git a/conf/CMakeLists.txt b/conf/CMakeLists.txt
index a37a5804..2cffca0e 100644
--- a/conf/CMakeLists.txt
+++ b/conf/CMakeLists.txt
@@ -17,8 +17,4 @@ configure_file(
     "${CMAKE_CURRENT_BINARY_DIR}/fuel${PROJECT_VERSION_MAJOR}.yaml" @ONLY)
 
 # Install the yaml configuration files in an unversioned location.
-install(FILES ${CMAKE_CURRENT_BINARY_DIR}/fuel${PROJECT_VERSION_MAJOR}.yaml DESTINATION ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATAROOTDIR}/gz/)
-
-# Install config.yaml
-install (FILES config.yaml DESTINATION
-  ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATAROOTDIR}/gz/${GZ_DESIGNATION}${PROJECT_VERSION_MAJOR}/)
+install(FILES ${CMAKE_CURRENT_BINARY_DIR}/fuel${PROJECT_VERSION_MAJOR}.yaml DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/gz/)
diff --git a/conf/config.yaml b/conf/config.yaml
deleted file mode 100644
index bf70a5dd..00000000
--- a/conf/config.yaml
+++ /dev/null
@@ -1,14 +0,0 @@
----
-# The list of servers.
-servers:
-  -
-    name: osrf
-    url: https://fuel.gazebosim.org
-
-  # -
-    # name: another_server
-    # url: https://myserver
-
-# Where are the assets stored in disk.
-# cache:
-#   path: /tmp/gz/fuel
diff --git a/include/gz/fuel_tools/WorldIdentifier.hh b/include/gz/fuel_tools/WorldIdentifier.hh
index 9be30374..62c8d738 100644
--- a/include/gz/fuel_tools/WorldIdentifier.hh
+++ b/include/gz/fuel_tools/WorldIdentifier.hh
@@ -148,6 +148,16 @@ namespace gz::fuel_tools
     /// \return World information string
     public: std::string AsPrettyString(const std::string &_prefix = "") const;
 
+    /// \brief Returns the privacy setting of the world.
+    /// \return True if the world is private, false if the world is
+    /// public.
+    public: bool Private() const;
+
+    /// \brief Set the privacy setting of the world.
+    /// \param[in] _private True indicates the world is private,
+    /// false indicates the world is public.
+    public: void SetPrivate(bool _private);
+
     /// \brief PIMPL
     private: std::unique_ptr<WorldIdentifierPrivate> dataPtr;
   };
diff --git a/package.xml b/package.xml
new file mode 100644
index 00000000..1cff2719
--- /dev/null
+++ b/package.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format2.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="2">
+  <name>gz-fuel_tools10</name>
+  <version>10.0.0</version>
+  <description>Gazebo Fuel Tools: Classes and tools for interacting with Gazebo Fuel</description>
+  <maintainer email="natekoenig@gmail.com">Nate Koenig</maintainer>
+  <license>Apache License 2.0</license>
+  <url type="website">https://github.com/gazebosim/gz-fuel-tools</url>
+
+  <buildtool_depend>cmake</buildtool_depend>
+
+  <build_depend>gz-cmake4</build_depend>
+
+  <depend>gz-common6</depend>
+  <depend>gz-math8</depend>
+  <depend>gz-msgs11</depend>
+  <depend>gz-tools2</depend>
+  <depend>gz-utils3</depend>
+  <depend>libcurl-dev</depend>
+  <depend>libgflags-dev</depend>
+  <depend>libjsoncpp-dev</depend>
+  <depend>libyaml-dev</depend>
+  <depend>libzip-dev</depend>
+  <depend>tinyxml2</depend>
+
+  <export>
+    <build_type>cmake</build_type>
+  </export>
+</package>
diff --git a/src/ClientConfig.cc b/src/ClientConfig.cc
index fe60242e..ba17c0f2 100644
--- a/src/ClientConfig.cc
+++ b/src/ClientConfig.cc
@@ -78,12 +78,22 @@ ClientConfig::ClientConfig() : dataPtr(new ClientConfigPrivate)
     return;
   }
 
-  if (!gz::common::isDirectory(gzFuelPath))
+  if (!gzFuelPath.empty())
   {
-    gzerr << "[" << gzFuelPath << "] is not a directory" << std::endl;
-    return;
+    if (!gz::common::isDirectory(gzFuelPath))
+      gzerr << "[" << gzFuelPath << "] is not a directory" << std::endl;
+    else
+      this->SetCacheLocation(gzFuelPath);
   }
-  this->SetCacheLocation(gzFuelPath);
+
+  std::string configYamlFile = common::joinPaths(this->CacheLocation(),
+                                                 "config.yaml");
+  std::string configYmlFile = common::joinPaths(this->CacheLocation(),
+                                                "config.yml");
+  if (gz::common::exists(configYamlFile))
+    this->LoadConfig(configYamlFile);
+  else if (gz::common::exists(configYmlFile))
+    this->LoadConfig(configYmlFile);
 }
 
 //////////////////////////////////////////////////
diff --git a/src/FuelClient_TEST.cc b/src/FuelClient_TEST.cc
index c7569f75..8b223e1a 100644
--- a/src/FuelClient_TEST.cc
+++ b/src/FuelClient_TEST.cc
@@ -1159,7 +1159,8 @@ TEST_F(FuelClientTest, DownloadWorld)
 /////////////////////////////////////////////////
 // Windows doesn't support colons in filenames
 // https://github.com/gazebosim/gz-fuel-tools/issues/106
-TEST_F(FuelClientTest, CachedWorld)
+// This is fixed in gz-fuel-tools9+, but not here to preserve behavior
+TEST_F(FuelClientTest, GZ_UTILS_TEST_DISABLED_ON_WIN32(CachedWorld))
 {
   ClientConfig config;
   config.SetCacheLocation(common::joinPaths(common::cwd(), "test_cache"));
@@ -1490,8 +1491,11 @@ TEST_F(FuelClientTest, UploadModelFail)
   EXPECT_EQ(ResultType::UPLOAD_ERROR, result.Type());
 }
 
-//////////////////////////////////////////////////
-TEST_F(FuelClientTest, PatchModelFail)
+/////////////////////////////////////////////////
+// Windows doesn't support colons in filenames
+// https://github.com/gazebosim/gz-fuel-tools/issues/106
+// This is fixed in gz-fuel-tools9+, but not here to preserve behavior
+TEST_F(FuelClientTest, GZ_UTILS_TEST_DISABLED_ON_WIN32(PatchModelFail))
 {
   FuelClient client;
   ModelIdentifier modelId;
diff --git a/src/LocalCache.cc b/src/LocalCache.cc
index 8a758bdf..32e15efa 100644
--- a/src/LocalCache.cc
+++ b/src/LocalCache.cc
@@ -279,16 +279,23 @@ Model LocalCache::MatchingModel(const ModelIdentifier &_id)
   // For the tip, we have to find the highest version
   bool tip = (_id.Version() == 0);
   Model tipModel;
+  if (!this->dataPtr->config)
+    return tipModel;
 
-  for (ModelIter iter = this->AllModels(); iter; ++iter)
+  std::string path = common::joinPaths(
+      this->dataPtr->config->CacheLocation(), uriToPath(_id.Server().Url()));
+
+  auto srvModels = this->dataPtr->ModelsInServer(path);
+  for (auto &model: srvModels)
   {
-    ModelIdentifier id = iter->Identification();
+    model.dataPtr->id.SetServer(_id.Server());
+    auto id = model.Identification();
     if (_id == id)
     {
       if (_id.Version() == id.Version())
-        return *iter;
+        return model;
       else if (tip && id.Version() > tipModel.Identification().Version())
-        tipModel = *iter;
+        tipModel = model;
     }
   }
 
@@ -302,16 +309,24 @@ bool LocalCache::MatchingWorld(WorldIdentifier &_id) const
   bool tip = (_id.Version() == 0);
   WorldIdentifier tipWorld;
 
-  for (auto id = this->AllWorlds(); id; ++id)
+  if (!this->dataPtr->config)
+    return false;
+
+  std::string path = common::joinPaths(
+      this->dataPtr->config->CacheLocation(), uriToPath(_id.Server().Url()));
+
+  auto srvWorlds = this->dataPtr->WorldsInServer(path);
+  for (auto id: srvWorlds)
   {
+    id.SetServer(_id.Server());
     if (_id == id)
     {
-      if (_id.Version() == id->Version())
+      if (_id.Version() == id.Version())
       {
         _id = id;
         return true;
       }
-      else if (tip && id->Version() > tipWorld.Version())
+      else if (tip && id.Version() > tipWorld.Version())
       {
         tipWorld = id;
       }
diff --git a/src/RestClient.cc b/src/RestClient.cc
index 222f0c8f..67f98c5b 100644
--- a/src/RestClient.cc
+++ b/src/RestClient.cc
@@ -124,13 +124,13 @@ size_t RestWriteMemoryCallback(void *_buffer, size_t _size, size_t _nmemb,
 }
 
 /////////////////////////////////////////////////
-struct curl_httppost *BuildFormPost(
+void AddFormPost(
+    curl_mime * const multipart,
     const std::multimap<std::string, std::string> &_form)
 {
-  struct curl_httppost *formpost = nullptr;
-  struct curl_httppost *lastptr = nullptr;
   for (const auto &[key, value] : _form)
   {
+    curl_mimepart *part = curl_mime_addpart(multipart);
     // follow same convention as curl cmdline tool
     // field starting with @ indicates path to file to upload
     // others are standard fields to describe the file
@@ -171,26 +171,17 @@ struct curl_httppost *BuildFormPost(
         }
       }
 
-      curl_formadd(&formpost,
-          &lastptr,
-          CURLFORM_COPYNAME, key.c_str(),
-          CURLFORM_FILENAME, uploadFilename.c_str(),
-          CURLFORM_FILE, path.c_str(),
-          CURLFORM_CONTENTTYPE, contentType.c_str(),
-          CURLFORM_END);
+      curl_mime_name(part, key.c_str());
+      curl_mime_filename(part, uploadFilename.c_str());
+      curl_mime_filedata(part, path.c_str());
+      curl_mime_type(part, contentType.c_str());
     }
     else
     {
-      // standard key:value fields
-      curl_formadd(&formpost,
-          &lastptr,
-          CURLFORM_COPYNAME, key.c_str(),
-          CURLFORM_COPYCONTENTS, value.c_str(),
-          CURLFORM_END);
+      curl_mime_name(part, key.c_str());
+      curl_mime_data(part, value.c_str(), CURL_ZERO_TERMINATED);
     }
   }
-
-  return formpost;
 }
 
 /////////////////////////////////////////////////
@@ -223,6 +214,7 @@ RestResponse Rest::Request(HttpMethod _method,
 
     encodedPath = curl_easy_escape(curl, decodedPath, decodedSize);
     url = RestJoinUrl(url, encodedPath);
+    curl_free(decodedPath);
   }
 
   // Process query strings.
@@ -293,7 +285,7 @@ RestResponse Rest::Request(HttpMethod _method,
   curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
 
   std::ifstream ifs;
-  struct curl_httppost *formpost = nullptr;
+  curl_mime *multipart = curl_mime_init(curl);
 
   // Send the request.
   if (_method == HttpMethod::GET)
@@ -302,9 +294,11 @@ RestResponse Rest::Request(HttpMethod _method,
   }
   else if (_method == HttpMethod::PATCH_FORM)
   {
-    formpost = BuildFormPost(_form);
+    AddFormPost(multipart, _form);
+
     curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH");
-    curl_easy_setopt(curl, CURLOPT_HTTPPOST, formpost);
+
+    curl_easy_setopt(curl, CURLOPT_MIMEPOST, multipart);
   }
   else if (_method == HttpMethod::POST)
   {
@@ -313,8 +307,8 @@ RestResponse Rest::Request(HttpMethod _method,
   }
   else if (_method == HttpMethod::POST_FORM)
   {
-    formpost = BuildFormPost(_form);
-    curl_easy_setopt(curl, CURLOPT_HTTPPOST, formpost);
+    AddFormPost(multipart, _form);
+    curl_easy_setopt(curl, CURLOPT_MIMEPOST, multipart);
   }
   else if (_method == HttpMethod::DELETE)
   {
@@ -354,9 +348,6 @@ RestResponse Rest::Request(HttpMethod _method,
   // Update the header data.
   res.headers = headerData;
 
-  if (formpost)
-    curl_formfree(formpost);
-
   // free encoded path char*
   if (encodedPath)
     curl_free(encodedPath);
@@ -365,6 +356,7 @@ RestResponse Rest::Request(HttpMethod _method,
   curl_slist_free_all(headers);
 
   // Cleaning.
+  curl_mime_free(multipart);
   curl_easy_cleanup(curl);
 
   if (ifs.is_open())
diff --git a/src/WorldIdentifier.cc b/src/WorldIdentifier.cc
index fd07e8fd..02d36313 100644
--- a/src/WorldIdentifier.cc
+++ b/src/WorldIdentifier.cc
@@ -44,8 +44,12 @@ class WorldIdentifierPrivate
   /// \brief World version. Valid versions start from 1, 0 means the tip.
   public: unsigned int version{0};
 
-  /// \brief Path of this model in the local cache
+  /// \brief Path of this world in the local cache
   public: std::string localPath;
+
+  /// \brief True indicates the world is private, false indicates the
+  /// world is public.
+  public: bool privacy{false};
 };
 
 //////////////////////////////////////////////////
@@ -229,4 +233,15 @@ std::string WorldIdentifier::AsPrettyString(const std::string &_prefix) const
       << this->Server().AsPrettyString(_prefix + "  ");
   return out.str();
 }
+//////////////////////////////////////////////////
+bool WorldIdentifier::Private() const
+{
+  return this->dataPtr->privacy;
+}
+
+//////////////////////////////////////////////////
+void WorldIdentifier::SetPrivate(bool _private)
+{
+  this->dataPtr->privacy = _private;
+}
 }  // namespace gz::fuel_tools
diff --git a/src/WorldIdentifier_TEST.cc b/src/WorldIdentifier_TEST.cc
index 91c2d963..4e293f41 100644
--- a/src/WorldIdentifier_TEST.cc
+++ b/src/WorldIdentifier_TEST.cc
@@ -37,6 +37,12 @@ TEST(WorldIdentifier, SetFields)
   EXPECT_EQ(std::string("hello"), id.Name());
   EXPECT_EQ(std::string("acai"), id.Owner());
   EXPECT_EQ(6u, id.Version());
+
+  EXPECT_FALSE(id.Private());
+  id.SetPrivate(true);
+  EXPECT_TRUE(id.Private());
+  id.SetPrivate(false);
+  EXPECT_FALSE(id.Private());
 }
 
 /////////////////////////////////////////////////
diff --git a/src/cmd/CMakeLists.txt b/src/cmd/CMakeLists.txt
index 6c027466..833f2856 100644
--- a/src/cmd/CMakeLists.txt
+++ b/src/cmd/CMakeLists.txt
@@ -57,4 +57,4 @@ install(
   FILES
     ${CMAKE_CURRENT_BINARY_DIR}/fuel${PROJECT_VERSION_MAJOR}.bash_completion.sh
   DESTINATION
-    ${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATAROOTDIR}/gz/gz${GZ_TOOLS_VER}.completion.d)
+    ${CMAKE_INSTALL_DATAROOTDIR}/gz/gz${GZ_TOOLS_VER}.completion.d)
diff --git a/src/cmd/cmdfuel.rb.in b/src/cmd/cmdfuel.rb.in
index 632d9443..23e89a67 100755
--- a/src/cmd/cmdfuel.rb.in
+++ b/src/cmd/cmdfuel.rb.in
@@ -25,7 +25,10 @@ else
   include Fiddle
 end
 
+require 'fileutils'
 require 'optparse'
+require 'uri'
+require 'yaml'
 
 # Constants.
 LIBRARY_NAME = '@library_location@'
@@ -61,6 +64,7 @@ COMMANDS = { 'fuel' =>
   "  gz fuel [action] [options]                                            \n"\
   "                                                                        \n"\
   "Available Actions:                                                      \n"\
+  "  configure                Create config.yaml configuration file        \n"\
   "  delete                   Delete resources                             \n"\
   "  download                 Download resources                           \n"\
   "  edit                     Edit a resource                              \n"\
@@ -80,6 +84,20 @@ COMMANDS = { 'fuel' =>
 }
 
 SUBCOMMANDS = {
+ 'configure' =>
+  "Create `~/.gz/fuel/config.yaml` to hold Fuel server configurations.     \n"\
+  "                                                                        \n"\
+  "  gz fuel configure [options]                                           \n"\
+  "                                                                        \n"\
+  "  --defaults               Use all the defaults and save.               \n"\
+  "                           This will overwrite ~/.gz/fuel/config.yaml.  \n"\
+  "  --console                Output to the console instead of to a file.  \n"\
+  "  -h [--help]              Print this help message.                     \n"\
+  "                                                                        \n"\
+  "  --force-version <VERSION>  Use a specific library version.            \n"\
+  "                                                                        \n"\
+  "  --versions               Show the available versions.                 \n",
+
  'delete' =>
   "Delete simulation resources                                             \n"\
   "                                                                        \n"\
@@ -208,7 +226,9 @@ class Cmd
       'pbtxt2config' => '',
       'private' => '',
       'onlymodels' => '0',
-      'onlyworlds' => '0'
+      'onlyworlds' => '0',
+      'defaults' => false,
+      'console' => false
     }
 
     usage = COMMANDS[args[0]]
@@ -276,6 +296,12 @@ class Cmd
       opts.on('--onlyworlds', 'Only update worlds') do
         options['onlyworlds'] = '1'
       end
+      opts.on('--defaults', 'Use default values') do
+        options['defaults'] = true
+      end
+      opts.on('--console', 'Output to console') do
+        options['console'] = true
+      end
     end # opt_parser do
 
     opt_parser.parse!(args)
@@ -347,6 +373,12 @@ class Cmd
   end # parse()
 
   def execute(args)
+    # Graceful exit on ctrl-c
+    Signal.trap("SIGINT") do
+      puts "\nExiting"
+      exit
+    end
+
     options = parse(args)
 
     # Read the plugin that handles the command.
@@ -394,6 +426,8 @@ class Cmd
       end
 
       case options['subcommand']
+      when 'configure'
+        configure(options['defaults'], options['console'])
       when 'delete'
         Importer.extern 'int deleteUrl(const char *, const char *)'
         if not Importer.deleteUrl(options['url'], options['header'])
@@ -463,4 +497,118 @@ class Cmd
            "from #{plugin}."
     end # begin
   end # execute
+
+  # Runs `gz fuel configure`
+  def configure(defaults, console)
+    # Default fuel directory and configuration path
+    local_fuel_dir = File.join(Dir.home, '.gz', 'fuel')
+    config_path = File.join(local_fuel_dir, 'config.yaml')
+
+    # The default Fuel server URL
+    default_url = 'https://fuel.gazebosim.org'
+
+    # A lambda function that prompts the user to enter Fuel server information
+    get_server_info = lambda {
+      server_url = default_url
+      default_name = 'Fuel'
+
+      # Prompt the user for a server name with a default value
+      # Repeat until the URL is valid, or the user hits ctrl-c
+      until defaults
+        print "Fuel server URL [#{default_url}]: "
+        server_url = STDIN.gets.chomp
+        server_url = default_url if server_url.empty?
+        begin
+          uri = URI.parse(server_url)
+          default_name = !uri.host || uri.host.empty? ? uri.to_s : uri.host
+          break
+        rescue URI::InvalidURIError
+          puts 'Invalid URL.\n'
+        end
+      end
+
+      # Prompt the user for an access token with a default value of an
+      # empty string
+      access_token = ''
+      unless defaults
+        print 'Optional access token [None]: '
+        access_token = STDIN.gets.chomp
+      end
+
+      # Get the cache location
+      cache = local_fuel_dir
+      unless defaults
+        print "Local cache path [#{local_fuel_dir}]: "
+        cache = STDIN.gets.chomp
+        cache = local_fuel_dir if cache.empty?
+      end
+
+      # Get a name to associate with this server
+      server_name = default_name
+      unless defaults
+        print "Name this server [#{default_name}]: "
+        server_name = STDIN.gets.chomp
+        server_name = default_name if server_name.empty?
+      end
+
+      [server_name, server_url, access_token, cache]
+    }
+
+    unless console
+      puts '# Set Fuel server configurations.'
+      puts "# This will create or replace `#{config_path}`.\n\n"
+    end
+
+    servers = []
+    confirmation = 'n'
+    # Allow the user to enter multiple Fuel servers
+    begin
+      server_name, server_url, access_token, cache = get_server_info.call
+      servers << { 'name' => server_name,
+                   'url' => server_url,
+                   'private-token' => access_token,
+                   'cache' => { 'path' => cache } }
+      unless defaults
+        print "\nAdd another Fuel server? [y/N]:"
+        confirmation = STDIN.gets.chomp.downcase
+        confirmation = 'n' if confirmation.empty?
+      end
+    end while confirmation == 'y'
+
+    unless defaults || console
+      puts "\nReview:\n"
+      servers.each do |server|
+        puts "    Name: #{server['name']}"
+        puts "    URL: #{server['url']}"
+        puts "    Cache: #{server['cache']['path']}"
+        puts "    Access token: #{server['private-token']}"
+        puts "\n" if servers.size > 1
+      end
+    end
+
+    confirmation = 'y'
+    unless defaults || console
+      print 'Save? [Y/n]:'
+      confirmation = STDIN.gets.chomp.downcase
+      confirmation = 'y' if confirmation.empty?
+    end
+
+    # Save settings
+    if confirmation == 'y'
+      config = {'servers' => servers}
+
+      if console
+        puts config.to_yaml
+      else
+        # Make sure the ~/.gz/fuel directory exists.
+        FileUtils.mkdir_p(local_fuel_dir)
+
+        # Write to the config.yaml file
+        File.open(config_path, 'w') { |file| file.write(config.to_yaml) }
+        puts 'Settings saved to ~/.gz/fuel/config.yaml.'
+      end
+    else
+      puts 'Settings not saved.'
+    end
+  end
 end # class
diff --git a/src/gz.cc b/src/gz.cc
index cb2e2b19..567de676 100644
--- a/src/gz.cc
+++ b/src/gz.cc
@@ -16,6 +16,7 @@
 */
 
 #include <curl/curl.h>
+#include <curl/easy.h>
 #include <string.h>
 #include <tinyxml2.h>
 
@@ -136,8 +137,10 @@ extern "C" void uglyPrint(
       std::cout << _serverConfig.Url().Str() << "/" << _serverConfig.Version()
                 << "/" << owner->first << "/" << _resourceType << "/"
                 << std::string(encodedRes) << std::endl;
+      curl_free(encodedRes);
     }
   }
+  curl_easy_cleanup(curl);
 }
 
 //////////////////////////////////////////////////
diff --git a/src/gz_TEST.cc b/src/gz_TEST.cc
index 815fa7f6..eb6ab82b 100644
--- a/src/gz_TEST.cc
+++ b/src/gz_TEST.cc
@@ -47,7 +47,7 @@ std::string custom_exec_str(std::string _cmd)
   return result;
 }
 
-auto g_version = std::string(strdup(GZ_FUEL_TOOLS_VERSION_FULL));
+auto g_version = std::string(GZ_FUEL_TOOLS_VERSION_FULL);
 auto g_exec = std::string(GZ_PATH);
 auto g_listCmd = g_exec + " fuel list -v 4 --force-version " + g_version;
 
@@ -100,8 +100,9 @@ TEST(CmdLine, GZ_UTILS_TEST_ENABLED_ONLY_ON_LINUX(ListFail))
 TEST(CmdLine,
     GZ_UTILS_TEST_ENABLED_ONLY_ON_LINUX(ModelListConfigServerUgly))
 {
-  auto output = custom_exec_str(g_listCmd + " -t model --raw");
-  EXPECT_NE(output.find("https://fuel.gazebosim.org/1.0/"),
+  auto output = custom_exec_str(g_listCmd +
+    " -t model --raw -u 'https://fuel.gazebosim.org' -o openrobotics");
+  EXPECT_NE(output.find("https://fuel.gazebosim.org"),
             std::string::npos) << output;
   EXPECT_EQ(output.find("owners"), std::string::npos) << output;
 }
@@ -151,3 +152,19 @@ TEST(CmdLine,
   EXPECT_NE(output.find("owners"), std::string::npos) << output;
   EXPECT_NE(output.find("worlds"), std::string::npos) << output;
 }
+
+TEST(CmdLine,
+    GZ_UTILS_TEST_ENABLED_ONLY_ON_LINUX(ConfigureDefaultsConsole))
+{
+  std::string output = custom_exec_str(
+    g_exec + " fuel configure --console --defaults");
+
+  std::string expected =
+     "---\n"
+     "servers:\n"
+     "- name: Fuel\n"
+     "  url: https://fuel.gazebosim.org\n"
+     "  private-token: ''\n";
+
+  EXPECT_EQ(output.find(expected), 0);
+}
diff --git a/src/gz_src_TEST.cc b/src/gz_src_TEST.cc
index 082fae62..e3ab3a7e 100644
--- a/src/gz_src_TEST.cc
+++ b/src/gz_src_TEST.cc
@@ -51,6 +51,8 @@ class CmdLine : public ::testing::Test
     // instead of on teardown leaves the folder intact for debugging if needed
     common::removeAll(testCachePath);
     ASSERT_TRUE(common::createDirectories(testCachePath));
+    ASSERT_TRUE(common::createDirectories(
+        common::joinPaths(testCachePath, "fuel.gazebosim.org")));
     setenv("GZ_FUEL_CACHE_PATH", this->testCachePath.c_str(), true);
   }
 
@@ -85,7 +87,7 @@ TEST_F(CmdLine, ModelListFail)
 
   EXPECT_NE(this->stdOutBuffer.str().find("Invalid URL"),
       std::string::npos) << this->stdOutBuffer.str();
-  EXPECT_TRUE(this->stdErrBuffer.str().empty());
+  EXPECT_TRUE(this->stdErrBuffer.str().empty()) << this->stdErrBuffer.str();
 }
 
 /////////////////////////////////////////////////
@@ -93,7 +95,8 @@ TEST_F(CmdLine, ModelListFail)
 // https://github.com/gazebosim/gz-fuel-tools/issues/105
 TEST_F(CmdLine, ModelListConfigServerUgly)
 {
-  EXPECT_TRUE(listModels("", "openroboticstest", "true"));
+  EXPECT_TRUE(listModels("https://fuel.gazebosim.org",
+                         "openroboticstest", "true"));
 
   EXPECT_NE(this->stdOutBuffer.str().find("https://fuel.gazebosim.org"),
       std::string::npos) << this->stdOutBuffer.str();
diff --git a/tutorials/02_configuration.md b/tutorials/02_configuration.md
index 620a071a..f1523fee 100644
--- a/tutorials/02_configuration.md
+++ b/tutorials/02_configuration.md
@@ -35,6 +35,17 @@ The `cache` section captures options related with the local storage of the
 assets. `path` specifies the local directory where all assets will be
 downloaded. If not used, all assets are stored under `$HOME/.gz/fuel`.
 
+## Guided Configuration
+
+The `gz fuel configure` CLI will walk you through the process of creating a 
+`~/.gz/fuel/config.yaml` file. Just run the following command, and answer
+the prompts. Note that this command will replace your existing `~/.gz/fuel/config.yaml`
+if you choose to save on the last step.
+
+```bash
+gz fuel configure
+```
+
 ## Custom configuration file path
 
 Gazebo Fuel's default configuration file is stored under