Add new and tui CLI commands

Add 'karapace new' to generate a manifest from templates or prompts.

Add 'karapace tui' to launch the terminal UI.

Improve env-id resolution errors in non-JSON output with suggestions.

Add dialoguer and toml as CLI dependencies.
This commit is contained in:
Marco Allegretti 2026-02-23 12:15:39 +01:00
parent e6e0f3dd6d
commit 8e90f45efc
19 changed files with 376 additions and 34 deletions

57
Cargo.lock generated
View file

@ -682,6 +682,19 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "dialoguer"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
dependencies = [
"console",
"shell-words",
"tempfile",
"thiserror 1.0.69",
"zeroize",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
@ -1078,6 +1091,7 @@ dependencies = [
"clap_complete",
"clap_mangen",
"console",
"dialoguer",
"indicatif",
"karapace-core",
"karapace-remote",
@ -1089,6 +1103,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"toml",
"tracing",
"tracing-subscriber",
]
@ -1110,7 +1125,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tracing",
]
@ -1125,7 +1140,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
@ -1142,7 +1157,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tracing",
"ureq",
]
@ -1158,7 +1173,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tracing",
]
@ -1170,7 +1185,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"toml",
]
@ -1203,7 +1218,7 @@ dependencies = [
"serde_json",
"tar",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"tracing",
]
@ -1902,6 +1917,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@ -2026,7 +2047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml",
"thiserror",
"thiserror 2.0.18",
"windows",
"windows-version",
]
@ -2044,13 +2065,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]

View file

@ -21,9 +21,11 @@ tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tempfile.workspace = true
indicatif.workspace = true
console.workspace = true
dialoguer = "0.11"
libc.workspace = true
karapace-schema = { path = "../karapace-schema" }
karapace-core = { path = "../karapace-core" }

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, Strin
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
engine.archive(&resolved).map_err(|e| e.to_string())?;
println!("archived environment {env_id}");
Ok(EXIT_SUCCESS)

View file

