From 26e145942ab4245b70c00606a80eda3552f4d409 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 16 Feb 2026 06:59:23 -0800 Subject: [PATCH] refactor yaak-cli phase 1 command architecture --- crates-cli/yaak-cli/PLAN.md | 141 ++++++ crates-cli/yaak-cli/src/cli.rs | 105 +++++ crates-cli/yaak-cli/src/commands/mod.rs | 3 + crates-cli/yaak-cli/src/commands/request.rs | 238 ++++++++++ crates-cli/yaak-cli/src/commands/send.rs | 7 + crates-cli/yaak-cli/src/commands/workspace.rs | 19 + crates-cli/yaak-cli/src/context.rs | 65 +++ crates-cli/yaak-cli/src/main.rs | 431 ++---------------- 8 files changed, 615 insertions(+), 394 deletions(-) create mode 100644 crates-cli/yaak-cli/src/cli.rs create mode 100644 crates-cli/yaak-cli/src/commands/mod.rs create mode 100644 crates-cli/yaak-cli/src/commands/request.rs create mode 100644 crates-cli/yaak-cli/src/commands/send.rs create mode 100644 crates-cli/yaak-cli/src/commands/workspace.rs create mode 100644 crates-cli/yaak-cli/src/context.rs diff --git a/crates-cli/yaak-cli/PLAN.md b/crates-cli/yaak-cli/PLAN.md index 91b7d196..38d7102c 100644 --- a/crates-cli/yaak-cli/PLAN.md +++ b/crates-cli/yaak-cli/PLAN.md @@ -5,6 +5,31 @@ Redesign the yaak-cli command structure to use a resource-oriented ` ` pattern that scales well, is discoverable, and supports both human and LLM workflows. +## Status Snapshot + +Current branch state: + +- Modular CLI structure with command modules and shared `CliContext` +- Resource/action hierarchy in place for: + - `workspace list` + - `request list` + - `request send` + - `request create` +- Top-level `send` exists as a request-send shortcut (not yet flexible request/folder/workspace resolution) +- Legacy `get` command removed +- No folder/workspace/environment CRUD surface yet +- No JSON merge/update flow yet +- No `request schema` command yet + +Progress checklist: + +- [x] Phase 1 complete +- [ ] Phase 2 complete +- [ ] Phase 3 complete +- [ ] Phase 4 complete +- [ ] Phase 5 complete +- [ ] Phase 6 complete + ## Command Architecture ### Design Principles @@ -191,6 +216,122 @@ Implementation: 4. For workspace: list all requests in workspace, send each 5. Add execution options: `--sequential` (default), `--parallel`, `--fail-fast` +## Execution Plan (PR Slices) + +### PR 1: Command tree refactor + compatibility aliases + +Scope: + +1. Introduce `commands/` modules and a `CliContext` for shared setup +2. Add new clap hierarchy (`workspace`, `request`, `folder`, `environment`) +3. Route existing behavior into: + - `workspace list` + - `request list ` + - `request send ` + - `request create ...` +4. Keep compatibility aliases temporarily: + - `workspaces` -> `workspace list` + - `requests ` -> `request list ` + - `create ...` -> `request create ...` +5. Remove `get` and update help text + +Acceptance criteria: + +- `yaakcli --help` shows noun/verb structure +- Existing list/send/create workflows still work +- No behavior change in HTTP send output format + +### PR 2: CRUD surface area + +Scope: + +1. Implement `show/create/update/delete` for `workspace`, `request`, `folder`, `environment` +2. Ensure delete commands require confirmation by default (`--yes` bypass) +3. Normalize output format for list/show/create/update/delete responses + +Acceptance criteria: + +- Every command listed in the "Commands" section parses and executes +- Delete commands are safe by default in interactive terminals +- `--yes` supports non-interactive scripts + +### PR 3: JSON input + merge patch semantics + +Scope: + +1. Add shared parser for `--json` and positional JSON shorthand +2. Add `create --json` and `update --json` for all mutable resources +3. Implement server-side RFC 7386 merge patch behavior +4. Add guardrails: + - `create --json`: reject non-empty `id` + - `update --json`: require `id` + +Acceptance criteria: + +- Partial `update --json` only modifies provided keys +- `null` clears optional values +- Invalid JSON and missing required fields return actionable errors + +### PR 4: `request schema` and plugin auth integration + +Scope: + +1. Add `schemars` to `yaak-models` and derive `JsonSchema` for request models +2. Implement `request schema ` +3. Merge plugin auth form inputs into `authentication` schema at runtime + +Acceptance criteria: + +- Command prints valid JSON schema +- Schema reflects installed auth providers at runtime +- No panic when plugins fail to initialize (degrade gracefully) + +### PR 5: Polymorphic request send + +Scope: + +1. Replace request resolution in `request send` with `get_any_request` +2. Dispatch by request type +3. Keep HTTP fully functional +4. Return explicit NYI errors for gRPC/WebSocket until implemented + +Acceptance criteria: + +- HTTP behavior remains unchanged +- gRPC/WebSocket IDs are recognized and return explicit status + +### PR 6: Top-level `send` + bulk execution + +Scope: + +1. Add top-level `send ` for request/folder/workspace IDs +2. Implement folder/workspace fan-out execution +3. Add execution controls: `--sequential`, `--parallel`, `--fail-fast` + +Acceptance criteria: + +- Correct ID dispatch order: request -> folder -> workspace +- Deterministic summary output (success/failure counts) +- Non-zero exit code when any request fails (unless explicitly configured otherwise) + +## Validation Matrix + +1. CLI parsing tests for every command path (including aliases while retained) +2. Integration tests against temp SQLite DB for CRUD flows +3. Snapshot tests for output text where scripting compatibility matters +4. Manual smoke tests: + - Send HTTP request with template/rendered vars + - JSON create/update for each resource + - Delete confirmation and `--yes` + - Top-level `send` on request/folder/workspace + +## Open Questions + +1. Should compatibility aliases (`workspaces`, `requests`, `create`) be removed immediately or after one release cycle? +2. For bulk `send`, should default behavior stop on first failure or continue and summarize? +3. Should command output default to human-readable text with an optional `--format json`, or return JSON by default for `show`/`list`? +4. For `request schema`, should plugin-derived auth fields be namespaced by plugin ID to avoid collisions? + ## Crate Changes - **yaak-cli**: restructure into modules, new clap hierarchy diff --git a/crates-cli/yaak-cli/src/cli.rs b/crates-cli/yaak-cli/src/cli.rs new file mode 100644 index 00000000..2e3de8f2 --- /dev/null +++ b/crates-cli/yaak-cli/src/cli.rs @@ -0,0 +1,105 @@ +use clap::{Args, Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "yaakcli")] +#[command(about = "Yaak CLI - API client from the command line")] +pub struct Cli { + /// Use a custom data directory + #[arg(long, global = true)] + pub data_dir: Option, + + /// Environment ID to use for variable substitution + #[arg(long, short, global = true)] + pub environment: Option, + + /// Enable verbose logging + #[arg(long, short, global = true)] + pub verbose: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Send an HTTP request by ID + Send(SendArgs), + + /// Workspace commands + Workspace(WorkspaceArgs), + + /// Request commands + Request(RequestArgs), + + /// Folder commands (coming soon) + #[command(hide = true)] + Folder(FolderArgs), + + /// Environment commands (coming soon) + #[command(hide = true)] + Environment(EnvironmentArgs), +} + +#[derive(Args)] +pub struct SendArgs { + /// Request ID + pub request_id: String, +} + +#[derive(Args)] +pub struct WorkspaceArgs { + #[command(subcommand)] + pub command: WorkspaceCommands, +} + +#[derive(Subcommand)] +pub enum WorkspaceCommands { + /// List all workspaces + List, +} + +#[derive(Args)] +pub struct RequestArgs { + #[command(subcommand)] + pub command: RequestCommands, +} + +#[derive(Subcommand)] +pub enum RequestCommands { + /// List requests in a workspace + List { + /// Workspace ID + workspace_id: String, + }, + + /// Send an HTTP request by ID + Send { + /// Request ID + request_id: String, + }, + + /// Create a new HTTP request + Create { + /// Workspace ID + workspace_id: String, + + /// Request name + #[arg(short, long)] + name: String, + + /// HTTP method + #[arg(short, long, default_value = "GET")] + method: String, + + /// URL + #[arg(short, long)] + url: String, + }, +} + +#[derive(Args)] +pub struct FolderArgs {} + +#[derive(Args)] +pub struct EnvironmentArgs {} diff --git a/crates-cli/yaak-cli/src/commands/mod.rs b/crates-cli/yaak-cli/src/commands/mod.rs new file mode 100644 index 00000000..d53be580 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod request; +pub mod send; +pub mod workspace; diff --git a/crates-cli/yaak-cli/src/commands/request.rs b/crates-cli/yaak-cli/src/commands/request.rs new file mode 100644 index 00000000..1dafbe57 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/request.rs @@ -0,0 +1,238 @@ +use crate::cli::{RequestArgs, RequestCommands}; +use crate::context::CliContext; +use log::info; +use serde_json::Value; +use std::collections::BTreeMap; +use tokio::sync::mpsc; +use yaak_http::path_placeholders::apply_path_placeholders; +use yaak_http::sender::{HttpSender, ReqwestSender}; +use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions}; +use yaak_models::models::{Environment, HttpRequest, HttpRequestHeader, HttpUrlParameter}; +use yaak_models::render::make_vars_hashmap; +use yaak_models::util::UpdateSource; +use yaak_plugins::events::{PluginContext, RenderPurpose}; +use yaak_plugins::template_callback::PluginTemplateCallback; +use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw}; + +pub async fn run(ctx: &CliContext, args: RequestArgs, environment: Option<&str>, verbose: bool) { + match args.command { + RequestCommands::List { workspace_id } => list(ctx, &workspace_id), + RequestCommands::Send { request_id } => { + send_request_by_id(ctx, &request_id, environment, verbose).await; + } + RequestCommands::Create { workspace_id, name, method, url } => { + create(ctx, workspace_id, name, method, url) + } + } +} + +fn list(ctx: &CliContext, workspace_id: &str) { + let requests = ctx + .db() + .list_http_requests(workspace_id) + .expect("Failed to list requests"); + if requests.is_empty() { + println!("No requests found in workspace {}", workspace_id); + } else { + for request in requests { + println!("{} - {} {}", request.id, request.method, request.name); + } + } +} + +fn create(ctx: &CliContext, workspace_id: String, name: String, method: String, url: String) { + let request = HttpRequest { + workspace_id, + name, + method: method.to_uppercase(), + url, + ..Default::default() + }; + + let created = ctx + .db() + .upsert_http_request(&request, &UpdateSource::Sync) + .expect("Failed to create request"); + + println!("Created request: {}", created.id); +} + +/// Send a request by ID and print response in the same format as legacy `send`. +pub async fn send_request_by_id( + ctx: &CliContext, + request_id: &str, + environment: Option<&str>, + verbose: bool, +) { + let request = ctx + .db() + .get_http_request(request_id) + .expect("Failed to get request"); + + let environment_chain = ctx + .db() + .resolve_environments( + &request.workspace_id, + request.folder_id.as_deref(), + environment, + ) + .unwrap_or_default(); + + let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone())); + let template_callback = PluginTemplateCallback::new( + ctx.plugin_manager.clone(), + ctx.encryption_manager.clone(), + &plugin_context, + RenderPurpose::Send, + ); + + let rendered_request = render_http_request( + &request, + environment_chain, + &template_callback, + &RenderOptions::throw(), + ) + .await + .expect("Failed to render request templates"); + + if verbose { + println!("> {} {}", rendered_request.method, rendered_request.url); + } + + let sendable = SendableHttpRequest::from_http_request( + &rendered_request, + SendableHttpRequestOptions::default(), + ) + .await + .expect("Failed to build request"); + + let (event_tx, mut event_rx) = mpsc::channel(100); + + let verbose_handle = if verbose { + Some(tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + println!("{}", event); + } + })) + } else { + tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); + None + }; + + let sender = ReqwestSender::new().expect("Failed to create HTTP client"); + let response = sender.send(sendable, event_tx).await.expect("Failed to send request"); + + if let Some(handle) = verbose_handle { + let _ = handle.await; + } + + 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); + } + println!(); + } + + let (body, _stats) = response.text().await.expect("Failed to read response body"); + println!("{}", body); +} + +/// Render an HTTP request with template variables and plugin functions. +async fn render_http_request( + request: &HttpRequest, + environment_chain: Vec, + callback: &PluginTemplateCallback, + options: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + + let mut url_parameters = Vec::new(); + for parameter in request.url_parameters.clone() { + if !parameter.enabled { + continue; + } + + url_parameters.push(HttpUrlParameter { + enabled: parameter.enabled, + name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?, + value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?, + id: parameter.id, + }) + } + + let mut headers = Vec::new(); + for header in request.headers.clone() { + if !header.enabled { + continue; + } + + headers.push(HttpRequestHeader { + enabled: header.enabled, + name: parse_and_render(header.name.as_str(), vars, callback, options).await?, + value: parse_and_render(header.value.as_str(), vars, callback, options).await?, + id: header.id, + }) + } + + let mut body = BTreeMap::new(); + for (key, value) in request.body.clone() { + body.insert(key, render_json_value_raw(value, vars, callback, options).await?); + } + + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + + match request.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; + } + Some(Value::String(template)) => { + disabled = parse_and_render(template.as_str(), vars, callback, options) + .await + .unwrap_or_default() + .is_empty(); + info!( + "Rendering authentication.disabled as a template: {disabled} from \"{template}\"" + ); + } + _ => {} + } + + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (key, value) in request.authentication.clone() { + if key == "disabled" { + auth.insert(key, Value::Bool(false)); + } else { + auth.insert(key, render_json_value_raw(value, vars, callback, options).await?); + } + } + } + + auth + }; + + let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?; + + let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters); + + Ok(HttpRequest { + url, + url_parameters, + headers, + body, + authentication, + ..request.to_owned() + }) +} diff --git a/crates-cli/yaak-cli/src/commands/send.rs b/crates-cli/yaak-cli/src/commands/send.rs new file mode 100644 index 00000000..39233e2e --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/send.rs @@ -0,0 +1,7 @@ +use crate::cli::SendArgs; +use crate::commands::request; +use crate::context::CliContext; + +pub async fn run(ctx: &CliContext, args: SendArgs, environment: Option<&str>, verbose: bool) { + request::send_request_by_id(ctx, &args.request_id, environment, verbose).await; +} diff --git a/crates-cli/yaak-cli/src/commands/workspace.rs b/crates-cli/yaak-cli/src/commands/workspace.rs new file mode 100644 index 00000000..ced4b9a9 --- /dev/null +++ b/crates-cli/yaak-cli/src/commands/workspace.rs @@ -0,0 +1,19 @@ +use crate::cli::{WorkspaceArgs, WorkspaceCommands}; +use crate::context::CliContext; + +pub fn run(ctx: &CliContext, args: WorkspaceArgs) { + match args.command { + WorkspaceCommands::List => list(ctx), + } +} + +fn list(ctx: &CliContext) { + let workspaces = ctx.db().list_workspaces().expect("Failed to list workspaces"); + if workspaces.is_empty() { + println!("No workspaces found"); + } else { + for workspace in workspaces { + println!("{} - {}", workspace.id, workspace.name); + } + } +} diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs new file mode 100644 index 00000000..47c42524 --- /dev/null +++ b/crates-cli/yaak-cli/src/context.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; +use std::sync::Arc; +use yaak_crypto::manager::EncryptionManager; +use yaak_models::db_context::DbContext; +use yaak_models::query_manager::QueryManager; +use yaak_plugins::events::PluginContext; +use yaak_plugins::manager::PluginManager; + +pub struct CliContext { + query_manager: QueryManager, + pub encryption_manager: Arc, + pub plugin_manager: Arc, +} + +impl CliContext { + pub async fn initialize(data_dir: PathBuf, app_id: &str) -> Self { + let db_path = data_dir.join("db.sqlite"); + let blob_path = data_dir.join("blobs.sqlite"); + + let (query_manager, _blob_manager, _rx) = + yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize database"); + + let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); + + let vendored_plugin_dir = data_dir.join("vendored-plugins"); + let installed_plugin_dir = data_dir.join("installed-plugins"); + let node_bin_path = PathBuf::from("node"); + + let plugin_runtime_main = + std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs") + }); + + let plugin_manager = Arc::new( + PluginManager::new( + vendored_plugin_dir, + installed_plugin_dir, + node_bin_path, + plugin_runtime_main, + false, + ) + .await, + ); + + let plugins = query_manager.connect().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 { + eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg); + } + } + + Self { query_manager, encryption_manager, plugin_manager } + } + + pub fn db(&self) -> DbContext<'_> { + self.query_manager.connect() + } + + pub async fn shutdown(&self) { + self.plugin_manager.terminate().await; + } +} diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index ab8cd9f1..5f249e21 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -1,409 +1,52 @@ -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::util::UpdateSource; -use yaak_plugins::events::{PluginContext, RenderPurpose}; -use yaak_plugins::manager::PluginManager; -use yaak_plugins::template_callback::PluginTemplateCallback; -use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw}; +mod cli; +mod commands; +mod context; -#[derive(Parser)] -#[command(name = "yaakcli")] -#[command(about = "Yaak CLI - API client from the command line")] -struct Cli { - /// Use a custom data directory - #[arg(long, global = true)] - data_dir: Option, - - /// Environment ID to use for variable substitution - #[arg(long, short, global = true)] - environment: Option, - - /// Enable verbose logging - #[arg(long, short, global = true)] - verbose: bool, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// List all workspaces - Workspaces, - /// List requests in a workspace - Requests { - /// Workspace ID - workspace_id: String, - }, - /// Send an HTTP request by ID - Send { - /// Request ID - request_id: String, - }, - /// Send a GET request to a URL - Get { - /// URL to request - url: String, - }, - /// Create a new HTTP request - Create { - /// Workspace ID - workspace_id: String, - /// Request name - #[arg(short, long)] - name: String, - /// HTTP method - #[arg(short, long, default_value = "GET")] - method: String, - /// URL - #[arg(short, long)] - url: String, - }, -} - -/// Render an HTTP request with template variables and plugin functions -async fn render_http_request( - r: &HttpRequest, - environment_chain: Vec, - cb: &PluginTemplateCallback, - opt: &RenderOptions, -) -> yaak_templates::error::Result { - 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() }) -} +use clap::Parser; +use cli::{Cli, Commands}; +use context::CliContext; #[tokio::main] async fn main() { - let cli = Cli::parse(); + let Cli { data_dir, environment, verbose, command } = Cli::parse(); - // Initialize logging - if cli.verbose { + if verbose { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); } - // Use the same app_id for both data directory and keyring let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" }; - let data_dir = cli.data_dir.unwrap_or_else(|| { - dirs::data_dir().expect("Could not determine data directory").join(app_id) - }); + let data_dir = + data_dir.unwrap_or_else(|| dirs::data_dir().expect("Could not determine data directory").join(app_id)); - let db_path = data_dir.join("db.sqlite"); - let blob_path = data_dir.join("blobs.sqlite"); + let context = CliContext::initialize(data_dir, app_id).await; - let (query_manager, _blob_manager, _rx) = - yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize database"); - - 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"); - - // Use system node for CLI (must be in PATH) - let node_bin_path = PathBuf::from("node"); - - // Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path - let plugin_runtime_main = - std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| { - // Development fallback: look relative to crate root - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs") - }); - - // 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, - plugin_runtime_main, - false, - ) - .await, - ); - - // Initialize plugins from database - 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 { - eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg); + let exit_code = match command { + Commands::Send(args) => { + commands::send::run(&context, args, environment.as_deref(), verbose).await; + 0 } + Commands::Workspace(args) => { + commands::workspace::run(&context, args); + 0 + } + Commands::Request(args) => { + commands::request::run(&context, args, environment.as_deref(), verbose).await; + 0 + } + Commands::Folder(_) => { + eprintln!("Folder commands are not implemented yet"); + 1 + } + Commands::Environment(_) => { + eprintln!("Environment commands are not implemented yet"); + 1 + } + }; + + context.shutdown().await; + + if exit_code != 0 { + std::process::exit(exit_code); } - - match cli.command { - Commands::Workspaces => { - let workspaces = db.list_workspaces().expect("Failed to list workspaces"); - if workspaces.is_empty() { - println!("No workspaces found"); - } else { - for ws in workspaces { - println!("{} - {}", ws.id, ws.name); - } - } - } - Commands::Requests { workspace_id } => { - let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests"); - if requests.is_empty() { - println!("No requests found in workspace {}", workspace_id); - } else { - for req in requests { - println!("{} - {} {}", req.id, req.method, req.name); - } - } - } - Commands::Send { request_id } => { - let request = db.get_http_request(&request_id).expect("Failed to get request"); - - // 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(), - ) - .await - .expect("Failed to render request templates"); - - if cli.verbose { - println!("> {} {}", rendered_request.method, rendered_request.url); - } - - // 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 - }; - - // 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"); - - // Wait for event handler to finish - if let Some(handle) = verbose_handle { - let _ = handle.await; - } - - // 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); - } - println!(); - } - - // Print body - let (body, _stats) = response.text().await.expect("Failed to read response body"); - println!("{}", body); - } - Commands::Get { url } => { - if cli.verbose { - println!("> GET {}", url); - } - - // Build a simple GET request - let sendable = SendableHttpRequest { - url: url.clone(), - method: "GET".to_string(), - headers: vec![], - body: None, - options: SendableHttpRequestOptions::default(), - }; - - // 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 { - tokio::spawn(async move { while event_rx.recv().await.is_some() {} }); - None - }; - - // 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"); - - if let Some(handle) = verbose_handle { - let _ = handle.await; - } - - // 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); - } - println!(); - } - - // Print body - let (body, _stats) = response.text().await.expect("Failed to read response body"); - println!("{}", body); - } - Commands::Create { workspace_id, name, method, url } => { - let request = HttpRequest { - workspace_id, - name, - method: method.to_uppercase(), - url, - ..Default::default() - }; - - let created = db - .upsert_http_request(&request, &UpdateSource::Sync) - .expect("Failed to create request"); - - println!("Created request: {}", created.id); - } - } - - // Terminate plugin manager gracefully - plugin_manager.terminate().await; }