diff --git a/cli/asc.json b/cli/asc.json
index c675b64b58..fbbe218b70 100644
--- a/cli/asc.json
+++ b/cli/asc.json
@@ -188,7 +188,8 @@
       " tail-calls          Tail call operations.",
       " multi-value         Multi value types."
     ],
-    "type": "S"
+    "type": "S",
+    "mutuallyExclusive": "disable"
   },
   "disable": {
     "category": "Features",
@@ -198,7 +199,8 @@
       " mutable-globals     Mutable global imports and exports.",
       ""
     ],
-    "type": "S"
+    "type": "S",
+    "mutuallyExclusive": "enable"
   },
   "use": {
     "category": "Features",
diff --git a/cli/util/options.d.ts b/cli/util/options.d.ts
index 630c496030..e12013fef3 100644
--- a/cli/util/options.d.ts
+++ b/cli/util/options.d.ts
@@ -3,6 +3,11 @@
  * @license Apache-2.0
  */
 
+/** A set of options. */
+export interface OptionSet {
+  [key: string]: number | string
+}
+
 /** Command line option description. */
 export interface OptionDescription {
   /** Textual description. */
@@ -10,7 +15,7 @@ export interface OptionDescription {
   /** Data type. One of (b)oolean [default], (i)nteger, (f)loat or (s)tring. Uppercase means multiple values. */
   type?: "b" | "i" | "f" | "s" | "I" | "F" | "S",
   /** Substituted options, if any. */
-  value?: { [key: string]: number | string },
+  value?: OptionSet,
   /** Short alias, if any. */
   alias?: string
   /** The default value, if any. */
@@ -27,19 +32,17 @@ interface Config {
 /** Parsing result. */
 interface Result {
   /** Parsed options. */
-  options: { [key: string]: number | string },
+  options: OptionSet,
   /** Unknown options. */
   unknown: string[],
   /** Normal arguments. */
   arguments: string[],
   /** Trailing arguments. */
-  trailing: string[],
-  /** Provided arguments from the cli. */
-  provided: Set<string>
+  trailing: string[]
 }
 
 /** Parses the specified command line arguments according to the given configuration. */
-export function parse(argv: string[], config: Config): Result;
+export function parse(argv: string[], config: Config, propagateDefaults?: boolean): Result;
 
 /** Help formatting options. */
 interface HelpOptions {
@@ -53,3 +56,9 @@ interface HelpOptions {
 
 /** Generates the help text for the specified configuration. */
 export function help(config: Config, options?: HelpOptions): string;
+
+/** Merges two sets of options into one, preferring the current over the parent set. */
+export function merge(config: Config, currentOptions: OptionSet, parentOptions: OptionSet): OptionSet;
+
+/** Populates default values on a parsed options result. */
+export function addDefaults(config: Config, options: OptionSet): OptionSet;
diff --git a/cli/util/options.js b/cli/util/options.js
index ac20ba68e7..8d3b39ed61 100644
--- a/cli/util/options.js
+++ b/cli/util/options.js
@@ -16,12 +16,11 @@ const colorsUtil = require("./colors");
 // S    | string array
 
 /** Parses the specified command line arguments according to the given configuration. */
-function parse(argv, config) {
+function parse(argv, config, propagateDefaults = true) {
   var options = {};
   var unknown = [];
   var args = [];
   var trailing = [];
-  var provided = new Set();
 
   // make an alias map and initialize defaults
   var aliases = {};
@@ -54,13 +53,15 @@ function parse(argv, config) {
       else { args.push(arg); continue; } // argument
     }
     if (option) {
-      if (option.type == null || option.type === "b") {
-        options[key] = true; // flag
-        provided.add(key);
+      if (option.value) {
+        // alias setting fixed values
+        Object.keys(option.value).forEach(k => options[k] = option.value[k]);
+      } else if (option.type == null || option.type === "b") {
+        // boolean flag not taking a value
+        options[key] = true;
       } else {
-        // the argument was provided
-        if (i + 1 < argv.length && argv[i + 1].charCodeAt(0) != 45) { // present
-          provided.add(key);
+        if (i + 1 < argv.length && argv[i + 1].charCodeAt(0) != 45) {
+          // non-boolean with given value
           switch (option.type) {
             case "i": options[key] = parseInt(argv[++i], 10); break;
             case "I": options[key] = (options[key] || []).concat(parseInt(argv[++i], 10)); break;
@@ -70,7 +71,8 @@ function parse(argv, config) {
             case "S": options[key] = (options[key] || []).concat(argv[++i].split(",")); break;
             default: unknown.push(arg); --i;
           }
-        } else { // omitted
+        } else {
+          // non-boolean with omitted value
           switch (option.type) {
             case "i":
             case "f": options[key] = option.default || 0; break;
@@ -82,12 +84,12 @@ function parse(argv, config) {
           }
         }
       }
-      if (option.value) Object.keys(option.value).forEach(k => options[k] = option.value[k]);
     } else unknown.push(arg);
   }
   while (i < k) trailing.push(argv[i++]); // trailing
+  if (propagateDefaults) addDefaults(config, options);
 
-  return { options, unknown, arguments: args, trailing, provided };
+  return { options, unknown, arguments: args, trailing };
 }
 
 exports.parse = parse;
@@ -138,3 +140,93 @@ function help(config, options) {
 }
 
 exports.help = help;
+
+/** Sanitizes an option value to be a valid value of the option's type. */
+function sanitizeValue(value, type) {
+  if (value != null) {
+    switch (type) {
+      case undefined:
+      case "b": return Boolean(value);
+      case "i": return Math.trunc(value) || 0;
+      case "f": return Number(value) || 0;
+      case "s": return String(value);
+      case "I": {
+        if (!Array.isArray(value)) value = [ value ];
+        return value.map(v => Math.trunc(v) || 0);
+      }
+      case "F": {
+        if (!Array.isArray(value)) value = [ value ];
+        return value.map(v => Number(v) || 0);
+      }
+      case "S": {
+        if (!Array.isArray(value)) value = [ value ];
+        return value.map(String);
+      }
+    }
+  }
+  return undefined;
+}
+
+/** Merges two sets of options into one, preferring the current over the parent set. */
+function merge(config, currentOptions, parentOptions) {
+  const mergedOptions = {};
+  for (const [key, { type, mutuallyExclusive }] of Object.entries(config)) {
+    let currentValue = sanitizeValue(currentOptions[key], type);
+    let parentValue = sanitizeValue(parentOptions[key], type);
+    if (currentValue == null) {
+      if (parentValue != null) {
+        // only parent value present
+        if (Array.isArray(parentValue)) {
+          let exclude;
+          if (mutuallyExclusive != null && (exclude = currentOptions[mutuallyExclusive])) {
+            mergedOptions[key] = parentValue.filter(value => !exclude.includes(value));
+          } else {
+            mergedOptions[key] = parentValue.slice();
+          }
+        } else {
+          mergedOptions[key] = parentValue;
+        }
+      }
+    } else if (parentValue == null) {
+      // only current value present
+      if (Array.isArray(currentValue)) {
+        mergedOptions[key] = currentValue.slice();
+      } else {
+        mergedOptions[key] = currentValue;
+      }
+    } else {
+      // both current and parent values present
+      if (Array.isArray(currentValue)) {
+        let exclude;
+        if (mutuallyExclusive != null && (exclude = currentOptions[mutuallyExclusive])) {
+          mergedOptions[key] = [
+            ...currentValue,
+            ...parentValue.filter(value => !currentValue.includes(value) && !exclude.includes(value))
+          ];
+        } else {
+          mergedOptions[key] = [
+            ...currentValue,
+            ...parentValue.filter(value => !currentValue.includes(value)) // dedup
+          ];
+        }
+      } else {
+        mergedOptions[key] = currentValue;
+      }
+    }
+  }
+  return mergedOptions;
+}
+
+exports.merge = merge;
+
+/** Populates default values on a parsed options result. */
+function addDefaults(config, options) {
+  for (const [key, { default: defaultValue }] of Object.entries(config)) {
+    if (options[key] == null && defaultValue != null) {
+      options[key] = defaultValue;
+    }
+  }
+  return options;
+}
+
+exports.addDefaults = addDefaults;
diff --git a/tests/cli/options.js b/tests/cli/options.js
new file mode 100644
index 0000000000..e24575b876
--- /dev/null
+++ b/tests/cli/options.js
@@ -0,0 +1,49 @@
+const assert = require("assert");
+const optionsUtil = require("../../cli/util/options");
+
+const config = {
+  "enable": {
+    "type": "S",
+    "mutuallyExclusive": "disable"
+  },
+  "disable": {
+    "type": "S",
+    "mutuallyExclusive": "enable"
+  },
+  "other": {
+    "type": "S",
+    "default": ["x"]
+  }
+};
+
+// Present in both should concat
+var merged = optionsUtil.merge(config, { enable: ["a"] }, { enable: ["b"] });
+assert.deepEqual(merged.enable, ["a", "b"]);
+
+merged = optionsUtil.merge(config, { enable: ["a"] }, { enable: ["a", "b"] });
+assert.deepEqual(merged.enable, ["a", "b"]);
+
+// Mutually exclusive should exclude
+merged = optionsUtil.merge(config, { enable: ["a", "b"] }, { disable: ["a", "c"] });
+assert.deepEqual(merged.enable, ["a", "b"]);
+assert.deepEqual(merged.disable, ["c"]);
+
+merged = optionsUtil.merge(config, { disable: ["a", "b"] }, { enable: ["a", "c"] });
+assert.deepEqual(merged.enable, ["c"]);
+assert.deepEqual(merged.disable, ["a", "b"]);
+
+// Populating defaults should work after the fact
+merged = optionsUtil.addDefaults(config, {});
+assert.deepEqual(merged.other, ["x"]);
+
+merged = optionsUtil.addDefaults(config, { other: ["y"] });
+assert.deepEqual(merged.other, ["y"]);
+
+// Complete usage test
+var result = optionsUtil.parse(["--enable", "a", "--disable", "b"], config, false);
+merged = optionsUtil.merge(config, result.options, { enable: ["b", "c"] });
+merged = optionsUtil.merge(config, merged, { disable: ["a", "d"] });
+optionsUtil.addDefaults(config, merged);
+assert.deepEqual(merged.enable, ["a", "c"]);
+assert.deepEqual(merged.disable, ["b", "d"]);
+assert.deepEqual(merged.other, ["x"]);