@ -1,4 +1,4 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use super::{json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -7,7 +7,11 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Resu
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = if json {
resolve_env_id(engine, env_id)?
} else {
resolve_env_id_pretty(engine, env_id)?
};
let tar_hash = engine.commit(&resolved).map_err(|e| e.to_string())?;
if json {
let payload = serde_json::json!({

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, Strin
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
engine.destroy(&resolved).map_err(|e| e.to_string())?;
println!("destroyed environment {env_id}");
Ok(EXIT_SUCCESS)

View file

@ -1,8 +1,12 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use super::{json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::Engine;
pub fn run(engine: &Engine, env_id: &str, json: bool) -> Result<u8, String> {
let resolved = resolve_env_id(engine, env_id)?;
let resolved = if json {
resolve_env_id(engine, env_id)?
} else {
resolve_env_id_pretty(engine, env_id)?
};
let report =
karapace_core::diff_overlay(engine.store_layout(), &resolved).map_err(|e| e.to_string())?;

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -12,7 +12,7 @@ pub fn run(
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
if command.is_empty() {
engine.enter(&resolved).map_err(|e| e.to_string())?;
} else {

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -13,7 +13,7 @@ pub fn run(
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
engine.exec(&resolved, command).map_err(|e| e.to_string())?;
Ok(EXIT_SUCCESS)
}

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, Strin
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
engine.freeze(&resolved).map_err(|e| e.to_string())?;
println!("frozen environment {env_id}");
Ok(EXIT_SUCCESS)

View file

@ -1,8 +1,12 @@
use super::{colorize_state, json_pretty, resolve_env_id, EXIT_SUCCESS};
use super::{colorize_state, json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::Engine;
pub fn run(engine: &Engine, env_id: &str, json: bool) -> Result<u8, String> {
let resolved = resolve_env_id(engine, env_id)?;
let resolved = if json {
resolve_env_id(engine, env_id)?
} else {
resolve_env_id_pretty(engine, env_id)?
};
let meta = engine.inspect(&resolved).map_err(|e| e.to_string())?;
if json {
println!("{}", json_pretty(&meta)?);

View file

@ -13,6 +13,7 @@ pub mod inspect;
pub mod list;
pub mod man_pages;
pub mod migrate;
pub mod new;
pub mod pull;
pub mod push;
pub mod rebuild;
@ -20,6 +21,7 @@ pub mod rename;
pub mod restore;
pub mod snapshots;
pub mod stop;
pub mod tui;
pub mod verify_store;
use indicatif::{ProgressBar, ProgressStyle};
@ -96,6 +98,75 @@ pub fn resolve_env_id(engine: &Engine, input: &str) -> Result<String, String> {
}
}
fn format_env_suggestion(meta: &karapace_store::EnvMetadata) -> String {
let label = meta
.name
.as_deref()
.unwrap_or_else(|| meta.short_id.as_str());
format!("{label} ({}) {}", meta.short_id, meta.state)
}
pub fn resolve_env_id_pretty(engine: &Engine, input: &str) -> Result<String, String> {
if input.len() == 64 {
return Ok(input.to_owned());
}
let envs = engine.list().map_err(|e| e.to_string())?;
for e in &envs {
if *e.env_id == *input || *e.short_id == *input || e.name.as_deref() == Some(input) {
return Ok(e.env_id.to_string());
}
}
let prefix_matches: Vec<_> = envs
.iter()
.filter(|e| e.env_id.starts_with(input) || e.short_id.starts_with(input))
.collect();
match prefix_matches.len() {
0 => {
let needle = input.to_lowercase();
let mut suggestions: Vec<_> = envs
.iter()
.filter(|e| {
e.name
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&needle)
|| e.short_id.to_lowercase().contains(&needle)
})
.collect();
suggestions.truncate(5);
if suggestions.is_empty() {
Err(format!("no environment matching '{input}'"))
} else {
let rendered = suggestions
.into_iter()
.map(|m| format!(" {}", format_env_suggestion(m)))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"no environment matching '{input}'\n\nDid you mean:\n{rendered}\n\nUse 'karapace list' to see all environments."
))
}
}
1 => Ok(prefix_matches[0].env_id.to_string()),
n => {
let rendered = prefix_matches
.iter()
.take(10)
.map(|m| format!(" {}", format_env_suggestion(m)))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"ambiguous env_id prefix '{input}': matches {n} environments\n\nMatches:\n{rendered}\n\nUse a longer prefix, a full env_id, or a unique name."
))
}
}
}
pub fn make_remote_backend(
remote_url: Option<&str>,
) -> Result<karapace_remote::http::HttpBackend, String> {

View file

@ -0,0 +1,177 @@
use super::{json_pretty, EXIT_SUCCESS};
use dialoguer::{Confirm, Input, Select};
use karapace_schema::manifest::{
parse_manifest_str, BaseSection, ManifestV1, MountsSection, RuntimeSection, SystemSection,
};
use std::io::{stdin, IsTerminal};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
const DEST_MANIFEST: &str = "karapace.toml";
fn template_source(name: &str) -> Option<&'static str> {
match name {
"minimal" => Some(include_str!("../../../../examples/minimal.toml")),
"dev" => Some(include_str!("../../../../examples/dev.toml")),
"gui-dev" => Some(include_str!("../../../../examples/gui-dev.toml")),
"rust-dev" => Some(include_str!("../../../../examples/rust-dev.toml")),
"ubuntu-dev" => Some(include_str!("../../../../examples/ubuntu-dev.toml")),
_ => None,
}
}
fn load_template(name: &str) -> Result<ManifestV1, String> {
let src = template_source(name).ok_or_else(|| {
format!("unknown template '{name}' (expected: minimal, dev, gui-dev, rust-dev, ubuntu-dev)")
})?;
parse_manifest_str(src).map_err(|e| format!("template parse error: {e}"))
}
fn write_atomic(dest: &Path, content: &str) -> Result<(), String> {
let dir = dest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let mut tmp = NamedTempFile::new_in(&dir).map_err(|e| format!("write temp file: {e}"))?;
use std::io::Write;
tmp.write_all(content.as_bytes())
.map_err(|e| format!("write temp file: {e}"))?;
tmp.as_file()
.sync_all()
.map_err(|e| format!("fsync temp file: {e}"))?;
tmp.persist(dest)
.map_err(|e| format!("persist manifest: {}", e.error))?;
Ok(())
}
pub fn run(name: &str, template: Option<&str>, force: bool, json: bool) -> Result<u8, String> {
let dest = Path::new(DEST_MANIFEST);
let is_tty = stdin().is_terminal();
if dest.exists() && !force {
if is_tty {
let overwrite = Confirm::new()
.with_prompt(format!("overwrite ./{DEST_MANIFEST}?"))
.default(false)
.interact()
.map_err(|e| format!("prompt failed: {e}"))?;
if !overwrite {
return Err(format!(
"refusing to overwrite existing ./{DEST_MANIFEST} (pass --force)"
));
}
} else {
return Err(format!(
"refusing to overwrite existing ./{DEST_MANIFEST} (pass --force)"
));
}
}
let mut manifest = match template {
Some(tpl) => load_template(tpl)?,
None => {
if !is_tty {
return Err("no --template provided and stdin is not a TTY".to_owned());
}
let image: String = Input::new()
.with_prompt("base image")
.default("rolling".to_owned())
.interact_text()
.map_err(|e| format!("prompt failed: {e}"))?;
ManifestV1 {
manifest_version: 1,
base: BaseSection { image },
system: SystemSection::default(),
gui: Default::default(),
hardware: Default::default(),
mounts: MountsSection::default(),
runtime: RuntimeSection::default(),
}
}
};
if is_tty {
let packages: String = Input::new()
.with_prompt("packages (space-separated, empty to skip)")
.allow_empty(true)
.interact_text()
.map_err(|e| format!("prompt failed: {e}"))?;
if !packages.trim().is_empty() {
manifest
.system
.packages
.extend(packages.split_whitespace().map(str::to_owned));
}
let mount: String = Input::new()
.with_prompt("mount (format '<host>:<container>', empty to skip)")
.allow_empty(true)
.interact_text()
.map_err(|e| format!("prompt failed: {e}"))?;
if !mount.trim().is_empty() {
manifest
.mounts
.entries
.insert("workspace".to_owned(), mount);
}
let backends = ["namespace", "oci", "mock"];
let default_idx = backends
.iter()
.position(|b| *b == manifest.runtime.backend.as_str())
.unwrap_or(0);
let idx = Select::new()
.with_prompt("runtime backend")
.items(&backends)
.default(default_idx)
.interact()
.map_err(|e| format!("prompt failed: {e}"))?;
manifest.runtime.backend = backends[idx].to_owned();
let isolated = Confirm::new()
.with_prompt("enable network isolation?")
.default(manifest.runtime.network_isolation)
.interact()
.map_err(|e| format!("prompt failed: {e}"))?;
manifest.runtime.network_isolation = isolated;
} else if template.is_none() {
return Err("interactive prompts require a TTY".to_owned());
}
let toml =
toml::to_string_pretty(&manifest).map_err(|e| format!("TOML serialization failed: {e}"))?;
write_atomic(dest, &toml)?;
if json {
let payload = serde_json::json!({
"status": "written",
"path": format!("./{DEST_MANIFEST}"),
"name": name,
"template": template,
});
println!("{}", json_pretty(&payload)?);
} else {
println!("wrote ./{DEST_MANIFEST} for '{name}'");
if let Some(tpl) = template {
println!("template: {tpl}");
}
}
Ok(EXIT_SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_parse() {
for tpl in ["minimal", "dev", "gui-dev", "rust-dev", "ubuntu-dev"] {
let m = load_template(tpl).unwrap();
assert_eq!(m.manifest_version, 1);
assert!(!m.base.image.is_empty());
}
}
}

View file

@ -1,5 +1,6 @@
use super::{
json_pretty, make_remote_backend, resolve_env_id, spin_fail, spin_ok, spinner, EXIT_SUCCESS,
json_pretty, make_remote_backend, resolve_env_id, resolve_env_id_pretty, spin_fail, spin_ok,
spinner, EXIT_SUCCESS,
};
use karapace_core::Engine;
@ -10,7 +11,11 @@ pub fn run(
remote_url: Option<&str>,
json: bool,
) -> Result<u8, String> {
let resolved = resolve_env_id(engine, env_id)?;
let resolved = if json {
resolve_env_id(engine, env_id)?
} else {
resolve_env_id_pretty(engine, env_id)?
};
let backend = make_remote_backend(remote_url)?;
let pb = spinner("pushing environment…");

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str, new_name: &str) ->
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
engine
.rename(&resolved, new_name)
.map_err(|e| e.to_string())?;

View file

@ -1,4 +1,4 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use super::{json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -13,7 +13,11 @@ pub fn run(
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = if json {
resolve_env_id(engine, env_id)?
} else {
resolve_env_id_pretty(engine, env_id)?
};
engine
.restore(&resolved, snapshot_hash)
.map_err(|e| e.to_string())?;

View file

@ -1,4 +1,4 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use super::{json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::Engine;
use karapace_store::StoreLayout;
use std::path::Path;
@ -6,7 +6,11 @@ use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Result<u8, String> {
let _layout = StoreLayout::new(store_path);
let resolved = resolve_env_id(engine, env_id)?;
let resolved = if json {
resolve_env_id(engine, env_id)?
} else {
resolve_env_id_pretty(engine, env_id)?
};
let snapshots = engine
.list_snapshots(&resolved)
.map_err(|e| e.to_string())?;

View file

@ -1,4 +1,4 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use super::{resolve_env_id_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, Strin
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let resolved = resolve_env_id_pretty(engine, env_id)?;
engine.stop(&resolved).map_err(|e| e.to_string())?;
println!("stopped environment {env_id}");
Ok(EXIT_SUCCESS)

View file

@ -0,0 +1,10 @@
use super::EXIT_SUCCESS;
use std::path::Path;
pub fn run(store_path: &Path, json: bool) -> Result<u8, String> {
if json {
return Err("JSON output is not supported for 'tui'".to_owned());
}
karapace_tui::run(store_path)?;
Ok(EXIT_SUCCESS)
}

View file

@ -36,6 +36,13 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Commands {
New {
name: String,
#[arg(long)]
template: Option<String>,
#[arg(long, default_value_t = false)]
force: bool,
},
/// Build an environment from a manifest.
Build {
/// Path to manifest TOML file.
@ -164,6 +171,8 @@ enum Commands {
#[arg(default_value = "man")]
dir: PathBuf,
},
/// Launch the terminal UI.
Tui,
/// Run diagnostic checks on the system and store.
Doctor,
/// Check store version and show migration guidance.
@ -202,6 +211,7 @@ fn main() -> ExitCode {
| Commands::Enter { .. }
| Commands::Exec { .. }
| Commands::Rebuild { .. }
| Commands::Tui
);
if needs_runtime && std::env::var("KARAPACE_SKIP_PREREQS").as_deref() != Ok("1") {
let missing = karapace_runtime::check_namespace_prereqs();
@ -212,6 +222,11 @@ fn main() -> ExitCode {
}
let result = match cli.command {
Commands::New {
name,
template,
force,
} => commands::new::run(&name, template.as_deref(), force, json_output),
Commands::Build { manifest, name } => commands::build::run(
&engine,
&store_path,
@ -269,6 +284,7 @@ fn main() -> ExitCode {
}
Commands::Completions { shell } => commands::completions::run::<Cli>(shell),
Commands::ManPages { dir } => commands::man_pages::run::<Cli>(&dir),
Commands::Tui => commands::tui::run(&store_path, json_output),
Commands::Doctor => commands::doctor::run(&store_path, json_output),
Commands::Migrate => commands::migrate::run(&store_path, json_output),
};