Compare commits

..

2 Commits

Author SHA1 Message Date
Gregory Schier
986143c4ae Add yaak-actions-builtin crate and integrate with CLI 2026-02-01 09:01:37 -08:00
Gregory Schier
50b0e23d53 Add yaak-actions crate for centralized action system
Implements a unified action system that serves as a single source of truth
for all operations in Yaak (Tauri app, CLI, plugins, deep links, MCP server).

Key features:
- ActionExecutor: Combined registry and execution engine with async RwLock
- ActionHandler: Trait-based handlers using async closures
- Context system: RequiredContext and CurrentContext for action availability
- Action groups: Organize related actions
- TypeScript bindings: Auto-generated via ts-rs for frontend use

Design highlights:
- Handlers are closures (no dependencies on other yaak crates)
- Registration requires both metadata and handler (prevents orphan actions)
- Flexible return values via serde_json::Value
- All methods are async using tokio

All 33 tests passing. Ready for integration with yaak-core and yaak-app.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 09:00:31 -08:00
35 changed files with 2508 additions and 174 deletions

29
Cargo.lock generated
View File

@@ -7994,6 +7994,33 @@ dependencies = [
"rustix 1.0.7",
]
[[package]]
name = "yaak-actions"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"ts-rs",
]
[[package]]
name = "yaak-actions-builtin"
version = "0.1.0"
dependencies = [
"log",
"serde",
"serde_json",
"tokio",
"yaak-actions",
"yaak-crypto",
"yaak-http",
"yaak-models",
"yaak-plugins",
"yaak-templates",
]
[[package]]
name = "yaak-app"
version = "0.0.0"
@@ -8063,6 +8090,8 @@ dependencies = [
"log",
"serde_json",
"tokio",
"yaak-actions",
"yaak-actions-builtin",
"yaak-crypto",
"yaak-http",
"yaak-models",

View File

@@ -2,6 +2,8 @@
resolver = "2"
members = [
# Shared crates (no Tauri dependency)
"crates/yaak-actions",
"crates/yaak-actions-builtin",
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
@@ -45,6 +47,8 @@ tokio = "1.48.0"
ts-rs = "11.1.0"
# Internal crates - shared
yaak-actions = { path = "crates/yaak-actions" }
yaak-actions-builtin = { path = "crates/yaak-actions-builtin" }
yaak-core = { path = "crates/yaak-core" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }

View File

@@ -15,6 +15,8 @@ env_logger = "0.11"
log = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
yaak-actions = { workspace = true }
yaak-actions-builtin = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }

View File

@@ -1,21 +1,13 @@
use clap::{Parser, Subcommand};
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
use yaak_models::render::make_vars_hashmap;
use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
#[derive(Parser)]
#[command(name = "yaakcli")]
@@ -72,86 +64,6 @@ enum Commands {
},
}
/// Render an HTTP request with template variables and plugin functions
async fn render_http_request(
r: &HttpRequest,
environment_chain: Vec<yaak_models::models::Environment>,
cb: &PluginTemplateCallback,
opt: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt).await?;
// Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
@@ -176,10 +88,6 @@ async fn main() {
let db = query_manager.connect();
// Initialize encryption manager for secure() template function
// Use the same app_id as the Tauri app for keyring access
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
// Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = data_dir.join("installed-plugins");
@@ -198,9 +106,9 @@ async fn main() {
// Create plugin manager (plugins may not be available in CLI context)
let plugin_manager = Arc::new(
PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
vendored_plugin_dir.clone(),
installed_plugin_dir.clone(),
node_bin_path.clone(),
plugin_runtime_main,
false,
)
@@ -239,94 +147,67 @@ async fn main() {
}
}
Commands::Send { request_id } => {
let request = db.get_http_request(&request_id).expect("Failed to get request");
use yaak_actions::{
ActionExecutor, ActionId, ActionParams, ActionResult, ActionTarget, CurrentContext,
};
use yaak_actions_builtin::{BuiltinActionDependencies, register_http_actions};
// Resolve environment chain for variable substitution
let environment_chain = db
.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
cli.environment.as_deref(),
)
.unwrap_or_default();
// Create template callback with plugin support
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let template_callback = PluginTemplateCallback::new(
plugin_manager.clone(),
encryption_manager.clone(),
&plugin_context,
RenderPurpose::Send,
);
// Render templates in the request
let rendered_request = render_http_request(
&request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
// Create dependencies
let deps = BuiltinActionDependencies::new_standalone(
&db_path,
&blob_path,
&app_id,
vendored_plugin_dir.clone(),
installed_plugin_dir.clone(),
node_bin_path.clone(),
)
.await
.expect("Failed to render request templates");
.expect("Failed to initialize dependencies");
if cli.verbose {
println!("> {} {}", rendered_request.method, rendered_request.url);
}
// Create executor and register actions
let executor = ActionExecutor::new();
executor.register_builtin_groups().await.expect("Failed to register groups");
register_http_actions(&executor, &deps).await.expect("Failed to register HTTP actions");
// Convert to sendable request
let sendable = SendableHttpRequest::from_http_request(
&rendered_request,
SendableHttpRequestOptions::default(),
)
.await
.expect("Failed to build request");
// Create event channel for progress
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to print events if verbose
let verbose = cli.verbose;
let verbose_handle = if verbose {
Some(tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
println!("{}", event);
}
}))
} else {
// Drain events silently
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
None
// Prepare context
let context = CurrentContext {
target: Some(ActionTarget::HttpRequest { id: request_id.clone() }),
environment_id: cli.environment.clone(),
workspace_id: None,
has_window: false,
can_prompt: false,
};
// Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
// Prepare params
let params = ActionParams {
data: serde_json::json!({
"render": true,
"follow_redirects": false,
"timeout_ms": 30000,
}),
};
// Wait for event handler to finish
if let Some(handle) = verbose_handle {
let _ = handle.await;
}
// Invoke action
let action_id = ActionId::builtin("http", "send-request");
let result = executor.invoke(&action_id, context, params).await.expect("Action failed");
// Print response
if verbose {
println!();
}
println!(
"HTTP {} {}",
response.status,
response.status_reason.as_deref().unwrap_or("")
);
if verbose {
for (name, value) in &response.headers {
println!("{}: {}", name, value);
// Handle result
match result {
ActionResult::Success { data, message } => {
if let Some(msg) = message {
println!("{}", msg);
}
if let Some(data) = data {
println!("{}", serde_json::to_string_pretty(&data).unwrap());
}
}
ActionResult::RequiresInput { .. } => {
eprintln!("Action requires input (not supported in CLI)");
}
ActionResult::Cancelled => {
eprintln!("Action cancelled");
}
println!();
}
// Print body
let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body);
}
Commands::Get { url } => {
if cli.verbose {

View File

@@ -0,0 +1,18 @@
[package]
name = "yaak-actions-builtin"
version = "0.1.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
yaak-actions = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-templates = { workspace = true }
yaak-plugins = { workspace = true }
yaak-crypto = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt-multi-thread"] }
log = { workspace = true }

View File

@@ -0,0 +1,88 @@
//! Dependency injection for built-in actions.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use yaak_crypto::manager::EncryptionManager;
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Dependencies needed by built-in action implementations.
///
/// This struct bundles all the dependencies that action handlers need,
/// providing a clean way to initialize them in different contexts
/// (CLI, Tauri app, MCP server, etc.).
pub struct BuiltinActionDependencies {
pub query_manager: Arc<QueryManager>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
}
impl BuiltinActionDependencies {
/// Create dependencies for standalone usage (CLI, MCP server, etc.)
///
/// This initializes all the necessary managers following the same pattern
/// as the yaak-cli implementation.
pub async fn new_standalone(
db_path: &Path,
blob_path: &Path,
app_id: &str,
plugin_vendored_dir: PathBuf,
plugin_installed_dir: PathBuf,
node_path: PathBuf,
) -> Result<Self, Box<dyn std::error::Error>> {
// Initialize database
let (query_manager, _, _) = yaak_models::init_standalone(db_path, blob_path)?;
// Initialize encryption manager (takes QueryManager by value)
let encryption_manager = Arc::new(EncryptionManager::new(
query_manager.clone(),
app_id.to_string(),
));
let query_manager = Arc::new(query_manager);
// Find plugin runtime
let plugin_runtime_main = std::env::var("YAAK_PLUGIN_RUNTIME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
// Development fallback
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
});
// Initialize plugin manager
let plugin_manager = Arc::new(
PluginManager::new(
plugin_vendored_dir,
plugin_installed_dir,
node_path,
plugin_runtime_main,
false, // not sandboxed in CLI
)
.await,
);
// Initialize plugins from database
let db = query_manager.connect();
let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() {
let errors = plugin_manager
.initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
for (plugin_dir, error_msg) in errors {
log::warn!(
"Failed to initialize plugin '{}': {}",
plugin_dir,
error_msg
);
}
}
Ok(Self {
query_manager,
plugin_manager,
encryption_manager,
})
}
}

View File

@@ -0,0 +1,24 @@
//! HTTP action implementations.
pub mod send;
use crate::BuiltinActionDependencies;
use yaak_actions::{ActionError, ActionExecutor, ActionSource};
/// Register all HTTP-related actions with the executor.
pub async fn register_http_actions(
executor: &ActionExecutor,
deps: &BuiltinActionDependencies,
) -> Result<(), ActionError> {
let handler = send::HttpSendActionHandler {
query_manager: deps.query_manager.clone(),
plugin_manager: deps.plugin_manager.clone(),
encryption_manager: deps.encryption_manager.clone(),
};
executor
.register(send::metadata(), ActionSource::Builtin, handler)
.await?;
Ok(())
}

View File

@@ -0,0 +1,293 @@
//! HTTP send action implementation.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use yaak_actions::{
ActionError, ActionGroupId, ActionHandler, ActionId, ActionMetadata,
ActionParams, ActionResult, ActionScope, CurrentContext,
RequiredContext,
};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_plugins::events::{PluginContext, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions};
/// Handler for HTTP send action.
pub struct HttpSendActionHandler {
pub query_manager: Arc<QueryManager>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
}
/// Metadata for the HTTP send action.
pub fn metadata() -> ActionMetadata {
ActionMetadata {
id: ActionId::builtin("http", "send-request"),
label: "Send HTTP Request".to_string(),
description: Some("Execute an HTTP request and return the response".to_string()),
icon: Some("play".to_string()),
scope: ActionScope::HttpRequest,
keyboard_shortcut: None,
requires_selection: true,
enabled_condition: None,
group_id: Some(ActionGroupId::builtin("send")),
order: 10,
required_context: RequiredContext::requires_target(),
}
}
impl ActionHandler for HttpSendActionHandler {
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ActionResult, ActionError>> + Send + 'static>,
> {
let query_manager = self.query_manager.clone();
let plugin_manager = self.plugin_manager.clone();
let encryption_manager = self.encryption_manager.clone();
Box::pin(async move {
// Extract request_id from context
let request_id = context
.target
.as_ref()
.ok_or_else(|| {
ActionError::ContextMissing {
missing_fields: vec!["target".to_string()],
}
})?
.id()
.ok_or_else(|| {
ActionError::ContextMissing {
missing_fields: vec!["target.id".to_string()],
}
})?
.to_string();
// Fetch request and environment from database (synchronous)
let (request, environment_chain) = {
let db = query_manager.connect();
// Fetch HTTP request from database
let request = db.get_http_request(&request_id).map_err(|e| {
ActionError::Internal(format!("Failed to fetch request {}: {}", request_id, e))
})?;
// Resolve environment chain for variable substitution
let environment_chain = if let Some(env_id) = &context.environment_id {
db.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
Some(env_id),
)
.unwrap_or_default()
} else {
db.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
None,
)
.unwrap_or_default()
};
(request, environment_chain)
}; // db is dropped here
// Create template callback with plugin support
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
let template_callback = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&plugin_context,
RenderPurpose::Send,
);
// Render templates in the request
let rendered_request = render_http_request(
&request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
.map_err(|e| ActionError::Internal(format!("Failed to render request: {}", e)))?;
// Build sendable request
let options = SendableHttpRequestOptions {
timeout: params
.data
.get("timeout_ms")
.and_then(|v| v.as_u64())
.map(|ms| std::time::Duration::from_millis(ms)),
follow_redirects: params
.data
.get("follow_redirects")
.and_then(|v| v.as_bool())
.unwrap_or(false),
};
let sendable = SendableHttpRequest::from_http_request(&rendered_request, options)
.await
.map_err(|e| ActionError::Internal(format!("Failed to build request: {}", e)))?;
// Create event channel
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to drain events
let _event_handle = tokio::spawn(async move {
while event_rx.recv().await.is_some() {
// For now, just drain events
// In the future, we could log them or emit them to UI
}
});
// Send the request
let sender = ReqwestSender::new()
.map_err(|e| ActionError::Internal(format!("Failed to create HTTP client: {}", e)))?;
let response = sender
.send(sendable, event_tx)
.await
.map_err(|e| ActionError::Internal(format!("Failed to send request: {}", e)))?;
// Consume response body
let status = response.status;
let status_reason = response.status_reason.clone();
let headers = response.headers.clone();
let url = response.url.clone();
let (body_text, stats) = response
.text()
.await
.map_err(|e| ActionError::Internal(format!("Failed to read response body: {}", e)))?;
// Return success result with response data
Ok(ActionResult::Success {
data: Some(json!({
"status": status,
"statusReason": status_reason,
"headers": headers,
"body": body_text,
"contentLength": stats.size_decompressed,
"url": url,
})),
message: Some(format!("HTTP {}", status)),
})
})
}
}
/// Helper function to render templates in an HTTP request.
/// Copied from yaak-cli implementation.
async fn render_http_request(
r: &HttpRequest,
environment_chain: Vec<yaak_models::models::Environment>,
cb: &PluginTemplateCallback,
opt: &RenderOptions,
) -> Result<HttpRequest, String> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
value: parse_and_render(p.value.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
value: parse_and_render(p.value.as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
id: p.id,
})
}
let mut body = BTreeMap::new();
for (k, v) in r.body.clone() {
body.insert(
k,
render_json_value_raw(v, vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(
k,
render_json_value_raw(v, vars, cb, opt)
.await
.map_err(|e| e.to_string())?,
);
}
}
}
auth
};
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt)
.await
.map_err(|e| e.to_string())?;
// Apply path placeholders (e.g., /users/:id -> /users/123)
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest {
url,
url_parameters,
headers,
body,
authentication,
..r.to_owned()
})
}

