Repository: NREL/OpenStudio (language: python)

Issue:
Rewrite MeasureManager in C++
Enhancement Request
--------------

Even in the `labs` subcommand, the MeasureManager is purely Ruby, so you can't use it to compute arguments for a Python Measure. 

## Detailed Description

https://github.com/NREL/OpenStudio/blob/f4f51c3e1888721212fb6c2002f75023abfc0011/src/cli/MeasureUpdateCommand.cpp#L369-L389

## Possible Implementation

We have the buildings blocks in place to compute arguments already in the MeasureUpdateCommand::getAndUpdateMeasure. But we'd need to write an HTTP server in C++. We have cpprestsdk that we already use for RemoteBCL. boost asio is another option, so is Poco.
Add a swig out typemap for Json::Value + openstudio::path typemap in/out for Python + add toJSON() for Workfow files and BCLMeasure
Pull request overview
---------------------

- Converts the Json::Value
    - Ruby: to a hash, with symbols as keys (not sure that's what we really want or not)
    - Python: a dict.
    - CSharp: ignored (for now at least) 

Context: As part of the rewrite of Measure Manager to C++, I'm going to be using Json::Value outputs a lot more than currently. I'm just trying to isolate changes for easier review.

Test code:

ruby:

```shell
Products/openstudio labs -e "m = OpenStudio::Model::Model.new; ft = OpenStudio::EnergyPlus::ForwardTranslator.new; ft.setExcludeLCCObjects(true); w = ft.translateModel(m); d = OpenStudio::EPJSON::toJSON(w); puts d.class; require 'json'; puts JSON.pretty_generate(d)"
```

python:

```shell
Products/openstudio labs -c "m = openstudio.model.Model(); z = openstudio.model.ThermalZone(m); ft = openstudio.energyplus.ForwardTranslator(); ft.setExcludeLCCObjects(True); w = ft.translateModel(m); d = openstudio.epjson.toJSON(w); print(type(d)); import json; print(json.dumps(d, indent=2))"
```


### Pull Request Author

<!--- Add to this list or remove from it as applicable.  This is a simple templated set of guidelines. -->

 - [ ] Model API Changes / Additions
 - [ ] Any new or modified fields have been implemented in the EnergyPlus ForwardTranslator (and ReverseTranslator as appropriate)
 - [ ] Model API methods are tested (in `src/model/test`)
 - [ ] EnergyPlus ForwardTranslator Tests (in `src/energyplus/Test`)
 - [ ] If a new object or method, added a test in NREL/OpenStudio-resources: Add Link
 - [ ] If needed, added VersionTranslation rules for the objects (`src/osversion/VersionTranslator.cpp`)
 - [ ] Verified that C# bindings built fine on Windows, partial classes used as needed, etc.
 - [ ] All new and existing tests passes
 - [ ] If methods have been deprecated, update rest of code to use the new methods

**Labels:**

 - [ ] If change to an IDD file, add the label `IDDChange`
 - [ ] If breaking existing API, add the label `APIChange`
 - [ ] If deemed ready, add label `Pull Request - Ready for CI` so that CI builds your PR

### Review Checklist

This will not be exhaustively relevant to every PR.
 - [ ] Perform a Code Review on GitHub
 - [ ] Code Style, strip trailing whitespace, etc.
 - [ ] All related changes have been implemented: model changes, model tests, FT changes, FT tests, VersionTranslation, OS App
 - [ ] Labeling is ok
 - [ ] If defect, verify by running develop branch and reproducing defect, then running PR and reproducing fix
 - [ ] If feature, test running new feature, try creative ways to break it
 - [ ] CI status: all green or justified

Rewrite MeasureManager in C++
Enhancement Request
--------------

Even in the `labs` subcommand, the MeasureManager is purely Ruby, so you can't use it to compute arguments for a Python Measure. 

## Detailed Description

https://github.com/NREL/OpenStudio/blob/f4f51c3e1888721212fb6c2002f75023abfc0011/src/cli/MeasureUpdateCommand.cpp#L369-L389

## Possible Implementation

We have the buildings blocks in place to compute arguments already in the MeasureUpdateCommand::getAndUpdateMeasure. But we'd need to write an HTTP server in C++. We have cpprestsdk that we already use for RemoteBCL. boost asio is another option, so is Poco.
Ruby/Python Workflow crash
The attached workflow is crashing on Mac OS.

[Crash.zip](https://github.com/NREL/OpenStudio/files/12441782/Crash.zip)



Discussion / hints:
I scoped it out, implemented a basic http server with cpprestsdk, looks like it wouldn’t be a daunting task
I agree that a small http server using what we already have, cpprestsdk, is a good path.

I scoped it out, implemented a basic http server with cpprestsdk, looks like it wouldn’t be a daunting task
I agree that a small http server using what we already have, cpprestsdk, is a good path.


Fix patch:
diff --git a/.gitignore b/.gitignore
index 8379e657ae7..8808334fecb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,13 @@ clang_format.patch
 
 .DS_Store
 __pycache__/
-*.pyc
\ No newline at end of file
+*.pyc
+
+# Unit test / coverage reports
+htmlcov/
+.coverage
+.coverage.*
+.cache
+coverage.xml
+.pytest_cache/
+junit.xml
diff --git a/python/engine/PythonEngine.cpp b/python/engine/PythonEngine.cpp
index 86a6559da41..95161e7f3bf 100644
--- a/python/engine/PythonEngine.cpp
+++ b/python/engine/PythonEngine.cpp
@@ -7,9 +7,10 @@
 #include <utilities/core/ApplicationPathHelpers.hpp>
 #include "../../src/utilities/core/Filesystem.hpp"
 #include <fmt/format.h>
+
 #include <stdexcept>
 #include <string>
-#include <iostream>
+#include <type_traits>
 
 #ifdef __GNUC__
 #  pragma GCC diagnostic push
@@ -27,15 +28,31 @@
 namespace openstudio {
 
 void addToPythonPath(const openstudio::path& includePath) {
-  if (!includePath.empty()) {
-    PyObject* sys = PyImport_ImportModule("sys");
-    PyObject* sysPath = PyObject_GetAttrString(sys, "path");
-    Py_DECREF(sys);  // PyImport_ImportModule returns a new reference, decrement it
 
-    // fmt::print("Prepending '{}' to sys.path\n", includePath);
-    PyObject* unicodeIncludePath = PyUnicode_FromString(includePath.string().c_str());
-    PyList_Insert(sysPath, 0, unicodeIncludePath);
-    Py_DECREF(sysPath);  // PyObject_GetAttrString returns a new reference, decrement it
+  if (includePath.empty()) {
+    return;
+  }
+
+  // fmt::print("Prepending '{}' to sys.path\n", includePath);
+
+  PyObject* unicodeIncludePath = nullptr;
+  if constexpr (std::is_same_v<typename openstudio::path::value_type, wchar_t>) {
+    const std::wstring ws = includePath.generic_wstring();
+    unicodeIncludePath = PyUnicode_FromWideChar(ws.c_str(), static_cast<Py_ssize_t>(ws.size()));  // New reference
+  } else {
+    const std::string s = includePath.generic_string();
+    unicodeIncludePath = PyUnicode_FromString(s.c_str());  // New reference
+  }
+
+  if (unicodeIncludePath == nullptr) {
+    throw std::runtime_error(fmt::format("Unable to convert path '{}' for addition to sys.path in Python", includePath.generic_string()));
+  }
+
+  PyObject* sysPath = PySys_GetObject("path");  // Borrowed reference
+  int ret = PyList_Insert(sysPath, 0, unicodeIncludePath);
+  Py_DECREF(unicodeIncludePath);
+  if (ret != 0) {
+    throw std::runtime_error(fmt::format("Unable to add path '{}' to the sys.path in Python", includePath.generic_string()));
   }
 }
 
@@ -113,6 +130,18 @@ PythonEngine::~PythonEngine() {
 }
 
 void PythonEngine::importOpenStudio() {
+#if defined(__APPLE__)
+  // RTLD_LOCAL is import an Apple so that that Python and Ruby do not conflict
+  const std::string set_dlflags_cmd = R"(
+import sys
+import os
+pre_os_dl_open_flags = sys.getdlopenflags()
+sys.setdlopenflags(os.RTLD_LOCAL)
+  )";
+
+  exec(set_dlflags_cmd.c_str());
+#endif
+
   // generic_string() converts to a POSIX path, with forward slashes, so that pyimport doesn't choke on backslashes understood as escape sequence
   if (moduleIsRunningFromBuildDirectory()) {
     const auto bindingsDir = getOpenStudioModuleDirectory();
@@ -123,6 +152,15 @@ void PythonEngine::importOpenStudio() {
   }
   // Somehow that doesn't suffice to register it...
   exec("import openstudio");
+
+#if defined(__APPLE__)
+  // Reset the dlopen flags to the value prior to importOpenStudio
+  const std::string reset_dlflags_cmd = R"(
+sys.setdlopenflags(pre_os_dl_open_flags)
+  )";
+
+  exec(reset_dlflags_cmd.c_str());
+#endif
 }
 
 struct PythonObject
@@ -242,6 +280,95 @@ void* PythonEngine::getAs_impl(ScriptObject& obj, const std::type_info& ti) {
 
   return return_value;
 }
+
+std::string PythonEngine::inferMeasureClassName(const openstudio::path& measureScriptPath) {
+
+  auto inferClassNameCmd = fmt::format(R"python(
+import importlib.util
+import inspect
+spec = importlib.util.spec_from_file_location(f"throwaway", "{}")
+
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+class_members = inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, openstudio.measure.OSMeasure))
+assert len(class_members) == 1
+measure_name, measure_typeinfo = class_members[0]
+)python",
+                                       measureScriptPath.generic_string());
+
+  std::string className;
+  try {
+    exec(inferClassNameCmd);
+    ScriptObject measureClassNameObject = eval("measure_name");
+    className = *getAs<std::string*>(measureClassNameObject);
+  } catch (const std::runtime_error& e) {
+    auto msg = fmt::format("Failed to infer measure name from {}: {}", measureScriptPath.generic_string(), e.what());
+    fmt::print(stderr, "{}\n", msg);
+  }
+
+  return className;
+}
+
+// Ideally this would return a openstudio::measure::OSMeasure* or a shared_ptr<openstudio::measure::OSMeasure> but this poses memory management
+// issue for the underlying ScriptObject (and VALUE or PyObject), so just return the ScriptObject
+ScriptObject PythonEngine::loadMeasure(const openstudio::path& measureScriptPath, std::string_view className) {
+
+  ScriptObject result;
+
+  auto importCmd = fmt::format(R"python(
+import importlib.util
+spec = importlib.util.spec_from_file_location('{}', r'{}')
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+)python",
+                               className, measureScriptPath.generic_string());
+
+  // fmt::print("\nimportCmd:\n{}\n", importCmd);
+  try {
+    exec(importCmd);
+  } catch (const std::runtime_error& e) {
+    auto msg = fmt::format("Failed to load measure '{}' from '{}': {}", className, measureScriptPath.generic_string(), e.what());
+    fmt::print(stderr, "{}\n", msg);
+  }
+
+  try {
+    result = eval(fmt::format("module.{}()", className));
+  } catch (const std::runtime_error& e) {
+    auto msg = fmt::format("Failed to instantiate measure '{}' from '{}': {}", className, measureScriptPath.generic_string(), e.what());
+    fmt::print(stderr, "{}\n", msg);
+  }
+
+  return result;
+}
+
+int PythonEngine::numberOfArguments(ScriptObject& classInstanceObject, std::string_view methodName) {
+
+  int numberOfArguments = -1;
+
+  auto val = std::any_cast<PythonObject>(classInstanceObject.object);
+  if (PyObject_HasAttrString(val.obj_, methodName.data()) == 0) {
+    // FAILED
+    return numberOfArguments;
+  }
+
+  PyObject* method = PyObject_GetAttrString(val.obj_, methodName.data());  // New reference
+  if (PyMethod_Check(method)) {
+    PyObject* func = PyMethod_Function(method);   // Borrowed
+    if (auto* code = PyFunction_GetCode(func)) {  // Borrowed
+      auto* co = (PyCodeObject*)code;
+      numberOfArguments = co->co_argcount - 1;  // This includes `self`
+    }
+  } else if (PyFunction_Check(method)) {
+    // Shouldn't enter this block here
+    if (auto code = PyFunction_GetCode(method)) {
+      auto* co = (PyCodeObject*)code;
+      numberOfArguments = co->co_argcount;
+    }
+  }
+  Py_DECREF(method);
+  return numberOfArguments;
+}
+
 }  // namespace openstudio
 
 extern "C"
diff --git a/python/engine/PythonEngine.hpp b/python/engine/PythonEngine.hpp
index c2a03bad1cb..0252e59586b 100644
--- a/python/engine/PythonEngine.hpp
+++ b/python/engine/PythonEngine.hpp
@@ -32,6 +32,12 @@ class PythonEngine final : public ScriptEngine
 
   virtual void setupPythonPath(const std::vector<openstudio::path>& includeDirs) override;
 
+  virtual std::string inferMeasureClassName(const openstudio::path& measureScriptPath) override;
+
+  virtual ScriptObject loadMeasure(const openstudio::path& measureScriptPath, std::string_view className) override;
+
+  virtual int numberOfArguments(ScriptObject& methodObject, std::string_view methodName) override;
+
  protected:
   void* getAs_impl(ScriptObject& obj, const std::type_info&) override;
   void importOpenStudio();
diff --git a/resources/Examples/compact_osw/compact_python_then_ruby.osw b/resources/Examples/compact_osw/compact_python_then_ruby.osw
new file mode 100644
index 00000000000..7569a98671d
--- /dev/null
+++ b/resources/Examples/compact_osw/compact_python_then_ruby.osw
@@ -0,0 +1,22 @@
+{
+	"weather_file": "srrl_2013_amy.epw",
+	"seed_file": "seb.osm",
+	"steps": [
+		{
+			"measure_dir_name": "IncreaseRoofRValuePython",
+			"arguments": {
+				"r_value": 45
+			}
+		},
+		{
+			"measure_dir_name": "SetEplusInfiltration",
+			"arguments": {
+				"flowPerZoneFloorArea": 10.76
+			}
+		},
+		{
+			"measure_dir_name": "Standard Reports",
+			"arguments": {}
+		}
+	]
+}
diff --git a/resources/Examples/compact_osw/compact_ruby_and_python.osw b/resources/Examples/compact_osw/compact_ruby_then_python.osw
similarity index 100%
rename from resources/Examples/compact_osw/compact_ruby_and_python.osw
rename to resources/Examples/compact_osw/compact_ruby_then_python.osw
diff --git a/ruby/bindings/InitRubyBindings.cpp b/ruby/bindings/InitRubyBindings.cpp
index 40eb7bc2252..26bd87f88ec 100644
--- a/ruby/bindings/InitRubyBindings.cpp
+++ b/ruby/bindings/InitRubyBindings.cpp
@@ -356,6 +356,76 @@ module Ruleset
   end
 
 end # module Ruleset
+
+module Measure
+class RubyMeasureInfoBinding < OpenStudio::Measure::MeasureInfoBinding
+  def initialize
+    super() # needed
+    @info = nil
+  end
+  def setMeasureInfo(info)
+    @info = info
+  end
+  def measure_info
+    @info
+  end
+  def error
+    @info.error.to_s
+  end
+  def measureType
+    @info.measureType.valueName.to_s
+  end
+  def className
+    @info.className.to_s
+  end
+  def name
+    @info.name.to_s
+  end
+  def description
+    @info.description.to_s
+  end
+  def taxonomy
+    @info.taxonomy.to_s
+  end
+  def modelerDescription
+    @info.modelerDescription.to_s
+  end
+  def arguments
+    @info.arguments.map{|a| a.toJSON()}
+  end
+  def outputs
+    @info.outputs.map{|a| a.toJSON()}
+  end
+  def get_binding
+    result = binding()
+    return result
+  end
+  def renderFile(readme_in_path)
+    require 'erb'
+    begin
+      readme_out_path = File.join(File.dirname(readme_in_path), File.basename(readme_in_path, File.extname(readme_in_path)))
+      readme_in = File.read(readme_in_path)
+      renderer = ERB.new(readme_in)
+      readme_out = renderer.result(get_binding())
+    rescue
+      info = OpenStudio::Measure::OSMeasureInfo.new(e.message)
+      return false # @info
+    end
+    # write README.md file
+    File.open(readme_out_path, 'w') do |file|
+      file << readme_out
+      # make sure data is written to the disk one way or the other
+      begin
+        file.fsync
+      rescue StandardError
+        file.flush
+      end
+    end
+    return true #  @info
+  end
+end
+end # module Measure
+
 end # module OpenStudio
 
 module OpenStudio
diff --git a/ruby/engine/RubyEngine.cpp b/ruby/engine/RubyEngine.cpp
index c8c53ad8c14..12d0ddbf434 100644
--- a/ruby/engine/RubyEngine.cpp
+++ b/ruby/engine/RubyEngine.cpp
@@ -5,6 +5,7 @@
 
 #include "RubyEngine.hpp"
 #include "InitRubyBindings.hpp"
+#include "RubyException.hpp"
 #include <embedded_files.hxx>
 #include <csignal>
 #include <stdexcept>
@@ -118,6 +119,92 @@ void* RubyEngine::getAs_impl(ScriptObject& obj, const std::type_info& ti) {
   return return_value;
 }
 
+std::string RubyEngine::inferMeasureClassName(const openstudio::path& measureScriptPath) {
+
+  auto inferClassNameCmd = fmt::format(R"ruby(
+ObjectSpace.garbage_collect
+ObjectSpace.garbage_collect
+# Measure should be at root level (not inside a module) so we can just get constants
+measurePath = '{}'
+prev = Object.constants
+# puts "prev = #{{prev}}"
+load measurePath # need load in case have seen this script before
+just_defined = Object.constants - prev
+# puts "just_defined = #{{just_defined}}"
+just_defined.select!{{|c| Object.const_get(c).ancestors.include?(OpenStudio::Measure::OSMeasure)}}
+# puts "just_defined, filtered = #{{just_defined}}"
+
+if just_defined.empty?
+  raise "Unable to extract OpenStudio::Measure::OSMeasure object from " +
+       measurePath + ". The script should contain a class that derives " +
+      "from OpenStudio::Measure::OSMeasure and should close with a line stating " +
+      "the class name followed by .new.registerWithApplication."
+end
+if just_defined.size > 1
+  raise "Found more than one OSMeasure at #{{measurePath}}: #{{just_defined}}"
+end
+c = just_defined[0]
+class_info = Object.const_get(c)
+$measure_name = class_info.to_s
+
+# Undef what we loaded
+just_defined.each {{|x| Object.send(:remove_const, x) }}
+ObjectSpace.garbage_collect
+ObjectSpace.garbage_collect
+)ruby",
+                                       measureScriptPath.generic_string());
+
+  std::string className;
+
+  try {
+    exec(inferClassNameCmd);
+    ScriptObject measureClassNameObject = eval("$measure_name");
+    // measureClassNameObject = rubyEngine->eval(fmt::format("{}.new()", className));
+    // ScriptObject measureClassNameObject = rubyEngine->eval(inferClassName);
+    className = *getAs<std::string*>(measureClassNameObject);
+  } catch (const RubyException& e) {
+    auto msg = fmt::format("Failed to infer measure name from {}: {}\nlocation={}", measureScriptPath.generic_string(), e.what(), e.location());
+    fmt::print(stderr, "{}\n", msg);
+  }
+
+  return className;
+}
+
+// Ideally this would return a openstudio::measure::OSMeasure* or a shared_ptr<openstudio::measure::OSMeasure> but this poses memory management
+// issue for the underlying ScriptObject (and VALUE or PyObject), so just return the ScriptObject
+ScriptObject RubyEngine::loadMeasure(const openstudio::path& measureScriptPath, std::string_view className) {
+
+  ScriptObject result;
+  try {
+    auto importCmd = fmt::format("load '{}'", measureScriptPath.generic_string());
+    exec(importCmd);
+  } catch (const RubyException& e) {
+    auto msg =
+      fmt::format("Failed to load measure '{}' from '{}': {}\nlocation={}", className, measureScriptPath.generic_string(), e.what(), e.location());
+    fmt::print(stderr, "{}\n", msg);
+  }
+  try {
+    result = eval(fmt::format("{}.new()", className));
+  } catch (const RubyException& e) {
+    auto msg = fmt::format("Failed to instantiate measure '{}' from '{}': {}\nlocation={}", className, measureScriptPath.generic_string(), e.what(),
+                           e.location());
+    fmt::print(stderr, "{}\n", msg);
+  }
+  return result;
+}
+
+int RubyEngine::numberOfArguments(ScriptObject& methodObject, std::string_view methodName) {
+  auto val = std::any_cast<VALUE>(methodObject.object);
+  ID method_id = rb_intern(methodName.data());
+  // Ruby IS SO WEIRD. This will return n for methods that take a fixed number of arguments, and -n - 1 where n is the number of required arguments...
+  // def f() => 0
+  // def f(a) => 1
+  // def f(a = nil) => -1! Because WHY NOT, right?
+  // def f(a, c = nil) => -2
+  // def f(a, b = nil, c = nil) => also -2
+  return rb_obj_method_arity(val, method_id);
+}
+
 }  // namespace openstudio
 
 extern "C"
