Repository: tweag/nickel
Pull request #1632 (2023-09-26 11:24:59)

Make `eval` an explicit subcommand and restructure the CLI somewhat
This change makes `eval` an explicit subcommand for the Nickel CLI and adds the ability to pass multiple files to a single CLI invocation. If multiple files are parsed, they will be evaluated separately and the results merged.

Additionally, this PR decouples the customize mode from `nickel export` and instead makes it available anywhere full evaluation is performed.


Patch:
diff --git a/cli/src/cli.rs b/cli/src/cli.rs
index 01ba7c14..e3af9738 100644
--- a/cli/src/cli.rs
+++ b/cli/src/cli.rs
@@ -1,5 +1,4 @@
 //! Command-line options and subcommands.
-use std::path::PathBuf;
 
 use git_version::git_version;
 
@@ -39,34 +38,23 @@ pub struct Options {
     pub global: GlobalOptions,
 
     #[command(subcommand)]
-    pub command: Option<Command>,
+    pub command: Command,
 }
 
 #[derive(clap::Parser, Debug)]
 pub struct GlobalOptions {
-    #[cfg(debug_assertions)]
-    /// Skips the standard library import. For debugging only. This does not affect REPL
-    #[arg(long, global = true)]
-    pub nostdlib: bool,
-
     /// Configure when to output messages in color
     #[arg(long, global = true, value_enum, default_value_t)]
     pub color: clap::ColorChoice,
-
-    /// Input file, omit to read from stdin
-    #[arg(long, short, global = true)]
-    pub file: Option<PathBuf>,
 }
 
 /// Available subcommands.
 #[derive(clap::Subcommand, Debug)]
 pub enum Command {
     /// Evaluate a Nickel program and pretty-print the result.
-    #[command(hide = true)]
     Eval(EvalCommand),
-
-    /// Converts the parsed representation (AST) back to Nickel source code and prints it. Used for
-    /// debugging purpose
+    /// Converts the parsed representation (AST) back to Nickel source code and
+    /// prints it. Used for debugging purpose
     PprintAst(PprintAstCommand),
     /// Exports the result to a different format
     Export(ExportCommand),
diff --git a/cli/src/customize.rs b/cli/src/customize.rs
new file mode 100644
index 00000000..8dfb7136
--- /dev/null
+++ b/cli/src/customize.rs
@@ -0,0 +1,593 @@
+use std::{
+    collections::{BTreeMap, HashMap},
+    ffi::OsString,
+};
+
+use clap::{parser::ValueSource, ArgAction, Command};
+use nickel_lang_core::{
+    combine::Combine,
+    eval::cache::lazy::CBNCache,
+    identifier::LocIdent,
+    program::{FieldOverride, Program},
+    repl::query_print::{write_query_result, Attributes},
+    term::{
+        record::{Field, RecordData},
+        LabeledType, MergePriority, RuntimeContract, Term, TypeAnnotation,
+    },
+    typ::{RecordRowF, RecordRowsIteratorItem, Type, TypeF},
+};
+
+use crate::error::CliResult;
+
+/// The maximal number of overridable fields displayed. Because there might be a lot of them, we
+/// don't list them all by default.
+const OVERRIDES_LIST_MAX_COUNT: usize = 15;
+
+/// The value name used through the CLI to indicate that an option accepts any Nickel expression as
+/// a value.
+const NICKEL_VALUE_NAME: &str = "NICKEL EXPRESSION";
+
+const EXPERIMENTAL_MSG: &str =
+    "[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.";
+
+#[derive(clap::Parser, Debug)]
+pub struct CustomizeMode {
+    /// \[WARNING\] Customize mode is experimental. Its interface is subject to breaking changes.
+    ///
+    /// Customize mode turns the nickel invocation into a new CLI based on the configuration to be
+    /// exported, where the value of fields can be set directly through CLI arguments. Print the
+    /// corresponding help (`nickel export <file.ncl> -- --help`) to see available options.
+    #[arg(last = true)]
+    pub customize_mode: Vec<OsString>,
+}
+/// The interface of a configuration (a Nickel program) which represents all nested field paths
+/// that are accessible from the root term together with their associated metadata.
+///
+/// Interface is used to derive a command-line interface from a configuration when using the
+/// `customize_mode` option.
+#[derive(Debug, Clone, Default)]
+struct TermInterface {
+    // We use a BTreeMap so that the end result is being sorted as we build the interface.
+    fields: BTreeMap<LocIdent, FieldInterface>,
+}
+
+/// The interface of a specific input field.
+#[derive(Debug, Clone, Default)]
+struct FieldInterface {
+    /// The interface of the subfields of this field, if it's a record itself.
+    subfields: Option<TermInterface>,
+    field: Field,
+}
+
+/// The interface of a non-input field (overridable).
+#[derive(Debug, Clone, Default)]
+struct OverrideInterface {
+    /// The path of the overridable field.
+    path: Vec<String>,
+    /// An optional description, built from the field's metadata, and inserted into the general
+    /// help message of the `--override` flag.
+    // help isn't used yet, hence the leading underscore. We need to think first about how to
+    // display it without cluttering the customize mode's help message.
+    _help: Option<String>,
+}
+
+impl TermInterface {
+    /// Create a new, empty interface.
+    fn new() -> Self {
+        Self::default()
+    }
+
+    /// Build a command description from this interface.
+    ///
+    /// This method recursively lists all existing field paths, and reports input fields (as
+    /// defined per [FieldInterface::is_input]) as stand-alone arguments. For example, if the
+    /// configuration contains:
+    ///
+    /// ```nickel
+    /// foo.bar.baz | Number,
+    /// ```
+    ///
+    /// This will give rise to a corresponding `--foo.bar.baz` argument listed in the `help` message
+    /// which can be set.
+    ///
+    /// Non-input fields are still listed in the description of the `--override` command, which
+    /// has the ability of setting any field.
+    ///
+    /// # Return
+    ///
+    /// In addition to the updated command, `build_cmd` returns a mapping from clap argument ids to
+    /// their corresponding full field path as an array of fields.
+    fn build_cmd(&self) -> TermCommand {
+        let clap_cmd = Command::new("customize-mode")
+            .about("Customize a Nickel configuration through the command line before exporting")
+            .after_help(EXPERIMENTAL_MSG)
+            .no_binary_name(true);
+
+        let mut term_cmd = TermCommand::new(clap_cmd);
+
+        for (id, field) in &self.fields {
+            term_cmd = field.add_args(term_cmd, vec![id.to_string()])
+        }
+
+        let mut overrides_list: Vec<String> = term_cmd
+            .overrides
+            .keys()
+            .take(OVERRIDES_LIST_MAX_COUNT)
+            .map(|field_path| format!("- {field_path}"))
+            .collect();
+
+        if term_cmd.overrides.len() > OVERRIDES_LIST_MAX_COUNT {
+            overrides_list.push("- ...".into());
+        }
+
+        let has_override = term_cmd.inputs.contains_key("override");
+        let has_help = term_cmd.inputs.contains_key("help");
+
+        let override_arg_label = "override";
+        let override_help = format!(
+            "Override any field of the configuration with a valid Nickel expression provided as \
+            a string. The new value will be merged with the configuration with a `force` \
+            priority.\n\n\
+            Overridable fields:\n{}\n\n",
+            overrides_list.join("\n")
+        );
+        let override_arg = clap::Arg::new(override_arg_label)
+            .long(override_arg_label)
+            .value_name(NICKEL_VALUE_NAME.to_owned())
+            .number_of_values(2)
+            .value_names(["field", "value"])
+            .action(ArgAction::Append)
+            .required(false)
+            .help(override_help);
+
+        term_cmd.clap_cmd = term_cmd.clap_cmd.arg(override_arg);
+
+        if has_help || has_override {
+            let conflict_field = if has_override {
+                "override"
+            } else if has_help {
+                "help"
+            } else {
+                // We tested in the parent `if` that one of those two booleans must be set
+                unreachable!()
+            };
+
+            let extra = if has_help && has_override {
+                ". The same applies to the conflicting `help` field"
+            } else {
+                ""
+            };
+
+            term_cmd.clap_cmd = term_cmd.clap_cmd.after_long_help(format!(
+                "\
+            [CONFLICT] This configuration has a field named `{conflict_field}` which conflicts \
+            with the built-in `--{conflict_field}` argument. To set this field to e.g. \
+            \"some_value\", use `--override {conflict_field} \"some_value\"` instead of \
+            `--{conflict_field} \"some_value\"`\
+            {extra}\n\n\
+            {EXPERIMENTAL_MSG}"
+            ));
+        }
+
+        term_cmd
+    }
+}
+
+impl Combine for TermInterface {
+    fn combine(first: Self, second: Self) -> Self {
+        let TermInterface { mut fields } = first;
+
+        for (id, field) in second.fields.into_iter() {
+            if let Some(prev) = fields.remove(&id) {
+                fields.insert(id, Combine::combine(prev, field));
+            } else {
+                fields.insert(id, field);
+            }
+        }
+
+        TermInterface { fields }
+    }
+}
+
+impl Combine for FieldInterface {
+    fn combine(first: Self, second: Self) -> Self {
+        FieldInterface {
+            subfields: Combine::combine(first.subfields, second.subfields),
+            field: Field {
+                metadata: Combine::combine(first.field.metadata, second.field.metadata),
+                // Value is used only to show a default value, and to determine if a field has a
+                // definition. We don't bother actually merging the content, but just keep any
+                // side that is defined.
+                value: first.field.value.or(second.field.value),
+                ..Default::default()
+            },
+        }
+    }
+}
+
+impl From<&RecordData> for TermInterface {
+    fn from(value: &RecordData) -> Self {
+        TermInterface {
+            fields: value
+                .fields
+                .iter()
+                .map(|(id, field)| (*id, field.into()))
+                .collect(),
+        }
+    }
+}
+
+impl From<&Term> for TermInterface {
+    fn from(term: &Term) -> Self {
+        term.extract_interface().unwrap_or_else(TermInterface::new)
+    }
+}
+
+trait ExtractInterface {
+    fn extract_interface(&self) -> Option<TermInterface>;
+}
+
+impl ExtractInterface for &Term {
+    fn extract_interface(&self) -> Option<TermInterface> {
+        if let Term::Record(rd) = self {
+            Some(TermInterface::from(rd))
+        } else {
+            None
+        }
+    }
+}
+
+impl ExtractInterface for Field {
+    fn extract_interface(&self) -> Option<TermInterface> {
+        self.value.as_ref().map(|t| TermInterface::from(t.as_ref()))
+    }
+}
+
+impl ExtractInterface for Type {
+    fn extract_interface(&self) -> Option<TermInterface> {
+        match &self.typ {
+            TypeF::Record(rrows) => Some(TermInterface {
+                fields: rrows
+                    .iter()
+                    .filter_map(|item| {
+                        if let RecordRowsIteratorItem::Row(rrow) = item {
+                            Some((rrow.id, FieldInterface::from(&rrow)))
+                        } else {
+                            None
+                        }
+                    })
+                    .collect(),
+            }),
+            // Contract information is already extracted from runtime contracts, which are
+            // evaluated. Here, we focus on pure static type annotations and ignore flat types as
+            // well
+            _ => None,
+        }
+    }
+}
+
+impl ExtractInterface for RuntimeContract {
+    fn extract_interface(&self) -> Option<TermInterface> {
+        self.contract.as_ref().extract_interface()
+    }
+}
+
+impl ExtractInterface for LabeledType {
+    fn extract_interface(&self) -> Option<TermInterface> {
+        self.typ.extract_interface()
+    }
+}
+
+impl From<&Field> for FieldInterface {
+    fn from(field: &Field) -> Self {
+        // We collect field information from all the sources we can: static types and contracts
+        // (both either as type annotations or contract annotations), and the value of the field if
+        // it's a record.
+
+        let subfields_from_types = field
+            .pending_contracts
+            .iter()
+            .map(ExtractInterface::extract_interface);
+
+        let subfields_from_contracts = field
+            .metadata
+            .annotation
+            .iter()
+            .map(ExtractInterface::extract_interface);
+
+        let subfields_from_value = std::iter::once(field.extract_interface());
+
+        let subfields = subfields_from_types
+            .chain(subfields_from_contracts)
+            .chain(subfields_from_value)
+            .reduce(Combine::combine)
+            .flatten();
+
+        FieldInterface {
+            subfields,
+            field: field.clone(),
+        }
+    }
+}
+
+impl From<&RecordRowF<&Type>> for FieldInterface {
+    fn from(rrow: &RecordRowF<&Type>) -> Self {
+        FieldInterface {
+            subfields: rrow.typ.extract_interface(),
+            ..Default::default()
+        }
+    }
+}
+
+impl FieldInterface {
+    /// Take a clap command and enrich it with either one argument if this subfield is an input
+    /// field, or with all the subfields that are inputs. If this field or some of its subfields
+    /// aren't inputs, they are pushed to `overrides`. See [TermInterface::build_cmd].
+    ///
+    /// `add_args` updates `paths` as well, which maps clap argument ids to the corresponding field
+    /// path represented as an array of string names.
+    fn add_args(&self, mut term_cmd: TermCommand, path: Vec<String>) -> TermCommand {
+        // This is a terminal field, which gives rise to an argument or an overridable value.
+        if !self.has_subfields() {
+            if self.is_input() {
+                match path.as_slice() {
+                    [ref field] if field == "override" || field == "help" => {
+                        term_cmd.register_input(path.clone())
+                    }
+                    _ => {
+                        term_cmd.register_arg(path.clone());
+                        term_cmd.clap_cmd = self.add_arg(term_cmd.clap_cmd, path.join("."))
+                    }
+                };
+            } else {
+                term_cmd.register_override(path.clone(), self.help());
+            }
+        }
+
+        for (id, field) in self.subfields.iter().flat_map(|intf| intf.fields.iter()) {
+            let mut path = path.clone();
+            path.push(id.to_string());
+
+            term_cmd = field.add_args(term_cmd, path);
+        }
+
+        term_cmd
+    }
+
+    /// Add a single argument to the CLI `cmd`, based on the interface of this field, and return
+    /// the updated command.
+    fn add_arg(&self, cmd: clap::Command, path: String) -> clap::Command {
+        let mut arg = clap::Arg::new(&path)
+            .long(path)
+            .value_name(get_value_name(&self.field.metadata.annotation))
+            // TODO: Create clap argument groups
+            .required(!self.is_default());
+
+        if let Some(help) = &self.help() {
+            arg = arg.help(help);
+        };
+
+        if let Some(default) = self.default_value() {
+            arg = arg.default_value(default);
+        }
+
+        cmd.arg(arg)
+    }
+
+    /// Define if a field is an input of a configuration that is intended to be filled, and will be
+    /// given a dedicated CLI argument. If the field is not an input, it can only be overridden via
+    /// `--override`.
+    ///
+    /// Currently, the difference is mostly conceptual: in practice, we simply gather the arguments
+    /// and their values, either via direct arguments or `--override` (although the latter sets a
+    /// different priority), and then merge the elaborated record with original term.
+    ///
+    /// However, from a user experience point of view, we want to make a clear distinction between
+    /// filling a bespoke input of a configuration and overriding an existing value. The latter is
+    /// more "low-level" and should only be done knowingly.
+    ///
+    /// Currently, the logic is simply that a field is an input if it doesn't have a definition or
+    /// it has a default priority. This definition might evolve, as there are ongoing discussions
+    /// on what is should be the meaning of "input", "output", and if those concept should be made
+    /// first-class ([related issue](https://github.com/tweag/nickel/issues/1505)). For now, this
+    /// logic seems to be a reasonable first approximation.
+    fn is_input(&self) -> bool {
+        !self.is_defined() || self.is_default()
+    }
+
+    /// Return `true` is the field has a value.
+    fn is_defined(&self) -> bool {
+        self.field.value.is_some()
+    }
+
+    /// Return true is the field's merge priority is `default`.
+    fn is_default(&self) -> bool {
+        matches!(self.field.metadata.priority, MergePriority::Bottom)
+    }
+
+    fn has_subfields(&self) -> bool {
+        matches!(&self.subfields, Some(ref intf) if !intf.fields.is_empty())
+    }
+
+    /// Return the default value, if any.
+    fn default_value(&self) -> Option<String> {
+        match (&self.field.metadata.priority, &self.field.value) {
+            (MergePriority::Bottom, Some(value)) => Some(value.to_string()),
+            _ => None,
+        }
+    }
+
+    /// Render a help message similar to the output of a metadata query to serve as an help text
+    /// for this argument.
+    fn help(&self) -> Option<String> {
+        let mut output: Vec<u8> = Vec::new();
+
+        // We only need to render the documentation: the rest is printed separately as part of the
+        // clap command that is built.
+        let attributes = Attributes {
+            doc: true,
+            contract: false,
+            typ: false,
+            default: false,
+            value: false,
+        };
+
+        write_query_result(&mut output, &self.field, attributes)
+            .unwrap_or(false)
+            .then(|| {
+                String::from_utf8(output)
+                    .expect("the query printer should always output valid utf8")
+            })
+    }
+}
+
+fn get_value_name(annotation: &TypeAnnotation) -> String {
+    if annotation.is_empty() {
+        NICKEL_VALUE_NAME.into()
+    } else {
+        let anns: Vec<String> = annotation
+            .iter()
+            .map(|ctr| ctr.label.typ.to_string())
+            .collect();
+        anns.join(",")
+    }
+}
+
+/// A command dynamically built from a Nickel term.
+struct TermCommand {
+    /// The corresponding clap command
+    clap_cmd: clap::Command,
+    /// A mapping between clap argument ids and the corresponding field path (for inputs).
+    args: HashMap<clap::Id, Vec<String>>,
+    /// Mapping between existing input fields (as a dot-separated path string) and their path
+    /// represented as a vector. This is used both as a `HashSet` to check the existence of an
+    /// input field, and to sidestep the issue of parsing a path again when reading an input
+    /// argument.
+    inputs: HashMap<String, Vec<String>>,
+    /// A mapping between a field path and the corresponding override data.
+    overrides: BTreeMap<String, OverrideInterface>,
+}
+
+impl TermCommand {
+    fn new(command: clap::Command) -> Self {
+        TermCommand {
+            clap_cmd: command,
+            overrides: BTreeMap::new(),
+            inputs: HashMap::new(),
+            args: HashMap::new(),
+        }
+    }
+
+    /// Register a new argument corresponding to an input. Add the paths and the argument's id to
+    /// the relevant maps.
+    fn register_arg(&mut self, path: Vec<String>) {
+        let path_string = path.join(".");
+        let arg_id = clap::Id::from(path_string.clone());
+
+        debug_assert!(!self.args.contains_key(&arg_id));
+        debug_assert!(!self.inputs.contains_key(&path_string));
+
+        self.args.insert(arg_id, path.clone());
+        self.inputs.insert(path_string, path);
+    }
+
+    /// Register an input, but don't register the corresponding CLI argument. Used for `override`
+    /// and `help` which conflict with the built-in `--override` and `--help` arguments.
+    fn register_input(&mut self, path: Vec<String>) {
+        let path_string = path.join(".");
+
+        debug_assert!(!self.inputs.contains_key(&path_string));
+
+        self.inputs.insert(path_string, path);
+    }
+
+    fn register_override(&mut self, path: Vec<String>, help: Option<String>) {
+        let path_string = path.join(".");
+
+        self.overrides
+            .insert(path_string, OverrideInterface { path, _help: help });
+    }
+}
+
+pub trait Customize {
+    fn customize(&self, program: Program<CBNCache>) -> CliResult<Program<CBNCache>>;
+}
+
+impl Customize for CustomizeMode {
+    // XXX: we should give a nice error message when someone tries to evaluate some
+    //      expression that has unset values, telling them they can set them using
+    //      this method
+    fn customize(&self, mut program: Program<CBNCache>) -> CliResult<Program<CBNCache>> {
+        if self.customize_mode.is_empty() {
+            return Ok(program);
+        }
+
+        let evaled = match program.eval_record_spine() {
+            Ok(evaled) => evaled,
+            // We need a `return` control-flow to be able to take `program` out
+            Err(error) => return CliResult::Err(crate::error::Error::Program { error, program }),
+        };
+
+        let cmd = TermInterface::from(evaled.as_ref()).build_cmd();
+        let arg_matches = cmd.clap_cmd.get_matches_from(self.customize_mode.iter());
+
+        let force_overrides: Result<Vec<_>, super::error::CliUsageError> = arg_matches
+            .get_occurrences("override")
+            .into_iter()
+            .flat_map(|occurs| {
+                occurs.map(|mut pair| {
+                    let (path, value): (&String, &String) =
+                        (pair.next().unwrap(), pair.next().unwrap());
+
+                    cmd.overrides
+                        .get(path)
+                        .map(|intf| intf.path.clone())
+                        // We allow --override to set inputs as well, in particular as an
+                        // escape mechanism to set an input field named `override` or `help`,
+                        // which would conflict with the corresponding builtin flags.
+                        .or(cmd.inputs.get(path).cloned())
+                        .map(|path| FieldOverride {
+                            path,
+                            value: value.clone(),
+                            priority: MergePriority::Top,
+                        })
+                        .ok_or_else(|| super::error::CliUsageError::InvalidOverride {
+                            path: path.clone(),
+                        })
+                })
+            })
+            .collect();
+
+        let force_overrides = match force_overrides {
+            Ok(force_overrides) => force_overrides,
+            Err(error) => return CliResult::Err(crate::error::Error::CliUsage { error, program }),
+        };
+
+        program.add_overrides(
+            arg_matches
+                .ids()
+                .filter_map(|id| -> Option<FieldOverride> {
+                    (!matches!(
+                        arg_matches.value_source(id.as_str()),
+                        Some(ValueSource::DefaultValue)
+                    ) && id.as_str() != "override")
+                        .then(|| FieldOverride {
+                            path: cmd.args.get(id).unwrap().clone(),
+                            value: arg_matches.get_one::<String>(id.as_str()).unwrap().clone(),
+                            priority: MergePriority::default(),
+                        })
+                })
+                .chain(force_overrides),
+        );
+        Ok(program)
+    }
+}
+
+#[derive(clap::Args, Debug)]
+pub struct NoCustomizeMode;
+
+impl Customize for NoCustomizeMode {
+    fn customize(&self, program: Program<CBNCache>) -> CliResult<Program<CBNCache>> {
+        Ok(program)
+    }
+}
diff --git a/cli/src/doc.rs b/cli/src/doc.rs
index b454fd96..9ad82236 100644
--- a/cli/src/doc.rs
+++ b/cli/src/doc.rs
@@ -12,7 +12,8 @@ use nickel_lang_core::{
 use crate::{
     cli::GlobalOptions,
     error::{CliResult, ResultErrorExt},
-    eval,
+    eval::EvalCommand,
+    input::Prepare,
 };
 
 #[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
@@ -53,20 +54,18 @@ pub struct DocCommand {
     /// The output format for the generated documentation.
     #[arg(long, value_enum, default_value_t)]
     pub format: crate::doc::DocFormat,
+
+    #[command(flatten)]
+    pub evaluation: EvalCommand,
 }
 
 impl DocCommand {
     pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = eval::prepare(&global)?;
-        self.export_doc(&mut program, &global)
-            .report_with_program(program)
+        let mut program = self.evaluation.prepare(&global)?;
+        self.export_doc(&mut program).report_with_program(program)
     }
 
-    fn export_doc(
-        self,
-        program: &mut Program<CacheImpl>,
-        global: &GlobalOptions,
-    ) -> Result<(), Error> {
+    fn export_doc(self, program: &mut Program<CacheImpl>) -> Result<(), Error> {
         let doc = program.extract_doc()?;
         let mut out: Box<dyn std::io::Write> = if self.stdout {
             Box::new(std::io::stdout())
@@ -96,7 +95,7 @@ impl DocCommand {
 
                         let mut has_file_name = false;
 
-                        if let Some(path) = &global.file {
+                        if let Some(path) = self.evaluation.input.files.get(0) {
                             if let Some(file_stem) = path.file_stem() {
                                 output_file.push(file_stem);
                                 has_file_name = true;
diff --git a/cli/src/eval.rs b/cli/src/eval.rs
index b6c39538..bf428437 100644
--- a/cli/src/eval.rs
+++ b/cli/src/eval.rs
@@ -2,39 +2,44 @@ use nickel_lang_core::{eval::cache::lazy::CBNCache, program::Program};
 
 use crate::{
     cli::GlobalOptions,
+    customize::CustomizeMode,
     error::{CliResult, ResultErrorExt},
+    input::{InputOptions, Prepare},
 };
 
 #[derive(clap::Parser, Debug)]
-pub struct EvalCommand {}
+pub struct EvalCommand {
+    #[cfg(debug_assertions)]
+    /// Skips the standard library import. For debugging only
+    #[arg(long, global = true)]
+    pub nostdlib: bool,
+
+    #[command(flatten)]
+    pub input: InputOptions<CustomizeMode>,
+}
 
 impl EvalCommand {
     pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = prepare(&global)?;
+        let mut program = self.prepare(&global)?;
         program
             .eval_full()
             .map(|t| println!("{t}"))
             .report_with_program(program)
     }
-
-    pub fn prepare(&self, global: &GlobalOptions) -> CliResult<Program<CBNCache>> {
-        prepare(global)
-    }
 }
 
-pub fn prepare(global: &GlobalOptions) -> CliResult<Program<CBNCache>> {
-    let mut program = global
-        .file
-        .clone()
-        .map(|f| Program::new_from_file(f, std::io::stderr()))
-        .unwrap_or_else(|| Program::new_from_stdin(std::io::stderr()))?;
+impl Prepare for EvalCommand {
+    fn prepare(&self, global: &GlobalOptions) -> CliResult<Program<CBNCache>> {
+        // Rust warns about `program` not needing to be mutable if
+        // `debug_assertions` are off
+        #![allow(unused_mut)]
+        let mut program = self.input.prepare(global)?;
 
-    #[cfg(debug_assertions)]
-    if global.nostdlib {
-        program.set_skip_stdlib();
-    }
+        #[cfg(debug_assertions)]
+        if self.nostdlib {
+            program.set_skip_stdlib();
+        }
 
-    program.color_opt = global.color.into();
-
-    Ok(program)
+        Ok(program)
+    }
 }
diff --git a/cli/src/export.rs b/cli/src/export.rs
index 21624785..0f7d70b0 100644
--- a/cli/src/export.rs
+++ b/cli/src/export.rs
@@ -1,47 +1,22 @@
-use std::{
-    collections::{BTreeMap, HashMap},
-    ffi::OsString,
-    io::Write,
-    {fs, path::PathBuf},
-};
-
-use clap::{parser::ValueSource, ArgAction, Command};
+use std::{fs, io::Write, path::PathBuf};
 
 use nickel_lang_core::{
-    combine::Combine,
     error::{Error, IOError},
     eval::cache::lazy::CBNCache,
-    identifier::LocIdent,
-    program::{FieldOverride, Program},
-    repl::query_print::{write_query_result, Attributes},
+    program::Program,
     serialize::{self, ExportFormat},
-    term::{
-        record::{Field, RecordData},
-        LabeledType, MergePriority, RuntimeContract, Term, TypeAnnotation,
-    },
-    typ::{RecordRowF, RecordRowsIteratorItem, Type, TypeF},
 };
 
 use crate::{
     cli::GlobalOptions,
     error::{CliResult, ResultErrorExt},
     eval::EvalCommand,
+    input::Prepare,
 };
 
-/// The maximal number of overridable fields displayed. Because there might be a lot of them, we
-/// don't list them all by default.
-const OVERRIDES_LIST_MAX_COUNT: usize = 15;
-
-/// The value name used through the CLI to indicate that an option accepts any Nickel expression as
-/// a value.
-const NICKEL_VALUE_NAME: &str = "NICKEL EXPRESSION";
-
-const EXPERIMENTAL_MSG: &str =
-    "[WARNING] Customize mode is experimental. Its interface is subject to breaking changes.";
-
 #[derive(clap::Parser, Debug)]
 pub struct ExportCommand {
-    #[arg(long, value_enum, default_value_t)]
+    #[arg(long, short, value_enum, default_value_t)]
     pub format: ExportFormat,
 
     /// Output file. Standard output by default
@@ -50,570 +25,17 @@ pub struct ExportCommand {
 
     #[command(flatten)]
     pub evaluation: EvalCommand,
-
-    /// \[WARNING\] Customize mode is experimental. Its interface is subject to breaking changes.
-    ///
-    /// Customize mode turns the nickel invocation into a new CLI based on the configuration to be
-    /// exported, where the value of fields can be set directly through CLI arguments. Print the
-    /// corresponding help (`nickel export -f <file.ncl> -- --help`) to see available options.
-    #[arg(last = true)]
-    pub customize_mode: Vec<OsString>,
-}
-
-/// The interface of a configuration (a Nickel program) which represents all nested field paths
-/// that are accessible from the root term together with their associated metadata.
-///
-/// Interface is used to derive a command-line interface from a configuration when using the
-/// `customize_mode` option.
-#[derive(Debug, Clone, Default)]
-struct TermInterface {
-    // We use a BTreeMap so that the end result is being sorted as we build the interface.
-    fields: BTreeMap<LocIdent, FieldInterface>,
-}
-
-/// The interface of a specific input field.
-#[derive(Debug, Clone, Default)]
-struct FieldInterface {
-    /// The interface of the subfields of this field, if it's a record itself.
-    subfields: Option<TermInterface>,
-    field: Field,
-}
-
-/// The interface of a non-input field (overridable).
-#[derive(Debug, Clone, Default)]
-struct OverrideInterface {
-    /// The path of the overridable field.
-    path: Vec<String>,
-    /// An optional description, built from the field's metadata, and inserted into the general
-    /// help message of the `--override` flag.
-    // help isn't used yet, hence the leading underscore. We need to think first about how to
-    // display it without cluttering the customize mode's help message.
-    _help: Option<String>,
-}
-
-impl TermInterface {
-    /// Create a new, empty interface.
-    fn new() -> Self {
-        Self::default()
-    }
-
-    /// Build a command description from this interface.
-    ///
-    /// This method recursively lists all existing field paths, and reports input fields (as
-    /// defined per [FieldInterface::is_input]) as stand-alone arguments. For example, if the
-    /// configuration contains:
-    ///
-    /// ```nickel
-    /// foo.bar.baz | Number,
-    /// ```
-    ///
-    /// This will give rise to a corresponding `--foo.bar.baz` argument listed in the `help` message
-    /// which can be set.
-    ///
-    /// Non-input fields are still listed in the description of the `--override` command, which
-    /// has the ability of setting any field.
-    ///
-    /// # Return
-    ///
-    /// In addition to the updated command, `build_cmd` returns a mapping from clap argument ids to
-    /// their corresponding full field path as an array of fields.
-    fn build_cmd(&self) -> TermCommand {
-        let clap_cmd = Command::new("customize-mode")
-            .about("Customize a Nickel configuration through the command line before exporting")
-            .after_help(EXPERIMENTAL_MSG)
-            .no_binary_name(true);
-
-        let mut term_cmd = TermCommand::new(clap_cmd);
-
-        for (id, field) in &self.fields {
-            term_cmd = field.add_args(term_cmd, vec![id.to_string()])
-        }
-
-        let mut overrides_list: Vec<String> = term_cmd
-            .overrides
-            .keys()
-            .take(OVERRIDES_LIST_MAX_COUNT)
-            .map(|field_path| format!("- {field_path}"))
-            .collect();
-
-        if term_cmd.overrides.len() > OVERRIDES_LIST_MAX_COUNT {
-            overrides_list.push("- ...".into());
-        }
-
-        let has_override = term_cmd.inputs.contains_key("override");
-        let has_help = term_cmd.inputs.contains_key("help");
-
-        let override_arg_label = "override";
-        let override_help = format!(
-            "Override any field of the configuration with a valid Nickel expression provided as \
-            a string. The new value will be merged with the configuration with a `force` \
-            priority.\n\n\
-            Overridable fields:\n{}\n\n",
-            overrides_list.join("\n")
-        );
-        let override_arg = clap::Arg::new(override_arg_label)
-            .long(override_arg_label)
-            .value_name(NICKEL_VALUE_NAME.to_owned())
-            .number_of_values(2)
-            .value_names(["field", "value"])
-            .action(ArgAction::Append)
-            .required(false)
-            .help(override_help);
-
-        term_cmd.clap_cmd = term_cmd.clap_cmd.arg(override_arg);
-
-        if has_help || has_override {
-            let conflict_field = if has_override {
-                "override"
-            } else if has_help {
-                "help"
-            } else {
-                // We tested in the parent `if` that one of those two booleans must be set
-                unreachable!()
-            };
-
-            let extra = if has_help && has_override {
-                ". The same applies to the conflicting `help` field"
-            } else {
-                ""
-            };
-
-            term_cmd.clap_cmd = term_cmd.clap_cmd.after_long_help(format!(
-                "\
-            [CONFLICT] This configuration has a field named `{conflict_field}` which conflicts \
-            with the built-in `--{conflict_field}` argument. To set this field to e.g. \
-            \"some_value\", use `--override {conflict_field} \"some_value\"` instead of \
-            `--{conflict_field} \"some_value\"`\
-            {extra}\n\n\
-            {EXPERIMENTAL_MSG}"
-            ));
-        }
-
-        term_cmd
-    }
-}
-
-impl Combine for TermInterface {
-    fn combine(first: Self, second: Self) -> Self {
-        let TermInterface { mut fields } = first;
-
-        for (id, field) in second.fields.into_iter() {
-            if let Some(prev) = fields.remove(&id) {
-                fields.insert(id, Combine::combine(prev, field));
-            } else {
-                fields.insert(id, field);
-            }
-        }
-
-        TermInterface { fields }
-    }
-}
-
-impl Combine for FieldInterface {
-    fn combine(first: Self, second: Self) -> Self {
-        FieldInterface {
-            subfields: Combine::combine(first.subfields, second.subfields),
-            field: Field {
-                metadata: Combine::combine(first.field.metadata, second.field.metadata),
-                // Value is used only to show a default value, and to determine if a field has a
-                // definition. We don't bother actually merging the content, but just keep any
-                // side that is defined.
-                value: first.field.value.or(second.field.value),
-                ..Default::default()
-            },
-        }
-    }
-}
-
-impl From<&RecordData> for TermInterface {
-    fn from(value: &RecordData) -> Self {
-        TermInterface {
-            fields: value
-                .fields
-                .iter()
-                .map(|(id, field)| (*id, field.into()))
-                .collect(),
-        }
-    }
-}
-
-impl From<&Term> for TermInterface {
-    fn from(term: &Term) -> Self {
-        term.extract_interface().unwrap_or_else(TermInterface::new)
-    }
-}
-
-trait ExtractInterface {
-    fn extract_interface(&self) -> Option<TermInterface>;
-}
-
-impl ExtractInterface for &Term {
-    fn extract_interface(&self) -> Option<TermInterface> {
-        if let Term::Record(rd) = self {
-            Some(TermInterface::from(rd))
-        } else {
-            None
-        }
-    }
-}
-
-impl ExtractInterface for Field {
-    fn extract_interface(&self) -> Option<TermInterface> {
-        self.value.as_ref().map(|t| TermInterface::from(t.as_ref()))
-    }
-}
-
-impl ExtractInterface for Type {
-    fn extract_interface(&self) -> Option<TermInterface> {
-        match &self.typ {
-            TypeF::Record(rrows) => Some(TermInterface {
-                fields: rrows
-                    .iter()
-                    .filter_map(|item| {
-                        if let RecordRowsIteratorItem::Row(rrow) = item {
-                            Some((rrow.id, FieldInterface::from(&rrow)))
-                        } else {
-                            None
-                        }
-                    })
-                    .collect(),
-            }),
-            // Contract information is already extracted from runtime contracts, which are
-            // evaluated. Here, we focus on pure static type annotations and ignore flat types as
-            // well
-            _ => None,
-        }
-    }
-}
-
-impl ExtractInterface for RuntimeContract {
-    fn extract_interface(&self) -> Option<TermInterface> {
-        self.contract.as_ref().extract_interface()
-    }
-}
-
-impl ExtractInterface for LabeledType {
-    fn extract_interface(&self) -> Option<TermInterface> {
-        self.typ.extract_interface()
-    }
-}
-
-impl From<&Field> for FieldInterface {
-    fn from(field: &Field) -> Self {
-        // We collect field information from all the sources we can: static types and contracts
-        // (both either as type annotations or contract annotations), and the value of the field if
-        // it's a record.
-
-        let subfields_from_types = field
-            .pending_contracts
-            .iter()
-            .map(ExtractInterface::extract_interface);
-
-        let subfields_from_contracts = field
-            .metadata
-            .annotation
-            .iter()
-            .map(ExtractInterface::extract_interface);
-
-        let subfields_from_value = std::iter::once(field.extract_interface());
-
-        let subfields = subfields_from_types
-            .chain(subfields_from_contracts)
-            .chain(subfields_from_value)
-            .reduce(Combine::combine)
-            .flatten();
-
-        FieldInterface {
-            subfields,
-            field: field.clone(),
-        }
-    }
-}
-
-impl From<&RecordRowF<&Type>> for FieldInterface {
-    fn from(rrow: &RecordRowF<&Type>) -> Self {
-        FieldInterface {
-            subfields: rrow.typ.extract_interface(),
-            ..Default::default()
-        }
-    }
-}
-
-impl FieldInterface {
-    /// Take a clap command and enrich it with either one argument if this subfield is an input
-    /// field, or with all the subfields that are inputs. If this field or some of its subfields
-    /// aren't inputs, they are pushed to `overrides`. See [TermInterface::build_cmd].
-    ///
-    /// `add_args` updates `paths` as well, which maps clap argument ids to the corresponding field
-    /// path represented as an array of string names.
-    fn add_args(&self, mut term_cmd: TermCommand, path: Vec<String>) -> TermCommand {
-        // This is a terminal field, which gives rise to an argument or an overridable value.
-        if !self.has_subfields() {
-            if self.is_input() {
-                match path.as_slice() {
-                    [ref field] if field == "override" || field == "help" => {
-                        term_cmd.register_input(path.clone())
-                    }
-                    _ => {
-                        term_cmd.register_arg(path.clone());
-                        term_cmd.clap_cmd = self.add_arg(term_cmd.clap_cmd, path.join("."))
-                    }
-                };
-            } else {
-                term_cmd.register_override(path.clone(), self.help());
-            }
-        }
-
-        for (id, field) in self.subfields.iter().flat_map(|intf| intf.fields.iter()) {
-            let mut path = path.clone();
-            path.push(id.to_string());
-
-            term_cmd = field.add_args(term_cmd, path);
-        }
-
-        term_cmd
-    }
-
-    /// Add a single argument to the CLI `cmd`, based on the interface of this field, and return
-    /// the updated command.
-    fn add_arg(&self, cmd: clap::Command, path: String) -> clap::Command {
-        let mut arg = clap::Arg::new(&path)
-            .long(path)
-            .value_name(get_value_name(&self.field.metadata.annotation))
-            // TODO: Create clap argument groups
-            .required(!self.is_default());
-
-        if let Some(help) = &self.help() {
-            arg = arg.help(help);
-        };
-
-        if let Some(default) = self.default_value() {
-            arg = arg.default_value(default);
-        }
-
-        cmd.arg(arg)
-    }
-
-    /// Define if a field is an input of a configuration that is intended to be filled, and will be
-    /// given a dedicated CLI argument. If the field is not an input, it can only be overridden via
-    /// `--override`.
-    ///
-    /// Currently, the difference is mostly conceptual: in practice, we simply gather the arguments
-    /// and their values, either via direct arguments or `--override` (although the latter sets a
-    /// different priority), and then merge the elaborated record with original term.
-    ///
-    /// However, from a user experience point of view, we want to make a clear distinction between
-    /// filling a bespoke input of a configuration and overriding an existing value. The latter is
-    /// more "low-level" and should only be done knowingly.
-    ///
-    /// Currently, the logic is simply that a field is an input if it doesn't have a definition or
-    /// it has a default priority. This definition might evolve, as there are ongoing discussions
-    /// on what is should be the meaning of "input", "output", and if those concept should be made
-    /// first-class ([related issue](https://github.com/tweag/nickel/issues/1505)). For now, this
-    /// logic seems to be a reasonable first approximation.
-    fn is_input(&self) -> bool {
-        !self.is_defined() || self.is_default()
-    }
-
-    /// Return `true` is the field has a value.
-    fn is_defined(&self) -> bool {
-        self.field.value.is_some()
-    }
-
-    /// Return true is the field's merge priority is `default`.
-    fn is_default(&self) -> bool {
-        matches!(self.field.metadata.priority, MergePriority::Bottom)
-    }
-
-    fn has_subfields(&self) -> bool {
-        matches!(&self.subfields, Some(ref intf) if !intf.fields.is_empty())
-    }
-
-    /// Return the default value, if any.
-    fn default_value(&self) -> Option<String> {
-        match (&self.field.metadata.priority, &self.field.value) {
-            (MergePriority::Bottom, Some(value)) => Some(value.to_string()),
-            _ => None,
-        }
-    }
-
-    /// Render a help message similar to the output of a metadata query to serve as an help text
-    /// for this argument.
-    fn help(&self) -> Option<String> {
-        let mut output: Vec<u8> = Vec::new();
-
-        // We only need to render the documentation: the rest is printed separately as part of the
-        // clap command that is built.
-        let attributes = Attributes {
-            doc: true,
-            contract: false,
-            typ: false,
-            default: false,
-            value: false,
-        };
-
-        write_query_result(&mut output, &self.field, attributes)
-            .unwrap_or(false)
-            .then(|| {
-                String::from_utf8(output)
-                    .expect("the query printer should always output valid utf8")
-            })
-    }
-}
-
-fn get_value_name(annotation: &TypeAnnotation) -> String {
-    if annotation.is_empty() {
-        NICKEL_VALUE_NAME.into()
-    } else {
-        let anns: Vec<String> = annotation
-            .iter()
-            .map(|ctr| ctr.label.typ.to_string())
-            .collect();
-        anns.join(",")
-    }
-}
-
-/// A command dynamically built from a Nickel term.
-struct TermCommand {
-    /// The corresponding clap command
-    clap_cmd: clap::Command,
-    /// A mapping between clap argument ids and the corresponding field path (for inputs).
-    args: HashMap<clap::Id, Vec<String>>,
-    /// Mapping between existing input fields (as a dot-separated path string) and their path
-    /// represented as a vector. This is used both as a `HashSet` to check the existence of an
-    /// input field, and to sidestep the issue of parsing a path again when reading an input
-    /// argument.
-    inputs: HashMap<String, Vec<String>>,
-    /// A mapping between a field path and the corresponding override data.
-    overrides: BTreeMap<String, OverrideInterface>,
-}
-
-impl TermCommand {
-    fn new(command: clap::Command) -> Self {
-        TermCommand {
-            clap_cmd: command,
-            overrides: BTreeMap::new(),
-            inputs: HashMap::new(),
-            args: HashMap::new(),
-        }
-    }
-
-    /// Register a new argument corresponding to an input. Add the paths and the argument's id to
-    /// the relevant maps.
-    fn register_arg(&mut self, path: Vec<String>) {
-        let path_string = path.join(".");
-        let arg_id = clap::Id::from(path_string.clone());
-
-        debug_assert!(!self.args.contains_key(&arg_id));
-        debug_assert!(!self.inputs.contains_key(&path_string));
-
-        self.args.insert(arg_id, path.clone());
-        self.inputs.insert(path_string, path);
-    }
-
-    /// Register an input, but don't register the corresponding CLI argument. Used for `override`
-    /// and `help` which conflict with the built-in `--override` and `--help` arguments.
-    fn register_input(&mut self, path: Vec<String>) {
-        let path_string = path.join(".");
-
-        debug_assert!(!self.inputs.contains_key(&path_string));
-
-        self.inputs.insert(path_string, path);
-    }
-
-    fn register_override(&mut self, path: Vec<String>, help: Option<String>) {
-        let path_string = path.join(".");
-
-        self.overrides
-            .insert(path_string, OverrideInterface { path, _help: help });
-    }
 }
 
 impl ExportCommand {
-    pub fn run(mut self, global: GlobalOptions) -> CliResult<()> {
+    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
         let mut program = self.evaluation.prepare(&global)?;
 
-        let overrides = if self.customize_mode.is_empty() {
-            Vec::new()
-        } else {
-            let evaled = match program.eval_record_spine() {
-                Ok(evaled) => evaled,
-                // We need a `return` control-flow to be able to take `program` out
-                Err(error) => {
-                    return CliResult::Err(crate::error::Error::Program { error, program })
-                }
-            };
-
-            let cmd = TermInterface::from(evaled.as_ref()).build_cmd();
-            let arg_matches = cmd
-                .clap_cmd
-                .get_matches_from(std::mem::take(&mut self.customize_mode));
-
-            let force_overrides: Result<Vec<_>, super::error::CliUsageError> = arg_matches
-                .get_occurrences("override")
-                .into_iter()
-                .flat_map(|occurs| {
-                    occurs.map(|mut pair| {
-                        let (path, value): (&String, &String) =
-                            (pair.next().unwrap(), pair.next().unwrap());
-
-                        cmd.overrides
-                            .get(path)
-                            .map(|intf| intf.path.clone())
-                            // We allow --override to set inputs as well, in particular as an
-                            // escape mechanism to set an input field named `override` or `help`,
-                            // which would conflict with the corresponding builtin flags.
-                            .or(cmd.inputs.get(path).cloned())
-                            .map(|path| FieldOverride {
-                                path,
-                                value: value.clone(),
-                                priority: MergePriority::Top,
-                            })
-                            .ok_or_else(|| super::error::CliUsageError::InvalidOverride {
-                                path: path.clone(),
-                            })
-                    })
-                })
-                .collect();
-
-            let force_overrides = match force_overrides {
-                Ok(force_overrides) => force_overrides,
-                Err(error) => {
-                    return CliResult::Err(crate::error::Error::CliUsage { error, program })
-                }
-            };
-
-            arg_matches
-                .ids()
-                .filter_map(|id| -> Option<FieldOverride> {
-                    (!matches!(
-                        arg_matches.value_source(id.as_str()),
-                        Some(ValueSource::DefaultValue)
-                    ) && id.as_str() != "override")
-                        .then(|| FieldOverride {
-                            path: cmd.args.get(id).unwrap().clone(),
-                            value: arg_matches.get_one::<String>(id.as_str()).unwrap().clone(),
-                            priority: MergePriority::default(),
-                        })
-                })
-                .chain(force_overrides)
-                .collect::<Vec<_>>()
-        };
-
-        self.export(&mut program, overrides)
-            .report_with_program(program)
+        self.export(&mut program).report_with_program(program)
     }
 
-    // XXX: do we want this attached specifically to the `export` command?
-    // XXX: we should give a nice error message when someone tries to evaluate some
-    //      expression that has unset values, telling them they can set them using
-    //      this method
-    fn export(
-        self,
-        program: &mut Program<CBNCache>,
-        overrides: Vec<FieldOverride>,
-    ) -> Result<(), Error> {
-        let rt = program.eval_full_for_export(overrides)?;
+    fn export(self, program: &mut Program<CBNCache>) -> Result<(), Error> {
+        let rt = program.eval_full_for_export()?;
 
         // We only add a trailing newline for JSON exports. Both YAML and TOML
         // exporters already append a trailing newline by default.
diff --git a/cli/src/format.rs b/cli/src/format.rs
index 9521cabe..84fff195 100644
--- a/cli/src/format.rs
+++ b/cli/src/format.rs
@@ -7,7 +7,9 @@ use std::{
 
 use tempfile::NamedTempFile;
 
-use crate::{cli::GlobalOptions, error::CliResult};
+use crate::{
+    cli::GlobalOptions, customize::NoCustomizeMode, error::CliResult, input::InputOptions,
+};
 
 #[derive(Debug)]
 pub enum FormatError {
@@ -75,27 +77,25 @@ impl Write for Output {
 
 #[derive(clap::Parser, Debug)]
 pub struct FormatCommand {
-    /// Output file. Standard output by default.
-    #[arg(short, long)]
-    output: Option<PathBuf>,
-
-    /// Format in place, overwriting the input file.
-    #[arg(short, long, requires = "file")]
-    in_place: bool,
+    #[command(flatten)]
+    input: InputOptions<NoCustomizeMode>,
 }
 
 impl FormatCommand {
-    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut output: Output = match (&self.output, &global.file, self.in_place) {
-            (None, None, _) | (None, Some(_), false) => Output::Stdout,
-            (None, Some(file), true) | (Some(file), _, _) => Output::from_path(file)?,
-        };
-        let input: Box<dyn Read> = match global.file {
-            None => Box::new(stdin()),
-            Some(f) => Box::new(BufReader::new(File::open(f)?)),
-        };
-        nickel_lang_core::format::format(input, &mut output).map_err(FormatError::FormatError)?;
-        output.persist();
+    pub fn run(self, _global: GlobalOptions) -> CliResult<()> {
+        fn format(input: impl Read, mut output: Output) -> CliResult<()> {
+            nickel_lang_core::format::format(input, &mut output)
+                .map_err(FormatError::FormatError)?;
+            output.persist();
+            Ok(())
+        }
+        if self.input.files.is_empty() {
+            return format(stdin(), Output::Stdout);
+        }
+
+        for file in self.input.files.iter() {
+            format(BufReader::new(File::open(file)?), Output::from_path(file)?)?;
+        }
         Ok(())
     }
 }
diff --git a/cli/src/input.rs b/cli/src/input.rs
new file mode 100644
index 00000000..93ad23ab
--- /dev/null
+++ b/cli/src/input.rs
@@ -0,0 +1,32 @@
+use std::path::PathBuf;
+
+use nickel_lang_core::{eval::cache::lazy::CBNCache, program::Program};
+
+use crate::{cli::GlobalOptions, customize::Customize, error::CliResult};
+
+#[derive(clap::Parser, Debug)]
+pub struct InputOptions<Customize: clap::Args> {
+    /// Input files, omit to read from stdin
+    pub files: Vec<PathBuf>,
+
+    #[command(flatten)]
+    pub customize_mode: Customize,
+}
+
+pub trait Prepare {
+    fn prepare(&self, global: &GlobalOptions) -> CliResult<Program<CBNCache>>;
+}
+
+impl<C: clap::Args + Customize> Prepare for InputOptions<C> {
+    fn prepare(&self, global: &GlobalOptions) -> CliResult<Program<CBNCache>> {
+        let mut program = match self.files.as_slice() {
+            [] => Program::new_from_stdin(std::io::stderr()),
+            [p] => Program::new_from_file(p, std::io::stderr()),
+            files => Program::new_from_files(files, std::io::stderr()),
+        }?;
+
+        program.color_opt = global.color.into();
+
+        self.customize_mode.customize(program)
+    }
+}
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 869780e4..6508080f 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -9,23 +9,23 @@ mod repl;
 
 mod cli;
 mod completions;
+mod customize;
 mod error;
 mod eval;
 mod export;
+mod input;
 mod pprint_ast;
 mod query;
 mod typecheck;
 
 use std::process::ExitCode;
 
-use eval::EvalCommand;
-
 use crate::cli::{Command, Options};
 
 fn main() -> ExitCode {
     let opts = <Options as clap::Parser>::parse();
 
-    let result = match opts.command.unwrap_or(Command::Eval(EvalCommand {})) {
+    let result = match opts.command {
         Command::Eval(eval) => eval.run(opts.global),
         Command::PprintAst(pprint_ast) => pprint_ast.run(opts.global),
         Command::Export(export) => export.run(opts.global),
diff --git a/cli/src/pprint_ast.rs b/cli/src/pprint_ast.rs
index bb2a439c..cd940d6b 100644
--- a/cli/src/pprint_ast.rs
+++ b/cli/src/pprint_ast.rs
@@ -1,7 +1,8 @@
 use crate::{
     cli::GlobalOptions,
+    customize::NoCustomizeMode,
     error::{CliResult, ResultErrorExt},
-    eval,
+    input::{InputOptions, Prepare},
 };
 
 #[derive(clap::Parser, Debug)]
@@ -9,11 +10,14 @@ pub struct PprintAstCommand {
     /// Performs code transformations before printing
     #[arg(long)]
     pub transform: bool,
+
+    #[command(flatten)]
+    pub inputs: InputOptions<NoCustomizeMode>,
 }
 
 impl PprintAstCommand {
     pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = eval::prepare(&global)?;
+        let mut program = self.inputs.prepare(&global)?;
         program
             .pprint_ast(&mut std::io::stdout(), self.transform)
             .report_with_program(program)
diff --git a/cli/src/query.rs b/cli/src/query.rs
index 121f0a13..d1972252 100644
--- a/cli/src/query.rs
+++ b/cli/src/query.rs
@@ -2,12 +2,15 @@ use nickel_lang_core::repl::query_print;
 
 use crate::{
     cli::GlobalOptions,
+    customize::NoCustomizeMode,
     error::{CliResult, ResultErrorExt, Warning},
-    eval::EvalCommand,
+    input::{InputOptions, Prepare},
 };
 
 #[derive(clap::Parser, Debug)]
 pub struct QueryCommand {
+    /// A field path to inspect. If omitted, we query the top level value.
+    #[arg(long, short)]
     pub path: Option<String>,
 
     #[arg(long)]
@@ -26,7 +29,7 @@ pub struct QueryCommand {
     pub value: bool,
 
     #[command(flatten)]
-    pub evaluation: EvalCommand,
+    pub inputs: InputOptions<NoCustomizeMode>,
 }
 
 impl QueryCommand {
@@ -50,7 +53,7 @@ impl QueryCommand {
     }
 
     pub fn run(mut self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = self.evaluation.prepare(&global)?;
+        let mut program = self.inputs.prepare(&global)?;
 
         if self.path.is_none() {
             program.report(Warning::EmptyQueryPath)
diff --git a/cli/src/typecheck.rs b/cli/src/typecheck.rs
index 68c0b016..76d05900 100644
--- a/cli/src/typecheck.rs
+++ b/cli/src/typecheck.rs
@@ -1,15 +1,19 @@
 use crate::{
     cli::GlobalOptions,
+    customize::NoCustomizeMode,
     error::{CliResult, ResultErrorExt},
-    eval,
+    input::{InputOptions, Prepare},
 };
 
 #[derive(clap::Parser, Debug)]
-pub struct TypecheckCommand {}
+pub struct TypecheckCommand {
+    #[command(flatten)]
+    inputs: InputOptions<NoCustomizeMode>,
+}
 
 impl TypecheckCommand {
     pub fn run(self, global: GlobalOptions) -> CliResult<()> {
-        let mut program = eval::prepare(&global)?;
+        let mut program = self.inputs.prepare(&global)?;
         program.typecheck().report_with_program(program)
     }
 }
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_annotated_record_pattern_typecheck_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_annotated_record_pattern_typecheck_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_annotated_record_pattern_typecheck_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_annotated_record_pattern_typecheck_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_array_at_empty_array.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_array_at_empty_array.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_array_at_out_of_bound.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_array_at_out_of_bound.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_array_contract_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_contract_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_array_contract_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_contract_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_array_merge_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_merge_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_array_merge_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_merge_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_array_range_reversed_indices.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_array_range_reversed_indices.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_array_range_step_negative_step.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_array_range_step_negative_step.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_blame_custom_message_ansi_escaping.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_blame_custom_message_ansi_escaping.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_blame_custom_message_ansi_escaping.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_blame_custom_message_ansi_escaping.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_caller_contract_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_caller_contract_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_contract_with_custom_diagnostic.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_contract_with_custom_diagnostic.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_contract_with_custom_diagnostic.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_contract_with_custom_diagnostic.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_assign_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_assign_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_assign_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_assign_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_closed_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_closed_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_closed_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_closed_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_default_merge.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_default_merge.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_default_merge.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_default_merge.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_nonexistent_idents.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_nonexistent_idents.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_nonexistent_idents.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_nonexistent_idents.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_repeated_ident.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_repeated_ident.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_repeated_ident.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_repeated_ident.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_repeated_ident_typed.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_repeated_ident_typed.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_repeated_ident_typed.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_repeated_ident_typed.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_rest_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_rest_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_rest_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_rest_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_type_mismatch_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_type_mismatch_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_type_mismatch_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_type_mismatch_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_type_mismatch_field_pattern_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_type_mismatch_field_pattern_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_type_mismatch_field_pattern_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_type_mismatch_field_pattern_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_type_mismatch_nested_destructuring_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_type_mismatch_nested_destructuring_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_type_mismatch_nested_destructuring_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_type_mismatch_nested_destructuring_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_typecontract_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_typecontract_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_destructuring_typecontract_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_typecontract_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_dictionary_contract_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_dictionary_contract_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_dictionary_contract_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_dictionary_contract_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_enum_forall_parametricity_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_enum_forall_parametricity_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_enum_forall_parametricity_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_enum_forall_parametricity_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_fun_contract_range_nested.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_fun_contract_range_nested.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_fun_contract_range_nested.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_fun_contract_range_nested.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_fun_contract_range_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_fun_contract_range_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_fun_contract_range_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_fun_contract_range_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_function_contract_domain_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_function_contract_domain_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_function_contract_domain_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_function_contract_domain_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_function_contract_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_function_contract_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_function_contract_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_function_contract_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_interpolate_record_type_field.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_interpolate_record_type_field.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_interpolate_record_type_field.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_interpolate_record_type_field.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_invalid_record_type.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_invalid_record_type.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_invalid_record_type.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_invalid_record_type.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_mismatched_row_record_pattern_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_mismatched_row_record_pattern_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_mismatched_row_record_pattern_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_mismatched_row_record_pattern_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_nested_annotated_record_pattern_typecheck_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_nested_annotated_record_pattern_typecheck_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_nested_annotated_record_pattern_typecheck_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_nested_annotated_record_pattern_typecheck_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_record_access.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_access.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_record_access.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_access.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_record_destructuring_duplicate_ident.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_destructuring_duplicate_ident.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_record_destructuring_duplicate_ident.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_destructuring_duplicate_ident.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_record_forall_constraints_contract.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_forall_constraints_contract.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_record_forall_constraints_contract.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_forall_constraints_contract.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_record_forall_constraints_typecheck.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_forall_constraints_typecheck.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_record_forall_constraints_typecheck.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_forall_constraints_typecheck.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_record_forall_parametricity_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_forall_parametricity_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_record_forall_parametricity_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_forall_parametricity_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_record_type_repeated_field.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_type_repeated_field.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_record_type_repeated_field.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_record_type_repeated_field.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_simple_contract_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_simple_contract_fail.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_simple_contract_fail.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_simple_contract_fail.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_string_delimiter_mismatch.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_string_delimiter_mismatch.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_string_delimiter_mismatch.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_string_delimiter_mismatch.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_subcontract_nested_custom_diagnostics.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_nested_custom_diagnostics.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_subcontract_nested_custom_diagnostics.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_nested_custom_diagnostics.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_subcontract_type_path_underline.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_type_path_underline.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_subcontract_type_path_underline.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_subcontract_type_path_underline.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_trace_not_saturated.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_trace_not_saturated.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_trace_not_saturated.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_trace_not_saturated.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_typed_field_without_annotation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_typed_field_without_annotation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_typed_field_without_annotation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_typed_field_without_annotation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_unification_variable_aliasing.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_unification_variable_aliasing.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_unification_variable_aliasing.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_unification_variable_aliasing.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stderr_value_contract_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_value_contract_violation.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stderr_value_contract_violation.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stderr_value_contract_violation.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stdout_escaping.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stdout_escaping.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stdout_escaping.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stdout_escaping.ncl.snap
diff --git a/cli/tests/snapshot/snapshots/snapshot__error_stdout_records.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stdout_records.ncl.snap
similarity index 100%
rename from cli/tests/snapshot/snapshots/snapshot__error_stdout_records.ncl.snap
rename to cli/tests/snapshot/snapshots/snapshot__eval_stdout_records.ncl.snap
diff --git a/core/src/cache.rs b/core/src/cache.rs
index 96e98c9a..fef8394f 100644
--- a/core/src/cache.rs
+++ b/core/src/cache.rs
@@ -221,6 +221,7 @@ impl<E> From<E> for CacheError<E> {
 }
 
 impl<E> CacheError<E> {
+    #[track_caller]
     pub fn unwrap_error(self, msg: &str) -> E {
         match self {
             CacheError::Error(err) => err,
@@ -260,6 +261,7 @@ pub enum SourcePath {
     ReplTypecheck,
     ReplQuery,
     Override(Vec<String>),
+    Generated(String),
 }
 
 impl<'a> TryFrom<&'a SourcePath> for &'a OsStr {
@@ -287,6 +289,7 @@ impl From<SourcePath> for OsString {
             SourcePath::ReplTypecheck => "<repl-typecheck>".into(),
             SourcePath::ReplQuery => "<repl-query>".into(),
             SourcePath::Override(path) => format!("<override {}>", path.join(".")).into(),
+            SourcePath::Generated(description) => format!("<generated {}>", description).into(),
         }
     }
 }
diff --git a/core/src/program.rs b/core/src/program.rs
index 3e35387f..2c0383f5 100644
--- a/core/src/program.rs
+++ b/core/src/program.rs
@@ -22,7 +22,7 @@
 //! Each such value is added to the initial environment before the evaluation of the program.
 use crate::{
     cache::*,
-    error::{report, ColorOpt, Error, IntoDiagnostics, ParseError},
+    error::{report, ColorOpt, Error, EvalError, IOError, IntoDiagnostics, ParseError},
     eval,
     eval::{cache::Cache as EvalCache, VirtualMachine},
     identifier::LocIdent,
@@ -111,6 +111,7 @@ impl QueryPath {
 
 /// Several CLI commands accept additional overrides specified directly on the command line. They
 /// are represented by this structure.
+#[derive(Clone)]
 pub struct FieldOverride {
     /// The field path identifying the (potentially nested) field to override.
     pub path: Vec<String>,
@@ -131,6 +132,13 @@ pub struct Program<EC: EvalCache> {
     vm: VirtualMachine<Cache, EC>,
     /// The color option to use when reporting errors.
     pub color_opt: ColorOpt,
+    /// A list of [`FieldOverride`]s. During [`prepare_eval`], each
+    /// override is imported in a separate in-memory source, for complete isolation (this way,
+    /// overrides can't accidentally or intentionally capture other fields of the configuration).
+    /// A stub record is then built, which has all fields defined by `overrides`, and values are
+    /// an import referring to the corresponding isolated value. This stub is finally merged with
+    /// the current program before being evaluated for import.
+    overrides: Vec<FieldOverride>,
 }
 
 impl<EC: EvalCache> Program<EC> {
@@ -139,6 +147,36 @@ impl<EC: EvalCache> Program<EC> {
         Program::new_from_source(io::stdin(), "<stdin>", trace)
     }
 
+    /// Create program from possibly multiple files. Each input `path` is
+    /// turned into a [`Term::Import`] and the main program will be the
+    /// [`BinaryOp::Merge`] of all the inputs.
+    pub fn new_from_files<I, P>(paths: I, trace: impl Write + 'static) -> std::io::Result<Self>
+    where
+        I: IntoIterator<Item = P>,
+        P: Into<OsString>,
+    {
+        let mut cache = Cache::new(ErrorTolerance::Strict);
+
+        let merge_term = paths
+            .into_iter()
+            .map(|f| RichTerm::from(Term::Import(f.into())))
+            .reduce(|acc, f| mk_term::op2(BinaryOp::Merge(Label::default().into()), acc, f))
+            .unwrap();
+        let main_id = cache.add_string(
+            SourcePath::Generated("main".into()),
+            format!("{merge_term}"),
+        );
+
+        let vm = VirtualMachine::new(cache, trace);
+
+        Ok(Self {
+            main_id,
+            vm,
+            color_opt: clap::ColorChoice::Auto.into(),
+            overrides: Vec::new(),
+        })
+    }
+
     pub fn new_from_file(
         path: impl Into<OsString>,
         trace: impl Write + 'static,
@@ -146,11 +184,11 @@ impl<EC: EvalCache> Program<EC> {
         let mut cache = Cache::new(ErrorTolerance::Strict);
         let main_id = cache.add_file(path)?;
         let vm = VirtualMachine::new(cache, trace);
-
         Ok(Self {
             main_id,
             vm,
             color_opt: clap::ColorChoice::Auto.into(),
+            overrides: Vec::new(),
         })
     }
 
@@ -173,9 +211,14 @@ impl<EC: EvalCache> Program<EC> {
             main_id,
             vm,
             color_opt: clap::ColorChoice::Auto.into(),
+            overrides: Vec::new(),
         })
     }
 
+    pub fn add_overrides(&mut self, overrides: impl IntoIterator<Item = FieldOverride>) {
+        self.overrides.extend(overrides);
+    }
+
     /// Only parse the program, don't typecheck or evaluate. returns the [`RichTerm`] AST
     pub fn parse(&mut self) -> Result<RichTerm, Error> {
         self.vm
@@ -195,7 +238,37 @@ impl<EC: EvalCache> Program<EC> {
     /// Retrieve the parsed term and typecheck it, and generate a fresh initial environment. Return
     /// both.
     fn prepare_eval(&mut self) -> Result<(RichTerm, eval::Environment), Error> {
-        self.vm.prepare_eval(self.main_id)
+        // If there are no overrides, we avoid the boilerplate of creating an empty record and
+        // merging it with the current program
+        if self.overrides.is_empty() {
+            return self.vm.prepare_eval(self.main_id);
+        }
+
+        let mut record = builder::Record::new();
+
+        for ovd in self.overrides.iter().cloned() {
+            let value_file_id = self
+                .vm
+                .import_resolver_mut()
+                .add_string(SourcePath::Override(ovd.path.clone()), ovd.value);
+            self.vm.prepare_eval(value_file_id)?;
+            record = record
+                .path(ovd.path)
+                .priority(ovd.priority)
+                .value(Term::ResolvedImport(value_file_id));
+        }
+
+        let (t, initial_env) = self.vm.prepare_eval(self.main_id)?;
+        let built_record = record.build();
+        // For now, we can't do much better than using `Label::default`, but this is
+        // hazardous. `Label::default` was originally written for tests, and although it
+        // doesn't happen in practice as of today, it could theoretically generate invalid
+        // codespan file ids (because it creates a new file database on the spot just to
+        // generate a dummy file id).
+        // We'll have to adapt `Label` and `MergeLabel` to be generated programmatically,
+        // without referring to any source position.
+        let wrapper = mk_term::op2(BinaryOp::Merge(Label::default().into()), t, built_record);
+        Ok((wrapper, initial_env))
     }
 
     /// Parse if necessary, typecheck and then evaluate the program.
@@ -225,45 +298,8 @@ impl<EC: EvalCache> Program<EC> {
     ///   A stub record is then built, which has all fields defined by `overrides`, and values are
     ///   an import referring to the corresponding isolated value. This stub is finally merged with
     ///   the current program before being evaluated for import.
-    pub fn eval_full_for_export(
-        &mut self,
-        overrides: impl IntoIterator<Item = FieldOverride>,
-    ) -> Result<RichTerm, Error> {
-        let mut overrides = overrides.into_iter().peekable();
-
-        let (t, initial_env) = match overrides.peek() {
-            // If there are no overrides, we avoid the boilerplate of creating an empty record and
-            // merging it with the current program
-            None => self.prepare_eval()?,
-            Some(_) => {
-                let mut record = builder::Record::new();
-
-                for ovd in overrides {
-                    let value_file_id = self
-                        .vm
-                        .import_resolver_mut()
-                        .add_string(SourcePath::Override(ovd.path.clone()), ovd.value);
-                    self.vm.prepare_eval(value_file_id)?;
-                    record = record
-                        .path(ovd.path)
-                        .priority(ovd.priority)
-                        .value(Term::ResolvedImport(value_file_id));
-                }
-
-                let (t, initial_env) = self.prepare_eval()?;
-                let built_record = record.build();
-                // For now, we can't do much better than using `Label::default`, but this is
-                // hazardous. `Label::default` was originally written for tests, and although it
-                // doesn't happen in practice as of today, it could theoretically generate invalid
-                // codespan file ids (because it creates a new file database on the spot just to
-                // generate a dummy file id).
-                // We'll have to adapt `Label` and `MergeLabel` to be generated programmatically,
-                // without referring to any source position.
-                let wrapper =
-                    mk_term::op2(BinaryOp::Merge(Label::default().into()), t, built_record);
-                (wrapper, initial_env)
-            }
-        };
+    pub fn eval_full_for_export(&mut self) -> Result<RichTerm, Error> {
+        let (t, initial_env) = self.prepare_eval()?;
         self.vm.reset();
         self.vm
             .eval_full_for_export(t, &initial_env)
@@ -384,7 +420,6 @@ impl<EC: EvalCache> Program<EC> {
     /// when evaluating a field, this field is just left as it is and the evaluation proceeds.
     #[cfg(feature = "doc")]
     pub fn eval_record_spine(&mut self) -> Result<RichTerm, Error> {
-        use crate::error::EvalError;
         use crate::eval::{Closure, Environment};
         use crate::match_sharedterm;
         use crate::term::{record::RecordData, RuntimeContract};
@@ -505,12 +540,13 @@ impl<EC: EvalCache> Program<EC> {
 
         let rt = vm.import_resolver().parse_nocache(*main_id)?.0;
         let rt = if apply_transforms {
-            crate::transform::transform(rt, None).unwrap()
+            crate::transform::transform(rt, None).map_err(EvalError::from)?
         } else {
             rt
         };
         let doc: DocBuilder<_, ()> = rt.pretty(&allocator);
-        doc.render(80, out).unwrap();
+        doc.render(80, out).map_err(IOError::from)?;
+        writeln!(out).map_err(IOError::from)?;
         Ok(())
     }
 }
diff --git a/core/src/transform/import_resolution.rs b/core/src/transform/import_resolution.rs
index 04baa057..d8657d95 100644
--- a/core/src/transform/import_resolution.rs
+++ b/core/src/transform/import_resolution.rs
@@ -11,6 +11,7 @@ pub mod strict {
     use codespan::FileId;
 
     /// The result of an import resolution transformation.
+    #[derive(Debug)]
     pub struct ResolveResult {
         /// The transformed term after the import resolution transformation.
         pub transformed_term: RichTerm,
@@ -80,6 +81,7 @@ pub mod tolerant {
     use codespan::FileId;
 
     /// The result of an error tolerant import resolution.
+    #[derive(Debug)]
     pub struct ResolveResult {
         /// The main result of the transformation. May still contain unresolved import terms in
         /// case of errors.


Test patch:
diff --git a/cli/tests/integration/main.rs b/cli/tests/integration/main.rs
index a993c1de..d3b15fc5 100644
--- a/cli/tests/integration/main.rs
+++ b/cli/tests/integration/main.rs
@@ -40,11 +40,6 @@ fn doc_creates_output_files() {
     test_creates_output_files(&["doc", "--format", "json"]);
 }
 
-#[test]
-fn format_creates_output_files() {
-    test_creates_output_files(&["format"]);
-}
-
 #[test]
 fn automatic_color_on_non_tty() {
     let nickel_bin = env!("CARGO_BIN_EXE_nickel");
diff --git a/cli/tests/snapshot/inputs/customize-mode/has_help.ncl b/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
index 289a4dcb..878dc186 100644
--- a/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
+++ b/cli/tests/snapshot/inputs/customize-mode/has_help.ncl
@@ -1,5 +1,6 @@
 # capture = 'stdout'
-# command = ['export', '--', '--help']
+# command = ['export']
+# extra_args = ['--', '--help']
 {
   help | String,
   input | Number,
diff --git a/cli/tests/snapshot/inputs/customize-mode/has_override.ncl b/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
index 832cbc2d..f0cea7e0 100644
--- a/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
+++ b/cli/tests/snapshot/inputs/customize-mode/has_override.ncl
@@ -1,5 +1,6 @@
 # capture = 'stdout'
-# command = ['export', '--', '--help']
+# command = ['export']
+# extra_args = ['--', '--help']
 {
   override | String,
   input | Number,
diff --git a/cli/tests/snapshot/inputs/customize-mode/help.ncl b/cli/tests/snapshot/inputs/customize-mode/help.ncl
index d0eee470..2d25d929 100644
--- a/cli/tests/snapshot/inputs/customize-mode/help.ncl
+++ b/cli/tests/snapshot/inputs/customize-mode/help.ncl
@@ -1,5 +1,6 @@
 # capture = 'stdout'
-# command = ['export', '--', '--help']
+# command = ['export']
+# extra_args = ['--', '--help']
 {
   input.foo.bar | String,
   input.foo.baz | Array Number,
diff --git a/cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl b/cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
index 853d4e6c..1aad959e 100644
--- a/cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
+++ b/cli/tests/snapshot/inputs/customize-mode/not_an_input.ncl
@@ -1,5 +1,6 @@
 # capture = 'stderr'
-# command = ['export', '--', '--override.first', '"test"']
+# command = ['export']
+# extra_args =['--', '--override.first', '"test"']
 {
   input.foo.bar | String,
   input.foo.baz | Array Number,
diff --git a/cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl b/cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
index e5d94dfe..b6cf1ad0 100644
--- a/cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
+++ b/cli/tests/snapshot/inputs/customize-mode/simple_adder.ncl
@@ -1,5 +1,6 @@
 # capture = 'stdout'
-# command = ['export', '--', '--input', '5']
+# command = ['export']
+# extra_args = ['--', '--input', '5']
 {
   input | Number,
   output = input + 1,
diff --git a/cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl b/cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
index 231f5d1d..b24bf724 100644
--- a/cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
+++ b/cli/tests/snapshot/inputs/customize-mode/unknown_override.ncl
@@ -1,6 +1,6 @@
 # capture = 'stderr'
-# command = [
-#  'export',
+# command = ['export']
+# extra_args = [
 #  '--',
 #  '--input.foo.bar',
 #  '"hello"',
diff --git a/cli/tests/snapshot/inputs/errors/annotated_record_pattern_typecheck_fail.ncl b/cli/tests/snapshot/inputs/errors/annotated_record_pattern_typecheck_fail.ncl
index b2daef64..88009452 100644
--- a/cli/tests/snapshot/inputs/errors/annotated_record_pattern_typecheck_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/annotated_record_pattern_typecheck_fail.ncl
@@ -1,6 +1,6 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (
   let { x : Bool } = { x = 5 } in
   x
-) : _
\ No newline at end of file
+) : _
diff --git a/cli/tests/snapshot/inputs/errors/array_at_empty_array.ncl b/cli/tests/snapshot/inputs/errors/array_at_empty_array.ncl
index 1856c1f3..7b5a541a 100644
--- a/cli/tests/snapshot/inputs/errors/array_at_empty_array.ncl
+++ b/cli/tests/snapshot/inputs/errors/array_at_empty_array.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 std.array.at 0 []
diff --git a/cli/tests/snapshot/inputs/errors/array_at_out_of_bound.ncl b/cli/tests/snapshot/inputs/errors/array_at_out_of_bound.ncl
index 6ad9fc57..7e6306de 100644
--- a/cli/tests/snapshot/inputs/errors/array_at_out_of_bound.ncl
+++ b/cli/tests/snapshot/inputs/errors/array_at_out_of_bound.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 std.array.at 2 [1]
diff --git a/cli/tests/snapshot/inputs/errors/array_contract_fail.ncl b/cli/tests/snapshot/inputs/errors/array_contract_fail.ncl
index 980e2d0c..1a707093 100644
--- a/cli/tests/snapshot/inputs/errors/array_contract_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/array_contract_fail.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
 # Regression test for https://github.com/tweag/nickel/issues/1021
 let Foo = Array Number in %force% (["a"] | Foo)
diff --git a/cli/tests/snapshot/inputs/errors/array_merge_fail.ncl b/cli/tests/snapshot/inputs/errors/array_merge_fail.ncl
index 42e2e215..8656e0fe 100644
--- a/cli/tests/snapshot/inputs/errors/array_merge_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/array_merge_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let foo = [1,2,3] in
 foo & [1,2,4]
diff --git a/cli/tests/snapshot/inputs/errors/array_range_reversed_indices.ncl b/cli/tests/snapshot/inputs/errors/array_range_reversed_indices.ncl
index 913118f5..4102189b 100644
--- a/cli/tests/snapshot/inputs/errors/array_range_reversed_indices.ncl
+++ b/cli/tests/snapshot/inputs/errors/array_range_reversed_indices.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 std.array.range 1 0
diff --git a/cli/tests/snapshot/inputs/errors/array_range_step_negative_step.ncl b/cli/tests/snapshot/inputs/errors/array_range_step_negative_step.ncl
index 239ca5df..93332924 100644
--- a/cli/tests/snapshot/inputs/errors/array_range_step_negative_step.ncl
+++ b/cli/tests/snapshot/inputs/errors/array_range_step_negative_step.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 std.array.range_step 0 10 (-1)
diff --git a/cli/tests/snapshot/inputs/errors/blame_custom_message_ansi_escaping.ncl b/cli/tests/snapshot/inputs/errors/blame_custom_message_ansi_escaping.ncl
index e42e2e5b..cda6e25d 100644
--- a/cli/tests/snapshot/inputs/errors/blame_custom_message_ansi_escaping.ncl
+++ b/cli/tests/snapshot/inputs/errors/blame_custom_message_ansi_escaping.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 null | std.FailWith "\x1B[9;33;47m strike through with background \"and\" \"quotes\""
diff --git a/cli/tests/snapshot/inputs/errors/caller_contract_violation.ncl b/cli/tests/snapshot/inputs/errors/caller_contract_violation.ncl
index 22945663..f26bacc1 100644
--- a/cli/tests/snapshot/inputs/errors/caller_contract_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/caller_contract_violation.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 std.array.map std.function.id 'not-an-array
 
diff --git a/cli/tests/snapshot/inputs/errors/contract_with_custom_diagnostic.ncl b/cli/tests/snapshot/inputs/errors/contract_with_custom_diagnostic.ncl
index e348583a..8e34feb1 100644
--- a/cli/tests/snapshot/inputs/errors/contract_with_custom_diagnostic.ncl
+++ b/cli/tests/snapshot/inputs/errors/contract_with_custom_diagnostic.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let Contract = fun label _value =>
   label
   |> std.contract.label.with_message "main error message"
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_assign_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_assign_fail.ncl
index 762b84e0..11c8c2d5 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_assign_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_assign_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let {a=x} = {a=1}
 in a == 1
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_closed_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_closed_fail.ncl
index 9cfbd7bd..393ee5cf 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_closed_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_closed_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let {a} = {a=1, b=2}
 in a == 1
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_default_merge.ncl b/cli/tests/snapshot/inputs/errors/destructuring_default_merge.ncl
index f28baf44..4b0ff877 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_default_merge.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_default_merge.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
-let { x ? true } = { x | default = false } in x
\ No newline at end of file
+# command = ['eval']
+let { x ? true } = { x | default = false } in x
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_nonexistent_idents.ncl b/cli/tests/snapshot/inputs/errors/destructuring_nonexistent_idents.ncl
index 6ba082b4..4f9bdbae 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_nonexistent_idents.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_nonexistent_idents.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (
   let { a, b } = { a = 1, c = 2 } in
   a: Number
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident.ncl b/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident.ncl
index 7b3fe2a0..73a95399 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let f = fun { duped, duped, .. } => duped
 in f { duped = 1, other = "x" }
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident_typed.ncl b/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident_typed.ncl
index 86087c01..7b298dba 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident_typed.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_repeated_ident_typed.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (
   let { a, a, .. } = { a = 1, b = 2 } in
   a : Number
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_rest_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_rest_fail.ncl
index 7586713a..ab7a3080 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_rest_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_rest_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let {a, ..y} = {a=1, b=2} in
 y.a
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_fail.ncl
index 8145e0e5..cf1ab0ef 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (let { a : Number } = { a = "hi" } in
 a) : _
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_field_pattern_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_field_pattern_fail.ncl
index 3b10a2e7..17725a57 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_field_pattern_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_field_pattern_fail.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (let { a : Number = b } = { a = "x" } in b) : _
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_nested_destructuring_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_nested_destructuring_fail.ncl
index ae360588..716a91cb 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_nested_destructuring_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_type_mismatch_nested_destructuring_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (let { a : { b : Number } = { b }} = { a = { b = "no" }} in
 b) : _
diff --git a/cli/tests/snapshot/inputs/errors/destructuring_typecontract_fail.ncl b/cli/tests/snapshot/inputs/errors/destructuring_typecontract_fail.ncl
index 0db2625c..43900bbe 100644
--- a/cli/tests/snapshot/inputs/errors/destructuring_typecontract_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/destructuring_typecontract_fail.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let {a | String} = {a=1} in
 a
diff --git a/cli/tests/snapshot/inputs/errors/dictionary_contract_fail.ncl b/cli/tests/snapshot/inputs/errors/dictionary_contract_fail.ncl
index 96eea7c1..705faf7b 100644
--- a/cli/tests/snapshot/inputs/errors/dictionary_contract_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/dictionary_contract_fail.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 { foo = 1, bar = "bar" } | {_: String}
diff --git a/cli/tests/snapshot/inputs/errors/enum_forall_parametricity_violation.ncl b/cli/tests/snapshot/inputs/errors/enum_forall_parametricity_violation.ncl
index c838fca3..57241553 100644
--- a/cli/tests/snapshot/inputs/errors/enum_forall_parametricity_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/enum_forall_parametricity_violation.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let f : forall r. [| 'x; r |] -> [| 'y ; r |] =
   match { 'x => 'y, _ => 'z }
 in f 'x
diff --git a/cli/tests/snapshot/inputs/errors/fun_contract_range_nested.ncl b/cli/tests/snapshot/inputs/errors/fun_contract_range_nested.ncl
index a24924b0..a8fbdfe9 100644
--- a/cli/tests/snapshot/inputs/errors/fun_contract_range_nested.ncl
+++ b/cli/tests/snapshot/inputs/errors/fun_contract_range_nested.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
 # Regression test for https://github.com/tweag/nickel/issues/1021
-let Foo = { foo : Number } in %force% (((fun x => { foo = "a" }) | Dyn -> Foo) null)
\ No newline at end of file
+let Foo = { foo : Number } in %force% (((fun x => { foo = "a" }) | Dyn -> Foo) null)
diff --git a/cli/tests/snapshot/inputs/errors/fun_contract_range_violation.ncl b/cli/tests/snapshot/inputs/errors/fun_contract_range_violation.ncl
index 2d3ba335..7be5eaa0 100644
--- a/cli/tests/snapshot/inputs/errors/fun_contract_range_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/fun_contract_range_violation.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
 # Regression test for https://github.com/tweag/nickel/issues/1021
-let Foo = Number -> Number in ((fun x => "a") | Foo) 0
\ No newline at end of file
+let Foo = Number -> Number in ((fun x => "a") | Foo) 0
diff --git a/cli/tests/snapshot/inputs/errors/function_contract_domain_violation.ncl b/cli/tests/snapshot/inputs/errors/function_contract_domain_violation.ncl
index 108d89b3..ffab2944 100644
--- a/cli/tests/snapshot/inputs/errors/function_contract_domain_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/function_contract_domain_violation.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
 # Regression test for https://github.com/tweag/nickel/issues/1021
-let Foo = Number -> Number in ((fun x => x) | Foo) "a"
\ No newline at end of file
+let Foo = Number -> Number in ((fun x => x) | Foo) "a"
diff --git a/cli/tests/snapshot/inputs/errors/function_contract_violation.ncl b/cli/tests/snapshot/inputs/errors/function_contract_violation.ncl
index 353b99e8..f8e85c2c 100644
--- a/cli/tests/snapshot/inputs/errors/function_contract_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/function_contract_violation.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let r = { f | Number -> Number = fun x => 'not-a-number } in r.f 7
diff --git a/cli/tests/snapshot/inputs/errors/interpolate_record_type_field.ncl b/cli/tests/snapshot/inputs/errors/interpolate_record_type_field.ncl
index e69785eb..3eff38fe 100644
--- a/cli/tests/snapshot/inputs/errors/interpolate_record_type_field.ncl
+++ b/cli/tests/snapshot/inputs/errors/interpolate_record_type_field.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
 let a = "foo" in { "%{a}" : Number }
diff --git a/cli/tests/snapshot/inputs/errors/invalid_record_type.ncl b/cli/tests/snapshot/inputs/errors/invalid_record_type.ncl
index c0a79834..ce72a422 100644
--- a/cli/tests/snapshot/inputs/errors/invalid_record_type.ncl
+++ b/cli/tests/snapshot/inputs/errors/invalid_record_type.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
-{a: Number, b = 1; r}
\ No newline at end of file
+{a: Number, b = 1; r}
diff --git a/cli/tests/snapshot/inputs/errors/mismatched_row_record_pattern_fail.ncl b/cli/tests/snapshot/inputs/errors/mismatched_row_record_pattern_fail.ncl
index 3e438a92..7770a552 100644
--- a/cli/tests/snapshot/inputs/errors/mismatched_row_record_pattern_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/mismatched_row_record_pattern_fail.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (
   let { x : { a : Number } = { a : String } } = { x = { a = true } } 
   in a
diff --git a/cli/tests/snapshot/inputs/errors/nested_annotated_record_pattern_typecheck_fail.ncl b/cli/tests/snapshot/inputs/errors/nested_annotated_record_pattern_typecheck_fail.ncl
index 8582ff56..76ed3717 100644
--- a/cli/tests/snapshot/inputs/errors/nested_annotated_record_pattern_typecheck_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/nested_annotated_record_pattern_typecheck_fail.ncl
@@ -1,6 +1,6 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 (
   let { x = { a : Number }} = { x = { a = "" }} in
   x
-) : _
\ No newline at end of file
+) : _
diff --git a/cli/tests/snapshot/inputs/errors/query_non_record.ncl b/cli/tests/snapshot/inputs/errors/query_non_record.ncl
index 97452c1c..0c08dd8f 100644
--- a/cli/tests/snapshot/inputs/errors/query_non_record.ncl
+++ b/cli/tests/snapshot/inputs/errors/query_non_record.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = [ 'query', 'value' ]
+# command = [ 'query', '--path', 'value' ]
 1
diff --git a/cli/tests/snapshot/inputs/errors/record_access.ncl b/cli/tests/snapshot/inputs/errors/record_access.ncl
index 24b6c419..f6ebd6b2 100644
--- a/cli/tests/snapshot/inputs/errors/record_access.ncl
+++ b/cli/tests/snapshot/inputs/errors/record_access.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
-({ "%{(if false then "foo" else "bar")}" = false, bar = true }).foo
\ No newline at end of file
+# command = ['eval']
+({ "%{(if false then "foo" else "bar")}" = false, bar = true }).foo
diff --git a/cli/tests/snapshot/inputs/errors/record_destructuring_duplicate_ident.ncl b/cli/tests/snapshot/inputs/errors/record_destructuring_duplicate_ident.ncl
index 28a98337..561a7cfc 100644
--- a/cli/tests/snapshot/inputs/errors/record_destructuring_duplicate_ident.ncl
+++ b/cli/tests/snapshot/inputs/errors/record_destructuring_duplicate_ident.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let f = fun { one, two, one } => { one, two }
-in f { one = 1, two = "2" }
\ No newline at end of file
+in f { one = 1, two = "2" }
diff --git a/cli/tests/snapshot/inputs/errors/record_forall_constraints_contract.ncl b/cli/tests/snapshot/inputs/errors/record_forall_constraints_contract.ncl
index 88251a53..f0d16908 100644
--- a/cli/tests/snapshot/inputs/errors/record_forall_constraints_contract.ncl
+++ b/cli/tests/snapshot/inputs/errors/record_forall_constraints_contract.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let f | forall r. { ; r } -> { x: Number; r } = fun r => %record_insert% "x" r 1 in f { x = 0 }
diff --git a/cli/tests/snapshot/inputs/errors/record_forall_constraints_typecheck.ncl b/cli/tests/snapshot/inputs/errors/record_forall_constraints_typecheck.ncl
index e0b27961..63647fc7 100644
--- a/cli/tests/snapshot/inputs/errors/record_forall_constraints_typecheck.ncl
+++ b/cli/tests/snapshot/inputs/errors/record_forall_constraints_typecheck.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let f | forall r. { ; r } -> { x: Number; r } = fun r => %record_insert% "x" r 1 in (f { x = 0 } : _)
diff --git a/cli/tests/snapshot/inputs/errors/record_forall_parametricity_violation.ncl b/cli/tests/snapshot/inputs/errors/record_forall_parametricity_violation.ncl
index aee07acc..d1c6a70b 100644
--- a/cli/tests/snapshot/inputs/errors/record_forall_parametricity_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/record_forall_parametricity_violation.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let f : forall r. { x : String } -> { x : String; r } =
   fun x => x
 in f { x = 1 }
diff --git a/cli/tests/snapshot/inputs/errors/record_type_repeated_field.ncl b/cli/tests/snapshot/inputs/errors/record_type_repeated_field.ncl
index 51ab3418..57095750 100644
--- a/cli/tests/snapshot/inputs/errors/record_type_repeated_field.ncl
+++ b/cli/tests/snapshot/inputs/errors/record_type_repeated_field.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
-({foo.bar.baz = "a"} : {foo : String, foo : Number})
\ No newline at end of file
+# command = ['eval']
+({foo.bar.baz = "a"} : {foo : String, foo : Number})
diff --git a/cli/tests/snapshot/inputs/errors/simple_contract_fail.ncl b/cli/tests/snapshot/inputs/errors/simple_contract_fail.ncl
index 8a4e37ea..5045007b 100644
--- a/cli/tests/snapshot/inputs/errors/simple_contract_fail.ncl
+++ b/cli/tests/snapshot/inputs/errors/simple_contract_fail.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 1 | String
diff --git a/cli/tests/snapshot/inputs/errors/string_delimiter_mismatch.ncl b/cli/tests/snapshot/inputs/errors/string_delimiter_mismatch.ncl
index e935df6c..5efdbc09 100644
--- a/cli/tests/snapshot/inputs/errors/string_delimiter_mismatch.ncl
+++ b/cli/tests/snapshot/inputs/errors/string_delimiter_mismatch.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 m%"Hello"%%
diff --git a/cli/tests/snapshot/inputs/errors/subcontract_nested_custom_diagnostics.ncl b/cli/tests/snapshot/inputs/errors/subcontract_nested_custom_diagnostics.ncl
index 6c93f4ec..31807fc5 100644
--- a/cli/tests/snapshot/inputs/errors/subcontract_nested_custom_diagnostics.ncl
+++ b/cli/tests/snapshot/inputs/errors/subcontract_nested_custom_diagnostics.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let ChildContract = fun label value =>
   label
   |> std.contract.label.with_message "child's message"
diff --git a/cli/tests/snapshot/inputs/errors/subcontract_type_path_underline.ncl b/cli/tests/snapshot/inputs/errors/subcontract_type_path_underline.ncl
index a01922e6..07c2d4cf 100644
--- a/cli/tests/snapshot/inputs/errors/subcontract_type_path_underline.ncl
+++ b/cli/tests/snapshot/inputs/errors/subcontract_type_path_underline.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 let val | Array (Array {foo: Dyn -> Number }) = [
   [],
   [{ foo = fun x => "string" }]
diff --git a/cli/tests/snapshot/inputs/errors/trace_not_saturated.ncl b/cli/tests/snapshot/inputs/errors/trace_not_saturated.ncl
index 6e88153f..b2b3c4d1 100644
--- a/cli/tests/snapshot/inputs/errors/trace_not_saturated.ncl
+++ b/cli/tests/snapshot/inputs/errors/trace_not_saturated.ncl
@@ -1,3 +1,3 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 %trace% "too few arguments"
diff --git a/cli/tests/snapshot/inputs/errors/typed_field_without_annotation.ncl b/cli/tests/snapshot/inputs/errors/typed_field_without_annotation.ncl
index f86f17af..0a771952 100644
--- a/cli/tests/snapshot/inputs/errors/typed_field_without_annotation.ncl
+++ b/cli/tests/snapshot/inputs/errors/typed_field_without_annotation.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 {
   foo : Number
       | doc "foo",
diff --git a/cli/tests/snapshot/inputs/errors/unification_variable_aliasing.ncl b/cli/tests/snapshot/inputs/errors/unification_variable_aliasing.ncl
index df8a2f2b..96b6a8ba 100644
--- a/cli/tests/snapshot/inputs/errors/unification_variable_aliasing.ncl
+++ b/cli/tests/snapshot/inputs/errors/unification_variable_aliasing.ncl
@@ -1,5 +1,5 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 
 # Regression test for #1312
 let f : forall a. (forall r. { bla : Bool, blo : a, ble : a; r } -> a) = fun r => if r.bla then (r.blo + 1) else r.ble
diff --git a/cli/tests/snapshot/inputs/errors/value_contract_violation.ncl b/cli/tests/snapshot/inputs/errors/value_contract_violation.ncl
index 8d74ce76..63d4cd43 100644
--- a/cli/tests/snapshot/inputs/errors/value_contract_violation.ncl
+++ b/cli/tests/snapshot/inputs/errors/value_contract_violation.ncl
@@ -1,4 +1,4 @@
 # capture = 'stderr'
-# command = []
+# command = ['eval']
 { foo | std.FailWith "no reason" = 1 }.foo
 
diff --git a/cli/tests/snapshot/inputs/eval/escaping.ncl b/cli/tests/snapshot/inputs/eval/escaping.ncl
index e67e7e3a..d91ca5ae 100644
--- a/cli/tests/snapshot/inputs/eval/escaping.ncl
+++ b/cli/tests/snapshot/inputs/eval/escaping.ncl
@@ -1,3 +1,3 @@
 # capture = 'stdout'
-# command = []
+# command = ['eval']
 "a\"bcd\""
diff --git a/cli/tests/snapshot/inputs/eval/records.ncl b/cli/tests/snapshot/inputs/eval/records.ncl
index 747a3aed..996d15a0 100644
--- a/cli/tests/snapshot/inputs/eval/records.ncl
+++ b/cli/tests/snapshot/inputs/eval/records.ncl
@@ -1,3 +1,3 @@
 # capture = 'stdout'
-# command = []
+# command = ['eval']
 { a = true, b | Number = 6 * 7 }
diff --git a/cli/tests/snapshot/main.rs b/cli/tests/snapshot/main.rs
index 9dbb4b08..aa22f87f 100644
--- a/cli/tests/snapshot/main.rs
+++ b/cli/tests/snapshot/main.rs
@@ -4,7 +4,7 @@ use nickel_lang_utils::{
 };
 use serde::Deserialize;
 use std::{
-    ffi::OsStr,
+    ffi::{OsStr, OsString},
     path::PathBuf,
     process::{Command, Output},
 };
@@ -36,12 +36,15 @@ fn check_snapshots(path: &str) {
         .first()
         .map(|s| s.as_str())
         // TODO: er, maybe?
-        .unwrap_or("error")
+        .unwrap_or("no_subcommand")
         .to_string();
 
     // We set the `-f somefile.ncl` argument before the others, because it can't come after
     // customize mode's delimiter `--`.
-    let invocation = NickelInvocation::new().file(&file).args(annotation.command);
+    let invocation = NickelInvocation::new()
+        .file(&file)
+        .args(annotation.command)
+        .extra_args(annotation.extra_args);
 
     match annotation.capture {
         SnapshotCapture::Stderr => {
@@ -93,27 +96,33 @@ impl TestFile {
         format!("{prefix}_{file_name}")
     }
 
-    fn as_nickel_argument(&self) -> &str {
-        self.path_buf
-            .to_str()
-            .expect("Could not convert path arg to str")
+    fn as_nickel_argument(&self) -> &OsStr {
+        self.path_buf.as_os_str()
     }
 }
 
-struct NickelInvocation {
+struct NickelInvocation<'a> {
     cmd: Command,
+    file: Option<&'a TestFile>,
+    args: Vec<OsString>,
+    extra_args: Vec<OsString>,
 }
 
-impl NickelInvocation {
+impl<'a> NickelInvocation<'a> {
     fn new() -> Self {
         let nickel_loc = env!("CARGO_BIN_EXE_nickel");
         let mut cmd = Command::new(nickel_loc);
         cmd.args(["--color", "never"]);
-        Self { cmd }
+        Self {
+            cmd,
+            file: None,
+            args: Vec::new(),
+            extra_args: Vec::new(),
+        }
     }
 
-    fn file(mut self, f: &TestFile) -> Self {
-        self.cmd.args(["-f", f.as_nickel_argument()]);
+    fn file(mut self, f: &'a TestFile) -> Self {
+        self.file = Some(f);
         self
     }
 
@@ -122,11 +131,26 @@ impl NickelInvocation {
         I: IntoIterator<Item = S>,
         S: AsRef<OsStr>,
     {
-        self.cmd.args(args);
+        self.args
+            .extend(args.into_iter().map(|s| s.as_ref().into()));
+        self
+    }
+
+    fn extra_args<I, S>(mut self, extra_args: I) -> Self
+    where
+        I: IntoIterator<Item = S>,
+        S: AsRef<OsStr>,
+    {
+        self.extra_args
+            .extend(extra_args.into_iter().map(|s| s.as_ref().into()));
         self
     }
 
     fn run(mut self) -> Output {
+        self.cmd.args(self.args);
+        self.cmd
+            .args(self.file.into_iter().map(|f| f.as_nickel_argument()));
+        self.cmd.args(self.extra_args);
         self.cmd.output().expect("Should be able to capture output")
     }
 
@@ -152,6 +176,8 @@ impl NickelInvocation {
 struct SnapshotAnnotation {
     capture: SnapshotCapture,
     command: Vec<String>,
+    #[serde(default)]
+    extra_args: Vec<String>,
 }
 
 #[derive(Deserialize)]