View File

@@ -0,0 +1,11 @@
//! Built-in action implementations for Yaak.
//!
//! This crate provides concrete implementations of built-in actions using
//! the yaak-actions framework. It depends on domain-specific crates like
//! yaak-http, yaak-models, yaak-plugins, etc.
pub mod dependencies;
pub mod http;
pub use dependencies::BuiltinActionDependencies;
pub use http::register_http_actions;

View File

@@ -0,0 +1,15 @@
[package]
name = "yaak-actions"
version = "0.1.0"
edition = "2021"
description = "Centralized action system for Yaak"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
ts-rs = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

View File

@@ -0,0 +1,14 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Availability status for an action.
*/
export type ActionAvailability = { "status": "available" } | { "status": "available-with-prompt",
/**
* Fields that will require prompting.
*/
prompt_fields: Array<string>, } | { "status": "unavailable",
/**
* Fields that are missing.
*/
missing_fields: Array<string>, } | { "status": "not-found" };

View File

@@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupId } from "./ActionGroupId";
import type { ActionId } from "./ActionId";
import type { ActionScope } from "./ActionScope";
/**
* Errors that can occur during action operations.
*/
export type ActionError = { "type": "not-found" } & ActionId | { "type": "disabled", action_id: ActionId, reason: string, } | { "type": "invalid-scope", expected: ActionScope, actual: ActionScope, } | { "type": "timeout" } & ActionId | { "type": "plugin-error" } & string | { "type": "validation-error" } & string | { "type": "permission-denied" } & string | { "type": "cancelled" } | { "type": "internal" } & string | { "type": "context-missing",
/**
* The context fields that are missing.
*/
missing_fields: Array<string>, } | { "type": "group-not-found" } & ActionGroupId | { "type": "group-already-exists" } & ActionGroupId;