diff --git a/ruby/engine/RubyEngine.hpp b/ruby/engine/RubyEngine.hpp
index 1d3ff7c1a97..a38b4364186 100644
--- a/ruby/engine/RubyEngine.hpp
+++ b/ruby/engine/RubyEngine.hpp
@@ -33,6 +33,12 @@ class RubyEngine final : public ScriptEngine
                                  const openstudio::path& gemHomeDir, const openstudio::path& bundleGemFilePath,
                                  const openstudio::path& bundleGemDirPath, const std::string& bundleWithoutGroups) override;
 
+  virtual std::string inferMeasureClassName(const openstudio::path& measureScriptPath) override;
+
+  virtual ScriptObject loadMeasure(const openstudio::path& measureScriptPath, std::string_view className) override;
+
+  virtual int numberOfArguments(ScriptObject& methodObject, std::string_view methodName) override;
+
  protected:
   // convert the underlying object to the correct type, then return it as a void *
   // so the above template function can provide it back to the caller.
diff --git a/ruby/interpreter/RubyEval.hpp b/ruby/interpreter/RubyEval.hpp
index bc157ecdc1e..6312f862418 100644
--- a/ruby/interpreter/RubyEval.hpp
+++ b/ruby/interpreter/RubyEval.hpp
@@ -21,13 +21,62 @@ inline VALUE evalString(const std::string& t_str) {
   // save and restore the current working directory in case the call to ruby upsets it
   VALUE result = rb_protect(evaluateSimpleImpl, val, &error);
   if (error != 0) {
-    VALUE errval = rb_eval_string("$!.to_s");
-    char* str = StringValuePtr(errval);
-    std::string err(str);
-    VALUE locval = rb_eval_string("$@.to_s");
-    str = StringValuePtr(locval);
-    std::string loc(str);
-    throw RubyException(err, loc);
+
+    // VALUE errval = rb_eval_string("$!.to_s");
+    // char* str = StringValuePtr(errval);
+    // std::string err(str);
+
+    VALUE errinfo = rb_errinfo();
+    VALUE exception_class = rb_obj_class(errinfo);
+    VALUE classNameValue = rb_class_name(exception_class);
+    std::string className(StringValuePtr(classNameValue));
+
+    VALUE errstr = rb_obj_as_string(errinfo);
+    std::string errMessage(StringValuePtr(errstr));
+
+    std::string totalErr = className + ": " + errMessage;
+
+    // I just **cannot** figure out a way to get the error location in C, without calling $@.to_s. Seems nothing is available here
+    int locationError;
+    VALUE locval = rb_eval_string_protect("$@.to_s", &locationError);
+    std::string loc;
+    if (locationError == 0) {
+      loc = StringValuePtr(locval);
+    } else {
+      loc = "Failed to get VM location";
+    }
+
+    // Generally speaking, the backtrace is there, but not for the case where it's a stack too deep error
+    const ID ID_backtrace = rb_intern_const("backtrace");
+    if (exception_class != rb_eSysStackError && rb_respond_to(errinfo, ID_backtrace)) {
+      std::vector<std::string> backtrace_lines;
+      std::string btlines;
+      /*volatile*/ VALUE backtrace;
+      if (!NIL_P(backtrace = rb_funcall(errinfo, ID_backtrace, 0))) {
+        VALUE backtracejoin = rb_ary_join(backtrace, rb_str_new2("\n"));
+        btlines = StringValuePtr(backtracejoin);
+
+        // Get the backing C array of the ruby array
+        VALUE* elements = RARRAY_PTR(backtrace);
+        for (long c = 0; c < RARRAY_LEN(backtrace); ++c) {
+          VALUE entry = elements[c];
+          [[maybe_unused]] char* backtrace_line = RSTRING_PTR(entry);
+          char* backtrace_line2 = StringValuePtr(entry);
+          backtrace_lines.emplace_back(backtrace_line2);
+        }
+      }
+
+      if (!btlines.empty()) {
+        loc += "\n\nTraceback:\n" + btlines;
+      }
+    } else {
+    }
+
+    // Clear the last error, we raise it on our side only, so Ruby needs to be cleared.
+    // Otherwise later API calls might read the old value and think another exception has occurred
+    rb_set_errinfo(Qnil);
+
+    throw RubyException(totalErr, loc);
   }
 
   return result;
diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt
index 36c5296ab3e..e41b1833798 100644
--- a/src/cli/CMakeLists.txt
+++ b/src/cli/CMakeLists.txt
@@ -12,6 +12,8 @@ set(CLI_SRC
   UpdateCommand.cpp
   MeasureUpdateCommand.hpp
   MeasureUpdateCommand.cpp
+  MeasureManager.hpp
+  MeasureManager.cpp
 )
 
 if(WIN32)
@@ -225,11 +227,17 @@ if(BUILD_TESTING)
   set_tests_properties(OpenStudioCLI.Labs.Run_PythonOnly_TwoMeasure PROPERTIES RESOURCE_LOCK "compact_osw")
 
   add_test(NAME OpenStudioCLI.Labs.Run_RubyPython
-    COMMAND $<TARGET_FILE:openstudio> labs run -w compact_ruby_and_python.osw
+    COMMAND $<TARGET_FILE:openstudio> labs run -w compact_ruby_then_python.osw
     WORKING_DIRECTORY "${PROJECT_BINARY_DIR}/resources/Examples/compact_osw/"
   )
   set_tests_properties(OpenStudioCLI.Labs.Run_RubyPython PROPERTIES RESOURCE_LOCK "compact_osw")
 
+  add_test(NAME OpenStudioCLI.Labs.Run_PythonRuby
+    COMMAND $<TARGET_FILE:openstudio> labs run -w compact_python_then_ruby.osw
+    WORKING_DIRECTORY "${PROJECT_BINARY_DIR}/resources/Examples/compact_osw/"
+  )
+  set_tests_properties(OpenStudioCLI.Labs.Run_PythonRuby PROPERTIES RESOURCE_LOCK "compact_osw")
+
   if (Pytest_AVAILABLE)
     add_test(NAME OpenStudioCLI.Labs.test_loglevel
       COMMAND ${Python_EXECUTABLE} -m pytest --verbose ${Pytest_XDIST_OPTS} --os-cli-path $<TARGET_FILE:openstudio> "${CMAKE_CURRENT_SOURCE_DIR}/test/test_loglevel.py"
diff --git a/src/cli/MeasureManager.cpp b/src/cli/MeasureManager.cpp
new file mode 100644
index 00000000000..09cf78ad12a
--- /dev/null
+++ b/src/cli/MeasureManager.cpp
@@ -0,0 +1,1011 @@
+#include "MeasureManager.hpp"
+#include "../utilities/bcl/RemoteBCL.hpp"
+#include "../utilities/bcl/BCLMeasure.hpp"
+#include "../utilities/core/Checksum.hpp"
+#include "../utilities/core/Filesystem.hpp"
+#include "../utilities/core/FilesystemHelpers.hpp"
+#include "../src/utilities/core/StringHelpers.hpp"
+#include "../osversion/VersionTranslator.hpp"
+#include "../energyplus/ForwardTranslator.hpp"
+#include "energyplus/ForwardTranslator.hpp"
+#include "utilities/bcl/LocalBCL.hpp"
+#include "utilities/idf/ValidityEnums.hpp"
+#include <utilities/idd/IddEnums.hxx>
+#include "../measure/OSMeasure.hpp"
+#include "../measure/ModelMeasure.hpp"
+#include "../measure/EnergyPlusMeasure.hpp"
+#include "../measure/ReportingMeasure.hpp"
+#include "../measure/OSArgument.hpp"
+#include "../measure/OSOutput.hpp"
+#include "../measure/OSRunner.hpp"
+#include "../measure/OSMeasureInfoGetter.hpp"
+#include "../model/Model_Impl.hpp"  // For casting
+#include "../../ruby/interpreter/RubyException.hpp"
+
+#include <cpprest/asyncrt_utils.h>
+#include <json/json.h>
+#include <fmt/format.h>
+#include <json/value.h>
+#include <pugixml.hpp>
+
+#include <utility>  // make_pair
+#include <condition_variable>
+#include <mutex>
+#include <csignal>
+#include <cstdio>
+
+namespace openstudio {
+
+#if (defined(_WIN32) || defined(_WIN64))
+openstudio::path toPath(const std::wstring& s) {
+  return openstudio::path(s);
+}
+#endif
+
+web::json::value toWebJSON(const std::string& s) {
+  return web::json::value::string(utility::conversions::to_string_t(s));
+}
+// This just participates in overload resolution, otherwise it's ambiguous since Json::Value and std::string are constructible from char *
+web::json::value toWebJSON(const char* s) {
+  return web::json::value::string(utility::conversions::to_string_t(s));
+}
+
+web::json::value toWebJSON(const Json::Value& json) {
+  return web::json::value::parse(json.toStyledString());
+}
+
+template <typename T>
+boost::optional<T> get_field(const web::json::value& body, const std::string& field_name) {}
+
+template <>
+boost::optional<std::string> get_field(const web::json::value& body, const std::string& field_name) {
+  auto key = utility::conversions::to_string_t(field_name);
+  if (body.has_string_field(key)) {
+    return toString(body.at(key).as_string());
+  }
+  return boost::none;
+}
+
+template <>
+boost::optional<openstudio::path> get_field(const web::json::value& body, const std::string& field_name) {
+  auto key = utility::conversions::to_string_t(field_name);
+  if (body.has_string_field(key)) {
+    return toPath(body.at(key).as_string());
+  }
+  return boost::none;
+}
+
+template <>
+boost::optional<bool> get_field(const web::json::value& body, const std::string& field_name) {
+  auto key = utility::conversions::to_string_t(field_name);
+  body.has_string_field(key);
+  if (body.has_boolean_field(key)) {
+    return body.at(key).as_bool();
+  }
+  return boost::none;
+}
+
+template <typename T>
+T get_field(const web::json::value& body, const std::string& field_name, const T& defaultValue) {}
+
+template <>
+std::string get_field(const web::json::value& body, const std::string& field_name, const std::string& defaultValue) {
+  auto key = utility::conversions::to_string_t(field_name);
+  if (body.has_string_field(key)) {
+    return toString(body.at(key).as_string());
+  }
+  return defaultValue;
+}
+
+template <>
+openstudio::path get_field(const web::json::value& body, const std::string& field_name, const openstudio::path& defaultValue) {
+  auto key = utility::conversions::to_string_t(field_name);
+  if (body.has_string_field(key)) {
+    return toPath(body.at(key).as_string());
+  }
+  return defaultValue;
+}
+
+template <>
+bool get_field(const web::json::value& body, const std::string& field_name, const bool& defaultValue) {
+  auto key = utility::conversions::to_string_t(field_name);
+  body.has_string_field(key);
+  if (body.has_boolean_field(key)) {
+    return body.at(key).as_bool();
+  }
+  return defaultValue;
+}
+
+MeasureManager::MeasureManager(ScriptEngineInstance& t_rubyEngine, ScriptEngineInstance& t_pythonEngine)
+  : rubyEngine(t_rubyEngine), pythonEngine(t_pythonEngine) {
+  // rubyEngine->exec("puts 'Hello from ruby'");
+  // pythonEngine->exec("print('Hello from python')");
+}
+//   :
+// #if USE_RUBY_ENGINE
+//     rubyEngine(t_rubyEngine),
+// #endif
+// #if USE_PYTHON_ENGINE
+//     pythonEngine(t_pythonEngine)
+// #endif
+// {
+// }
+
+Json::Value MeasureManager::internalState() const {
+  Json::Value result(Json::objectValue);
+
+  Json::Value osms(Json::arrayValue);
+  for (const auto& [k, v] : m_osms) {
+    Json::Value osmInfo(Json::objectValue);
+    osmInfo["osm_path"] = k.generic_string();
+    osmInfo["checksum"] = v.checksum;
+    osms.append(std::move(osmInfo));
+  }
+  result["osm"] = std::move(osms);
+
+  Json::Value idfs(Json::arrayValue);
+  for (const auto& [k, v] : m_idfs) {
+    Json::Value idfInfo(Json::objectValue);
+    idfInfo["idf_path"] = k.generic_string();
+    idfInfo["checksum"] = v.checksum;
+    idfs.append(std::move(idfInfo));
+  }
+  result["idf"] = std::move(idfs);
+
+  auto& measures = result["measures"];
+  for (const auto& [measureDirPath, bclMeasureInfo] : m_measures) {
+    Json::Value mInfo(Json::objectValue);
+    measures.append(bclMeasureInfo.measure.toJSON());
+  }
+
+  auto& measureInfos = result["measure_info"];
+  // Json::Value measureInfos(Json::arrayValue);
+  for (const auto& [measureDirPath, bclMeasureInfo] : m_measures) {
+    for (const auto& [osmOrIdfPath, measureInfo] : bclMeasureInfo.measureInfos) {
+      Json::Value mInfo(Json::objectValue);
+      mInfo["measureDirPath"] = measureDirPath.generic_string();
+      mInfo["osm_path"] = osmOrIdfPath.generic_string();
+      auto& args = mInfo["arguments"];
+      for (const auto& arg : measureInfo.bclMeasureArguments()) {
+        args.append(arg.toJSON());
+      }
+
+      measureInfos.append(std::move(mInfo));
+    }
+  }
+  // result["measure_info"] = std::move(measureInfos);
+
+  return result;
+}
+
+size_t MeasureManager::clearMeasureInfoForOsmorIdfPath(const openstudio::path& osmOrIdfPath) {
+  size_t totalRemoved = 0;
+  for (auto& [key, value] : m_measures) {
+    totalRemoved += value.measureInfos.erase(osmOrIdfPath);
+  }
+  return totalRemoved;
+}
+
+boost::optional<OSMInfo> MeasureManager::getModel(const openstudio::path& osmPath, bool force_reload) {
+
+  if (!openstudio::filesystem::is_regular_file(osmPath)) {
+    fmt::print("Model '{}' does not exist\n", osmPath.generic_string());
+    m_osms.erase(osmPath);
+    clearMeasureInfoForOsmorIdfPath(osmPath);
+    return boost::none;
+  }
+
+  OSMInfo current;
+  current.checksum = openstudio::checksum(osmPath);
+
+  if (!force_reload) {
+    auto it = m_osms.find(osmPath);
+    if (it != m_osms.end()) {
+      auto& cached = it->second;
+      if (current.checksum == cached.checksum) {
+        fmt::print("Using cached model {}\n", osmPath.generic_string());
+        return cached;
+      } else {
+        fmt::print("Checksum of cached model does not match current checksum for '{}'\n", osmPath.generic_string());
+      }
+    }
+  }
+
+  clearMeasureInfoForOsmorIdfPath(osmPath);
+
+  fmt::print("Attempting to load model '{}'\n", osmPath.generic_string());
+  openstudio::osversion::VersionTranslator vt;
+  if (auto model_ = vt.loadModel(osmPath)) {
+    fmt::print("Successfully loaded model '{}'\n", osmPath.generic_string());
+    current.model = std::move(*model_);
+    openstudio::energyplus::ForwardTranslator ft;
+    current.workspace = ft.translateModel(current.model);
+    auto [it, ok] = m_osms.insert_or_assign(osmPath, std::move(current));
+    return it->second;
+  }
+
+  fmt::print("Failed to load model '{}'\n", osmPath.generic_string());
+  m_osms.erase(osmPath);
+
+  return boost::none;
+}
+
+boost::optional<IDFInfo> MeasureManager::getIdf(const openstudio::path& idfPath, bool force_reload) {
+
+  if (!openstudio::filesystem::is_regular_file(idfPath)) {
+    fmt::print("Idf '{}' does not exist\n", idfPath.generic_string());
+    m_idfs.erase(idfPath);
+    clearMeasureInfoForOsmorIdfPath(idfPath);
+    return boost::none;
+  }
+
+  IDFInfo current;
+  current.checksum = openstudio::checksum(idfPath);
+
+  if (!force_reload) {
+    auto it = m_idfs.find(idfPath);
+    if (it != m_idfs.end()) {
+      auto& cached = it->second;
+      if (current.checksum == cached.checksum) {
+        fmt::print("Using cached workspace {}\n", idfPath.generic_string());
+        return cached;
+      } else {
+        fmt::print("Checksum of cached workspace does not match current checksum for '{}'\n", idfPath.generic_string());
+      }
+    }
+  }
+
+  clearMeasureInfoForOsmorIdfPath(idfPath);
+
+  fmt::print("Attempting to load idf '{}'\n", idfPath.generic_string());
+  if (auto workspace_ = openstudio::Workspace::load(idfPath, openstudio::IddFileType::EnergyPlus)) {
+    fmt::print("Successfully loaded idf '{}'\n", idfPath.generic_string());
+
+    if (workspace_->isValid(openstudio::StrictnessLevel::Draft)) {
+      current.workspace = std::move(*workspace_);
+      auto [it, ok] = m_idfs.insert_or_assign(idfPath, std::move(current));
+      return it->second;
+    } else {
+      fmt::print("Workspace loaded from '{}' is not valid to Draft StrictnessLevel\n", idfPath.generic_string());
+    }
+  } else {
+    fmt::print("Failed to load idf '{}'\n", idfPath.generic_string());
+  }
+
+  m_idfs.erase(idfPath);
+
+  return boost::none;
+}
+
+boost::optional<BCLMeasure> MeasureManager::getMeasure(const openstudio::path& measureDirPath, bool force_reload) {
+
+  const auto& measureDirPathStr = measureDirPath.string();
+
+  // check if measure exists on disk
+  if (!openstudio::filesystem::is_directory(measureDirPath)) {
+    fmt::print("Measure '{}' does not exist.\n", measureDirPathStr);
+    m_measures.erase(measureDirPath);
+    return boost::none;
+  }
+  if (!openstudio::filesystem::is_regular_file(measureDirPath / "measure.xml")) {
+    fmt::print("Measure directory '{}' exists but does not have a measure.xml.\n", measureDirPathStr);
+    m_measures.erase(measureDirPath);
+    return boost::none;
+  }
+
+  BCLMeasureInfo* measureInfo_ = nullptr;
+  if (!force_reload) {
+    auto it = m_measures.find(measureDirPath);
+    if (it != m_measures.end()) {
+      measureInfo_ = &(it->second);
+      fmt::print("Using cached measure {}\n", measureDirPath.generic_string());
+    }
+  }
+
+  if (!measureInfo_) {
+    m_measures.erase(measureDirPath);
+
+    // load from disk
+    fmt::print("Attempting to load measure '{}'\n", measureDirPathStr);
+
+    boost::optional<BCLMeasure> measure_ = openstudio::BCLMeasure::load(measureDirPath);
+
+    if (!measure_) {
+      fmt::print("Failed to load measure '{}'\n", measureDirPathStr);
+      return boost::none;
+    }
+    fmt::print("Successfully loaded measure '{}'\n", measureDirPathStr);
+    auto [it, ok] = m_measures.insert({measureDirPath, BCLMeasureInfo{std::move(*measure_)}});
+    measureInfo_ = &(it->second);
+  }
+
+  auto& measure = measureInfo_->measure;
+
+  // see if there are updates, want to make sure to perform both checks so do outside of conditional
+  bool file_updates = measure.checkForUpdatesFiles();  // checks if any files have been updated
+  bool xml_updates = measure.checkForUpdatesXML();     // only checks if xml as loaded has been changed since last save
+
+  auto readmeInPath = measureDirPath / "README.md.erb";
+  auto readmeOutPath = measureDirPath / "README.md";
+
+  const bool hasReadmeIn = openstudio::filesystem::is_regular_file(readmeInPath);
+  const bool hasReadmeOut = openstudio::filesystem::is_regular_file(readmeOutPath);
+  const bool readme_out_of_date = hasReadmeIn && !hasReadmeOut;
+
+  // TODO: try catch like in measure_manager.rb?
+  bool missing_fields = measure.missingRequiredFields();
+
+  if (file_updates || xml_updates || missing_fields || readme_out_of_date) {
+    fmt::print("Changes detected, updating '{}'\n", measureDirPathStr);
+
+    // Clear cache before calling getMeasureInfo
+    measureInfo_->measureInfos.clear();
+
+    openstudio::measure::OSMeasureInfo info = getMeasureInfo(measureDirPath, measure, openstudio::path{});
+    info.update(measure);
+
+    if (hasReadmeIn) {
+      if (hasReadmeOut) {
+        openstudio::filesystem::remove(readmeOutPath);
+      }
+      ScriptObject measureInfoBindingObject;
+      try {
+        measureInfoBindingObject = rubyEngine->eval("OpenStudio::Measure::RubyMeasureInfoBinding.new()");
+      } catch (const RubyException& e) {
+        auto msg = fmt::format("Failed to instantiate a RubyMeasureInfoBinding: {}\nlocation={}", e.what(), e.location());
+        fmt::print(stderr, "{}\n", msg);
+        LOG_AND_THROW(msg);
+      }
+      auto* measureInfoBindingPtr = rubyEngine->getAs<openstudio::measure::MeasureInfoBinding*>(measureInfoBindingObject);
+      measureInfoBindingPtr->setMeasureInfo(info);
+      const bool result = measureInfoBindingPtr->renderFile(readmeInPath.generic_string());
+      if (result) {
+        // check for file updates again
+        file_updates = measure.checkForUpdatesFiles();
+      } else {
+        fmt::print(stderr, "Failed to generate the README.md via ERB\n");
+      }
+    }
+
+    // Save the xml file with changes triggered by checkForUpdatesFiles() / checkForUpdatesXML() above
+    measure.save();
+  }
+
+  return measure;
+}
+
+openstudio::measure::OSMeasureInfo MeasureManager::getMeasureInfo(const openstudio::path& measureDirPath, const BCLMeasure& measure,
+                                                                  const openstudio::path& osmOrIdfPath, const boost::optional<model::Model>& model_,
+                                                                  const boost::optional<Workspace>& workspace_) {
+
+  if (!m_measures.contains(measureDirPath)) {
+    LOG_AND_THROW("Measure isn't recorded in m_measures, that should NOT happen");
+  }
+  auto& bclMeasureInfo = m_measures.at(measureDirPath);
+  auto it2 = bclMeasureInfo.measureInfos.find(osmOrIdfPath);
+  if (it2 != bclMeasureInfo.measureInfos.end()) {
+    fmt::print("Using cached OSMeasureInfo for '{}', '{}'\n", measureDirPath.generic_string(), osmOrIdfPath.generic_string());
+    return it2->second;
+  }
+
+  auto scriptPath_ = measure.primaryScriptPath();
+  if (!scriptPath_) {
+    throw std::runtime_error(
+      fmt::format("Unable to locate primary Ruby script path for BCLMeasure '{}' located at '{}'", measure.name(), measureDirPath.generic_string()));
+  }
+
+  auto getOrCreateModel = [this, &model_, &osmOrIdfPath]() -> openstudio::model::Model {
+    if (model_) {
+      // model should already have been cloned in the endpoint, so no need to do it twice
+      return *model_;  // _->clone(true).cast<openstudio::model::Model>();
+    } else if (!osmOrIdfPath.empty()) {
+      // TODO: not sure we want to keep this here or not..
+      if (auto osmInfo_ = getModel(osmOrIdfPath)) {
+        return osmInfo_->model.clone().cast<openstudio::model::Model>();
+      } else {
+        LOG_AND_THROW("Failed to load the Model at " << osmOrIdfPath);
+      }
+    } else {
+      return {};
+    }
+  };
+
+  auto getOrCreateWorkspace = [this, &workspace_, &osmOrIdfPath]() -> openstudio::Workspace {
+    if (workspace_) {
+      return *workspace_;  // ->clone(true);
+    } else if (!osmOrIdfPath.empty()) {
+      if (auto idfInfo_ = getIdf(osmOrIdfPath)) {
+        return idfInfo_->workspace.clone();
+      } else {
+        LOG_AND_THROW("Failed to load the Model at " << osmOrIdfPath);
+      }
+    } else {
+      return {openstudio::StrictnessLevel::Draft, openstudio::IddFileType::EnergyPlus};
+    }
+  };
+
+  ScriptEngineInstance* thisEngine = nullptr;
+  const MeasureLanguage measureLanguage = measure.measureLanguage();
+  if (measureLanguage == MeasureLanguage::Ruby) {
+    thisEngine = &rubyEngine;
+  } else if (measureLanguage == MeasureLanguage::Python) {
+    thisEngine = &pythonEngine;
+  }
+
+  std::string className = (*thisEngine)->inferMeasureClassName(*scriptPath_);
+  fmt::print("className={}\n", className);
+  if (className.empty()) {
+    // TODO: could also just fallback on trusting the BCLXML is up to date with respect to the class name and use BCLMeasure::className()
+    auto msg = fmt::format("Failed to infer measure name from '{}'", scriptPath_->generic_string());
+    fmt::print(stderr, "{}\n", msg);
+    return openstudio::measure::OSMeasureInfo(msg);
+  }
+  ScriptObject measureScriptObject = (*thisEngine)->loadMeasure(*scriptPath_, className);
+  if (measureScriptObject.empty()) {
+    auto msg = fmt::format("Failed to load measure '{}' from '{}'\n", className, scriptPath_->generic_string());
+    fmt::print(stderr, "{}\n", msg);
+    return openstudio::measure::OSMeasureInfo(msg);
+  }
+
+  MeasureType measureType;
+
+  std::string name;
+  std::string description;
+  std::string taxonomy;
+  std::string modelerDescription;
+
+  std::vector<measure::OSArgument> arguments;
+  std::vector<measure::OSOutput> outputs;
+
+  try {
+    auto* osMeasurePtr = (*thisEngine)->getAs<openstudio::measure::OSMeasure*>(measureScriptObject);
+    measureType = osMeasurePtr->measureType();
+    fmt::print("measureType={}\n", measureType.valueName());
+
+    if (measureType == MeasureType::ModelMeasure) {
+      auto* measurePtr = static_cast<openstudio::measure::ModelMeasure*>(osMeasurePtr);
+      name = measurePtr->name();
+      description = measurePtr->description();
+      taxonomy = measurePtr->taxonomy();
+      modelerDescription = measurePtr->modeler_description();
+
+      auto model = getOrCreateModel();
+      arguments = measurePtr->arguments(model);
+      outputs = measurePtr->outputs();
+
+    } else if (measureType == MeasureType::EnergyPlusMeasure) {
+      auto* measurePtr = static_cast<openstudio::measure::EnergyPlusMeasure*>(osMeasurePtr);
+      name = measurePtr->name();
+      description = measurePtr->description();
+      taxonomy = measurePtr->taxonomy();
+      modelerDescription = measurePtr->modeler_description();
+
+      auto workspace = getOrCreateWorkspace();
+      arguments = measurePtr->arguments(workspace);
+      outputs = measurePtr->outputs();
+    } else if (measureType == MeasureType::ReportingMeasure) {
+      auto* measurePtr = static_cast<openstudio::measure::ReportingMeasure*>(osMeasurePtr);
+      name = measurePtr->name();
+      description = measurePtr->description();
+      taxonomy = measurePtr->taxonomy();
+      modelerDescription = measurePtr->modeler_description();
+
+      // TODO: for ruby at least, need to try the arity... model was added later, at 3.0.0
+      const int numArgs = (*thisEngine)->numberOfArguments(measureScriptObject, "arguments");
+      fmt::print("numArgs={}\n", numArgs);
+      if (numArgs == 0) {
+        if (measureLanguage == MeasureLanguage::Ruby) {
+          auto patchArgumentsCmd = fmt::format(R"ruby(
+module {0}Extensions
+  def arguments(model)
+    super()
+  end
+end
+
+class {0}
+  prepend {0}Extensions # the only change to above: prepend instead of include
+end
+)ruby",
+                                               className);
+          rubyEngine->exec(patchArgumentsCmd);
+          arguments = measurePtr->arguments(openstudio::model::Model{});
+          rubyEngine->exec(fmt::format("Object.send(:remove_const, :{}Extensions)", className));
+        } else {
+          auto msg =
+            fmt::format("Wrong number of parameters for method `arguments`in measure '{}' from '{}'\n", className, scriptPath_->generic_string());
+          fmt::print(stderr, "{}\n", msg);
+          return openstudio::measure::OSMeasureInfo(msg);
+        }
+      } else {
+        auto model = getOrCreateModel();
+        arguments = measurePtr->arguments(model);
+      }
+      outputs = measurePtr->outputs();
+
+    } else {
+      throw std::runtime_error("Unknown");
+    }
+  } catch (const std::exception& e) {
+    auto msg = fmt::format("Failed to query information from measure '{}' from '{}': {}\n", className, scriptPath_->generic_string(), e.what());
+    fmt::print(stderr, "{}\n", msg);
+    return openstudio::measure::OSMeasureInfo(msg);
+  }
+
+  if (name.empty()) {
+    name = className;
+  }
+
+  if (measureLanguage == MeasureLanguage::Ruby) {
+    rubyEngine->exec(fmt::format("Object.send(:remove_const, :{})", className));
+  }
+
+  openstudio::measure::OSMeasureInfo info(measureType, className, name, description, taxonomy, modelerDescription, arguments, outputs);
+  auto [it, ok] = bclMeasureInfo.measureInfos.insert({osmOrIdfPath, std::move(info)});
+  return it->second;
+}
+
+void MeasureManager::reset() {
+  m_osms.clear();
+  m_idfs.clear();
+  m_measures.clear();
+}
+
+MeasureManagerServer::MeasureManagerServer(unsigned port, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine)
+  : m_measureManager(rubyEngine, pythonEngine),
+    m_url(fmt::format("http://localhost:{}/", port)),
+    my_measures_dir(openstudio::filesystem::home_path() / "OpenStudio/Measures") {
+
+  web::uri_builder uri_builder;
+#if (defined(_WIN32) || defined(_WIN64))
+  // Works only for localhost...
+  uri_builder.set_scheme(utility::conversions::to_string_t("http")).set_host(utility::conversions::to_string_t("localhost")).set_port(port);
+#else
+  // Works for 127.0.0.1, 0.0.0.0 and localhost on TCP4
+  uri_builder.set_scheme(utility::conversions::to_string_t("http")).set_host(utility::conversions::to_string_t("0.0.0.0")).set_port(port);
+#endif
+  auto builder_uri = uri_builder.to_uri();
+
+  m_listener = web::http::experimental::listener::http_listener(builder_uri);
+
+  m_listener.support(web::http::methods::GET, [this](auto&& request) { handle_get(std::forward<decltype(request)>(request)); });
+  m_listener.support(web::http::methods::POST, [this](auto&& request) { handle_post(std::forward<decltype(request)>(request)); });
+}
+
+void MeasureManagerServer::handle_error(pplx::task<void>& t) {
+  try {
+    t.get();
+  } catch (...) {
+    // Ignore the error, Log it if a logger is available
+  }
+}
+
+// TODO:: https://github.com/microsoft/cpprestsdk/blob/master/Release/samples/CasaLens/casalens.cpp
+bool MeasureManagerServer::open() {
+  auto status = m_listener.open().then([](pplx::task<void> t) { handle_error(t); }).wait();
+  return status == pplx::task_group_status::completed;
+}
+bool MeasureManagerServer::close() {
+  auto status = m_listener.close().then([](pplx::task<void> t) { handle_error(t); }).wait();
+  return status == pplx::task_group_status::completed;
+}
+
+void MeasureManagerServer::handle_get(web::http::http_request message) {
+  const std::string uri = toString(web::http::uri::decode(message.relative_uri().path()));
+
+  if (uri == "/") {
+    Json::Value result;
+    result["status"] = "running";
+    result["my_measures_dir"] = my_measures_dir.generic_string();
+    message.reply(web::http::status_codes::OK, toWebJSON(result));
+    return;
+  }
+
+  // Cpprestsdk has it's own json implementation.....
+  if (uri == "/internal_state") {
+    handle_request(message, web::json::value(), &MeasureManagerServer::internal_state);
+    return;
+  }
+
+  message.reply(web::http::status_codes::BadRequest, toWebJSON(fmt::format("Error, unknown path '{}'", uri)));
+}
+
+void MeasureManagerServer::handle_post(web::http::http_request message) {
+  const std::string uri = toString(web::http::uri::decode(message.relative_uri().path()));
+
+  if (uri == "/reset") {
+    handle_request(message, web::json::value(), &MeasureManagerServer::reset);
+    return;
+  }
+
+  if (uri == "/set") {
+    // curl -H "Content-Type: application/json" -X POST  --data '{"my_measures_dir": "/Users/julien/OpenStudio/Measures"}' http://localhost:8090/set
+    message.extract_json().then([this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::set); });
+    return;
+  }
+
+  if (uri == "/download_bcl_measure") {
+    message.extract_json().then(
+      [this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::download_bcl_measure); });
+    return;
+  }
+
+  // TODO: for testing only, remove
+  if (uri == "/get_model") {
+    message.extract_json().then([this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::get_model); });
+    return;
+  }
+
+  if (uri == "/bcl_measures") {
+    handle_request(message, web::json::value(), &MeasureManagerServer::bcl_measures);
+    return;
+  }
+
+  if (uri == "/update_measures") {
+    message.extract_json().then(
+      [this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::update_measures); });
+    return;
+  }
+
+  if (uri == "/compute_arguments") {
+    message.extract_json().then(
+      [this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::compute_arguments); });
+    return;
+  }
+
+  if (uri == "/create_measure") {
+    message.extract_json().then(
+      [this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::create_measure); });
+    return;
+  }
+
+  if (uri == "/duplicate_measure") {
+    message.extract_json().then(
+      [this, message](const web::json::value& body) { handle_request(message, body, &MeasureManagerServer::duplicate_measure); });
+    return;
+  }
+
+  message.reply(web::http::status_codes::NotFound, toWebJSON("404: Unknown Endpoint"));
+}
+MeasureManagerServer::ResponseType MeasureManagerServer::internal_state([[maybe_unused]] const web::json::value& body) {
+  Json::Value result;
+  result["status"] = "running";
+  result["my_measures_dir"] = my_measures_dir.generic_string();
+
+  auto internalState = m_measureManager.internalState();
+  for (const auto& key : internalState.getMemberNames()) {
+    result[key] = internalState[key];
+  }
+
+  return {web::http::status_codes::OK, toWebJSON(result)};
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::reset([[maybe_unused]] const web::json::value& body) {
+  m_measureManager.reset();
+  return {web::http::status_codes::OK, toWebJSON("Resetting internal state")};
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::set(const web::json::value& body) {
+  if (auto p_ = get_field<openstudio::path>(body, "my_measures_dir")) {
+    if (!openstudio::filesystem::is_directory(*p_)) {
+      // Issue an error message
+      return {web::http::status_codes::BadRequest,
+              toWebJSON(fmt::format("Error, my_measures_dir '{}' is a not a valid directory", p_->generic_string()))};
+    }
+    this->my_measures_dir = std::move(*p_);
+    return {web::http::status_codes::OK, web::json::value()};
+  } else {
+    return {web::http::status_codes::BadRequest, toWebJSON("Missing the my_measures_dir in the post data")};
+  }
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::download_bcl_measure(const web::json::value& body) {  // NOLINT
+  if (auto uid_ = get_field<std::string>(body, "uid")) {
+    const RemoteBCL r;
+    if (auto bclMeasure_ = r.getMeasure(*uid_)) {
+      return {web::http::status_codes::OK, toWebJSON(bclMeasure_->toJSON())};
+    } else {
+      return {web::http::status_codes::BadRequest, toWebJSON(fmt::format("Cannot find measure with uid='{}'", *uid_))};
+    }
+  } else {
+    fmt::print("Missing the uid in the post data\n");
+    return {web::http::status_codes::BadRequest, toWebJSON("Missing the uid in the post data")};
+  }
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::get_model(const web::json::value& body) {
+  if (auto osmPath_ = get_field<openstudio::path>(body, "osm_path")) {
+    auto osmInfo_ = m_measureManager.getModel(*osmPath_);
+    if (osmInfo_) {
+      return {web::http::status_codes::OK, toWebJSON(fmt::format("OK, loaded model with checksum {}", osmInfo_->checksum))};
+    } else {
+      return {web::http::status_codes::BadRequest, toWebJSON(fmt::format("Wrong osm path: '{}'", osmPath_->generic_string()))};
+    }
+  } else {
+    return {web::http::status_codes::BadRequest, toWebJSON("The 'osm_path' (string/path) must be in the post data")};
+  }
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::bcl_measures([[maybe_unused]] const web::json::value& body) {
+  const bool force_reload = false;  // Not supposed to mess with the BCL Measures!
+  auto& localBCL = openstudio::LocalBCL::instance();
+  std::vector<web::json::value> result;
+  for (auto& measure : localBCL.measures()) {
+    auto measureDir = measure.directory();
+    if (boost::optional<BCLMeasure> measure_ = m_measureManager.getMeasure(measureDir, force_reload)) {
+      result.emplace_back(toWebJSON(measure_->toJSON()));
+    } else {
+      fmt::print("Directory '{}' is not a measure\n", measureDir.generic_string());
+    }
+  }
+
+  return {web::http::status_codes::OK, web::json::value::array(result)};
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::update_measures(const web::json::value& body) {
+  auto measuresDir = get_field<openstudio::path>(body, "measures_dir", my_measures_dir);
+  const bool force_reload = get_field<bool>(body, "force_reload", false);
+
+  // Scan the directory for measures
+  std::vector<web::json::value> result;
+  for (const auto& dirEnt : openstudio::filesystem::directory_iterator{measuresDir}) {
+    if (openstudio::filesystem::is_directory(dirEnt)) {
+      const auto& measureDir = dirEnt.path();
+      if (boost::optional<BCLMeasure> measure_ = m_measureManager.getMeasure(measureDir, force_reload)) {
+        result.emplace_back(toWebJSON(measure_->toJSON()));
+      } else {
+        fmt::print("Directory '{}' is not a measure\n", measureDir.generic_string());
+      }
+    }
+  }
+
+  return {web::http::status_codes::OK, web::json::value::array(result)};
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::compute_arguments(const web::json::value& body) {
+  openstudio::path measureDir;
+  if (boost::optional<openstudio::path> p_ = get_field<openstudio::path>(body, "measure_dir")) {  // Not passing a default value => optional
+    measureDir = std::move(*p_);
+  } else {
+    return {web::http::status_codes::BadRequest, toWebJSON("The 'measure_dir' (string) must be in the post data")};
+  }
+
+  // NOTE: this endpoint expects an OSM (even if it's an EnergyPlusMeasure), NOT an OSM or IDF like the CLI --compute_arguments flag does
+  const openstudio::path osmPath = get_field<openstudio::path>(body, "osm_path", {});  // defaults to an empty path
+  bool has_valid_osm_path = false;
+  if (!osmPath.empty()) {
+    if (osmPath.extension() != ".osm") {
+      auto msg =
+        fmt::format("For /compute_arguments endpoint, parameter 'osm_path' must always be an '.osm' file, not '{}'", osmPath.generic_string());
+      fmt::print(stderr, "{}\n", msg);
+      return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+    }
+    has_valid_osm_path = true;
+  }
+
+  const bool force_reload = get_field<bool>(body, "force_reload", false);
+
+  auto measure_ = m_measureManager.getMeasure(measureDir, force_reload);
+  if (!measure_) {
+    auto msg = fmt::format("Cannot load measure at '{}'", measureDir.generic_string());
+    fmt::print(stderr, "{}\n", msg);
+    return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+  }
+
+  boost::optional<model::Model> model_;
+  boost::optional<Workspace> workspace_;
+
+  if (has_valid_osm_path) {
+    if (auto osmInfo_ = m_measureManager.getModel(osmPath, force_reload)) {
+      // Clone and keep handles
+      model_ = osmInfo_->model.clone(true).cast<openstudio::model::Model>();
+      workspace_ = osmInfo_->workspace.clone(true);
+    } else {
+      auto msg = fmt::format("Cannot load model at '{}'", osmPath.generic_string());
+      fmt::print(stderr, "{}\n", msg);
+      return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+    }
+  }
+
+  const openstudio::measure::OSMeasureInfo info = m_measureManager.getMeasureInfo(measureDir, *measure_, osmPath, model_, workspace_);
+  if (auto errorString_ = info.error()) {
+    return {web::http::status_codes::OK, toWebJSON(*errorString_)};
+  }
+
+  // TODO: maybe I should write an OSMeasureInfo::toJSON() method, but that'd be duplicating the code in BCLMeasure (BCLXML to be exact).
+  // So since the only thing that's different is the OSArgument (OSMeasureInfo) versus BCLMeasureArgument (BCLMeasure), we just override
+  auto result = measure_->toJSON();
+  if (has_valid_osm_path) {
+    auto& arguments = result["arguments"];
+    arguments.clear();
+    for (const measure::OSArgument& argument : info.arguments()) {
+      arguments.append(argument.toJSON());
+    }
+  }
+
+  return {web::http::status_codes::OK, toWebJSON(result)};
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::create_measure(const web::json::value& body) {
+  static const std::array<std::string, 7> requiredParams = {
+    "measure_dir", "display_name", "class_name", "taxonomy_tag", "measure_type", "description", "modeler_description",
+  };
+  for (const auto& requiredParam : requiredParams) {
+    auto key = utility::conversions::to_string_t(requiredParam);
+    if (!body.has_string_field(key)) {
+      auto msg = fmt::format("The '{}' (string) must be in the post data.", requiredParam);
+      fmt::print("{}\n", msg);
+      return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+    }
+  }
+
+  MeasureType measureType = MeasureType::ModelMeasure;
+  auto measureTypeString = get_field<std::string>(body, "measure_type", "ModelMeasure");
+  try {
+    measureType = MeasureType(measureTypeString);
+  } catch (const std::exception& e) {
+    auto msg = fmt::format("Couldn't convert '{}' to a MeasureType: {}", measureTypeString, e.what());
+    fmt::print("{}\n", msg);
+    return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+  }
+
+  MeasureLanguage measureLanguage = MeasureLanguage::Ruby;
+  if (auto measureLanguageString_ = get_field<std::string>(body, "measure_language")) {
+    try {
+      measureLanguage = MeasureLanguage(*measureLanguageString_);
+    } catch (const std::exception& e) {
+      auto msg = fmt::format("Couldn't convert '{}' to a MeasureLanguage: {}", *measureLanguageString_, e.what());
+      fmt::print("{}\n", msg);
+      return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+    }
+  }
+
+  const openstudio::path measureDir = get_field<openstudio::path>(body, "measure_dir", "");
+  // This is throwy when the directory already exists
+  if (openstudio::filesystem::is_directory(measureDir)) {
+    auto msg = fmt::format("The directory already exists at '{}'.", measureDir.generic_string());
+    fmt::print("{}\n", msg);
+    return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+  }
+  const BCLMeasure measure(get_field<std::string>(body, "display_name", "DisplayName"), get_field<std::string>(body, "class_name", "ClassName"),
+                           measureDir, get_field<std::string>(body, "taxonomy_tag", "taxonomy.tag"), measureType,
+                           get_field<std::string>(body, "description", "Description"),
+                           get_field<std::string>(body, "modeler_description", "ModelerDescription"), measureLanguage);
+
+  if (boost::optional<BCLMeasure> measure_ = m_measureManager.getMeasure(measureDir, true)) {
+    return {web::http::status_codes::OK, toWebJSON(measure_->toJSON())};
+  } else {
+    fmt::print("Failed to update measure after creation, this shouldn't happen.");
+    return {web::http::status_codes::BadRequest, toWebJSON(measure.toJSON())};
+  }
+}
+
+MeasureManagerServer::ResponseType MeasureManagerServer::duplicate_measure(const web::json::value& body) {
+  // Required parameters:
+  openstudio::path oldMeasureDir;
+  if (auto oldMeasureDir_ = get_field<openstudio::path>(body, "old_measure_dir")) {
+    oldMeasureDir = std::move(*oldMeasureDir_);
+  } else {
+    return {web::http::status_codes::BadRequest, toWebJSON("The 'old_measure_dir' (string) must be in the post data")};
+  }
+
+  openstudio::path newMeasureDir;
+  if (auto newMeasureDir_ = get_field<openstudio::path>(body, "measure_dir")) {
+    newMeasureDir = std::move(*newMeasureDir_);
+  } else {
+    return {web::http::status_codes::BadRequest, toWebJSON("The 'measure_dir' (string) must be in the post data")};
+  }
+
+  const bool force_reload = get_field<bool>(body, "force_reload", false);
+
+  boost::optional<BCLMeasure> oldMeasure_ = m_measureManager.getMeasure(oldMeasureDir, force_reload);
+  if (!oldMeasure_) {
+    auto msg = fmt::format("Cannot load measure at '{}'.", oldMeasureDir.generic_string());
+    fmt::print("{}\n", msg);
+    return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+  }
+
+  auto& oldMeasure = *oldMeasure_;
+
+  auto newMeasure_ = oldMeasure.clone(newMeasureDir);
+  if (!newMeasure_) {
+    auto msg = fmt::format("Cannot copy measure from '{}' to {}'", oldMeasureDir.generic_string(), newMeasureDir.generic_string());
+    fmt::print("{}\n", msg);
+    return {web::http::status_codes::BadRequest, toWebJSON(msg)};
+  }
+  auto& newMeasure = *newMeasure_;
+  // Force updating the UID
+  newMeasure.changeUID();
+  newMeasure.incrementVersionId();
+
+  if (auto val_ = get_field<std::string>(body, "display_name")) {
+    newMeasure.setDisplayName(*val_);
+  }
+  if (auto val_ = get_field<std::string>(body, "class_name")) {
+    newMeasure.setName(openstudio::toUnderscoreCase(*val_));
+    newMeasure.setClassName(*val_);
+  }
+  if (auto val_ = get_field<std::string>(body, "taxonomy_tag")) {
+    newMeasure.setTaxonomyTag(*val_);
+  }
+
+  // Changing the measure Language is not supported!
+  auto newMeasureLanguage = oldMeasure.measureLanguage();
+  // if (body.has_field("measure_language") && body.at("measure_language").is_string()) {
+  //   auto measureLanguageString = body.at("measure_type").as_string();
+  //   try {
+  //     newMeasureLanguage = MeasureLanguage(measureLanguageString);
+  //     newMeasure.setMeasureLanguage(measureLanguage);
+  //   } catch (...) {
+  //     fmt::print("Couldn't convert '{}' to a MeasureLanguage", measureLanguageString);
+  //   }
+  // }
+
+  // Changing the measure Type should maybe not be supported either, the method signatures will be wrong and it might be missing
+  // energyPlusOutputRequests
+  auto newMeasureType = oldMeasure.measureType();
+  if (auto measureTypeString_ = get_field<std::string>(body, "measure_type")) {
+    try {
+      newMeasureType = MeasureType(*measureTypeString_);
+      newMeasure.setMeasureType(newMeasureType);
+    } catch (...) {
+      fmt::print("Couldn't convert '{}' to a MeasureType", *measureTypeString_);
+    }
+  }
+
+  if (auto val_ = get_field<std::string>(body, "description")) {
+    newMeasure.setDescription(*val_);
+  }
+
+  if (auto val_ = get_field<std::string>(body, "modeler_description")) {
+    newMeasure.setModelerDescription(*val_);
+  }
+
+  newMeasure.updateMeasureScript(oldMeasure.measureType(), newMeasureType, oldMeasure.measureLanguage(), newMeasureLanguage, oldMeasure.className(),
+                                 newMeasure.className(), newMeasure.displayName(), newMeasure.description(), newMeasure.modelerDescription());
+  newMeasure.updateMeasureTests(oldMeasure.className(), newMeasure.className());
+
+  fmt::print("Cloned the {} {} with class name '{}' from '{}' to '{}'\n", oldMeasure.measureLanguage().valueName(),
+             oldMeasure.measureType().valueName(), oldMeasure.className(),
+             openstudio::toString(openstudio::filesystem::canonical(oldMeasure.directory())),
+             openstudio::toString(openstudio::filesystem::canonical(newMeasure.directory())));
+
+  newMeasure.checkForUpdatesFiles();
+  newMeasure.checkForUpdatesXML();
+  newMeasure.save();
+
+  if (boost::optional<BCLMeasure> measure_ = m_measureManager.getMeasure(newMeasureDir, true)) {
+    return {web::http::status_codes::OK, toWebJSON(measure_->toJSON())};
+  } else {
+    fmt::print("Failed to update measure after duplication, this shouldn't happen.");
+    return {web::http::status_codes::BadRequest, toWebJSON(newMeasure.toJSON())};
+  }
+}
+
+void MeasureManagerServer::handle_request(const web::http::http_request& message, const web::json::value& body,
+                                          memRequestHandlerFunPtr request_handler) {
+
+  std::packaged_task<ResponseType()> task([this, &body, &request_handler]() { return (this->*request_handler)(body); });
+
+  auto future_result = task.get_future();  // The task hasn't been started yet
+  tasks.push_back(std::move(task));        // It gets queued, the **main** thread will process it
+  try {
+    auto result = future_result.get();  // This block until it's been processed
+    message.reply(result.status_code, result.body);
+  } catch (const std::exception& e) {
+    constexpr auto msg = "MeasureManager Server encountered an error:\n\"{}\"\n";
+    fmt::print(msg, e.what());
+    message.reply(web::http::status_codes::InternalError, fmt::format(msg, e.what()));
+  }
+}
+
+void MeasureManagerServer::do_tasks_forever() {
+  fmt::print("MeasureManager Ready");
+  fmt::print("Accepting requests on: {}\n", m_url);
+  std::fflush(stdout);
+  while (true) {
+    auto task = tasks.wait_for_one();
+    task();
+  }
+}
+
+}  // namespace openstudio
diff --git a/src/cli/MeasureManager.hpp b/src/cli/MeasureManager.hpp
new file mode 100644
index 00000000000..acd292c7c52
--- /dev/null
+++ b/src/cli/MeasureManager.hpp
@@ -0,0 +1,142 @@
+#ifndef MEASUREMANAGER_H
+#define MEASUREMANAGER_H
+
+#include "../utilities/core/Logger.hpp"
+#include "../utilities/core/Path.hpp"
+#include "../utilities/core/ThreadSafeDeque.hpp"
+#include "../scriptengine/ScriptEngine.hpp"
+
+#include "../model/Model.hpp"
+#include "../utilities/idf/Workspace.hpp"
+#include "../measure/OSMeasureInfoGetter.hpp"
+#include "../utilities/bcl/BCLMeasure.hpp"
+#include <future>
+
+#if (defined(__GNUC__))
+#  pragma GCC diagnostic push
+#  pragma GCC diagnostic ignored "-Wnon-virtual-dtor"
+#endif
+#define _TURN_OFF_PLATFORM_STRING  // cpprestsdk has an ugly macro U() that makes fmt break...
+#include <cpprest/http_listener.h>
+#if (defined(__GNUC__))
+#  pragma GCC diagnostic pop
+#endif
+
+#include <map>
+#include <string>
+
+namespace Json {
+class Value;
+}
+
+namespace openstudio {
+
+struct OSMInfo
+{
+  std::string checksum;
+  openstudio::model::Model model;
+  openstudio::Workspace workspace;
+};
+
+struct IDFInfo
+{
+  std::string checksum;
+  openstudio::Workspace workspace;
+};
+
+struct BCLMeasureInfo
+{
+  explicit BCLMeasureInfo(openstudio::BCLMeasure t_measure) : measure(std::move(t_measure)) {}
+  openstudio::BCLMeasure measure;
+  // Map of osmPath to measureInfo
+  std::map<openstudio::path, openstudio::measure::OSMeasureInfo> measureInfos;
+};
+
+class MeasureManager
+{
+ public:
+  MeasureManager(ScriptEngineInstance& t_rubyEngine, ScriptEngineInstance& t_pythonEngine);
+
+  boost::optional<OSMInfo> getModel(const openstudio::path& osmPath, bool force_reload = false);
+  boost::optional<IDFInfo> getIdf(const openstudio::path& idfPath, bool force_reload = false);
+  boost::optional<BCLMeasure> getMeasure(const openstudio::path& measureDirPath, bool force_reload = false);
+  openstudio::measure::OSMeasureInfo getMeasureInfo(const openstudio::path& measureDirPath, const BCLMeasure& measure,
+                                                    const openstudio::path& osmOrIdfPath = "",
+                                                    const boost::optional<model::Model>& model_ = boost::none,
+                                                    const boost::optional<Workspace>& workspace_ = boost::none);
+
+  //  boost::optional<openstudio::measure::OSMeasureInfo> getMeasureInfo(const openstudio::path& measureDirPath, const openstudio::BCLMeasure& measure,
+  //                                                                     const boost::optional<model::Model>& model,
+  //                                                                     const boost::optional<Workspace>& workspace);
+
+  // getMeasureInfo;
+  // computeArguments
+  // getMeasureHash;
+  Json::Value internalState() const;
+
+  std::size_t clearMeasureInfoForOsmorIdfPath(const openstudio::path& osmOrIdfPath);
+
+  void reset();
+
+ private:
+  REGISTER_LOGGER("MeasureManager");
+  //#if USE_RUBY_ENGINE
+  ScriptEngineInstance& rubyEngine;
+  //#endif
+  //#if USE_PYTHON_ENGINE
+  ScriptEngineInstance& pythonEngine;
+  //#endif
+
+  std::map<openstudio::path, OSMInfo> m_osms;
+  std::map<openstudio::path, IDFInfo> m_idfs;
+  std::map<openstudio::path, BCLMeasureInfo> m_measures;
+};
+
+class MeasureManagerServer
+{
+ public:
+  explicit MeasureManagerServer(unsigned port, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine);
+
+  bool open();
+  bool close();
+  void do_tasks_forever();
+
+ protected:
+ private:
+  struct ResponseType
+  {
+    web::http::status_code status_code;
+    web::json::value body;
+  };
+
+  // Request handlers
+  ResponseType internal_state(const web::json::value& body);
+  ResponseType reset(const web::json::value& body);
+  ResponseType set(const web::json::value& body);
+  ResponseType download_bcl_measure(const web::json::value& body);
+  ResponseType get_model(const web::json::value& body);
+  ResponseType bcl_measures(const web::json::value& body);
+  ResponseType compute_arguments(const web::json::value& body);
+  ResponseType create_measure(const web::json::value& body);
+  ResponseType duplicate_measure(const web::json::value& body);
+  ResponseType update_measures(const web::json::value& body);
+
+  // Generally request handler, to ensure the work is done on the main thread.
+  // See commit message at https://github.com/NREL/OpenStudio/commit/3c4a1c32fd096ca183c5668e2aafe99ac6564fb4#diff-9785c162dbb96e5fdead1b101c7a2d639460e0bdb0d95c8ff21be7a451a8f377
+  using memRequestHandlerFunPtr = ResponseType (MeasureManagerServer::*)(const web::json::value& body);
+  void handle_request(const web::http::http_request& message, const web::json::value& body, memRequestHandlerFunPtr request_handler);
+
+  void handle_get(web::http::http_request message);
+  void handle_post(web::http::http_request message);
+  static void handle_error(pplx::task<void>& t);
+  MeasureManager m_measureManager;
+  web::http::experimental::listener::http_listener m_listener;
+  ThreadSafeDeque<std::packaged_task<ResponseType()>> tasks;
+
+  std::string m_url;
+  openstudio::path my_measures_dir;
+};
+
+}  // namespace openstudio
+
+#endif  // MEASUREMANAGER_H
diff --git a/src/cli/MeasureUpdateCommand.cpp b/src/cli/MeasureUpdateCommand.cpp
index 37d104bed8f..304a2b4045d 100644
--- a/src/cli/MeasureUpdateCommand.cpp
+++ b/src/cli/MeasureUpdateCommand.cpp
@@ -33,6 +33,8 @@
 ***********************************************************************************************************************/
 
 #include "MeasureUpdateCommand.hpp"
+#include "MeasureManager.hpp"
+
 #include "../utilities/core/Filesystem.hpp"
 #include "../utilities/bcl/BCLMeasure.hpp"
 #include "../scriptengine/ScriptEngine.hpp"
@@ -86,7 +88,7 @@ namespace cli {
                            ->excludes(updateOpt);
 
     auto* computeArgsOpt =
-      measureCommand->add_option("-a,--compute_arguments", opt->compute_arguments_model, "Specify the FILE path to the workflow to run")
+      measureCommand->add_option("-a,--compute_arguments", opt->compute_arguments_model, "Compute arguments for the given OSM or IDF")
         ->option_text("MODEL")
         ->needs(directoryPathOpt)
         ->excludes(updateOpt, updateAllOpt);
@@ -299,307 +301,29 @@ namespace cli {
     measureCommand->callback([opt, &rubyEngine, &pythonEngine] { MeasureUpdateOptions::execute(*opt, rubyEngine, pythonEngine); });
   }
 
-  boost::optional<BCLMeasure> getAndUpdateMeasure(const openstudio::path& directoryPath, ScriptEngineInstance& rubyEngine,
-                                                  ScriptEngineInstance& pythonEngine) {
-
-    const auto& directoryPathStr = directoryPath.string();
-
-    // check if measure exists on disk
-    if (!openstudio::filesystem::is_directory(directoryPath)) {
-      fmt::print("Measure '{}' does not exist.\n", directoryPathStr);
-      return boost::none;
-    }
-    if (!openstudio::filesystem::is_regular_file(directoryPath / "measure.xml")) {
-      fmt::print("Measure directory '{}' exists but does not have a measure.xml.\n", directoryPathStr);
-      return boost::none;
-    }
-
-    // load from disk
-    fmt::print("Attempting to load measure '{}'\n", directoryPathStr);
-
-    auto measure_ = openstudio::BCLMeasure::load(directoryPath);
-
-    if (!measure_) {
-      fmt::print("Failed to load measure '{}'\n", directoryPathStr);
-      return boost::none;
-    }
-    fmt::print("Successfully loaded measure '{}'\n", directoryPathStr);
-
-    // see if there are updates, want to make sure to perform both checks so do outside of conditional
-    bool file_updates = measure_->checkForUpdatesFiles();  // checks if any files have been updated
-    bool xml_updates = measure_->checkForUpdatesXML();     // only checks if xml as loaded has been changed since last save
-
-    auto readmeInPath = directoryPath / "README.md.erb";
-    auto readmeOutPath = directoryPath / "README.md";
-
-    bool readme_out_of_date = openstudio::filesystem::is_regular_file(readmeInPath) && !openstudio::filesystem::is_regular_file(readmeOutPath);
-
-    // TODO: try catch like in measure_manager.rb?
-    bool missing_fields = measure_->missingRequiredFields();
-
-    if (file_updates || xml_updates || missing_fields || readme_out_of_date) {
-      fmt::print("Changes detected, updating '{}'\n", directoryPathStr);
-
-      // TODO: the readme.md generation from readme.md.erb requires ruby.
-      fmt::print("Warn: readme.md generation from ERB is not supported yet\n");
-
-      // TODO: Also need to mimic OSMeasureInfoGetter::getInfo which is actually implemented in ruby itself...
-      // basically we need to compute the arguments of the measures, inspect outputs... so that requires doing something similar to what we do in the
-      // `run` method
-
-      fmt::print("Measure at {} uses language = {}.\n", directoryPathStr, measure_->measureLanguage().valueName());
-
-      auto scriptPath_ = measure_->primaryScriptPath();
-      if (!scriptPath_) {
-        throw std::runtime_error(
-          fmt::format("Unable to locate primary Ruby script path for BCLMeasure '{}' located at '{}'", measure_->name(), directoryPathStr));
-      }
-
-      // TODO: Here we need to do two things:
-      // * Find the class name by scanning the measure.rb/.py, then instantiate the measure
-      // * Do the same as in ApplyMeasure and compute the arguments with a dumb model/workspace, or the supplied one (for --compute_arguments)
-      ScriptEngineInstance* thisEngine = nullptr;
-      ScriptObject measureScriptObject;
-      // openstudio::measure::OSMeasure* measurePtr = nullptr;
-
-      std::string className;
-      MeasureType measureType;
-
-      std::string name;
-      std::string description;
-      std::string taxonomy;
-      std::string modelerDescription;
-
-      std::vector<measure::OSArgument> arguments;
-      std::vector<measure::OSOutput> outputs;
-
-      if (measure_->measureLanguage() == MeasureLanguage::Ruby) {
-        // same as the beginning of the OSMeasureInfoGetter::infoExtractorRubyFunction
-        auto importCmd = fmt::format(R"ruby(
-currentObjects = Hash.new
-ObjectSpace.each_object(OpenStudio::Measure::OSMeasure) do |obj|
-  currentObjects[obj] = true
-end
-
-measurePath = "{}"
-ObjectSpace.garbage_collect
-# This line is REQUIRED or the ObjectSpace order will change when garbage collection runs automatically
-# If ~12 measures are added and garbage collection runs, the following loop to grab the first measure
-# will get the wrong one and return incorrect arguments
-ObjectSpace.garbage_collect
-load measurePath # need load in case have seen this script before
-
-# Make them global, so C++ can grab them
-$measure = nil
-$measure_type = String.new
-$measure_name = String.new
-ObjectSpace.each_object(OpenStudio::Measure::OSMeasure) do |obj|
-  if not currentObjects[obj]
-    if obj.is_a? OpenStudio::Measure::ModelMeasure
-      $measure = obj
-      $measure_type = "ModelMeasure"
-    elsif obj.is_a? OpenStudio::Measure::EnergyPlusMeasure
-      $measure = obj
-      $measure_type = "EnergyPlusMeasure"
-    elsif obj.is_a? OpenStudio::Measure::ReportingMeasure
-      $measure = obj
-      $measure_type = "ReportingMeasure"
-    end
-  end
-end
-
-if not $measure
-  raise "Unable to extract OpenStudio::Measure::OSMeasure object from " +
-       measurePath + ". The script should contain a class that derives " +
-      "from OpenStudio::Measure::OSMeasure and should close with a line stating " +
-      "the class name followed by .new.registerWithApplication."
-end
-
-$measure_name = $measure.class.to_s
-puts "#{{$measure}}, #{{$measure_type}}, #{{$measure_name}}"
-)ruby",
-                                     scriptPath_->generic_string());
-        fmt::print("Debug: importCmd=\n{}\n\n", importCmd);
-        thisEngine = &rubyEngine;
-
-        rubyEngine->exec(importCmd);
-
-        fmt::print("Import done\n");
-        rubyEngine->exec("puts $measure_type");
-
-        // TODO: gotta figure out a way to retrieve a frigging string
-        // ScriptObject measureTypeObject = rubyEngine->eval("measure_type");
-        ScriptObject measureTypeObject = rubyEngine->eval("$measure_type");
-        std::string measureTypeStr = *(rubyEngine->getAs<std::string*>(measureTypeObject));
-        fmt::print("measureTypeStr={}\n", measureTypeStr);
-        measureType = MeasureType(measureTypeStr);
-
-        measureScriptObject = rubyEngine->eval("$measure");
-        ScriptObject measureClassNameObject = rubyEngine->eval("$measure_name");
-        className = *(*thisEngine)->getAs<std::string*>(measureClassNameObject);
-        fmt::print("className={}\n", className);
-
-        if (measureType == MeasureType::ModelMeasure) {
-          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
-
-          name = measurePtr->name();
-          description = measurePtr->description();
-          taxonomy = measurePtr->taxonomy();
-          modelerDescription = measurePtr->modeler_description();
-
-          openstudio::model::Model model;
-          arguments = measurePtr->arguments(model);
-          outputs = measurePtr->outputs();
-
-        } else if (measureType == MeasureType::EnergyPlusMeasure) {
-          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
-          name = measurePtr->name();
-          description = measurePtr->description();
-          taxonomy = measurePtr->taxonomy();
-          modelerDescription = measurePtr->modeler_description();
-
-          openstudio::Workspace workspace(openstudio::StrictnessLevel::Draft, openstudio::IddFileType::EnergyPlus);
-          arguments = measurePtr->arguments(workspace);
-          outputs = measurePtr->outputs();
-        } else if (measureType == MeasureType::ReportingMeasure) {
-          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
-          name = measurePtr->name();
-          description = measurePtr->description();
-          taxonomy = measurePtr->taxonomy();
-          modelerDescription = measurePtr->modeler_description();
-
-          openstudio::model::Model model;
-          arguments = measurePtr->arguments(model);
-          outputs = measurePtr->outputs();
-
-        } else {
-          throw std::runtime_error("Unknown");
-        }
-
-        if (name.empty()) {
-          name = className;
-        }
-
-      } else if (measure_->measureLanguage() == MeasureLanguage::Python) {
-        // TODO: call initialization of the pythonEngine
-        auto importCmd = fmt::format(R"python(
-import importlib.util
-import inspect
-spec = importlib.util.spec_from_file_location(f"throwaway", "{}")
-
-module = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(module)
-class_members = inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, openstudio.measure.OSMeasure))
-assert len(class_members) == 1
-measure_name, measure_typeinfo = class_members[0]
-print(f"{{measure_name}}, {{measure_typeinfo}}")
-measure_type = None
-if issubclass(measure_typeinfo, openstudio.measure.ModelMeasure):
-    measure_type = "ModelMeasure"
-elif issubclass(measure_typeinfo, openstudio.measure.EnergyPlusMeasure):
-    measure_type = "EnergyPlusMeasure"
-elif issubclass(measure_typeinfo, openstudio.measure.ReportingMeasure):
-    measure_type = "ReportingMeasure"
-print(f"{{measure_name}}, {{measure_typeinfo}}, {{measure_type}}")
-)python",
-                                     scriptPath_->generic_string());
-        pythonEngine->exec(importCmd);
-        thisEngine = &pythonEngine;
-        // measureScriptObject = pythonEngine->eval(fmt::format("module.{}()", className));
-        measureScriptObject = pythonEngine->eval("measure_typeinfo()");
-
-        // TODO: gotta figure out a way to retrieve a frigging string
-        ScriptObject measureTypeObject = pythonEngine->eval("measure_type");
-        std::string measureTypeStr = *(*thisEngine)->getAs<std::string*>(measureTypeObject);
-        measureType = MeasureType(measureTypeStr);
-
-        ScriptObject measureClassNameObject = pythonEngine->eval("measure_name");
-        className = *(*thisEngine)->getAs<std::string*>(measureClassNameObject);
-
-        fmt::print("measureTypeStr={}\n", measureTypeStr);
-
-        if (measureType == MeasureType::ModelMeasure) {
-          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
-
-          name = measurePtr->name();
-          description = measurePtr->description();
-          taxonomy = measurePtr->taxonomy();
-          modelerDescription = measurePtr->modeler_description();
-
-          openstudio::model::Model model;
-          arguments = measurePtr->arguments(model);
-          outputs = measurePtr->outputs();
-
-        } else if (measureType == MeasureType::EnergyPlusMeasure) {
-          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
-          name = measurePtr->name();
-          description = measurePtr->description();
-          taxonomy = measurePtr->taxonomy();
-          modelerDescription = measurePtr->modeler_description();
-
-          openstudio::Workspace workspace(openstudio::StrictnessLevel::Draft, openstudio::IddFileType::EnergyPlus);
-          arguments = measurePtr->arguments(workspace);
-          outputs = measurePtr->outputs();
-        } else if (measureType == MeasureType::ReportingMeasure) {
-          auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
-          name = measurePtr->name();
-          description = measurePtr->description();
-          taxonomy = measurePtr->taxonomy();
-          modelerDescription = measurePtr->modeler_description();
-
-          openstudio::model::Model model;
-          arguments = measurePtr->arguments(model);
-          outputs = measurePtr->outputs();
-
-        } else {
-          throw std::runtime_error("Unknown");
-        }
-
-        if (name.empty()) {
-          name = className;
-        }
-      }
-
-      openstudio::measure::OSMeasureInfo info(measureType, className, name, description, taxonomy, modelerDescription, arguments, outputs);
-      info.update(measure_.get());
-      for (auto& arg : arguments) {
-        fmt::print("arg={}\n", arg.displayName());
-        // auto outputs = measurePtr->outputs();
-      }
-
-      // Save the xml file with changes triggered by checkForUpdatesFiles() / checkForUpdatesXML() above
-      measure_->save();
-    }
-
-    return measure_;
-  }
-
   void MeasureUpdateOptions::execute(MeasureUpdateOptions const& opt, ScriptEngineInstance& rubyEngine, ScriptEngineInstance& pythonEngine) {
     // opt.debug_print();
 
     if (opt.server_port > 0) {
-      const auto measureManagerCmd = fmt::format(
-        R"ruby(
-require 'measure_manager_server'
-port = {}
-
-server = WEBrick::HTTPServer.new(:Port => port)
-
-server.mount "/", MeasureManagerServlet
-
-trap("INT") {{
-    server.shutdown
-}}
-
-server.start)ruby",
-        opt.server_port);
-
-      rubyEngine->exec(measureManagerCmd);
+      MeasureManagerServer server(opt.server_port, rubyEngine, pythonEngine);
+      server.open();              // This starts the cpprestsdk http listener, which processes tasks in subthreads
+      server.do_tasks_forever();  // This starts the event loop on the **main** thread to process the requests there and NOT in a subthread
 
+      return;
     } else if (opt.update) {
-      getAndUpdateMeasure(opt.directoryPath, rubyEngine, pythonEngine);
-
+      MeasureManager measureManager(rubyEngine, pythonEngine);
+      if (auto measure_ = measureManager.getMeasure(opt.directoryPath, true)) {
+        // TODO: maybe I should write an OSMeasureInfo::toJSON() method, but that'd be duplicating the code in BCLMeasure (BCLXML to be exact).
+        // So since the only thing that's different is the OSArgument (OSMeasureInfo) versus BCLMeasureArgument (BCLMeasure), we just override
+        auto result = measure_->toJSON();
+
+        fmt::print("{}\n", result.toStyledString());
+      } else {
+        fmt::print(stderr, "Cannot load measure from {}\n", opt.directoryPath.generic_string());
+      }
+      return;
     } else if (opt.update_all) {
+      MeasureManager measureManager(rubyEngine, pythonEngine);
       std::vector<openstudio::path> subDirPaths;
       for (auto const& dir_entry : boost::filesystem::directory_iterator{opt.directoryPath}) {
         const auto& subDirPath = dir_entry.path();
@@ -610,14 +334,46 @@ server.start)ruby",
       }
       fmt::print("Found {} measure directories to update\n", subDirPaths.size());
       for (const auto& subDirPath : subDirPaths) {
-        getAndUpdateMeasure(subDirPath, rubyEngine, pythonEngine);
+        measureManager.getMeasure(subDirPath, true);
       }
-
     } else if (!opt.compute_arguments_model.empty()) {
-      // TODO: Same comment as above: we need to call the measure methods same as what we do in OSWorkflow::run
-      throw std::runtime_error("compute_arguments_model not implemented yet");
+      // NOTE: cannot move into MeasureManager to avoid repeating the code shared with MeasureManagerServer. This expects an OSM **or an IDF** in the
+      // case of an EnergyPlus Measure, whereas the server /compute_arguments endpoint always expects an OSM
+      MeasureManager measureManager(rubyEngine, pythonEngine);
+      auto measure_ = measureManager.getMeasure(opt.directoryPath, true);
+      if (!measure_) {
+        auto msg = fmt::format("Cannot load measure at '{}'", opt.directoryPath.generic_string());
+        fmt::print(stderr, "{}\n", msg);
+        return;
+      }
+
+      openstudio::measure::OSMeasureInfo info = measureManager.getMeasureInfo(opt.directoryPath, *measure_, opt.compute_arguments_model);
+      if (auto errorString_ = info.error()) {
+        fmt::print(stderr, "{}\n", *errorString_);
+        return;
+      }
+      // TODO: maybe I should write an OSMeasureInfo::toJSON() method, but that'd be duplicating the code in BCLMeasure (BCLXML to be exact).
+      // So since the only thing that's different is the OSArgument (OSMeasureInfo) versus BCLMeasureArgument (BCLMeasure), we just override
+      auto result = measure_->toJSON();
+      auto& arguments = result["arguments"];
+      arguments.clear();
+      for (const measure::OSArgument& argument : info.arguments()) {
+        arguments.append(argument.toJSON());
+      }
+      Json::StreamWriterBuilder builder;
+      builder["commentStyle"] = "None";
+      builder["indentation"] = "  ";
+      std::string resultStr = Json::writeString(builder, result);
+      fmt::print("{}\n", resultStr);
+      return;
     } else if (opt.run_tests) {
-      auto measure_ = getAndUpdateMeasure(opt.directoryPath, rubyEngine, pythonEngine);
+      MeasureManager measureManager(rubyEngine, pythonEngine);
+      auto measure_ = measureManager.getMeasure(opt.directoryPath, true);
+      if (!measure_) {
+        auto msg = fmt::format("Cannot load measure at '{}'", opt.directoryPath.generic_string());
+        fmt::print(stderr, "{}\n", msg);
+        return;
+      }
       if (measure_->measureLanguage() == MeasureLanguage::Ruby) {
         // TODO: need to capture arguments and pass as ARGV
 
diff --git a/src/cli/TestMeasureManager.ipynb b/src/cli/TestMeasureManager.ipynb
new file mode 100644
index 00000000000..b8cbd851b1b
--- /dev/null
+++ b/src/cli/TestMeasureManager.ipynb
@@ -0,0 +1,611 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 22,
+   "id": "dfeea84e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import requests\n",
+    "from pathlib import Path\n",
+    "import subprocess\n",
+    "import signal"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "ec310324",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%load_ext autoreload\n",
+    "%autoreload 2\n",
+    "import sys\n",
+    "sys.path.insert(0, '/home/julien/Software/Others/OpenStudio/src/cli/')\n",
+    "from test_measure_manager import *"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "8e906687",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Using port=8090\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "'http://localhost:8090'"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "port = get_open_port_for_serving()\n",
+    "URL = f\"http://{HOST}:{port}\"\n",
+    "URL"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "63437bc8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "OPENSTUDIO_CLI = Path('/home/julien/Software/Others/OS-build/Products/openstudio')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 54,
+   "id": "80a3567b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p = subprocess.Popen([str(OPENSTUDIO_CLI), 'labs', 'measure', '-s', str(port)])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 56,
+   "id": "baffd1b1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p.poll()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 53,
+   "id": "f9631ac9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "p.send_signal(signal.SIGINT)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 57,
+   "id": "9291b304",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'my_measures_dir': '', 'status': 'running'}"
+      ]
+     },
+     "execution_count": 57,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r = requests.get(f\"{URL}\")\n",
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 58,
+   "id": "39d952bd",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.get(f\"{URL}/internal_state\")\n",
+    "r.raise_for_status()\n",
+    "assert r.json() == {'my_measures_dir': '', 'osm': [], 'status': 'running'}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 59,
+   "id": "4b237446",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'my_measures_dir': '', 'osm': [], 'status': 'running'}"
+      ]
+     },
+     "execution_count": 59,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 60,
+   "id": "58983952",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "my_measures_dir = str(Path('~/OpenStudio/Measures').expanduser())\n",
+    "r = requests.post(url=f\"{URL}/set\", json={\"my_measures_dir\": my_measures_dir})\n",
+    "r.raise_for_status()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 61,
+   "id": "d3dccb4e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.get(f\"{URL}/internal_state\")\n",
+    "r.raise_for_status()\n",
+    "assert r.json() == {'my_measures_dir': my_measures_dir, 'osm': [], 'status': 'running'}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 62,
+   "id": "2217a44f",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'my_measures_dir': '/home/julien/OpenStudio/Measures',\n",
+       " 'osm': [],\n",
+       " 'status': 'running'}"
+      ]
+     },
+     "execution_count": 62,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 63,
+   "id": "e8534851",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "True"
+      ]
+     },
+     "execution_count": 63,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "import openstudio\n",
+    "OSM_PATH = Path('/media/DataExt4/Software/Others/OS-build3/model.osm')\n",
+    "OSM_PATH2 = Path('/media/DataExt4/Software/Others/OS-build3/model2.osm')\n",
+    "\n",
+    "model = openstudio.model.Model()\n",
+    "model.save(str(OSM_PATH), True)\n",
+    "model = openstudio.model.Model()\n",
+    "model.save(str(OSM_PATH2), True)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 64,
+   "id": "d9bf1f1f",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "'9FA60624'"
+      ]
+     },
+     "execution_count": 64,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "openstudio.checksum(openstudio.toPath(str(OSM_PATH)))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 65,
+   "id": "c5653e94",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "\u001b[38;2;255;000;000m┌────────────────────────────────────────────────────────────────────────────────┐\n",
+      "│         The `labs` command is experimental - Do not use in production          │\n",
+      "└────────────────────────────────────────────────────────────────────────────────┘\u001b[0m\n",
+      "\n",
+      "MeasureUpdateOptions:\n",
+      "directoryPath=\n",
+      "update=false\n",
+      "update_all=false\n",
+      "compute_arguments_model=\n",
+      "run_tests=false\n",
+      "server_port=8090\n",
+      "\n",
+      "\n",
+      "Received GET request: GET / HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "uri=/\n",
+      "Received GET request: GET /internal_state HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "uri=/internal_state\n",
+      "Received POST request: POST /set HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Content-Length: 55\r\n",
+      "Content-Type: application/json\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "Received GET request: GET /internal_state HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "uri=/internal_state\n",
+      "Received POST request: POST /get_model HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Content-Length: 67\r\n",
+      "Content-Type: application/json\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "{\"osm_path\": \"/media/DataExt4/Software/Others/OS-build3/model.osm\"}Attempting to load model '/media/DataExt4/Software/Others/OS-build3/model.osm'\n",
+      "Successfully loaded model '/media/DataExt4/Software/Others/OS-build3/model.osm'\n",
+      "[openstudio.model.YearDescription] <1> 'UseWeatherFile' is selected in YearDescription, but there are no weather file set for the model.\n",
+      "[openstudio.model.YearDescription] <1> 'UseWeatherFile' is selected in YearDescription, but there are no weather file set for the model.\n",
+      "[utilities.idf.WorkspaceObject] <0> Object of type 'Schedule:Constant' and named 'Always On Discrete', points to an object named OnOff from field 1, but that object cannot be located.\n",
+      "[utilities.idf.WorkspaceObject] <0> Object of type 'Schedule:Constant' and named 'Always Off Discrete', points to an object named OnOff 1 from field 1, but that object cannot be located.\n",
+      "[utilities.idf.WorkspaceObject] <0> Object of type 'Schedule:Constant' and named 'Always On Continuous', points to an object named Fractional from field 1, but that object cannot be located.\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "'OK, loaded model with checksum 9FA60624'"
+      ]
+     },
+     "execution_count": 65,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r = requests.post(url=f\"{URL}/get_model\", json={\"osm_path\": str(OSM_PATH)})\n",
+    "r.raise_for_status()\n",
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 66,
+   "id": "b5354546",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{'my_measures_dir': '/home/julien/OpenStudio/Measures',\n",
+       " 'osm': [{'checksum': '9FA60624',\n",
+       "   'osm_path': '/media/DataExt4/Software/Others/OS-build3/model.osm'}],\n",
+       " 'status': 'running'}"
+      ]
+     },
+     "execution_count": 66,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r = requests.get(f\"{URL}/internal_state\")\n",
+    "r.raise_for_status()\n",
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "id": "bbf98b63",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "'OK'"
+      ]
+     },
+     "execution_count": 18,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r = requests.post(url=f\"{URL}/get_model\", json={\"osm_path\": str(OSM_PATH)})\n",
+    "r.raise_for_status()\n",
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 40,
+   "id": "d47442f7",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "E59D919B\n",
+      "Received POST request: POST /get_model HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Content-Length: 67\r\n",
+      "Content-Type: application/json\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "Using cached model /media/DataExt4/Software/Others/OS-build3/model.osm\n",
+      "E59D919B\n",
+      "Received POST request: POST /get_model HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Content-Length: 67\r\n",
+      "Content-Type: application/json\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "{\"osm_path\": \"/media/DataExt4/Software/Others/OS-build3/model.osm\"}Using cached model /media/DataExt4/Software/Others/OS-build3/model.osm\n",
+      "E59D919B\n",
+      "Received GET request: GET /internal_state HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "uri=/internal_state\n",
+      "Received POST request: POST /get_model HTTP/1.1\r\n",
+      "Accept: */*\r\n",
+      "Accept-Encoding: gzip, deflate, br\r\n",
+      "Connection: keep-alive\r\n",
+      "Content-Length: 68\r\n",
+      "Content-Type: application/json\r\n",
+      "Host: localhost:8090\r\n",
+      "User-Agent: python-requests/2.28.1\r\n",
+      "\r\n",
+      "Attempting to load model '/media/DataExt4/Software/Others/OS-build3/model2.osm'\n",
+      "Successfully loaded model '/media/DataExt4/Software/Others/OS-build3/model2.osm'\n",
+      "[openstudio.model.YearDescription] <1> 'UseWeatherFile' is selected in YearDescription, but there are no weather file set for the model.\n",
+      "[openstudio.model.YearDescription] <1> 'UseWeatherFile' is selected in YearDescription, but there are no weather file set for the model.\n",
+      "[utilities.idf.WorkspaceObject] <0> Object of type 'Schedule:Constant' and named 'Always On Discrete', points to an object named OnOff from field 1, but that object cannot be located.\n",
+      "[utilities.idf.WorkspaceObject] <0> Object of type 'Schedule:Constant' and named 'Always Off Discrete', points to an object named OnOff 1 from field 1, but that object cannot be located.\n",
+      "[utilities.idf.WorkspaceObject] <0> Object of type 'Schedule:Constant' and named 'Always On Continuous', points to an object named Fractional from field 1, but that object cannot be located.\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "'OK, loaded model with checksum 608AB8B6'"
+      ]
+     },
+     "execution_count": 40,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "r = requests.post(url=f\"{URL}/get_model\", json={\"osm_path\": str(OSM_PATH2)})\n",
+    "r.raise_for_status()\n",
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "bc3b9208",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.post(url=f\"{URL}/download_bcl_measure\", json={\"\": \"\"})\n",
+    "assert r.status_code == 400\n",
+    "assert r.json() == 'Missing the uid in the post data'"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "56727834",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.post(url=f\"{URL}/download_bcl_measure\", json={\"uid\": \"baduuid\"})\n",
+    "assert r.status_code == 400\n",
+    "assert r.json() == \"Cannot find measure with uid='baduuid'\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3f2d783f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "BCL_DIR = str(Path('~/BCL').expanduser())\n",
+    "TDB_UID = '8890787b-8c25-4dc8-8641-b6be1b6c2357'\n",
+    "OPENSTUDIO_RESULTS_UID = 'a25386cd-60e4-46bc-8b11-c755f379d916'"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "546b89b3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.post(url=f\"{URL}/download_bcl_measure\", json={\"uid\": TDB_UID})\n",
+    "r.raise_for_status()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4f92c1f7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.post(url=f\"{URL}/bcl_measures\")\n",
+    "r.raise_for_status()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8f3fc224",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "eb1e6e5b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = requests.post(url=f\"{URL}/reset\")\n",
+    "r.raise_for_status()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6d12768b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "for endpoint in ['update_measures', 'compute_arguments', 'create_measure', 'duplicate_measure']:\n",
+    "    r = requests.post(url=f\"http://{URL}/{endpoint}\")\n",
+    "    assert r.status_code == 501"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "cad18dd7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r.json()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "203ca62b",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.7"
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/src/cli/main.cpp b/src/cli/main.cpp
index 3834de4caca..24d99cdb6e4 100644
--- a/src/cli/main.cpp
+++ b/src/cli/main.cpp
@@ -17,6 +17,7 @@
 
 #include "RunCommand.hpp"
 #include "MeasureUpdateCommand.hpp"
+#include "measure/OSMeasureInfoGetter.hpp"
 
 #include <OpenStudio.hxx>
 
@@ -179,9 +180,11 @@ int main(int argc, char* argv[]) {
     std::function<void()> runSetupEmbeddedGems = [&rubyEngine, &includeDirs, &gemPathDirs, &gemHomeDir, &bundleGemFilePath, &bundleGemDirPath,
                                                   &bundleWithoutGroups]() {
       rubyEngine->setupEmbeddedGems(includeDirs, gemPathDirs, gemHomeDir, bundleGemFilePath, bundleGemDirPath, bundleWithoutGroups);
+      rubyEngine->registerType<openstudio::measure::OSMeasure*>("openstudio::measure::OSMeasure *");
       rubyEngine->registerType<openstudio::measure::ModelMeasure*>("openstudio::measure::ModelMeasure *");
       rubyEngine->registerType<openstudio::measure::EnergyPlusMeasure*>("openstudio::measure::EnergyPlusMeasure *");
       rubyEngine->registerType<openstudio::measure::ReportingMeasure*>("openstudio::measure::ReportingMeasure *");
+      rubyEngine->registerType<openstudio::measure::MeasureInfoBinding*>("openstudio::measure::MeasureInfoBinding *");
       // rubyEngine->registerType<std::string>("std::string");
       // rubyEngine->registerType<std::string*>("std::string *");
       rubyEngine->exec("OpenStudio::init_rest_of_openstudio()");
@@ -208,6 +211,7 @@ int main(int argc, char* argv[]) {
     std::function<void()> runSetupPythonPath = [&pythonEngine, &pythonPathDirs]() {
       // pythonHomeDir is retrieved from (argc, argv) actually, as Py_SetPythonHome has to be called before Py_Initialize
       pythonEngine->setupPythonPath(pythonPathDirs);
+      pythonEngine->registerType<openstudio::measure::OSMeasure*>("openstudio::measure::OSMeasure *");
       pythonEngine->registerType<openstudio::measure::ModelMeasure*>("openstudio::measure::ModelMeasure *");
       pythonEngine->registerType<openstudio::measure::EnergyPlusMeasure*>("openstudio::measure::EnergyPlusMeasure *");
       pythonEngine->registerType<openstudio::measure::ReportingMeasure*>("openstudio::measure::ReportingMeasure *");
diff --git a/src/measure/EnergyPlusMeasure.cpp b/src/measure/EnergyPlusMeasure.cpp
index cf049ed8ac7..ee5f7c19d56 100644
--- a/src/measure/EnergyPlusMeasure.cpp
+++ b/src/measure/EnergyPlusMeasure.cpp
@@ -14,6 +14,8 @@
 namespace openstudio {
 namespace measure {
 
+  EnergyPlusMeasure::EnergyPlusMeasure() : OSMeasure(MeasureType::EnergyPlusMeasure){};
+
   std::vector<OSArgument> EnergyPlusMeasure::arguments(const openstudio::Workspace& /*workspace*/) const {
     return {};
   }
diff --git a/src/measure/EnergyPlusMeasure.hpp b/src/measure/EnergyPlusMeasure.hpp
index 7a5abdb5a30..c4dba1cfb5d 100644
--- a/src/measure/EnergyPlusMeasure.hpp
+++ b/src/measure/EnergyPlusMeasure.hpp
@@ -55,7 +55,7 @@ namespace measure {
     //@}
 
    protected:
-    EnergyPlusMeasure() = default;
+    EnergyPlusMeasure();
 
    private:
     REGISTER_LOGGER("openstudio.measure.EnergyPlusMeasure");
diff --git a/src/measure/Measure.i b/src/measure/Measure.i
index 5c69aef5926..ea239940b8a 100644
--- a/src/measure/Measure.i
+++ b/src/measure/Measure.i
@@ -64,6 +64,7 @@
 %feature("director") ReportingMeasure;
 %feature("director") OSRunner;
 %feature("director") OSArgument;
+%feature("director") MeasureInfoBinding;
 
 //user scripts
 %template(OSArgumentVector) std::vector<openstudio::measure::OSArgument>;
diff --git a/src/measure/ModelMeasure.cpp b/src/measure/ModelMeasure.cpp
index 69d8fa46a6d..632208a590e 100644
--- a/src/measure/ModelMeasure.cpp
+++ b/src/measure/ModelMeasure.cpp
@@ -12,6 +12,8 @@
 namespace openstudio {
 namespace measure {
 
+  ModelMeasure::ModelMeasure() : OSMeasure(MeasureType::ModelMeasure){};
+
   std::vector<OSArgument> ModelMeasure::arguments(const openstudio::model::Model& /*model*/) const {
     return {};
   }
diff --git a/src/measure/ModelMeasure.hpp b/src/measure/ModelMeasure.hpp
index 0d8e5f610c8..4f7461eee5c 100644
--- a/src/measure/ModelMeasure.hpp
+++ b/src/measure/ModelMeasure.hpp
@@ -56,7 +56,7 @@ namespace measure {
     //@}
     //
    protected:
-    ModelMeasure() = default;
+    ModelMeasure();
 
    private:
     REGISTER_LOGGER("openstudio.measure.ModelMeasure");
diff --git a/src/measure/OSArgument.cpp b/src/measure/OSArgument.cpp
index c9ae463973c..586f15a0378 100644
--- a/src/measure/OSArgument.cpp
+++ b/src/measure/OSArgument.cpp
@@ -15,7 +15,10 @@
 
 #include <boost/lexical_cast.hpp>
 #include <boost/functional/value_factory.hpp>
+#include <json/json.h>
 
+#include <json/value.h>
+#include <limits>
 #include <sstream>
 
 namespace openstudio {
@@ -1041,5 +1044,110 @@ namespace measure {
     return argMap;
   }
 
+  Json::Value OSArgument::toJSON() const {
+    Json::Value root;
+    root["name"] = m_name;
+    // root["uid"] = openstudio::removeBraces(m_uuid);
+    // root["versionuuid"] = openstudio::removeBraces(m_versionUUID);
+    root["display_name"] = m_displayName;
+    if (m_description) {
+      root["description"] = *m_description;
+    }
+    root["type"] = m_type.valueName();
+    root["required"] = m_required;
+    root["model_dependent"] = m_modelDependent;
+
+    if (m_type == OSArgumentType::Boolean) {
+      if (hasDefaultValue()) {
+        root["default_value"] = defaultValueAsBool();
+      }
+
+    } else if (m_type == OSArgumentType::Double) {
+      if (m_units) {
+        root["units"] = *m_units;
+      }
+      if (hasDefaultValue()) {
+        root["default_value"] = defaultValueAsDouble();
+      }
+      if (hasDomain()) {
+        auto domain = domainAsDouble();
+        if (domain.size() != 2) {
+          LOG_AND_THROW("Domain for a double should be of size 2!");
+        }
+        auto& minValue = domain.front();
+        auto& maxValue = domain.back();
+        if (minValue != std::numeric_limits<double>::lowest()) {
+          root["min_value"] = minValue;
+        }
+        if (maxValue != std::numeric_limits<double>::max()) {
+          root["max_value"] = maxValue;
+        }
+      }
+
+    } else if (m_type == OSArgumentType::Quantity) {
+      // Note: This is heavily deprecated, and the std::variant can't contain it
+      if (m_units) {
+        root["units"] = *m_units;
+      }
+      if (hasDefaultValue()) {
+        root["default_value"] = defaultValueAsDouble();
+      }
+
+    } else if (m_type == OSArgumentType::Integer) {
+      if (m_units) {
+        root["units"] = *m_units;
+      }
+      if (hasDefaultValue()) {
+        root["default_value"] = defaultValueAsInteger();
+      }
+      if (hasDomain()) {
+        auto domain = domainAsInteger();
+        if (domain.size() != 2) {
+          LOG_AND_THROW("Domain for an integer should be of size 2!");
+        }
+        auto& minValue = domain.front();
+        auto& maxValue = domain.back();
+        if (minValue != std::numeric_limits<int>::lowest()) {
+          root["min_value"] = minValue;
+        }
+        if (maxValue != std::numeric_limits<int>::max()) {
+          root["max_value"] = maxValue;
+        }
+      }
+    } else if (m_type == OSArgumentType::String) {
+      if (hasDefaultValue()) {
+        root["default_value"] = defaultValueAsString();
+      }
+    } else if (m_type == OSArgumentType::Choice) {
+      if (hasDefaultValue()) {
+        root["default_value"] = defaultValueAsString();
+      }
+      auto& choiceValues = root["choices_values"];
+      for (auto& choice : m_choices) {
+        choiceValues.append(choice);
+      }
+      auto& choiceDisplayNames = root["choice_display_names"];
+      for (auto& choice : m_choiceDisplayNames) {
+        choiceDisplayNames.append(choice);
+      }
+
+    } else if (m_type == OSArgumentType::Path) {
+      if (hasDefaultValue()) {
+        root["default_value"] = openstudio::toString(defaultValueAsPath());
+      }
+
+    } else if (m_type == OSArgumentType::Separator) {
+      // No-op
+    } else {
+      LOG_AND_THROW("Unhandled type");
+    }
+
+    return root;
+  }
+
+  std::string OSArgument::toJSONString() const {
+    return toJSON().toStyledString();
+  }
+
 }  // namespace measure
 }  // namespace openstudio
diff --git a/src/measure/OSArgument.hpp b/src/measure/OSArgument.hpp
index 5854d8ffa11..b4896a0182d 100644
--- a/src/measure/OSArgument.hpp
+++ b/src/measure/OSArgument.hpp
@@ -16,6 +16,10 @@
 
 #include <variant>
 
+namespace Json {
+class Value;
+}
+
 namespace openstudio {
 namespace measure {
 
@@ -366,6 +370,9 @@ OPENSTUDIO_ENUM( OSDomainType,
    *  value, the string will be .empty(). */
     std::string printDefaultValue() const;
 
+    Json::Value toJSON() const;
+    std::string toJSONString() const;
+
     //@}
    private:
     REGISTER_LOGGER("openstudio.measure.OSArgument");
diff --git a/src/measure/OSMeasure.cpp b/src/measure/OSMeasure.cpp
index 0ced06be80f..9cc9512febb 100644
--- a/src/measure/OSMeasure.cpp
+++ b/src/measure/OSMeasure.cpp
@@ -8,6 +8,12 @@
 namespace openstudio {
 namespace measure {
 
+  OSMeasure::OSMeasure(MeasureType measureType) : m_measureType(measureType) {}
+
+  MeasureType OSMeasure::measureType() const {
+    return m_measureType;
+  }
+
   std::string OSMeasure::name() const {
     return "";
   }
diff --git a/src/measure/OSMeasure.hpp b/src/measure/OSMeasure.hpp
index 144244f459a..6067849da25 100644
--- a/src/measure/OSMeasure.hpp
+++ b/src/measure/OSMeasure.hpp
@@ -10,6 +10,7 @@
 
 #include "../utilities/core/Logger.hpp"
 #include "../utilities/core/Deprecated.hpp"
+#include "../utilities/bcl/BCLEnums.hpp"
 
 namespace openstudio {
 namespace measure {
@@ -44,6 +45,8 @@ namespace measure {
     /// user facing technical description of this measure, defaults to empty
     virtual std::string modeler_description() const;
 
+    MeasureType measureType() const;
+
     //@}
     /** @name Actions */
     //@{
@@ -57,11 +60,12 @@ namespace measure {
     OS_DEPRECATED(1, 13, 0) virtual bool registerWithApplication() const;
 
    protected:
-    OSMeasure() = default;
+    OSMeasure(MeasureType measureType);
 
     //@}
    private:
     REGISTER_LOGGER("openstudio.measure.OSMeasure");
+    MeasureType m_measureType;
   };
 
   /** \relates OSMeasure */
diff --git a/src/measure/OSMeasureInfoGetter.cpp b/src/measure/OSMeasureInfoGetter.cpp
index 473cafe82ab..f68e740f32f 100644
--- a/src/measure/OSMeasureInfoGetter.cpp
+++ b/src/measure/OSMeasureInfoGetter.cpp
@@ -62,6 +62,57 @@ namespace measure {
     return m_arguments;
   }
 
+  std::vector<BCLMeasureArgument> OSMeasureInfo::bclMeasureArguments() const {
+    unsigned n = m_arguments.size();
+    std::vector<BCLMeasureArgument> bclArguments;
+    bclArguments.reserve(n);
+    for (const OSArgument& argument : m_arguments) {
+
+      std::string bclMeasureType = argument.type().valueName();
+      if (argument.type() == OSArgumentType::Quantity) {
+        LOG(Warn, "Mapping deprecated OSArgumentType::Quantity to Double");
+        bclMeasureType = "Double";
+      }
+
+      boost::optional<std::string> defaultValue;
+      if (argument.hasDefaultValue()) {
+        defaultValue = argument.defaultValueAsString();
+      }
+
+      boost::optional<std::string> minValue;
+      boost::optional<std::string> maxValue;
+      if (argument.hasDomain()) {
+        if (argument.type() == OSArgumentType::Integer) {
+          auto domain = argument.domainAsInteger();
+          auto& low = domain.front();
+          auto& high = domain.back();
+          if (low > std::numeric_limits<int>::lowest()) {
+            minValue = std::to_string(low);
+          }
+          if (high < std::numeric_limits<int>::max()) {
+            maxValue = std::to_string(high);
+          }
+        } else if (argument.type() == OSArgumentType::Double) {
+          auto domain = argument.domainAsDouble();
+          auto& low = domain.front();
+          auto& high = domain.back();
+          if (low > std::numeric_limits<double>::lowest()) {
+            minValue = std::to_string(low);
+          }
+          if (high < std::numeric_limits<double>::max()) {
+            maxValue = std::to_string(high);
+          }
+        }
+      }
+
+      bclArguments.emplace_back(argument.name(), argument.displayName(), argument.description(), bclMeasureType, argument.units(),
+                                argument.required(), argument.modelDependent(), defaultValue, argument.choiceValues(),
+                                argument.choiceValueDisplayNames(), minValue, maxValue);
+    }
+
+    return bclArguments;
+  }
+
   std::vector<OSOutput> OSMeasureInfo::outputs() const {
     return m_outputs;
   }
@@ -118,61 +169,14 @@ namespace measure {
       measure.setModelerDescription(m_modelerDescription);
     }
 
-    unsigned n = m_arguments.size();
-    std::vector<BCLMeasureArgument> bclArguments;
-    bclArguments.reserve(n);
-    for (const OSArgument& argument : m_arguments) {
-
-      std::string bclMeasureType = argument.type().valueName();
-      if (argument.type() == OSArgumentType::Quantity) {
-        LOG(Warn, "Mapping deprecated OSArgumentType::Quantity to Double");
-        bclMeasureType = "Double";
-      }
-
-      boost::optional<std::string> defaultValue;
-      if (argument.hasDefaultValue()) {
-        defaultValue = argument.defaultValueAsString();
-      }
-
-      boost::optional<std::string> minValue;
-      boost::optional<std::string> maxValue;
-      if (argument.hasDomain()) {
-        if (argument.type() == OSArgumentType::Integer) {
-          auto domain = argument.domainAsInteger();
-          auto& low = domain.front();
-          auto& high = domain.back();
-          if (low > std::numeric_limits<int>::lowest()) {
-            minValue = std::to_string(low);
-          }
-          if (high < std::numeric_limits<int>::max()) {
-            maxValue = std::to_string(high);
-          }
-        } else if (argument.type() == OSArgumentType::Double) {
-          auto domain = argument.domainAsDouble();
-          auto& low = domain.front();
-          auto& high = domain.back();
-          if (low > std::numeric_limits<double>::lowest()) {
-            minValue = std::to_string(low);
-          }
-          if (high < std::numeric_limits<double>::max()) {
-            maxValue = std::to_string(high);
-          }
-        }
-      }
-
-      BCLMeasureArgument bclArgument(argument.name(), argument.displayName(), argument.description(), bclMeasureType, argument.units(),
-                                     argument.required(), argument.modelDependent(), defaultValue, argument.choiceValues(),
-                                     argument.choiceValueDisplayNames(), minValue, maxValue);
-
-      bclArguments.push_back(bclArgument);
-    }
+    std::vector<BCLMeasureArgument> bclArguments = bclMeasureArguments();
 
     std::vector<BCLMeasureArgument> otherArguments = measure.arguments();
-    if (otherArguments.size() != n) {
+    if (otherArguments.size() != bclArguments.size()) {
       result = true;
       measure.setArguments(bclArguments);
     } else {
-      for (unsigned i = 0; i < n; ++i) {
+      for (size_t i = 0; i < bclArguments.size(); ++i) {
         if (!(bclArguments[i] == otherArguments[i])) {
           result = true;
           measure.setArguments(bclArguments);
@@ -182,15 +186,14 @@ namespace measure {
     }
 
     // handle outputs
-    n = m_outputs.size();
+    auto n = m_outputs.size();
     std::vector<BCLMeasureOutput> bclOutputs;
     bclOutputs.reserve(n);
     for (const OSOutput& output : m_outputs) {
       std::string bclOutputType = output.type().valueName();
 
-      BCLMeasureOutput bclOutput(output.name(), output.displayName(), output.shortName(), output.description(), bclOutputType, output.units(),
-                                 output.modelDependent());
-      bclOutputs.push_back(bclOutput);
+      bclOutputs.emplace_back(output.name(), output.displayName(), output.shortName(), output.description(), bclOutputType, output.units(),
+                              output.modelDependent());
     }
 
     std::vector<BCLMeasureOutput> otherOutputs = measure.outputs();
@@ -198,7 +201,7 @@ namespace measure {
       result = true;
       measure.setOutputs(bclOutputs);
     } else {
-      for (unsigned i = 0; i < n; ++i) {
+      for (size_t i = 0; i < n; ++i) {
         if (!(bclOutputs[i] == otherOutputs[i])) {
           result = true;
           measure.setOutputs(bclOutputs);
@@ -303,5 +306,10 @@ namespace measure {
     return ss.str();
   }
 
+  void MeasureInfoBinding::setMeasureInfo(OSMeasureInfo& info){};
+  bool MeasureInfoBinding::renderFile(const std::string& readmeInPath) {
+    return false;
+  };
+
 }  // namespace measure
 }  // namespace openstudio
diff --git a/src/measure/OSMeasureInfoGetter.hpp b/src/measure/OSMeasureInfoGetter.hpp
index 24e6032aa10..3fc8b7bb8c5 100644
--- a/src/measure/OSMeasureInfoGetter.hpp
+++ b/src/measure/OSMeasureInfoGetter.hpp
@@ -64,6 +64,8 @@ namespace measure {
     /// result of measure's arguments method called with empty model if information was successfully extracted from the script, otherwise empty
     std::vector<OSArgument> arguments() const;
 
+    std::vector<BCLMeasureArgument> bclMeasureArguments() const;
+
     /// result of measure's outputs method called with empty model if information was successfully extracted from the script, otherwise empty
     std::vector<OSOutput> outputs() const;
 
@@ -111,6 +113,16 @@ namespace measure {
  *  pathway should produce identical results. \relates OSMeasureInfoGetter */
   MEASURE_API std::string infoExtractorRubyFunction();
 
+  /** Interface class for rendering the ruby readme via ERB, parent class for the RubyMeasureInfoBinding
+   *  declared in ruby/bindings/InitRubyBindings.cpp so we can use it from C++ */
+  class MEASURE_API MeasureInfoBinding
+  {
+   public:
+    virtual ~MeasureInfoBinding() = default;
+    virtual void setMeasureInfo(OSMeasureInfo& info);
+    virtual bool renderFile(const std::string& readmeInPath);
+  };
+
 }  // namespace measure
 }  // namespace openstudio
 
diff --git a/src/measure/OSOutput.cpp b/src/measure/OSOutput.cpp
index 98948ae8df7..2cc77cf16b6 100644
--- a/src/measure/OSOutput.cpp
+++ b/src/measure/OSOutput.cpp
@@ -7,6 +7,8 @@
 
 #include "../utilities/core/Assert.hpp"
 
+#include <json/value.h>
+
 #include <sstream>
 
 namespace openstudio {
@@ -102,5 +104,26 @@ namespace measure {
     return os;
   }
 
+  Json::Value OSOutput::toJSON() const {
+    Json::Value root;
+    root["name"] = m_name;
+    root["display_name"] = m_displayName;
+    root["m_shortName"] = m_shortName;
+    if (m_description) {
+      root["description"] = *m_description;
+    }
+    root["type"] = m_type.valueName();
+    if (m_units) {
+      root["units"] = *m_units;
+    }
+    root["model_dependent"] = m_modelDependent;
+
+    return root;
+  }
+
+  std::string OSOutput::toJSONString() const {
+    return toJSON().toStyledString();
+  }
+
 }  // namespace measure
 }  // namespace openstudio
diff --git a/src/measure/OSOutput.hpp b/src/measure/OSOutput.hpp
index 188673ab461..4dbe8040d0e 100644
--- a/src/measure/OSOutput.hpp
+++ b/src/measure/OSOutput.hpp
@@ -14,6 +14,10 @@
 #include "../utilities/core/Enum.hpp"
 #include "../utilities/core/Logger.hpp"
 
+namespace Json {
+class Value;
+}
+
 namespace openstudio {
 namespace measure {
 
@@ -112,6 +116,9 @@ OPENSTUDIO_ENUM(OSOutputType,
     /** Prints output data for debugging purposes. */
     std::string print() const;
 
+    Json::Value toJSON() const;
+    std::string toJSONString() const;
+
     //@}
    private:
     REGISTER_LOGGER("openstudio.measure.OSOutput");
diff --git a/src/measure/ReportingMeasure.cpp b/src/measure/ReportingMeasure.cpp
index aa27224c362..e40ad4f8b57 100644
--- a/src/measure/ReportingMeasure.cpp
+++ b/src/measure/ReportingMeasure.cpp
@@ -14,6 +14,8 @@
 namespace openstudio {
 namespace measure {
 
+  ReportingMeasure::ReportingMeasure() : OSMeasure(MeasureType::ReportingMeasure){};
+
   std::vector<OSArgument> ReportingMeasure::arguments(const openstudio::model::Model& /*model*/) const {
     return {};
   }
diff --git a/src/measure/ReportingMeasure.hpp b/src/measure/ReportingMeasure.hpp
index 13bd4097852..0fdc2142d69 100644
--- a/src/measure/ReportingMeasure.hpp
+++ b/src/measure/ReportingMeasure.hpp
@@ -65,7 +65,7 @@ namespace measure {
     virtual std::vector<IdfObject> energyPlusOutputRequests(OSRunner& runner, const std::map<std::string, OSArgument>& user_arguments) const;
     //@}
    protected:
-    ReportingMeasure() = default;
+    ReportingMeasure();
 
    private:
     REGISTER_LOGGER("openstudio.measure.ReportingMeasure");
diff --git a/src/scriptengine/ScriptEngine.hpp b/src/scriptengine/ScriptEngine.hpp
index 51dc8b8f395..256d87786e4 100644
--- a/src/scriptengine/ScriptEngine.hpp
+++ b/src/scriptengine/ScriptEngine.hpp
@@ -28,6 +28,10 @@ class ScriptEngine;
 struct ScriptObject
 {
   std::any object;
+
+  bool empty() const {
+    return !object.has_value();
+  }
 };
 }  // namespace openstudio
 
@@ -35,6 +39,10 @@ using ScriptEngineFactoryType = openstudio::ScriptEngine*(int, char**);
 
 namespace openstudio {
 
+namespace measure {
+  class OSMeasure;
+}
+
 class ScriptEngine
 {
  public:
@@ -63,6 +71,14 @@ class ScriptEngine
 
   virtual void setupPythonPath(const std::vector<openstudio::path>& includeDirs){};
 
+  virtual std::string inferMeasureClassName(const openstudio::path& measureScriptPath) = 0;
+
+  // Ideally this would return a openstudio::measure::OSMeasure* or a shared_ptr<openstudio::measure::OSMeasure> but this poses memory management
+  // issue for the underlying ScriptObject (and VALUE or PyObject), so just return the ScriptObject
+  virtual ScriptObject loadMeasure(const openstudio::path& measureScriptPath, std::string_view className) = 0;
+
+  virtual int numberOfArguments(ScriptObject& methodObject, std::string_view methodName) = 0;
+
   template <typename T>
   T getAs(ScriptObject& obj) {
     void* result = getAs_impl(obj, typeid(T));
diff --git a/src/utilities/CMakeLists.txt b/src/utilities/CMakeLists.txt
index 0fbf652ba65..6ea99c08af9 100644
--- a/src/utilities/CMakeLists.txt
+++ b/src/utilities/CMakeLists.txt
@@ -71,6 +71,7 @@ set(core_src
   core/StringStreamLogSink.cpp
   core/System.hpp
   core/System.cpp
+  core/ThreadSafeDeque.hpp
   core/UUID.hpp
   core/UUID.cpp
   core/UnzipFile.hpp
diff --git a/src/utilities/bcl/BCLMeasure.cpp b/src/utilities/bcl/BCLMeasure.cpp
index 8b77e672185..48d342185d7 100644
--- a/src/utilities/bcl/BCLMeasure.cpp
+++ b/src/utilities/bcl/BCLMeasure.cpp
@@ -820,11 +820,11 @@ bool BCLMeasure::updateMeasureScript(const MeasureType& oldMeasureType, const Me
                                                                           << newMeasureLanguage.valueName());
   }
   // TODO: deal with Python
-  boost::optional<openstudio::path> path = primaryRubyScriptPath();
-  if (path && exists(*path)) {
+  boost::optional<openstudio::path> rubyScriptPath_ = primaryRubyScriptPath();
+  if (rubyScriptPath_ && exists(*rubyScriptPath_)) {
 
     std::string fileString;
-    openstudio::filesystem::ifstream file(*path, std::ios_base::binary);
+    openstudio::filesystem::ifstream file(*rubyScriptPath_, std::ios_base::binary);
     if (file.is_open()) {
 
       // Replacement paterns
@@ -859,7 +859,7 @@ bool BCLMeasure::updateMeasureScript(const MeasureType& oldMeasureType, const Me
 
       file.close();
 
-      openstudio::filesystem::ofstream ofile(*path, std::ios_base::binary);
+      openstudio::filesystem::ofstream ofile(*rubyScriptPath_, std::ios_base::binary);
       if (ofile.is_open()) {
         ofile << fileString;
         // openstudio::filesystem::write(ofile, fileString);
diff --git a/src/utilities/bcl/BCLXML.cpp b/src/utilities/bcl/BCLXML.cpp
index ba7eb212ae5..d27a2d9cbd8 100644
--- a/src/utilities/bcl/BCLXML.cpp
+++ b/src/utilities/bcl/BCLXML.cpp
@@ -735,6 +735,7 @@ Json::Value BCLXML::toJSON() const {
 
   Json::Value root;
 
+  root["measure_dir"] = m_path.parent_path().generic_string();
   if (m_bclXMLType == BCLXMLType::ComponentXML) {
     root["type"] = "component";
   } else if (m_bclXMLType == BCLXMLType::MeasureXML) {
diff --git a/src/utilities/core/Containers.hpp b/src/utilities/core/Containers.hpp
index 9c032ba9951..8ac06a005f6 100644
--- a/src/utilities/core/Containers.hpp
+++ b/src/utilities/core/Containers.hpp
@@ -10,11 +10,9 @@
 #include "Compare.hpp"
 
 #include <boost/optional.hpp>
-
 #include <vector>
 #include <set>
 #include <string>
-
 #include <iterator>     // for make_move_iterator
 #include <type_traits>  // for declval, conditional, decay_t, enable_if_t
 
diff --git a/src/utilities/core/ThreadSafeDeque.hpp b/src/utilities/core/ThreadSafeDeque.hpp
new file mode 100644
index 00000000000..b974838a6b0
--- /dev/null
+++ b/src/utilities/core/ThreadSafeDeque.hpp
@@ -0,0 +1,46 @@
+/***********************************************************************************************************************
+*  OpenStudio(R), Copyright (c) Alliance for Sustainable Energy, LLC.
+*  See also https://openstudio.net/license
+***********************************************************************************************************************/
+
+#ifndef UTILITIES_CORE_THREADSAFEDEQUE_HPP
+#define UTILITIES_CORE_THREADSAFEDEQUE_HPP
+
+#include <condition_variable>
+#include <deque>
+#include <mutex>
+
+namespace openstudio {
+
+// Thank you https://codereview.stackexchange.com/questions/238347/a-simple-thread-safe-deque-in-c
+template <class T>
+class ThreadSafeDeque
+{
+ public:
+  T wait_for_one() {
+    // unique_lock can be unlocked, lock_guard can not
+    std::unique_lock<std::mutex> lock{m_mutex};  // locks
+    while (m_deque.empty()) {
+      m_condition.wait(lock);  // unlocks, sleeps and relocks when woken up
+    }
+    auto t = std::move(m_deque.front());
+    m_deque.pop_front();
+    return t;
+  }  // unlocks as goes out of scope
+
+  void push_back(T&& t) {
+    std::unique_lock<std::mutex> lock{m_mutex};
+    m_deque.push_back(std::move(t));
+    lock.unlock();
+    m_condition.notify_one();  // wakes up pop_front_waiting
+  }
+
+ private:
+  std::deque<T> m_deque;
+  std::mutex m_mutex;
+  std::condition_variable m_condition{};
+};
+
+}  // namespace openstudio
+
+#endif  // UTILITIES_CORE_THREADSAFEDEQUE_HPP
diff --git a/src/workflow/ApplyMeasure.cpp b/src/workflow/ApplyMeasure.cpp
index 6bfec3a55ac..54c56415650 100644
--- a/src/workflow/ApplyMeasure.cpp
+++ b/src/workflow/ApplyMeasure.cpp
@@ -104,12 +104,6 @@ void OSWorkflow::applyMeasures(MeasureType measureType, bool energyplus_output_r
     LOG(Debug, "Measure Type: " << bclMeasure.measureType().valueName());
     LOG(Debug, "Measure Language: " << measureLanguage.valueName());
 
-    //openstudio::measure::ModelMeasure* modelMeasurePtr = nullptr;
-    // TODO: probably want to do that ultimately, then static_cast appropriately
-    ScriptEngineInstance* thisEngine = nullptr;
-    ScriptObject measureScriptObject;
-    openstudio::measure::OSMeasure* measurePtr = nullptr;
-
     auto getArguments = [this, &measureType, &scriptPath_, &step](openstudio::measure::OSMeasure* measurePtr) -> measure::OSArgumentMap {
       if (!measurePtr) {
         throw std::runtime_error(fmt::format("Could not load measure at '{}'", openstudio::toString(scriptPath_.get())));
@@ -157,59 +151,30 @@ void OSWorkflow::applyMeasures(MeasureType measureType, bool energyplus_output_r
       return argumentMap;
     };
 
+    ScriptEngineInstance* thisEngine = nullptr;
     if (measureLanguage == MeasureLanguage::Ruby) {
       // TODO: probably need to do path formatting properly for windows
 #if USE_RUBY_ENGINE
-      auto importCmd = fmt::format("require '{}'", openstudio::toString(scriptPath_.get()));
-      rubyEngine->exec(importCmd);
-      measureScriptObject = rubyEngine->eval(fmt::format("{}.new()", className));
       thisEngine = &rubyEngine;
 #else
       throw std::runtime_error("Cannot run a Ruby measure when RubyEngine isn't enabled");
 #endif
     } else if (measureLanguage == MeasureLanguage::Python) {
 #if USE_PYTHON_ENGINE
-      // place measureDirPath in sys.path; do from measure import MeasureName
-      // I think this can't work without a "as xxx" otherwise we'll repeatedly try to import a module named 'measure'
-      // pythonEngine->pyimport("measure", openstudio::toString(measureDirPath.get()));
-      //         auto importCmd = fmt::format(R"python(
-      // import sys
-      // sys.path.insert(0, r'{}')
-      // force_reload = 'measure' in sys.modules
-      // import measure
-      // if force_reload:
-      //     # print("force reload measure")
-      //     import importlib
-      //     importlib.reload(measure)
-      // )python",
-      //                                      scriptPath_->parent_path().generic_string());
-      auto importCmd = fmt::format(R"python(
-import importlib.util
-spec = importlib.util.spec_from_file_location('{}', r'{}')
-module = importlib.util.module_from_spec(spec)
-spec.loader.exec_module(module)
-)python",
-                                   className, scriptPath_->generic_string());
-      // fmt::print("\nimportCmd:\n{}\n", importCmd);
-      pythonEngine->exec(importCmd);
-      // measureScriptObject = pythonEngine->eval(fmt::format("measure.{}()", className));
-      measureScriptObject = pythonEngine->eval(fmt::format("module.{}()", className));
-
       thisEngine = &pythonEngine;
 #else
       throw std::runtime_error("Cannot run a Python measure when PythonEngine isn't enabled");
 #endif
     }
 
+    ScriptObject measureScriptObject = (*thisEngine)->loadMeasure(*scriptPath_, className);
+    if (measureScriptObject.empty()) {
+      throw std::runtime_error(fmt::format("Failed to load measure '{}' from '{}'\n", className, openstudio::toString(scriptPath_.get())));
+    }
+
     // This pointer will only be valid for as long as the above PythonMeasure is in scope
     // After that, dereferencing the measure pointer will crash the program
-    if (measureType == MeasureType::ModelMeasure) {
-      measurePtr = (*thisEngine)->getAs<openstudio::measure::ModelMeasure*>(measureScriptObject);
-    } else if (measureType == MeasureType::EnergyPlusMeasure) {
-      measurePtr = (*thisEngine)->getAs<openstudio::measure::EnergyPlusMeasure*>(measureScriptObject);
-    } else if (measureType == MeasureType::ReportingMeasure) {
-      measurePtr = (*thisEngine)->getAs<openstudio::measure::ReportingMeasure*>(measureScriptObject);
-    }
+    auto* measurePtr = (*thisEngine)->getAs<openstudio::measure::OSMeasure*>(measureScriptObject);
 
     const auto argmap = getArguments(measurePtr);
     // There is a bug. I can run one measure but not two. The one measure can be either python or ruby


Test patch:
diff --git a/src/cli/test/conftest.py b/src/cli/test/conftest.py
index 9962ee4d403..88c8bc23e55 100644
--- a/src/cli/test/conftest.py
+++ b/src/cli/test/conftest.py
@@ -4,7 +4,7 @@
 
 
 def validate_file(arg):
-    if (filepath := Path(arg)).is_file():
+    if (filepath := Path(arg).expanduser()).is_file():
         return filepath
     else:
         raise FileNotFoundError(arg)
@@ -14,7 +14,7 @@ def pytest_addoption(parser):
     parser.addoption("--os-cli-path", type=validate_file, help="Path to the OS CLI")  # , required=True
 
 
-@pytest.fixture
+@pytest.fixture(scope="module")
 def osclipath(request):
     cli_path = request.config.getoption("--os-cli-path")
     if cli_path is None:
diff --git a/src/cli/test/test_measure_manager.py b/src/cli/test/test_measure_manager.py
new file mode 100644
index 00000000000..93a231fea6c
--- /dev/null
+++ b/src/cli/test/test_measure_manager.py
@@ -0,0 +1,545 @@
+"""Tests for the CLI's MeasureManagerServer.
+
+Calls the CLI to serve the MeasureManagerServer and uses python to send GET/POST requests
+
+Example Usage:
+
+```
+pytest --verbose --tb=short -rfEsxX --durations=0 test_measure_manager.py \
+       --os-cli-path=~/Software/Others/OS-build/Products/openstudio
+```
+"""
+import errno
+import shutil
+import signal
+import socket
+import subprocess
+import time
+from contextlib import closing
+from copy import deepcopy
+from pathlib import Path
+from typing import Any, Dict, Tuple
+from urllib.parse import urljoin
+
+import pytest
+import requests
+
+HOST = "localhost"
+DEFAULT_MEASURES_DIR = Path("~/OpenStudio/Measures").expanduser()
+
+BASE_INTERNAL_STATE: Dict[str, Any] = {
+    "idf": [],
+    "measure_info": None,
+    "measures": None,
+    "my_measures_dir": DEFAULT_MEASURES_DIR.as_posix(),
+    "osm": [],
+    "status": "running",
+}
+
+BCL_DIR = Path("~/BCL").expanduser()
+# This is 'Test Measure Recursive Folders'
+TEST_BCL_MEASURE_UID = "b79e1024-8a7a-4c9d-91bf-6c11de58f4a9"
+
+
+def get_url(port: int):
+    return f"http://{HOST}:{port}"
+
+
+class MeasureManagerClient(requests.Session):
+    """Stores the base url in the class so we don't repeat ourselves."""
+
+    def __init__(self, port):
+        self.base_url = get_url(port=port)
+        super().__init__()
+
+    def request(self, method, url, *args, **kwargs):
+        url = urljoin(self.base_url, url)
+        return super().request(method, url, *args, **kwargs)
+
+    def internal_state(self):
+        r = self.get("/internal_state")
+        r.raise_for_status()
+        return r.json()
+
+    def reset(self):
+        r = self.post("/reset")
+        r.raise_for_status()
+        assert r.json() == "Resetting internal state"
+
+    def reset_and_assert_internal_state(self):
+        self.reset()
+        assert self.internal_state() == BASE_INTERNAL_STATE
+
+
+@pytest.fixture
+def expected_internal_state():
+    return deepcopy(BASE_INTERNAL_STATE)
+
+
+def get_open_port_for_serving():
+    """Find an unused port for serving the Measure Manager."""
+
+    port = 8089
+    is_open = False
+    while not is_open:
+        port += 1
+        # print(f"Testing {port=}")
+        with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
+            try:
+                sock.bind((HOST, port))
+                is_open = True
+            except socket.error as e:
+                if e.errno == errno.EADDRINUSE:
+                    print(f"{port=} is already in use")
+                else:
+                    # something else raised the socket.error exception
+                    raise e
+    print(f"Using {port=}")
+    return port
+
+
+def launch_measure_manager_client(osclipath: Path) -> Tuple[subprocess.Popen, int]:
+    port = get_open_port_for_serving()
+    base_url = get_url(port=port)
+    proc = subprocess.Popen([str(osclipath), "labs", "measure", "-s", str(port)])  # NOTE: remove 'labs' to test old CLI
+    ok = False
+    while not ok:
+        try:
+            r = requests.get(base_url)
+            ok = r.status_code == requests.codes.ok
+            running = r.json()["status"] == "running"
+            ok &= running
+            # print(f"{ok=}, {running=}")
+        except Exception as e:
+            # print(f"{e.__repr__()}")
+            time.sleep(0.1)
+
+    r = requests.get(base_url)
+    assert r.json()["status"] == "running"
+    return proc, port
+
+
+@pytest.fixture
+def measure_manager_client(osclipath):
+    """Launches a measure manager server and waits for it to be ready, then destroys at end."""
+    # If you want to attach to your debug CLI session you have launched in a debugger, just do this isntead
+    # yield MeasureManagerClient(port=8094)
+    # return
+    # Regular
+    proc, port = launch_measure_manager_client(osclipath=osclipath)
+    yield MeasureManagerClient(port=port)
+    # if sys.platform == "win32":
+    #     proc.send_signal(signal.CTRL_C_EVENT)
+    # else:
+    #     proc.send_signal(signal.SIGINT)
+    proc.kill()
+
+
+# TODO: probably would be easier to just locate openstudio python bindings
+def _write_model_cli(osclipath: Path, osm_path: Path):
+    subprocess.check_output([str(osclipath), "-e", f"m = OpenStudio::Model::Model.new; m.save('{osm_path}',  true)"])
+    # model = openstudio.model.Model()
+    # model.save(str(osm_path), True)
+
+
+def _write_example_model_cli(osclipath: Path, osm_path: Path):
+    subprocess.check_output([str(osclipath), "-e", f"m = OpenStudio::Model::exampleModel; m.save('{osm_path}',  true)"])
+    # model = openstudio.model.exampleModel()
+    # model.save(str(osm_path), True)
+
+
+def _checksum_cli(osclipath: Path, osm_path: Path):
+    r = subprocess.check_output(
+        [str(osclipath), "-e", f"puts OpenStudio::checksum(OpenStudio::toPath('{osm_path}'))"], encoding="utf-8"
+    )
+    return r.splitlines()[0]
+    # openstudio.checksum(openstudio.toPath(str(osm_path)))
+
+
+def test_default_internal_state(measure_manager_client, expected_internal_state):
+    r = measure_manager_client.get("/internal_state")
+    r.raise_for_status()
+    assert r.json() == expected_internal_state
+    # Equivalent
+    assert measure_manager_client.internal_state() == expected_internal_state
+
+
+def test_set_measures_dir(measure_manager_client, expected_internal_state):
+    my_measures_dir = Path("~/OpenStudio/Measures2").expanduser()
+    r = measure_manager_client.post("/set", json={"my_measures_dir": str(my_measures_dir)})
+    r.raise_for_status()
+    expected_internal_state["my_measures_dir"] = my_measures_dir.as_posix()
+    assert measure_manager_client.internal_state() == expected_internal_state
+
+
+# tmp_path is a built-in pytest fixture hich will provide a temporary directory unique to the test invocation,
+# created in the base temporary directory.
+def test_get_model(
+    measure_manager_client: MeasureManagerClient,
+    expected_internal_state: Dict[str, Any],
+    tmp_path: Path,
+    osclipath: Path,
+):
+    osm_path = tmp_path / "model.osm"
+    osm_path2 = tmp_path / "model2.osm"
+
+    _write_model_cli(osclipath=osclipath, osm_path=osm_path)
+    model_checksum = _checksum_cli(osclipath=osclipath, osm_path=osm_path)
+    _write_model_cli(osclipath=osclipath, osm_path=osm_path2)
+    model_checksum2 = _checksum_cli(osclipath=osclipath, osm_path=osm_path2)
+    r = measure_manager_client.post("/get_model", json={"osm_path": str(osm_path)})
+    r.raise_for_status()
+    assert r.json() == f"OK, loaded model with checksum {model_checksum}"
+
+    expected_internal_state["osm"].append(
+        {
+            "checksum": model_checksum,
+            "osm_path": osm_path.as_posix(),
+        }
+    )
+    assert measure_manager_client.internal_state() == expected_internal_state
+
+    r = measure_manager_client.post("/get_model", json={"osm_path": str(osm_path2)})
+    r.raise_for_status()
+    assert r.json() == f"OK, loaded model with checksum {model_checksum2}"
+
+    expected_internal_state["osm"].append(
+        {
+            "checksum": model_checksum2,
+            "osm_path": osm_path2.as_posix(),
+        }
+    )
+    assert measure_manager_client.internal_state() == expected_internal_state
+
+    # Try to get model 1 again, it should just reload it
+    r = measure_manager_client.post("/get_model", json={"osm_path": str(osm_path)})
+    r.raise_for_status()
+    assert r.json() == f"OK, loaded model with checksum {model_checksum}"
+
+    # Overwrite model1 and try again, it should reload it
+    _write_model_cli(osclipath=osclipath, osm_path=osm_path)
+    model_checksum = _checksum_cli(osclipath=osclipath, osm_path=osm_path)
+
+    r = measure_manager_client.post("/get_model", json={"osm_path": str(osm_path)})
+    r.raise_for_status()
+    assert r.json() == f"OK, loaded model with checksum {model_checksum}"
+    expected_internal_state["osm"][0] = {
+        "checksum": model_checksum,
+        "osm_path": osm_path.as_posix(),
+    }
+
+    measure_manager_client.reset_and_assert_internal_state()
+
+
+def test_download_bcl_measures(measure_manager_client: MeasureManagerClient, expected_internal_state: Dict[str, Any]):
+    r = measure_manager_client.post(url="/download_bcl_measure", json={"": ""})
+    assert r.status_code == 400
+    assert r.json() == "Missing the uid in the post data"
+
+    r = measure_manager_client.post(url=f"/download_bcl_measure", json={"uid": "baduuid"})
+    assert r.status_code == 400
+    assert r.json() == "Cannot find measure with uid='baduuid'"
+
+    measure_dir = BCL_DIR / TEST_BCL_MEASURE_UID
+    if measure_dir.exists():
+        shutil.rmtree(measure_dir)
+
+    # TODO: this should be a get!
+    r = measure_manager_client.post("/bcl_measures")
+    r.raise_for_status()
+    measures = r.json()
+    assert isinstance(measures, list)
+    n_ori = len(measures)
+    ori_measures = [x["name"] for x in measures]
+
+    r = measure_manager_client.post(url="/download_bcl_measure", json={"uid": TEST_BCL_MEASURE_UID})
+    r.raise_for_status()
+    measure_data = r.json()
+    assert "name" in measure_data
+    assert "display_name" in measure_data
+    assert "version_id" in measure_data
+    assert measure_data["name"] == "test_measure_recursive_folders"
+    assert measure_data["display_name"] == "Test Measure Recursive Folders"
+
+    assert measure_dir.exists()
+
+    r = measure_manager_client.post("/bcl_measures")
+    r.raise_for_status()
+    measures = r.json()
+    assert isinstance(measures, list)
+    n_new = len(measures)
+    new_measures = [x["name"] for x in measures]
+    assert ori_measures + ["test_measure_recursive_folders"] == new_measures
+    assert n_ori + 1 == n_new
+
+
+def test_update_measures(measure_manager_client: MeasureManagerClient, expected_internal_state: Dict[str, Any]):
+    r = measure_manager_client.post(url="/update_measures")
+    r.raise_for_status()
+
+
+def modify_measure_for_model_dependent_arg(measure_dir: Path, is_ruby: bool):
+    ext = "rb" if is_ruby else "py"
+    measure_file = measure_dir / f"measure.{ext}"
+    if not measure_file.exists():
+        raise ValueError(f"{measure_file} doesn't exist")
+
+    with open(measure_file, "r") as f:
+        lines = f.read().splitlines()
+
+    in_block = False
+    arg_line = -1
+    for i, line in enumerate(lines):
+        line = line.strip()
+        if "def arguments" in line:
+            in_block = True
+        if not in_block:
+            continue
+        if line.startswith("args = "):
+            arg_line = i
+            break
+    assert arg_line > 0
+
+    indent: int = lines[arg_line].index("args")  # type: ignore
+
+    if is_ruby:
+        prog = """
+construction_handles = []
+construction_display_names = []
+
+model.getConstructions.each do |c|
+    construction_handles << c.handle.to_s
+    construction_display_names << c.nameString
+end
+construction = OpenStudio::Measure::OSArgument.makeChoiceArgument(
+    'construction', construction_handles, construction_display_names, true, true
+)
+construction.setDisplayName("Choose a Construction to Alter")
+construction.setDescription("Pick a construction from model")
+args << construction"""
+
+    else:
+        prog = """
+construction_handles = []
+construction_display_names = []
+
+for c in model.getConstructions():
+    construction_handles.append(str(c.handle()))
+    construction_display_names.append(c.nameString())
+
+construction = openstudio.measure.OSArgument.makeChoiceArgument(
+    'construction', construction_handles, construction_display_names, True, True
+)
+construction.setDisplayName("Choose a Construction to Alter")
+construction.setDescription("Pick a construction from model")
+args.append(construction)"""
+
+    for line in prog.splitlines():
+        arg_line += 1
+        if line:
+            lines.insert(arg_line, " " * indent + line)
+        else:
+            lines.insert(arg_line, "")
+
+    with open(measure_file, "w") as f:
+        f.write("\n".join(lines) + "\n")
+
+
+@pytest.mark.parametrize(
+    "measure_language",
+    ["Ruby", "Python"],
+)
+def test_compute_arguments(
+    measure_language: str,
+    measure_manager_client: MeasureManagerClient,
+    expected_internal_state: Dict[str, Any],
+    tmp_path: Path,
+    osclipath: Path,
+):
+    is_ruby = measure_language == "Ruby"
+    measure_dir = tmp_path / f"new_{measure_language}_measure"
+    assert not measure_dir.is_dir()
+    readme_out_path = measure_dir / "README.md"
+
+    data = {
+        # "name": "name",
+        "measure_dir": str(measure_dir),
+        "display_name": "A ModelMeasure that depends on model",
+        "class_name": "ModelDependentMeasure",
+        "taxonomy_tag": "taxonomy_tag",
+        "measure_type": "ModelMeasure",  # <=
+        "measure_language": measure_language,  # <=
+        "description": "This is the description",
+        "modeler_description": "This is the modeler description",
+    }
+    r = measure_manager_client.post(url="/create_measure", json=data)
+    r.raise_for_status
+    assert measure_dir.is_dir()
+
+    # Test README.md ERB generation
+    if is_ruby:
+        assert readme_out_path.exists()
+        readme_out_path.unlink()
+
+    # breakpoint()
+    r = measure_manager_client.post(url="/compute_arguments", json={"measure_dir": str(measure_dir)})
+    r.raise_for_status()
+
+    if is_ruby:
+        assert readme_out_path.exists()
+        readme_out_path.unlink()
+
+    modify_measure_for_model_dependent_arg(measure_dir=measure_dir, is_ruby=is_ruby)
+
+    r = measure_manager_client.post(url="/compute_arguments", json={"measure_dir": str(measure_dir)})
+    r.raise_for_status()
+
+    if is_ruby:
+        assert readme_out_path.exists()
+        readme_out_path.unlink()
+
+    osm_path = tmp_path / "model.osm"
+    _write_example_model_cli(osclipath=osclipath, osm_path=osm_path)
+    assert osm_path.is_file()
+    r = measure_manager_client.post(
+        url="/compute_arguments", json={"measure_dir": str(measure_dir), "osm_path": str(osm_path)}
+    )
+    r.raise_for_status()
+    measure_info = r.json()
+    arguments = measure_info["arguments"]
+    assert len(arguments) == 2
+    construction_arg = next(x for x in arguments if x["name"] == "construction")
+    assert "choice_display_names" in construction_arg
+    assert "choices_values" in construction_arg
+    assert len(construction_arg["choice_display_names"]) > 5
+    assert len(construction_arg["choice_display_names"]) == len(construction_arg["choices_values"])
+
+    if is_ruby:
+        assert readme_out_path.exists()
+        readme_out_md = readme_out_path.read_text()
+        assert "Choose a Construction to Alter" in readme_out_md
+
+
+def test_create_measure(
+    measure_manager_client: MeasureManagerClient,
+    expected_internal_state: Dict[str, Any],
+    tmp_path: Path,
+):
+    data = {
+        # "name": "name",
+        "display_name": "display_name",
+        "class_name": "class_name",
+        "taxonomy_tag": "taxonomy_tag",
+        "measure_type": "BadMeasureType",  # <=
+        "measure_language": "BadMeasureLanguage",  # <=
+        "description": "description",
+        "modeler_description": "modeler_description",
+    }
+    r = measure_manager_client.post(url="/create_measure", json=data)
+    assert not r.ok
+    assert r.json() == "The 'measure_dir' (string) must be in the post data."
+
+    measure_dir = tmp_path / "new_measure"
+    data["measure_dir"] = measure_dir.as_posix()
+    assert not measure_dir.is_dir()
+
+    # The enums are throwy! Make sure we handle that gracefully
+    r = measure_manager_client.post(url="/create_measure", json=data)
+    assert not r.ok
+    assert (
+        r.json()
+        == "Couldn't convert 'BadMeasureType' to a MeasureType: Unknown OpenStudio Enum Value 'BADMEASURETYPE' for Enum MeasureType"
+    )
+    data["measure_type"] = "ModelMeasure"
+
+    r = measure_manager_client.post(url="/create_measure", json=data)
+    assert not r.ok
+    assert (
+        r.json()
+        == "Couldn't convert 'BadMeasureLanguage' to a MeasureLanguage: Unknown OpenStudio Enum Value 'BADMEASURELANGUAGE' for Enum MeasureLanguage"
+    )
+    data["measure_language"] = "Python"
+
+    r = measure_manager_client.post("/create_measure", json=data)
+    r.raise_for_status()
+    assert measure_dir.is_dir()
+
+    measure_info = r.json()
+    for k in data:
+        if k in ["measure_type", "measure_language"]:
+            continue
+        if k == "taxonomy_tag":
+            assert len(measure_info["tags"]) == 1
+            measure_info["tags"][0] == data[k]
+            continue
+        assert k in measure_info, k
+        assert measure_info[k] == data[k], f"{k}: {measure_info[k]} vs {data[k]}"
+    assert next(x["value"] for x in measure_info["attributes"] if x["name"] == "Measure Type") == data["measure_type"]
+    assert (
+        next(x["value"] for x in measure_info["attributes"] if x["name"] == "Measure Language")
+        == data["measure_language"]
+    )
+
+    # Should fail gracefully when directory exists already
+    r = measure_manager_client.post("/create_measure", json=data)
+    assert not r.ok
+    assert "The directory already exists" in r.json()
+
+
+def test_duplicate_measure(
+    measure_manager_client: MeasureManagerClient,
+    expected_internal_state: Dict[str, Any],
+    tmp_path: Path,
+):
+    old_measure_dir = tmp_path / "old_measure"
+    new_measure_dir = tmp_path / "new_measure_dir"
+
+    assert not old_measure_dir.is_dir()
+    assert not new_measure_dir.is_dir()
+
+    data_ori = {
+        "measure_dir": old_measure_dir.as_posix(),
+        "display_name": "old_display_name",
+        "class_name": "old_class_name",
+        "taxonomy_tag": "old_taxonomy_tag",
+        "measure_type": "ModelMeasure",
+        "measure_language": "Python",
+        "description": "old_description",
+        "modeler_description": "old_modeler_description",
+    }
+    r = measure_manager_client.post(url="/create_measure", json=data_ori)
+    r.raise_for_status()
+
+    assert old_measure_dir.is_dir()
+    assert not new_measure_dir.is_dir()
+
+    data = {
+        "old_measure_dir": old_measure_dir.as_posix(),
+        "measure_dir": new_measure_dir.as_posix(),
+        "display_name": "display_name",
+        "class_name": "class_name",
+        "taxonomy_tag": "taxonomy_tag",
+        "measure_type": "EnergyPlusMeasure",
+        # "measure_language": "Python",
+        "description": "description",
+        "modeler_description": "modeler_description",
+    }
+    r = measure_manager_client.post("/duplicate_measure", json=data)
+    r.raise_for_status()
+    measure_info = r.json()
+    for k in data:
+        if k in ["old_measure_dir", "measure_type", "measure_language"]:
+            continue
+        if k == "taxonomy_tag":
+            assert len(measure_info["tags"]) == 1
+            measure_info["tags"][0] == data[k]
+            continue
+        assert k in measure_info, k
+        assert measure_info[k] == data[k], f"{k}: {measure_info[k]} vs {data[k]}"
+    assert next(x["value"] for x in measure_info["attributes"] if x["name"] == "Measure Type") == data["measure_type"]
+    assert (
+        next(x["value"] for x in measure_info["attributes"] if x["name"] == "Measure Language")
+        == data_ori["measure_language"]
+    )
+    assert old_measure_dir.is_dir()
+    assert new_measure_dir.is_dir()