feat(cli): add plugin install and complete prompt/render host events

This commit is contained in:
Gregory Schier
2026-03-03 15:58:23 -08:00
parent b01b3a4c57
commit ad31755dbb
10 changed files with 845 additions and 35 deletions

View File

@@ -9,12 +9,14 @@ name = "yaak"
path = "src/main.rs"
[dependencies]
arboard = "3"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
console = "0.15"
dirs = "6"
env_logger = "0.11"
futures = "0.3"
inquire = { version = "0.7", features = ["editor"] }
hex = { workspace = true }
include_dir = "0.7"
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }

View File

@@ -444,6 +444,9 @@ pub enum PluginCommands {
/// Generate a "Hello World" Yaak plugin
Generate(GenerateArgs),
/// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs),
/// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg),
}
@@ -464,3 +467,9 @@ pub struct GenerateArgs {
#[arg(long)]
pub dir: Option<PathBuf>,
}
#[derive(Args, Clone)]
pub struct InstallPluginArgs {
/// Local plugin directory path, or registry plugin spec (@org/plugin[@version])
pub source: String,
}

View File

@@ -1,4 +1,5 @@
use crate::cli::{GenerateArgs, PluginArgs, PluginCommands, PluginPathArg};
use crate::cli::{GenerateArgs, InstallPluginArgs, PluginPathArg};
use crate::context::CliContext;
use crate::ui;
use crate::utils::http;
use keyring::Entry;
@@ -15,6 +16,11 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use walkdir::WalkDir;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::models::{Plugin, PluginSource};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::install::download_and_install;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
@@ -57,12 +63,13 @@ pub async fn run_build(args: PluginPathArg) -> i32 {
}
}
pub async fn run(args: PluginArgs) -> i32 {
match args.command {
PluginCommands::Build(args) => run_build(args).await,
PluginCommands::Dev(args) => run_dev(args).await,
PluginCommands::Generate(args) => run_generate(args).await,
PluginCommands::Publish(args) => run_publish(args).await,
pub async fn run_install(context: &CliContext, args: InstallPluginArgs) -> i32 {
match install(context, args).await {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
@@ -250,6 +257,113 @@ async fn publish(args: PluginPathArg) -> CommandResult {
Ok(())
}
async fn install(context: &CliContext, args: InstallPluginArgs) -> CommandResult {
if args.source.starts_with('@') {
let (name, version) =
parse_registry_install_spec(args.source.as_str()).ok_or_else(|| {
"Invalid registry plugin spec. Expected format: @org/plugin or @org/plugin@version"
.to_string()
})?;
return install_from_registry(context, name, version).await;
}
install_from_directory(context, args.source.as_str()).await
}
async fn install_from_registry(
context: &CliContext,
name: String,
version: Option<String>,
) -> CommandResult {
let current_version = crate::version::cli_version();
let http_client = yaak_api_client(ApiClientKind::Cli, current_version)
.map_err(|err| format!("Failed to initialize API client: {err}"))?;
let installing_version = version.clone().unwrap_or_else(|| "latest".to_string());
ui::info(&format!("Installing registry plugin {name}@{installing_version}"));
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
let installed = download_and_install(
context.plugin_manager(),
context.query_manager(),
&http_client,
&plugin_context,
name.as_str(),
version,
)
.await
.map_err(|err| format!("Failed to install plugin: {err}"))?;
ui::success(&format!("Installed plugin {}@{}", installed.name, installed.version));
Ok(())
}
async fn install_from_directory(context: &CliContext, source: &str) -> CommandResult {
let plugin_dir = resolve_plugin_dir(Some(PathBuf::from(source)))?;
let plugin_dir_str = plugin_dir
.to_str()
.ok_or_else(|| {
format!("Plugin directory path is not valid UTF-8: {}", plugin_dir.display())
})?
.to_string();
ui::info(&format!("Installing plugin from directory {}", plugin_dir.display()));
let plugin = context
.db()
.upsert_plugin(
&Plugin {
directory: plugin_dir_str,
url: None,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::Background,
)
.map_err(|err| format!("Failed to save plugin in database: {err}"))?;
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
context
.plugin_manager()
.add_plugin(&plugin_context, &plugin)
.await
.map_err(|err| format!("Failed to load plugin runtime: {err}"))?;
ui::success(&format!("Installed plugin from {}", plugin.directory));
Ok(())
}
fn parse_registry_install_spec(source: &str) -> Option<(String, Option<String>)> {
if !source.starts_with('@') || !source.contains('/') {
return None;
}
let rest = source.get(1..)?;
let version_split = rest.rfind('@').map(|idx| idx + 1);
let (name, version) = match version_split {
Some(at_idx) => {
let (name, version) = source.split_at(at_idx);
let version = version.strip_prefix('@').unwrap_or_default();
if version.is_empty() {
return None;
}
(name.to_string(), Some(version.to_string()))
}
None => (source.to_string(), None),
};
if !name.starts_with('@') {
return None;
}
let without_scope = name.get(1..)?;
let (scope, plugin_name) = without_scope.split_once('/')?;
if scope.is_empty() || plugin_name.is_empty() {
return None;
}
Some((name, version))
}
#[derive(Deserialize)]
struct PublishResponse {
version: String,

View File

@@ -481,7 +481,8 @@ async fn send_http_request_by_id(
) -> Result<(), String> {
let cookie_jar_id = resolve_cookie_jar_id(ctx, workspace_id, cookie_jar_id)?;
let plugin_context = PluginContext::new(None, Some(workspace_id.to_string()));
let plugin_context =
PluginContext::new(Some("cli".to_string()), Some(workspace_id.to_string()));
let (event_tx, mut event_rx) = mpsc::channel::<SenderHttpResponseEvent>(100);
let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::<Vec<u8>>();

View File

@@ -8,7 +8,7 @@ mod version;
mod version_check;
use clap::Parser;
use cli::{Cli, Commands, RequestCommands};
use cli::{Cli, Commands, PluginCommands, RequestCommands};
use context::{CliContext, CliExecutionContext};
use std::sync::Arc;
use yaak_crypto::manager::EncryptionManager;
@@ -52,7 +52,29 @@ async fn main() {
let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Plugin(args) => commands::plugin::run(args).await,
Commands::Plugin(args) => match args.command {
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
PluginCommands::Install(install_args) => {
let query_manager = query_manager.clone();
let blob_manager = blob_manager.clone();
let execution_context = CliExecutionContext::default();
let context = CliContext::new(
data_dir.clone(),
query_manager.clone(),
blob_manager,
Arc::new(EncryptionManager::new(query_manager, app_id)),
true,
execution_context,
)
.await;
let exit_code = commands::plugin::run_install(&context, install_args).await;
context.shutdown().await;
exit_code
}
},
Commands::Build(args) => commands::plugin::run_build(args).await,
Commands::Dev(args) => commands::plugin::run_dev(args).await,
Commands::Generate(args) => commands::plugin::run_generate(args).await,

View File

@@ -1,6 +1,10 @@
use crate::context::CliExecutionContext;
use arboard::Clipboard;
use console::Term;
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::task::JoinHandle;
@@ -16,10 +20,11 @@ use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, RenderGrpcRequestResponse,
RenderHttpRequestResponse, SendHttpRequestResponse, TemplateRenderResponse, WindowInfoResponse,
WorkspaceInfo,
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, ListCookieNamesResponse, ListOpenWorkspacesResponse,
PluginContext, PromptFormRequest, PromptFormResponse, PromptTextRequest, PromptTextResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
@@ -404,19 +409,29 @@ async fn build_plugin_reply(
})),
}
}
HostRequest::CopyText(_) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Unsupported plugin request in CLI: copy_text_request".to_string(),
})),
HostRequest::PromptText(_) => {
Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Unsupported plugin request in CLI: prompt_text_request".to_string(),
}))
}
HostRequest::PromptForm(_) => {
Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Unsupported plugin request in CLI: prompt_form_request".to_string(),
}))
}
HostRequest::CopyText(req) => match copy_text_to_clipboard(req.text.as_str()) {
Ok(()) => Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})),
Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to copy text in CLI: {error}"),
})),
},
HostRequest::PromptText(req) => match prompt_text_for_cli(req) {
Ok(value) => {
Some(InternalEventPayload::PromptTextResponse(PromptTextResponse { value }))
}
Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to prompt text in CLI: {error}"),
})),
},
HostRequest::PromptForm(req) => match prompt_form_for_cli(req) {
Ok(values) => Some(InternalEventPayload::PromptFormResponse(PromptFormResponse {
values,
done: Some(true),
})),
Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to prompt form in CLI: {error}"),
})),
},
HostRequest::OpenWindow(_) => {
Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Unsupported plugin request in CLI: open_window_request".to_string(),
@@ -567,3 +582,464 @@ fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
let (name, value) = first_part.split_once('=')?;
Some((name.trim().to_string(), value.to_string()))
}
fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
}
fn prompt_text_for_cli(req: &PromptTextRequest) -> Result<Option<String>, String> {
if !std::io::stdin().is_terminal() {
return Err("cannot prompt in non-interactive mode".to_string());
}
let term = Term::stdout();
if let Some(description) = &req.description {
if !description.is_empty() {
term.write_line(description.as_str()).map_err(|e| e.to_string())?;
}
}
let label = if req.label.is_empty() { req.id.as_str() } else { req.label.as_str() };
let value = if req.password.unwrap_or(false) {
prompt_password_with_inquire(
label,
req.default_value.clone(),
req.required.unwrap_or(false),
)?
} else {
prompt_text_with_inquire(
label,
req.default_value.clone(),
req.placeholder.clone(),
req.required.unwrap_or(false),
)?
};
match value {
PromptValue::Cancelled => Ok(None),
PromptValue::Value(v) => Ok(v),
}
}
fn prompt_form_for_cli(
req: &PromptFormRequest,
) -> Result<Option<HashMap<String, JsonPrimitive>>, String> {
if !std::io::stdin().is_terminal() {
return Err("cannot prompt in non-interactive mode".to_string());
}
let term = Term::stdout();
if let Some(description) = &req.description {
if !description.is_empty() {
term.write_line(description.as_str()).map_err(|e| e.to_string())?;
}
}
let mut values = HashMap::new();
for input in &req.inputs {
if prompt_form_input_for_cli(input, &mut values)? == PromptOutcome::Cancelled {
return Ok(None);
}
}
Ok(Some(values))
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum PromptOutcome {
Continue,
Cancelled,
}
fn prompt_form_input_for_cli(
input: &FormInput,
values: &mut HashMap<String, JsonPrimitive>,
) -> Result<PromptOutcome, String> {
match input {
FormInput::Text(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let required = !input.base.optional.unwrap_or(false);
let value = if input.password.unwrap_or(false) {
prompt_password_with_inquire(
label.as_str(),
input.base.default_value.clone(),
required,
)?
} else {
prompt_text_with_inquire(
label.as_str(),
input.base.default_value.clone(),
input.placeholder.clone(),
required,
)?
};
match value {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::String(v));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::Editor(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let required = !input.base.optional.unwrap_or(false);
let value = prompt_editor_with_inquire(
label.as_str(),
input.base.default_value.clone(),
required,
)?;
match value {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::String(v));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::Select(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let options = input.options.iter().map(|o| o.value.clone()).collect::<Vec<_>>();
let value = prompt_select_with_inquire(
label.as_str(),
options,
input.base.default_value.clone(),
!input.base.optional.unwrap_or(false),
)?;
match value {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::String(v));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::Checkbox(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let default = input
.base
.default_value
.as_deref()
.map(|v| matches!(v, "1" | "true" | "yes" | "on"))
.unwrap_or(false);
match prompt_confirm_with_inquire(label.as_str(), default)? {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::Boolean(v == "true"));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::File(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let value = prompt_text_with_inquire(
label.as_str(),
input.base.default_value.clone(),
Some("Path".to_string()),
!input.base.optional.unwrap_or(false),
)?;
match value {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::String(v));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::HttpRequest(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let value = prompt_text_with_inquire(
label.as_str(),
input.base.default_value.clone(),
Some("Request ID".to_string()),
!input.base.optional.unwrap_or(false),
)?;
match value {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::String(v));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::KeyValue(input) => {
if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
let label = prompt_label_for_base(&input.base);
let value = prompt_text_with_inquire(
label.as_str(),
input.base.default_value.clone(),
Some("JSON string".to_string()),
!input.base.optional.unwrap_or(false),
)?;
match value {
PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),
PromptValue::Value(Some(v)) => {
values.insert(input.base.name.clone(), JsonPrimitive::String(v));
Ok(PromptOutcome::Continue)
}
PromptValue::Value(None) => Ok(PromptOutcome::Continue),
}
}
FormInput::Accordion(input) => {
if input.hidden.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
if let Some(inputs) = &input.inputs {
for nested in inputs {
if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled {
return Ok(PromptOutcome::Cancelled);
}
}
}
Ok(PromptOutcome::Continue)
}
FormInput::HStack(input) => {
if input.hidden.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
if let Some(inputs) = &input.inputs {
for nested in inputs {
if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled {
return Ok(PromptOutcome::Cancelled);
}
}
}
Ok(PromptOutcome::Continue)
}
FormInput::Banner(input) => {
if input.hidden.unwrap_or(false) {
return Ok(PromptOutcome::Continue);
}
if let Some(inputs) = &input.inputs {
for nested in inputs {
if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled {
return Ok(PromptOutcome::Cancelled);
}
}
}
Ok(PromptOutcome::Continue)
}
FormInput::Markdown(input) => {
if !input.hidden.unwrap_or(false) && !input.content.trim().is_empty() {
let term = Term::stdout();
term.write_line(input.content.as_str()).map_err(|e| e.to_string())?;
}
Ok(PromptOutcome::Continue)
}
}
}
enum PromptValue {
Value(Option<String>),
Cancelled,
}
fn prompt_text_with_inquire(
label: &str,
default_value: Option<String>,
placeholder: Option<String>,
required: bool,
) -> Result<PromptValue, String> {
let default_value = default_value.and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() { None } else { Some(v) }
});
loop {
let message = prompt_message(label);
let mut prompt = Text::new(message.as_str());
if let Some(v) = default_value.as_deref() {
prompt = prompt.with_default(v);
}
if let Some(v) = placeholder.as_deref() {
if !v.trim().is_empty() {
prompt = prompt.with_placeholder(v);
}
}
let result = prompt.prompt();
match result {
Ok(v) => {
let v = v.trim().to_string();
if v.is_empty() {
if let Some(default) = default_value.clone() {
if !default.trim().is_empty() {
return Ok(PromptValue::Value(Some(default)));
}
}
if required {
continue;
}
return Ok(PromptValue::Value(None));
}
return Ok(PromptValue::Value(Some(v)));
}
Err(inquire::InquireError::OperationCanceled)
| Err(inquire::InquireError::OperationInterrupted) => {
return Ok(PromptValue::Cancelled);
}
Err(err) => return Err(err.to_string()),
}
}
}
fn prompt_password_with_inquire(
label: &str,
default_value: Option<String>,
required: bool,
) -> Result<PromptValue, String> {
let default_value = default_value.and_then(|v| {
let trimmed = v.trim();
if trimmed.is_empty() { None } else { Some(v) }
});
loop {
let message = prompt_message(label);
let mut prompt = Password::new(message.as_str()).without_confirmation();
prompt = prompt.with_display_mode(PasswordDisplayMode::Masked);
if default_value.as_ref().is_some_and(|v| !v.trim().is_empty()) {
prompt = prompt.with_help_message("Leave blank to use the default value");
}
let result = prompt.prompt();
match result {
Ok(v) => {
let v = v.trim().to_string();
if v.is_empty() {
if let Some(default) = default_value.clone() {
if !default.trim().is_empty() {
return Ok(PromptValue::Value(Some(default)));
}
}
if required {
continue;
}
return Ok(PromptValue::Value(None));
}
return Ok(PromptValue::Value(Some(v)));
}
Err(inquire::InquireError::OperationCanceled)
| Err(inquire::InquireError::OperationInterrupted) => {
return Ok(PromptValue::Cancelled);
}
Err(err) => return Err(err.to_string()),
}
}
}
fn prompt_editor_with_inquire(
label: &str,
default_value: Option<String>,
required: bool,
) -> Result<PromptValue, String> {
loop {
let message = prompt_message(label);
let mut prompt = Editor::new(message.as_str());
if let Some(v) = default_value.as_deref() {
prompt = prompt.with_predefined_text(v);
}
let result = prompt.prompt();
match result {
Ok(v) => {
let v = v.trim().to_string();
if v.is_empty() {
if required {
continue;
}
return Ok(PromptValue::Value(None));
}
return Ok(PromptValue::Value(Some(v)));
}
Err(inquire::InquireError::OperationCanceled)
| Err(inquire::InquireError::OperationInterrupted) => {
return Ok(PromptValue::Cancelled);
}
Err(err) => return Err(err.to_string()),
}
}
}
fn prompt_select_with_inquire(
label: &str,
options: Vec<String>,
default_value: Option<String>,
required: bool,
) -> Result<PromptValue, String> {
if options.is_empty() {
if required {
return Err(format!("Select input '{label}' has no options"));
}
return Ok(PromptValue::Value(None));
}
let index = default_value
.as_ref()
.and_then(|d| options.iter().position(|o| o == d))
.unwrap_or_default();
let message = prompt_message(label);
let mut prompt = Select::new(message.as_str(), options);
if default_value.is_some() {
prompt = prompt.with_starting_cursor(index);
}
match prompt.prompt() {
Ok(v) => Ok(PromptValue::Value(Some(v))),
Err(inquire::InquireError::OperationCanceled)
| Err(inquire::InquireError::OperationInterrupted) => Ok(PromptValue::Cancelled),
Err(err) => Err(err.to_string()),
}
}
fn prompt_confirm_with_inquire(label: &str, default: bool) -> Result<PromptValue, String> {
let message = prompt_message(label);
match Confirm::new(message.as_str()).with_default(default).prompt() {
Ok(v) => Ok(PromptValue::Value(Some(if v { "true" } else { "false" }.to_string()))),
Err(inquire::InquireError::OperationCanceled)
| Err(inquire::InquireError::OperationInterrupted) => Ok(PromptValue::Cancelled),
Err(err) => Err(err.to_string()),
}
}
fn prompt_message(label: &str) -> String {
format!("{label}:")
}
fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String {
if let Some(label) = &base.label {
if !label.is_empty() {
return label.clone();
}
}
base.name.clone()
}