View File

@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Unique identifier for an action group.
*
* Format: `namespace:group-name`
* - Built-in: `yaak:export`
* - Plugin: `plugin.my-plugin:utilities`
*/
export type ActionGroupId = string;

View File

@@ -0,0 +1,32 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupId } from "./ActionGroupId";
import type { ActionScope } from "./ActionScope";
/**
* Metadata about an action group.
*/
export type ActionGroupMetadata = {
/**
* Unique identifier for this group.
*/
id: ActionGroupId,
/**
* Display name for the group.
*/
name: string,
/**
* Optional description of the group's purpose.
*/
description: string | null,
/**
* Icon to display for the group.
*/
icon: string | null,
/**
* Sort order for displaying groups (lower = earlier).
*/
order: number,
/**
* Optional scope restriction (if set, group only appears in this scope).
*/
scope: ActionScope | null, };

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Where an action group was registered from.
*/
export type ActionGroupSource = { "type": "builtin" } | { "type": "plugin",
/**
* Plugin reference ID.
*/
ref_id: string,
/**
* Plugin name.
*/
name: string, } | { "type": "dynamic",
/**
* Source identifier.
*/
source_id: string, };

View File

@@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupMetadata } from "./ActionGroupMetadata";
import type { ActionMetadata } from "./ActionMetadata";
/**
* A group with its actions for UI rendering.
*/
export type ActionGroupWithActions = {
/**
* Group metadata.
*/
group: ActionGroupMetadata,
/**
* Actions in this group.
*/
actions: Array<ActionMetadata>, };

10
crates/yaak-actions/bindings/ActionId.ts generated Normal file
View File

@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Unique identifier for an action.
*
* Format: `namespace:category:name`
* - Built-in: `yaak:http-request:send`
* - Plugin: `plugin.copy-curl:http-request:copy`
*/
export type ActionId = string;

View File

@@ -0,0 +1,54 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionGroupId } from "./ActionGroupId";
import type { ActionId } from "./ActionId";
import type { ActionScope } from "./ActionScope";
import type { RequiredContext } from "./RequiredContext";
/**
* Metadata about an action for discovery.
*/
export type ActionMetadata = {
/**
* Unique identifier for this action.
*/
id: ActionId,
/**
* Display label for the action.
*/
label: string,
/**
* Optional description of what the action does.
*/
description: string | null,
/**
* Icon name to display.
*/
icon: string | null,
/**
* The scope this action applies to.
*/
scope: ActionScope,
/**
* Keyboard shortcut (e.g., "Cmd+Enter").
*/
keyboardShortcut: string | null,
/**
* Whether the action requires a selection/target.
*/
requiresSelection: boolean,
/**
* Optional condition expression for when action is enabled.
*/
enabledCondition: string | null,
/**
* Optional group this action belongs to.
*/
groupId: ActionGroupId | null,
/**
* Sort order within a group (lower = earlier).
*/
order: number,
/**
* Context requirements for this action.
*/
requiredContext: RequiredContext, };

View File

@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Parameters passed to action handlers.
*/
export type ActionParams = {
/**
* Arbitrary JSON parameters.
*/
data: unknown, };

View File

@@ -0,0 +1,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InputPrompt } from "./InputPrompt";
/**
* Result of action execution.
*/
export type ActionResult = { "type": "success",
/**
* Optional data to return.
*/
data: unknown,
/**
* Optional message to display.
*/
message: string | null, } | { "type": "requires-input",
/**
* Prompt to show user.
*/
prompt: InputPrompt,
/**
* Continuation token.
*/
continuation_id: string, } | { "type": "cancelled" };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The scope in which an action can be invoked.
*/
export type ActionScope = "global" | "http-request" | "websocket-request" | "grpc-request" | "workspace" | "folder" | "environment" | "cookie-jar";

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Where an action was registered from.
*/
export type ActionSource = { "type": "builtin" } | { "type": "plugin",
/**
* Plugin reference ID.
*/
ref_id: string,
/**
* Plugin name.
*/
name: string, } | { "type": "dynamic",
/**
* Source identifier.
*/
source_id: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The target entity for an action.
*/
export type ActionTarget = { "type": "none" } | { "type": "http-request", id: string, } | { "type": "websocket-request", id: string, } | { "type": "grpc-request", id: string, } | { "type": "workspace", id: string, } | { "type": "folder", id: string, } | { "type": "environment", id: string, } | { "type": "multiple", targets: Array<ActionTarget>, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* How strictly a context field is required.
*/
export type ContextRequirement = "not-required" | "optional" | "required" | "required-with-prompt";

View File

@@ -0,0 +1,27 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionTarget } from "./ActionTarget";
/**
* Current context state from the application.
*/
export type CurrentContext = {
/**
* Current workspace ID (if any).
*/
workspaceId: string | null,
/**
* Current environment ID (if any).
*/
environmentId: string | null,
/**
* Currently selected target (if any).
*/
target: ActionTarget | null,
/**
* Whether a window context is available.
*/
hasWindow: boolean,
/**
* Whether the context provider can prompt for missing fields.
*/
canPrompt: boolean, };

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SelectOption } from "./SelectOption";
/**
* A prompt for user input.
*/
export type InputPrompt = { "type": "text", label: string, placeholder: string | null, default_value: string | null, } | { "type": "select", label: string, options: Array<SelectOption>, } | { "type": "confirm", label: string, };

View File

