From ad31755dbbe4b20e7e3e4065d12f164f0e3cdb0e Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 3 Mar 2026 15:58:23 -0800 Subject: [PATCH] feat(cli): add plugin install and complete prompt/render host events --- Cargo.lock | 125 ++++- crates-cli/yaak-cli/Cargo.toml | 2 + crates-cli/yaak-cli/src/cli.rs | 9 + crates-cli/yaak-cli/src/commands/plugin.rs | 128 ++++- crates-cli/yaak-cli/src/commands/request.rs | 3 +- crates-cli/yaak-cli/src/main.rs | 26 +- crates-cli/yaak-cli/src/plugin_events.rs | 512 +++++++++++++++++- .../package.json | 10 + .../src/index.ts | 59 ++ .../tsconfig.json | 6 + 10 files changed, 845 insertions(+), 35 deletions(-) create mode 100644 plugins/template-function-prompt-form-cli-test/package.json create mode 100644 plugins/template-function-prompt-form-cli-test/src/index.ts create mode 100644 plugins/template-function-prompt-form-cli-test/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index 929908b2..5a06b414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1200,7 +1200,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1405,6 +1405,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.3" @@ -2294,6 +2319,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local 1.1.9", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -3164,6 +3198,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.11.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "tempfile", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "interfaces" version = "0.0.8" @@ -3756,6 +3808,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log 0.4.29", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.4" @@ -3851,6 +3915,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -3942,7 +4015,7 @@ dependencies = [ "kqueue", "libc", "log 0.4.29", - "mio", + "mio 1.0.4", "notify-types", "walkdir", "windows-sys 0.59.0", @@ -4501,7 +4574,7 @@ dependencies = [ "textwrap", "thiserror 2.0.17", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -6171,7 +6244,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", "yansi", ] @@ -6196,7 +6269,7 @@ dependencies = [ "kqueue", "libc", "log 0.4.29", - "mio", + "mio 1.0.4", "rolldown-notify-types", "walkdir", "windows-sys 0.61.2", @@ -7173,6 +7246,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" @@ -8068,7 +8162,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -8215,7 +8309,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.0.4", "pin-project-lite", "signal-hook-registry", "socket2 0.6.1", @@ -8785,6 +8879,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -9562,6 +9662,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -10141,6 +10250,7 @@ dependencies = [ name = "yaak-cli" version = "0.1.0" dependencies = [ + "arboard", "assert_cmd", "base64 0.22.1", "clap", @@ -10150,6 +10260,7 @@ dependencies = [ "futures", "hex", "include_dir", + "inquire", "keyring", "log 0.4.29", "oxc_resolver", diff --git a/crates-cli/yaak-cli/Cargo.toml b/crates-cli/yaak-cli/Cargo.toml index a21f280c..4c62578e 100644 --- a/crates-cli/yaak-cli/Cargo.toml +++ b/crates-cli/yaak-cli/Cargo.toml @@ -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"] } diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs index 975d94d6..bdd77252 100644 --- a/crates-cli/yaak-cli/src/cli.rs +++ b/crates-cli/yaak-cli/src/cli.rs @@ -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, } + +#[derive(Args, Clone)] +pub struct InstallPluginArgs { + /// Local plugin directory path, or registry plugin spec (@org/plugin[@version]) + pub source: String, +} diff --git a/crates-cli/yaak-cli/src/commands/plugin.rs b/crates-cli/yaak-cli/src/commands/plugin.rs index 45403c79..d12f6f22 100644 --- a/crates-cli/yaak-cli/src/commands/plugin.rs +++ b/crates-cli/yaak-cli/src/commands/plugin.rs @@ -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, +) -> 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)> { + 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, diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs index 74c97490..a14e42f0 100644 --- a/crates-cli/yaak-cli/src/commands/request.rs +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -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::(100); let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::>(); diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index cc5e6e21..8545509f 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -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, diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index f0f730af..a2dc4dba 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -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, 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>, 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, +) -> Result { + 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::>(); + 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), + Cancelled, +} + +fn prompt_text_with_inquire( + label: &str, + default_value: Option, + placeholder: Option, + required: bool, +) -> Result { + 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, + required: bool, +) -> Result { + 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, + required: bool, +) -> Result { + 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, + default_value: Option, + required: bool, +) -> Result { + 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 { + 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() +} diff --git a/plugins/template-function-prompt-form-cli-test/package.json b/plugins/template-function-prompt-form-cli-test/package.json new file mode 100644 index 00000000..71c11560 --- /dev/null +++ b/plugins/template-function-prompt-form-cli-test/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gschier/prompt-form-cli-test", + "displayName": "Prompt Form CLI Test", + "description": "Tiny plugin to test prompt.form in the CLI host", + "private": true, + "version": "0.0.1", + "scripts": { + "build": "yaak plugin build" + } +} diff --git a/plugins/template-function-prompt-form-cli-test/src/index.ts b/plugins/template-function-prompt-form-cli-test/src/index.ts new file mode 100644 index 00000000..1410b10c --- /dev/null +++ b/plugins/template-function-prompt-form-cli-test/src/index.ts @@ -0,0 +1,59 @@ +export const plugin = { + templateFunctions: [ + { + name: 'prompt.form.demo', + description: 'Prompt for a few values using prompt.form and return a JSON string', + args: [], + async onRender(ctx, args) { + if (args.purpose !== 'send') { + return null; + } + + const values = await ctx.prompt.form({ + id: 'prompt-form-demo', + title: 'CLI Prompt Form Demo', + description: 'Fill out the fields to test prompt.form in the CLI.', + inputs: [ + { + type: 'text', + name: 'username', + label: 'Username', + defaultValue: 'alice' + }, + { + type: 'text', + name: 'password', + label: 'Password', + password: true, + optional: true + }, + { + type: 'select', + name: 'region', + label: 'Region', + defaultValue: 'us', + options: [ + { label: 'US', value: 'us' }, + { label: 'EU', value: 'eu' }, + { label: 'APAC', value: 'apac' } + ] + }, + { + type: 'checkbox', + name: 'remember', + label: 'Remember', + defaultValue: 'true', + optional: true + } + ] + }); + + if (values == null) { + throw new Error('Prompt form cancelled'); + } + + return JSON.stringify(values); + } + } + ] +}; diff --git a/plugins/template-function-prompt-form-cli-test/tsconfig.json b/plugins/template-function-prompt-form-cli-test/tsconfig.json new file mode 100644 index 00000000..94b096d4 --- /dev/null +++ b/plugins/template-function-prompt-form-cli-test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true + }, + "include": ["src"] +}