@@ -0,0 +1,23 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ContextRequirement } from "./ContextRequirement";
/**
* Specifies what context fields an action requires.
*/
export type RequiredContext = {
/**
* Action requires a workspace to be active.
*/
workspace: ContextRequirement,
/**
* Action requires an environment to be selected.
*/
environment: ContextRequirement,
/**
* Action requires a specific target entity (request, folder, etc.).
*/
target: ContextRequirement,
/**
* Action requires a window context (for UI operations).
*/
window: ContextRequirement, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* An option in a select prompt.
*/
export type SelectOption = { label: string, value: string, };

View File

@@ -0,0 +1,331 @@
//! Action context types and context-aware filtering.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::ActionScope;
/// Specifies what context fields an action requires.
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct RequiredContext {
/// Action requires a workspace to be active.
#[serde(default)]
pub workspace: ContextRequirement,
/// Action requires an environment to be selected.
#[serde(default)]
pub environment: ContextRequirement,
/// Action requires a specific target entity (request, folder, etc.).
#[serde(default)]
pub target: ContextRequirement,
/// Action requires a window context (for UI operations).
#[serde(default)]
pub window: ContextRequirement,
}
impl RequiredContext {
/// Action requires a target entity.
pub fn requires_target() -> Self {
Self {
target: ContextRequirement::Required,
..Default::default()
}
}
/// Action requires workspace and target.
pub fn requires_workspace_and_target() -> Self {
Self {
workspace: ContextRequirement::Required,
target: ContextRequirement::Required,
..Default::default()
}
}
/// Action works globally, no specific context needed.
pub fn global() -> Self {
Self::default()
}
/// Action requires target with prompt if missing.
pub fn requires_target_with_prompt() -> Self {
Self {
target: ContextRequirement::RequiredWithPrompt,
..Default::default()
}
}
/// Action requires environment with prompt if missing.
pub fn requires_environment_with_prompt() -> Self {
Self {
environment: ContextRequirement::RequiredWithPrompt,
..Default::default()
}
}
}
/// How strictly a context field is required.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum ContextRequirement {
/// Field is not needed.
#[default]
NotRequired,
/// Field is optional but will be used if available.
Optional,
/// Field must be present; action will fail without it.
Required,
/// Field must be present; prompt user to select if missing.
RequiredWithPrompt,
}
/// Current context state from the application.
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct CurrentContext {
/// Current workspace ID (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Current environment ID (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub environment_id: Option<String>,
/// Currently selected target (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<ActionTarget>,
/// Whether a window context is available.
#[serde(default)]
pub has_window: bool,
/// Whether the context provider can prompt for missing fields.
#[serde(default)]
pub can_prompt: bool,
}
/// The target entity for an action.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionTarget {
/// No target.
None,
/// HTTP request target.
HttpRequest { id: String },
/// WebSocket request target.
WebsocketRequest { id: String },
/// gRPC request target.
GrpcRequest { id: String },
/// Workspace target.
Workspace { id: String },
/// Folder target.
Folder { id: String },
/// Environment target.
Environment { id: String },
/// Multiple targets.
Multiple { targets: Vec<ActionTarget> },
}
impl ActionTarget {
/// Get the scope this target corresponds to.
pub fn scope(&self) -> Option<ActionScope> {
match self {
Self::None => None,
Self::HttpRequest { .. } => Some(ActionScope::HttpRequest),
Self::WebsocketRequest { .. } => Some(ActionScope::WebsocketRequest),
Self::GrpcRequest { .. } => Some(ActionScope::GrpcRequest),
Self::Workspace { .. } => Some(ActionScope::Workspace),
Self::Folder { .. } => Some(ActionScope::Folder),
Self::Environment { .. } => Some(ActionScope::Environment),
Self::Multiple { .. } => None,
}
}
/// Get the ID of the target (if single target).
pub fn id(&self) -> Option<&str> {
match self {
Self::HttpRequest { id }
| Self::WebsocketRequest { id }
| Self::GrpcRequest { id }
| Self::Workspace { id }
| Self::Folder { id }
| Self::Environment { id } => Some(id),
Self::None | Self::Multiple { .. } => None,
}
}
}
/// Availability status for an action.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum ActionAvailability {
/// Action is ready to execute.
Available,
/// Action can execute but will prompt for missing context.
AvailableWithPrompt {
/// Fields that will require prompting.
prompt_fields: Vec<String>,
},
/// Action cannot execute due to missing context.
Unavailable {
/// Fields that are missing.
missing_fields: Vec<String>,
},
/// Action not found in registry.
NotFound,
}
impl ActionAvailability {
/// Check if the action is available (possibly with prompts).
pub fn is_available(&self) -> bool {
matches!(self, Self::Available | Self::AvailableWithPrompt { .. })
}
/// Check if the action is immediately available without prompts.
pub fn is_immediately_available(&self) -> bool {
matches!(self, Self::Available)
}
}
/// Check if required context is satisfied by current context.
pub fn check_context_availability(
required: &RequiredContext,
current: &CurrentContext,
) -> ActionAvailability {
let mut missing_fields = Vec::new();
let mut prompt_fields = Vec::new();
// Check workspace
check_field(
"workspace",
current.workspace_id.is_some(),
&required.workspace,
current.can_prompt,
&mut missing_fields,
&mut prompt_fields,
);
// Check environment
check_field(
"environment",
current.environment_id.is_some(),
&required.environment,
current.can_prompt,
&mut missing_fields,
&mut prompt_fields,
);
// Check target
check_field(
"target",
current.target.is_some(),
&required.target,
current.can_prompt,
&mut missing_fields,
&mut prompt_fields,
);
// Check window
check_field(
"window",
current.has_window,
&required.window,
false, // Can't prompt for window
&mut missing_fields,
&mut prompt_fields,
);
if !missing_fields.is_empty() {
ActionAvailability::Unavailable { missing_fields }
} else if !prompt_fields.is_empty() {
ActionAvailability::AvailableWithPrompt { prompt_fields }
} else {
ActionAvailability::Available
}
}
fn check_field(
name: &str,
has_value: bool,
requirement: &ContextRequirement,
can_prompt: bool,
missing: &mut Vec<String>,
promptable: &mut Vec<String>,
) {
match requirement {
ContextRequirement::NotRequired | ContextRequirement::Optional => {}
ContextRequirement::Required => {
if !has_value {
missing.push(name.to_string());
}
}
ContextRequirement::RequiredWithPrompt => {
if !has_value {
if can_prompt {
promptable.push(name.to_string());
} else {
missing.push(name.to_string());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_context_available() {
let required = RequiredContext::requires_target();
let current = CurrentContext {
target: Some(ActionTarget::HttpRequest {
id: "123".to_string(),
}),
..Default::default()
};
let availability = check_context_availability(&required, &current);
assert!(matches!(availability, ActionAvailability::Available));
}
#[test]
fn test_context_missing() {
let required = RequiredContext::requires_target();
let current = CurrentContext::default();
let availability = check_context_availability(&required, &current);
assert!(matches!(
availability,
ActionAvailability::Unavailable { missing_fields } if missing_fields == vec!["target"]
));
}
#[test]
fn test_context_promptable() {
let required = RequiredContext::requires_target_with_prompt();
let current = CurrentContext {
can_prompt: true,
..Default::default()
};
let availability = check_context_availability(&required, &current);
assert!(matches!(
availability,
ActionAvailability::AvailableWithPrompt { prompt_fields } if prompt_fields == vec!["target"]
));
}
}

View File

@@ -0,0 +1,131 @@
//! Error types for the action system.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ts_rs::TS;
use crate::{ActionGroupId, ActionId};
/// Errors that can occur during action operations.
#[derive(Debug, Error, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionError {
/// Action not found in registry.
#[error("Action not found: {0}")]
NotFound(ActionId),
/// Action is disabled in current context.
#[error("Action is disabled: {action_id} - {reason}")]
Disabled { action_id: ActionId, reason: String },
/// Invalid scope for the action.
#[error("Invalid scope: expected {expected:?}, got {actual:?}")]
InvalidScope {
expected: crate::ActionScope,
actual: crate::ActionScope,
},
/// Action execution timed out.
#[error("Action timed out: {0}")]
Timeout(ActionId),
/// Error from plugin execution.
#[error("Plugin error: {0}")]
PluginError(String),
/// Validation error in action parameters.
#[error("Validation error: {0}")]
ValidationError(String),
/// Permission denied for action.
#[error("Permission denied: {0}")]
PermissionDenied(String),
/// Action was cancelled by user.
#[error("Action cancelled by user")]
Cancelled,
/// Internal error.
#[error("Internal error: {0}")]
Internal(String),
/// Required context is missing.
#[error("Required context missing: {missing_fields:?}")]
ContextMissing {
/// The context fields that are missing.
missing_fields: Vec<String>,
},
/// Action group not found.
#[error("Group not found: {0}")]
GroupNotFound(ActionGroupId),
/// Action group already exists.
#[error("Group already exists: {0}")]
GroupAlreadyExists(ActionGroupId),
}
impl ActionError {
/// Get a user-friendly error message.
pub fn user_message(&self) -> String {
match self {
Self::NotFound(id) => format!("Action '{}' is not available", id),
Self::Disabled { reason, .. } => reason.clone(),
Self::InvalidScope { expected, actual } => {
format!("Action requires {:?} scope, but got {:?}", expected, actual)
}
Self::Timeout(_) => "The operation took too long and was cancelled".into(),
Self::PluginError(msg) => format!("Plugin error: {}", msg),
Self::ValidationError(msg) => format!("Invalid input: {}", msg),
Self::PermissionDenied(resource) => format!("Permission denied for {}", resource),
Self::Cancelled => "Operation was cancelled".into(),
Self::Internal(_) => "An unexpected error occurred".into(),
Self::ContextMissing { missing_fields } => {
format!("Missing required context: {}", missing_fields.join(", "))
}
Self::GroupNotFound(id) => format!("Action group '{}' not found", id),
Self::GroupAlreadyExists(id) => format!("Action group '{}' already exists", id),
}
}
/// Whether this error should be reported to telemetry.
pub fn is_reportable(&self) -> bool {
matches!(self, Self::Internal(_) | Self::PluginError(_))
}
/// Whether this error can potentially be resolved by user interaction.
pub fn is_promptable(&self) -> bool {
matches!(self, Self::ContextMissing { .. })
}
/// Whether this is a user-initiated cancellation.
pub fn is_cancelled(&self) -> bool {
matches!(self, Self::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_messages() {
let err = ActionError::ContextMissing {
missing_fields: vec!["workspace".into()],
};
assert_eq!(err.user_message(), "Missing required context: workspace");
assert!(err.is_promptable());
assert!(!err.is_cancelled());
let cancelled = ActionError::Cancelled;
assert!(cancelled.is_cancelled());
assert!(!cancelled.is_promptable());
let not_found = ActionError::NotFound(ActionId::builtin("test", "action"));
assert_eq!(
not_found.user_message(),
"Action 'yaak:test:action' is not available"
);
}
}

View File

@@ -0,0 +1,606 @@
//! Action executor - central hub for action registration and invocation.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::{
check_context_availability, ActionAvailability, ActionError, ActionGroupId,
ActionGroupMetadata, ActionGroupSource, ActionGroupWithActions, ActionHandler, ActionId,
ActionMetadata, ActionParams, ActionResult, ActionScope, ActionSource, CurrentContext,
RegisteredActionGroup,
};
/// Options for listing actions.
#[derive(Clone, Debug, Default)]
pub struct ListActionsOptions {
/// Filter by scope.
pub scope: Option<ActionScope>,
/// Filter by group.
pub group_id: Option<ActionGroupId>,
/// Search term for label/description.
pub search: Option<String>,
}
/// A registered action with its handler.
struct RegisteredAction {
/// Action metadata.
metadata: ActionMetadata,
/// Where the action was registered from.
source: ActionSource,
/// The handler for this action.
handler: Arc<dyn ActionHandler>,
}
/// Central hub for action registration and invocation.
///
/// The executor owns all action metadata and handlers, ensuring every
/// registered action has a handler by construction.
pub struct ActionExecutor {
/// All registered actions indexed by ID.
actions: RwLock<HashMap<ActionId, RegisteredAction>>,
/// Actions indexed by scope for efficient filtering.
scope_index: RwLock<HashMap<ActionScope, Vec<ActionId>>>,
/// All registered groups indexed by ID.
groups: RwLock<HashMap<ActionGroupId, RegisteredActionGroup>>,
}
impl Default for ActionExecutor {
fn default() -> Self {
Self::new()
}
}
impl ActionExecutor {
/// Create a new empty executor.
pub fn new() -> Self {
Self {
actions: RwLock::new(HashMap::new()),
scope_index: RwLock::new(HashMap::new()),
groups: RwLock::new(HashMap::new()),
}
}
// ─────────────────────────────────────────────────────────────────────────
// Action Registration
// ─────────────────────────────────────────────────────────────────────────
/// Register an action with its handler.
///
/// Every action must have a handler - this is enforced by the API.
pub async fn register<H: ActionHandler + 'static>(
&self,
metadata: ActionMetadata,
source: ActionSource,
handler: H,
) -> Result<ActionId, ActionError> {
let id = metadata.id.clone();
let scope = metadata.scope.clone();
let action = RegisteredAction {
metadata,
source,
handler: Arc::new(handler),
};
// Insert action
{
let mut actions = self.actions.write().await;
actions.insert(id.clone(), action);
}
// Update scope index
{
let mut index = self.scope_index.write().await;
index.entry(scope).or_default().push(id.clone());
}
Ok(id)
}
/// Unregister an action.
pub async fn unregister(&self, id: &ActionId) -> Result<(), ActionError> {
let mut actions = self.actions.write().await;
let action = actions
.remove(id)
.ok_or_else(|| ActionError::NotFound(id.clone()))?;
// Update scope index
{
let mut index = self.scope_index.write().await;
if let Some(ids) = index.get_mut(&action.metadata.scope) {
ids.retain(|i| i != id);
}
}
// Remove from group if assigned
if let Some(group_id) = &action.metadata.group_id {
let mut groups = self.groups.write().await;
if let Some(group) = groups.get_mut(group_id) {
group.action_ids.retain(|i| i != id);
}
}
Ok(())
}
/// Unregister all actions from a specific source.
pub async fn unregister_source(&self, source_id: &str) -> Vec<ActionId> {
let actions_to_remove: Vec<ActionId> = {
let actions = self.actions.read().await;
actions
.iter()
.filter(|(_, a)| match &a.source {
ActionSource::Plugin { ref_id, .. } => ref_id == source_id,
ActionSource::Dynamic {
source_id: sid, ..
} => sid == source_id,
ActionSource::Builtin => false,
})
.map(|(id, _)| id.clone())
.collect()
};
for id in &actions_to_remove {
let _ = self.unregister(id).await;
}
actions_to_remove
}
// ─────────────────────────────────────────────────────────────────────────
// Action Invocation
// ─────────────────────────────────────────────────────────────────────────
/// Invoke an action with the given context and parameters.
///
/// This will:
/// 1. Look up the action metadata
/// 2. Check context availability
/// 3. Execute the handler
pub async fn invoke(
&self,
action_id: &ActionId,
context: CurrentContext,
params: ActionParams,
) -> Result<ActionResult, ActionError> {
// Get action and handler
let (metadata, handler) = {
let actions = self.actions.read().await;
let action = actions
.get(action_id)
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
(action.metadata.clone(), action.handler.clone())
};
// Check context availability
let availability = check_context_availability(&metadata.required_context, &context);
match availability {
ActionAvailability::Available | ActionAvailability::AvailableWithPrompt { .. } => {
// Context is satisfied, proceed with execution
}
ActionAvailability::Unavailable { missing_fields } => {
return Err(ActionError::ContextMissing { missing_fields });
}
ActionAvailability::NotFound => {
return Err(ActionError::NotFound(action_id.clone()));
}
}
// Execute handler
handler.handle(context, params).await
}
/// Invoke an action, skipping context validation.
///
/// Use this when you've already validated the context externally.
pub async fn invoke_unchecked(
&self,
action_id: &ActionId,
context: CurrentContext,
params: ActionParams,
) -> Result<ActionResult, ActionError> {
// Get handler
let handler = {
let actions = self.actions.read().await;
let action = actions
.get(action_id)
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
action.handler.clone()
};
// Execute handler
handler.handle(context, params).await
}
// ─────────────────────────────────────────────────────────────────────────
// Action Queries
// ─────────────────────────────────────────────────────────────────────────
/// Get action metadata by ID.
pub async fn get(&self, id: &ActionId) -> Option<ActionMetadata> {
let actions = self.actions.read().await;
actions.get(id).map(|a| a.metadata.clone())
}
/// List all actions, optionally filtered.
pub async fn list(&self, options: ListActionsOptions) -> Vec<ActionMetadata> {
let actions = self.actions.read().await;
let mut result: Vec<_> = actions
.values()
.filter(|a| {
// Scope filter
if let Some(scope) = &options.scope {
if &a.metadata.scope != scope {
return false;
}
}
// Group filter
if let Some(group_id) = &options.group_id {
if a.metadata.group_id.as_ref() != Some(group_id) {
return false;
}
}
// Search filter
if let Some(search) = &options.search {
let search = search.to_lowercase();
let matches_label = a.metadata.label.to_lowercase().contains(&search);
let matches_desc = a
.metadata
.description
.as_ref()
.map(|d| d.to_lowercase().contains(&search))
.unwrap_or(false);
if !matches_label && !matches_desc {
return false;
}
}
true
})
.map(|a| a.metadata.clone())
.collect();
// Sort by order then label
result.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.label.cmp(&b.label)));
result
}
/// List actions available in the given context.
pub async fn list_available(
&self,
context: &CurrentContext,
options: ListActionsOptions,
) -> Vec<(ActionMetadata, ActionAvailability)> {
let all_actions = self.list(options).await;
all_actions
.into_iter()
.map(|action| {
let availability =
check_context_availability(&action.required_context, context);
(action, availability)
})
.filter(|(_, availability)| availability.is_available())
.collect()
}
/// Get availability status for a specific action.
pub async fn get_availability(
&self,
id: &ActionId,
context: &CurrentContext,
) -> ActionAvailability {
let actions = self.actions.read().await;
match actions.get(id) {
Some(action) => {
check_context_availability(&action.metadata.required_context, context)
}
None => ActionAvailability::NotFound,
}
}
// ─────────────────────────────────────────────────────────────────────────
// Group Registration
// ─────────────────────────────────────────────────────────────────────────
/// Register an action group.
pub async fn register_group(
&self,
metadata: ActionGroupMetadata,
source: ActionGroupSource,
) -> Result<ActionGroupId, ActionError> {
let id = metadata.id.clone();
let mut groups = self.groups.write().await;
if groups.contains_key(&id) {
return Err(ActionError::GroupAlreadyExists(id));
}
groups.insert(
id.clone(),
RegisteredActionGroup {
metadata,
action_ids: Vec::new(),
source,
},
);
Ok(id)
}
/// Unregister a group (does not unregister its actions).
pub async fn unregister_group(&self, id: &ActionGroupId) -> Result<(), ActionError> {
let mut groups = self.groups.write().await;
groups
.remove(id)
.ok_or_else(|| ActionError::GroupNotFound(id.clone()))?;
Ok(())
}
/// Add an action to a group.
pub async fn add_to_group(
&self,
action_id: &ActionId,
group_id: &ActionGroupId,
) -> Result<(), ActionError> {
// Update action's group_id
{
let mut actions = self.actions.write().await;
let action = actions
.get_mut(action_id)
.ok_or_else(|| ActionError::NotFound(action_id.clone()))?;
action.metadata.group_id = Some(group_id.clone());
}
// Add to group's action list
{
let mut groups = self.groups.write().await;
let group = groups
.get_mut(group_id)
.ok_or_else(|| ActionError::GroupNotFound(group_id.clone()))?;
if !group.action_ids.contains(action_id) {
group.action_ids.push(action_id.clone());
}
}
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────
// Group Queries
// ─────────────────────────────────────────────────────────────────────────
/// Get a group by ID.
pub async fn get_group(&self, id: &ActionGroupId) -> Option<ActionGroupMetadata> {
let groups = self.groups.read().await;
groups.get(id).map(|g| g.metadata.clone())
}
/// List all groups, optionally filtered by scope.
pub async fn list_groups(&self, scope: Option<ActionScope>) -> Vec<ActionGroupMetadata> {
let groups = self.groups.read().await;
let mut result: Vec<_> = groups
.values()
.filter(|g| {
scope.as_ref().map_or(true, |s| {
g.metadata.scope.as_ref().map_or(true, |gs| gs == s)
})
})
.map(|g| g.metadata.clone())
.collect();
result.sort_by_key(|g| g.order);
result
}
/// List all actions in a specific group.
pub async fn list_by_group(&self, group_id: &ActionGroupId) -> Vec<ActionMetadata> {
let groups = self.groups.read().await;
let actions = self.actions.read().await;
groups
.get(group_id)
.map(|group| {
let mut result: Vec<_> = group
.action_ids
.iter()
.filter_map(|id| actions.get(id).map(|a| a.metadata.clone()))
.collect();
result.sort_by_key(|a| a.order);
result
})
.unwrap_or_default()
}
/// Get actions organized by their groups.
pub async fn list_grouped(&self, scope: Option<ActionScope>) -> Vec<ActionGroupWithActions> {
let group_list = self.list_groups(scope).await;
let mut result = Vec::new();
for group in group_list {
let actions = self.list_by_group(&group.id).await;
result.push(ActionGroupWithActions { group, actions });
}
result
}
// ─────────────────────────────────────────────────────────────────────────
// Built-in Registration
// ─────────────────────────────────────────────────────────────────────────
/// Register all built-in groups.
pub async fn register_builtin_groups(&self) -> Result<(), ActionError> {
for group in crate::groups::builtin::all() {
self.register_group(group, ActionGroupSource::Builtin).await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{handler_fn, RequiredContext};
async fn create_test_executor() -> ActionExecutor {
let executor = ActionExecutor::new();
executor
.register(
ActionMetadata {
id: ActionId::builtin("test", "echo"),
label: "Echo".to_string(),
description: None,
icon: None,
scope: ActionScope::Global,
keyboard_shortcut: None,
requires_selection: false,
enabled_condition: None,
group_id: None,
order: 0,
required_context: RequiredContext::default(),
},
ActionSource::Builtin,
handler_fn(|_ctx, params| async move {
let msg: String = params.get("message").unwrap_or_default();
Ok(ActionResult::with_message(msg))
}),
)
.await
.unwrap();
executor
}
#[tokio::test]
async fn test_register_and_invoke() {
let executor = create_test_executor().await;
let action_id = ActionId::builtin("test", "echo");
let params = ActionParams::from_json(serde_json::json!({
"message": "Hello, World!"
}));
let result = executor
.invoke(&action_id, CurrentContext::default(), params)
.await
.unwrap();
match result {
ActionResult::Success { message, .. } => {
assert_eq!(message, Some("Hello, World!".to_string()));
}
_ => panic!("Expected Success result"),
}
}
#[tokio::test]
async fn test_invoke_not_found() {
let executor = ActionExecutor::new();
let action_id = ActionId::builtin("test", "unknown");
let result = executor
.invoke(&action_id, CurrentContext::default(), ActionParams::empty())
.await;
assert!(matches!(result, Err(ActionError::NotFound(_))));
}
#[tokio::test]
async fn test_list_by_scope() {
let executor = ActionExecutor::new();
executor
.register(
ActionMetadata {
id: ActionId::builtin("global", "one"),
label: "Global One".to_string(),
description: None,
icon: None,
scope: ActionScope::Global,
keyboard_shortcut: None,
requires_selection: false,
enabled_condition: None,
group_id: None,
order: 0,
required_context: RequiredContext::default(),
},
ActionSource::Builtin,
handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }),
)
.await
.unwrap();
executor
.register(
ActionMetadata {
id: ActionId::builtin("http", "one"),
label: "HTTP One".to_string(),
description: None,
icon: None,
scope: ActionScope::HttpRequest,
keyboard_shortcut: None,
requires_selection: false,
enabled_condition: None,
group_id: None,
order: 0,
required_context: RequiredContext::default(),
},
ActionSource::Builtin,
handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) }),
)
.await
.unwrap();
let global_actions = executor
.list(ListActionsOptions {
scope: Some(ActionScope::Global),
..Default::default()
})
.await;
assert_eq!(global_actions.len(), 1);
let http_actions = executor
.list(ListActionsOptions {
scope: Some(ActionScope::HttpRequest),
..Default::default()
})
.await;
assert_eq!(http_actions.len(), 1);
}
#[tokio::test]
async fn test_groups() {
let executor = ActionExecutor::new();
executor.register_builtin_groups().await.unwrap();
let groups = executor.list_groups(None).await;
assert!(!groups.is_empty());
let export_group = executor.get_group(&ActionGroupId::builtin("export")).await;
assert!(export_group.is_some());
assert_eq!(export_group.unwrap().name, "Export");
}
#[tokio::test]
async fn test_unregister() {
let executor = create_test_executor().await;
let action_id = ActionId::builtin("test", "echo");
assert!(executor.get(&action_id).await.is_some());
executor.unregister(&action_id).await.unwrap();
assert!(executor.get(&action_id).await.is_none());
}
}

View File

@@ -0,0 +1,208 @@
//! Action group types and management.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{ActionId, ActionMetadata, ActionScope};
/// Unique identifier for an action group.
///
/// Format: `namespace:group-name`
/// - Built-in: `yaak:export`
/// - Plugin: `plugin.my-plugin:utilities`
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ActionGroupId(pub String);
impl ActionGroupId {
/// Create a namespaced group ID.
pub fn new(namespace: &str, name: &str) -> Self {
Self(format!("{}:{}", namespace, name))
}
/// Create ID for built-in groups.
pub fn builtin(name: &str) -> Self {
Self::new("yaak", name)
}
/// Create ID for plugin groups.
pub fn plugin(plugin_ref_id: &str, name: &str) -> Self {
Self::new(&format!("plugin.{}", plugin_ref_id), name)
}
/// Get the raw string value.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ActionGroupId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Metadata about an action group.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionGroupMetadata {
/// Unique identifier for this group.
pub id: ActionGroupId,
/// Display name for the group.
pub name: String,
/// Optional description of the group's purpose.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Icon to display for the group.
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// Sort order for displaying groups (lower = earlier).
#[serde(default)]
pub order: i32,
/// Optional scope restriction (if set, group only appears in this scope).
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<ActionScope>,
}
/// Where an action group was registered from.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionGroupSource {
/// Built into Yaak core.
Builtin,
/// Registered by a plugin.
Plugin {
/// Plugin reference ID.
ref_id: String,
/// Plugin name.
name: String,
},
/// Registered at runtime.
Dynamic {
/// Source identifier.
source_id: String,
},
}
/// A registered action group with its actions.
#[derive(Clone, Debug)]
pub struct RegisteredActionGroup {
/// Group metadata.
pub metadata: ActionGroupMetadata,
/// IDs of actions in this group (ordered by action's order field).
pub action_ids: Vec<ActionId>,
/// Where the group was registered from.
pub source: ActionGroupSource,
}
/// A group with its actions for UI rendering.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionGroupWithActions {
/// Group metadata.
pub group: ActionGroupMetadata,
/// Actions in this group.
pub actions: Vec<ActionMetadata>,
}
/// Built-in action group definitions.
pub mod builtin {
use super::*;
/// Export group - export and copy actions.
pub fn export() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("export"),
name: "Export".into(),
description: Some("Export and copy actions".into()),
icon: Some("download".into()),
order: 100,
scope: None,
}
}
/// Code generation group.
pub fn code_generation() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("code-generation"),
name: "Code Generation".into(),
description: Some("Generate code snippets from requests".into()),
icon: Some("code".into()),
order: 200,
scope: Some(ActionScope::HttpRequest),
}
}
/// Send group - request sending actions.
pub fn send() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("send"),
name: "Send".into(),
description: Some("Actions for sending requests".into()),
icon: Some("play".into()),
order: 50,
scope: Some(ActionScope::HttpRequest),
}
}
/// Import group.
pub fn import() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("import"),
name: "Import".into(),
description: Some("Import data from files".into()),
icon: Some("upload".into()),
order: 150,
scope: None,
}
}
/// Workspace management group.
pub fn workspace() -> ActionGroupMetadata {
ActionGroupMetadata {
id: ActionGroupId::builtin("workspace"),
name: "Workspace".into(),
description: Some("Workspace management actions".into()),
icon: Some("folder".into()),
order: 300,
scope: Some(ActionScope::Workspace),
}
}
/// Get all built-in group definitions.
pub fn all() -> Vec<ActionGroupMetadata> {
vec![send(), export(), import(), code_generation(), workspace()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_group_id_creation() {
let id = ActionGroupId::builtin("export");
assert_eq!(id.as_str(), "yaak:export");
let plugin_id = ActionGroupId::plugin("my-plugin", "utilities");
assert_eq!(plugin_id.as_str(), "plugin.my-plugin:utilities");
}
#[test]
fn test_builtin_groups() {
let groups = builtin::all();
assert!(!groups.is_empty());
assert!(groups.iter().any(|g| g.id == ActionGroupId::builtin("export")));
}
}

View File

@@ -0,0 +1,103 @@
//! Action handler types and execution.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::{ActionError, ActionParams, ActionResult, CurrentContext};
/// A boxed future for async action handlers.
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Function signature for action handlers.
pub type ActionHandlerFn = Arc<
dyn Fn(CurrentContext, ActionParams) -> BoxFuture<'static, Result<ActionResult, ActionError>>
+ Send
+ Sync,
>;
/// Trait for types that can handle action invocations.
pub trait ActionHandler: Send + Sync {
/// Execute the action with the given context and parameters.
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> BoxFuture<'static, Result<ActionResult, ActionError>>;
}
/// Wrapper to create an ActionHandler from a function.
pub struct FnHandler<F>(pub F);
impl<F, Fut> ActionHandler for FnHandler<F>
where
F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync,
Fut: Future<Output = Result<ActionResult, ActionError>> + Send + 'static,
{
fn handle(
&self,
context: CurrentContext,
params: ActionParams,
) -> BoxFuture<'static, Result<ActionResult, ActionError>> {
Box::pin((self.0)(context, params))
}
}
/// Create an action handler from an async function.
///
/// # Example
/// ```ignore
/// let handler = handler_fn(|ctx, params| async move {
/// Ok(ActionResult::ok())
/// });
/// ```
pub fn handler_fn<F, Fut>(f: F) -> FnHandler<F>
where
F: Fn(CurrentContext, ActionParams) -> Fut + Send + Sync,
Fut: Future<Output = Result<ActionResult, ActionError>> + Send + 'static,
{
FnHandler(f)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_handler_fn() {
let handler = handler_fn(|_ctx, _params| async move { Ok(ActionResult::ok()) });
let result = handler
.handle(CurrentContext::default(), ActionParams::empty())
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_handler_with_params() {
let handler = handler_fn(|_ctx, params| async move {
let name: Option<String> = params.get("name");
Ok(ActionResult::with_message(format!(
"Hello, {}!",
name.unwrap_or_else(|| "World".to_string())
)))
});
let params = ActionParams::from_json(serde_json::json!({
"name": "Yaak"
}));
let result = handler
.handle(CurrentContext::default(), params)
.await
.unwrap();
match result {
ActionResult::Success { message, .. } => {
assert_eq!(message, Some("Hello, Yaak!".to_string()));
}
_ => panic!("Expected Success result"),
}
}
}

View File

@@ -0,0 +1,18 @@
//! Centralized action system for Yaak.
//!
//! This crate provides a unified hub for registering and invoking actions
//! across all entry points: plugins, Tauri desktop app, CLI, deep links, and MCP server.
mod context;
mod error;
mod executor;
mod groups;
mod handler;
mod types;
pub use context::*;
pub use error::*;
pub use executor::*;
pub use groups::*;
pub use handler::*;
pub use types::*;

View File

@@ -0,0 +1,273 @@
//! Core types for the action system.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{ActionGroupId, RequiredContext};
/// Unique identifier for an action.
///
/// Format: `namespace:category:name`
/// - Built-in: `yaak:http-request:send`
/// - Plugin: `plugin.copy-curl:http-request:copy`
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ActionId(pub String);
impl ActionId {
/// Create a namespaced action ID.
pub fn new(namespace: &str, category: &str, name: &str) -> Self {
Self(format!("{}:{}:{}", namespace, category, name))
}
/// Create ID for built-in actions.
pub fn builtin(category: &str, name: &str) -> Self {
Self::new("yaak", category, name)
}
/// Create ID for plugin actions.
pub fn plugin(plugin_ref_id: &str, category: &str, name: &str) -> Self {
Self::new(&format!("plugin.{}", plugin_ref_id), category, name)
}
/// Get the raw string value.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ActionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The scope in which an action can be invoked.
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum ActionScope {
/// Global actions available everywhere.
Global,
/// Actions on HTTP requests.
HttpRequest,
/// Actions on WebSocket requests.
WebsocketRequest,
/// Actions on gRPC requests.
GrpcRequest,
/// Actions on workspaces.
Workspace,
/// Actions on folders.
Folder,
/// Actions on environments.
Environment,
/// Actions on cookie jars.
CookieJar,
}
/// Metadata about an action for discovery.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionMetadata {
/// Unique identifier for this action.
pub id: ActionId,
/// Display label for the action.
pub label: String,
/// Optional description of what the action does.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Icon name to display.
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// The scope this action applies to.
pub scope: ActionScope,
/// Keyboard shortcut (e.g., "Cmd+Enter").
#[serde(skip_serializing_if = "Option::is_none")]
pub keyboard_shortcut: Option<String>,
/// Whether the action requires a selection/target.
#[serde(default)]
pub requires_selection: bool,
/// Optional condition expression for when action is enabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_condition: Option<String>,
/// Optional group this action belongs to.
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option<ActionGroupId>,
/// Sort order within a group (lower = earlier).
#[serde(default)]
pub order: i32,
/// Context requirements for this action.
#[serde(default)]
pub required_context: RequiredContext,
}
/// Where an action was registered from.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionSource {
/// Built into Yaak core.
Builtin,
/// Registered by a plugin.
Plugin {
/// Plugin reference ID.
ref_id: String,
/// Plugin name.
name: String,
},
/// Registered at runtime (e.g., by MCP tools).
Dynamic {
/// Source identifier.
source_id: String,
},
}
/// Parameters passed to action handlers.
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ActionParams {
/// Arbitrary JSON parameters.
#[serde(default)]
#[ts(type = "unknown")]
pub data: serde_json::Value,
}
impl ActionParams {
/// Create empty params.
pub fn empty() -> Self {
Self {
data: serde_json::Value::Null,
}
}
/// Create params from a JSON value.
pub fn from_json(data: serde_json::Value) -> Self {
Self { data }
}
/// Get a typed value from the params.
pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
self.data
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
}
/// Result of action execution.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ActionResult {
/// Action completed successfully.
Success {
/// Optional data to return.
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(type = "unknown")]
data: Option<serde_json::Value>,
/// Optional message to display.
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
/// Action requires user input to continue.
RequiresInput {
/// Prompt to show user.
prompt: InputPrompt,
/// Continuation token.
continuation_id: String,
},
/// Action was cancelled by the user.
Cancelled,
}
impl ActionResult {
/// Create a success result with no data.
pub fn ok() -> Self {
Self::Success {
data: None,
message: None,
}
}
/// Create a success result with a message.
pub fn with_message(message: impl Into<String>) -> Self {
Self::Success {
data: None,
message: Some(message.into()),
}
}
/// Create a success result with data.
pub fn with_data(data: serde_json::Value) -> Self {
Self::Success {
data: Some(data),
message: None,
}
}
}
/// A prompt for user input.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum InputPrompt {
/// Text input prompt.
Text {
label: String,
placeholder: Option<String>,
default_value: Option<String>,
},
/// Selection prompt.
Select {
label: String,
options: Vec<SelectOption>,
},
/// Confirmation prompt.
Confirm { label: String },
}
/// An option in a select prompt.
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct SelectOption {
pub label: String,
pub value: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_id_creation() {
let id = ActionId::builtin("http-request", "send");
assert_eq!(id.as_str(), "yaak:http-request:send");
let plugin_id = ActionId::plugin("copy-curl", "http-request", "copy");
assert_eq!(plugin_id.as_str(), "plugin.copy-curl:http-request:copy");
}
#[test]
fn test_action_params() {
let params = ActionParams::from_json(serde_json::json!({
"name": "test",
"count": 42
}));
assert_eq!(params.get::<String>("name"), Some("test".to_string()));
assert_eq!(params.get::<i32>("count"), Some(42));
assert_eq!(params.get::<String>("missing"), None);
}
}