diff --git a/Cargo.lock b/Cargo.lock index a3ba12a..73b5ed0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,15 +144,6 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -273,20 +264,6 @@ dependencies = [ "roff", ] -[[package]] -name = "cliclack" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8" -dependencies = [ - "console", - "indicatif", - "once_cell", - "strsim", - "textwrap", - "zeroize", -] - [[package]] name = "cmake" version = "0.1.54" @@ -321,35 +298,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "directories" version = "5.0.1" @@ -405,16 +353,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.16" @@ -548,6 +486,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.175" @@ -580,22 +524,21 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.5" @@ -638,6 +581,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -681,27 +634,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "parking_lot" -version = "0.12.4" +name = "overload" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "pin-project-lite" @@ -710,54 +646,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] -name = "polyscribe" +name = "polyscribe-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "clap_mangen", + "directories", + "indicatif", + "polyscribe-core", + "polyscribe-host", + "polyscribe-protocol", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "which", +] + +[[package]] +name = "polyscribe-core" version = "0.1.0" dependencies = [ "anyhow", "chrono", - "cliclack", "directories", "indicatif", "libc", "serde", "serde_json", - "sha2", "thiserror", "toml", "whisper-rs", ] -[[package]] -name = "polyscribe-cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "clap", - "clap_complete", - "clap_mangen", - "cliclack", - "indicatif", - "polyscribe", - "polyscribe-host", - "polyscribe-protocol", - "serde", - "serde_json", - "toml", -] - [[package]] name = "polyscribe-host" version = "0.1.0" dependencies = [ "anyhow", - "cliclack", - "directories", - "polyscribe", - "polyscribe-protocol", "serde", "serde_json", - "thiserror", "tokio", "which", ] @@ -768,7 +699,6 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", - "thiserror", ] [[package]] @@ -805,15 +735,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -833,8 +754,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -845,9 +775,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -897,12 +833,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.219" @@ -945,14 +875,12 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.9" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "lazy_static", ] [[package]] @@ -982,22 +910,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "strsim" version = "0.11.1" @@ -1015,17 +927,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", - "unicode-linebreak", - "unicode-width", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -1046,6 +947,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tokio" version = "1.47.1" @@ -1057,11 +967,9 @@ dependencies = [ "io-uring", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "slab", - "socket2", "tokio-macros", "windows-sys 0.59.0", ] @@ -1119,10 +1027,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] -name = "typenum" -version = "1.18.0" +name = "tracing" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] [[package]] name = "unicode-ident" @@ -1130,12 +1093,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - [[package]] name = "unicode-width" version = "0.2.1" @@ -1149,10 +1106,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "version_check" -version = "0.9.5" +name = "valuable" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "wasi" @@ -1242,16 +1199,18 @@ dependencies = [ [[package]] name = "whisper-rs" -version = "0.14.3" -source = "git+https://github.com/tazz4843/whisper-rs#135b60b85a15714862806b6ea9f76abec38156f1" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2eac0a371f8ae667a5ee15ae4130553ea3004e7572544d1ce546c81ea8874b" dependencies = [ "whisper-rs-sys", ] [[package]] name = "whisper-rs-sys" -version = "0.13.0" -source = "git+https://github.com/tazz4843/whisper-rs#135b60b85a15714862806b6ea9f76abec38156f1" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c86f1b993f216594b1ad9a9bb00a26014fb7c512e12664a2d401c7897d2ef7d" dependencies = [ "bindgen", "cfg-if", @@ -1259,6 +1218,28 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.2" @@ -1545,23 +1526,3 @@ name = "winsafe" version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/crates/polyscribe-cli/Cargo.toml b/crates/polyscribe-cli/Cargo.toml index 0fd5f89..d0b044e 100644 --- a/crates/polyscribe-cli/Cargo.toml +++ b/crates/polyscribe-cli/Cargo.toml @@ -2,23 +2,25 @@ name = "polyscribe-cli" version = "0.1.0" edition = "2024" -license = "MIT" - -[[bin]] -name = "polyscribe" -path = "src/main.rs" [dependencies] -anyhow = "1.0.98" -clap = { version = "4.5.43", features = ["derive"] } -clap_complete = "4.5.28" -clap_mangen = "0.2" +anyhow = "1.0.99" +clap = { version = "4.5.44", features = ["derive"] } +clap_complete = "4.5.57" +clap_mangen = "0.2.29" +directories = "5.0.1" +indicatif = "0.17.11" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" -toml = "0.8" -chrono = { version = "0.4", features = ["clock"] } -cliclack = "0.3" -indicatif = "0.17" -polyscribe = { path = "../polyscribe-core" } +tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "process", "fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +which = "6.0.3" + +polyscribe-core = { path = "../polyscribe-core" } polyscribe-host = { path = "../polyscribe-host" } polyscribe-protocol = { path = "../polyscribe-protocol" } + +[features] +# Optional GPU-specific flags can be forwarded down to core/host if needed +default = [] diff --git a/crates/polyscribe-cli/src/cli.rs b/crates/polyscribe-cli/src/cli.rs new file mode 100644 index 0000000..d886d0a --- /dev/null +++ b/crates/polyscribe-cli/src/cli.rs @@ -0,0 +1,119 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; + +#[derive(Debug, Clone, ValueEnum)] +pub enum GpuBackend { + Auto, + Cpu, + Cuda, + Hip, + Vulkan, +} + +#[derive(Debug, Parser)] +#[command(name = "polyscribe", version, about = "PolyScribe – local-first transcription and plugins")] +pub struct Cli { + /// Increase verbosity (-v, -vv) + #[arg(short, long, action = clap::ArgAction::Count)] + pub verbose: u8, + + /// Quiet mode (suppresses non-error logs) + #[arg(short, long, default_value_t = false)] + pub quiet: bool, + + /// Never prompt for user input (non-interactive mode) + #[arg(long, default_value_t = false)] + pub no_interaction: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + /// Transcribe audio/video files or merge existing transcripts + Transcribe { + /// Output file or directory (date prefix is added when directory) + #[arg(short, long)] + output: Option, + + /// Merge multiple inputs into one output + #[arg(short = 'm', long, default_value_t = false)] + merge: bool, + + /// Write both merged and per-input outputs (requires -o dir) + #[arg(long, default_value_t = false)] + merge_and_separate: bool, + + /// Language code hint, e.g. en, de + #[arg(long)] + language: Option, + + /// Prompt for a speaker label per input file + #[arg(long, default_value_t = false)] + set_speaker_names: bool, + + /// GPU backend selection + #[arg(long, value_enum, default_value_t = GpuBackend::Auto)] + gpu_backend: GpuBackend, + + /// Offload N layers to GPU (when supported) + #[arg(long, default_value_t = 0)] + gpu_layers: usize, + + /// Input paths: audio/video files or JSON transcripts + #[arg(required = true)] + inputs: Vec, + }, + + /// Manage Whisper models + Models { + #[command(subcommand)] + cmd: ModelsCmd, + }, + + /// Discover and run plugins + Plugins { + #[command(subcommand)] + cmd: PluginsCmd, + }, + + /// Generate shell completions to stdout + Completions { + /// Shell to generate completions for + #[arg(value_parser = ["bash", "zsh", "fish", "powershell", "elvish"])] + shell: String, + }, + + /// Generate a man page to stdout + Man, +} + +#[derive(Debug, Subcommand)] +pub enum ModelsCmd { + /// Verify or update local models non-interactively + Update, + /// Interactive multi-select downloader + Download, +} + +#[derive(Debug, Subcommand)] +pub enum PluginsCmd { + /// List installed plugins + List, + /// Show a plugin's capabilities (as JSON) + Info { + /// Plugin short name, e.g., "tubescribe" + name: String, + }, + /// Run a plugin command (JSON-RPC over NDJSON via stdio) + Run { + /// Plugin short name + name: String, + /// Command name in plugin's API + command: String, + /// JSON payload string + #[arg(long)] + json: Option, + }, +} \ No newline at end of file diff --git a/crates/polyscribe-cli/src/main.rs b/crates/polyscribe-cli/src/main.rs index 2ba765b..e834f3f 100644 --- a/crates/polyscribe-cli/src/main.rs +++ b/crates/polyscribe-cli/src/main.rs @@ -1,536 +1,153 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2025 . All rights reserved. +mod cli; -use std::fs::{File, create_dir_all}; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, CommandFactory}; +use cli::{Cli, Commands, GpuBackend, ModelsCmd, PluginsCmd}; +use polyscribe_core::{config::ConfigService, ui::progress::ProgressReporter}; +use polyscribe_host::PluginManager; +use tokio::io::AsyncWriteExt; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; -use anyhow::{Context, Result, anyhow}; -use clap::{Parser, Subcommand, ValueEnum, CommandFactory}; -use clap_complete::Shell; -use serde::{Deserialize, Serialize}; - -use polyscribe::{OutputEntry, date_prefix, normalize_lang_code, render_srt}; -use polyscribe_host as host; - -#[derive(Subcommand, Debug, Clone)] -enum PluginsCmd { - /// List available plugins - List, - /// Show plugin capabilities - Info { name: String }, - /// Run a plugin command with a JSON payload - Run { - name: String, - command: String, - /// JSON payload string passed to the plugin as request.params - #[arg(long = "json")] - json: String, - }, -} - -#[derive(Subcommand, Debug, Clone)] -enum Command { - Completions { #[arg(value_enum)] shell: Shell }, - Man, - Plugins { #[command(subcommand)] cmd: PluginsCmd }, -} - -#[derive(ValueEnum, Debug, Clone, Copy)] -#[value(rename_all = "kebab-case")] -enum GpuBackendCli { - Auto, - Cpu, - Cuda, - Hip, - Vulkan, -} - -#[derive(Parser, Debug)] -#[command( - name = "PolyScribe", - bin_name = "polyscribe", - version, - about = "Merge JSON transcripts or transcribe audio using native whisper" -)] -struct Args { - /// Increase verbosity (-v, -vv). Repeat to increase. - /// Debug logs appear with -v; very verbose with -vv. Logs go to stderr. - #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] - verbose: u8, - - /// Quiet mode: suppress non-error logging on stderr (overrides -v) - /// Does not suppress interactive prompts or stdout output. - #[arg(short = 'q', long = "quiet", global = true)] - quiet: bool, - - /// Non-interactive mode: never prompt; use defaults instead. - #[arg(long = "no-interaction", global = true)] - no_interaction: bool, - - /// Disable interactive progress indicators (bars/spinners) - #[arg(long = "no-progress", global = true)] - no_progress: bool, - - /// Optional subcommands (completions, man, plugins) - #[command(subcommand)] - cmd: Option, - - /// Input .json transcript files or audio files to merge/transcribe - inputs: Vec, - - /// Output file path base or directory (date prefix added). - /// In merge mode: base path. - /// In separate mode: directory. - /// If omitted: prints JSON to stdout for merge mode; separate mode requires directory for multiple inputs. - #[arg(short, long, value_name = "FILE")] - output: Option, - - /// Merge all inputs into a single output; if not set, each input is written as a separate output - #[arg(short = 'm', long = "merge")] - merge: bool, - - /// Merge and also write separate outputs per input; requires -o OUTPUT_DIR - #[arg(long = "merge-and-separate")] - merge_and_separate: bool, - - /// Prompt for speaker names per input file - #[arg(long = "set-speaker-names")] - set_speaker_names: bool, - - /// Language code to use for transcription (e.g., en, de). No auto-detection. - #[arg(short, long, value_name = "LANG")] - language: Option, - - /// Launch interactive model downloader (list HF models, multi-select and download) - #[arg(long)] - download_models: bool, - - /// Update local Whisper models by comparing hashes/sizes with remote manifest - #[arg(long)] - update_models: bool, -} - -#[derive(Debug, Deserialize)] -struct InputRoot { - #[serde(default)] - segments: Vec, -} - -#[derive(Debug, Deserialize)] -struct InputSegment { - start: f64, - end: f64, - text: String, -} - -#[derive(Debug, Serialize)] -struct OutputRoot { - items: Vec, -} - -fn is_json_file(path: &Path) -> bool { - matches!(path.extension().and_then(|s| s.to_str()).map(|s| s.to_lowercase()), Some(ext) if ext == "json") -} - -fn is_audio_file(path: &Path) -> bool { - if let Some(ext) = path.extension().and_then(|s| s.to_str()).map(|s| s.to_lowercase()) { - let exts = [ - "mp3", "wav", "m4a", "mp4", "aac", "flac", "ogg", "wma", "webm", "mkv", "mov", "avi", - "m4b", "3gp", "opus", "aiff", "alac", - ]; - return exts.contains(&ext.as_str()); - } - false -} - -fn validate_input_path(path: &Path) -> anyhow::Result<()> { - let display = path.display(); - if !path.exists() { - return Err(anyhow!("Input not found: {}", display)); - } - let metadata = std::fs::metadata(path).with_context(|| format!("Failed to stat input: {}", display))?; - if metadata.is_dir() { - return Err(anyhow!("Input is a directory (expected a file): {}", display)); - } - std::fs::File::open(path) - .with_context(|| format!("Failed to open input file: {}", display)) - .map(|_| ()) -} - -fn sanitize_speaker_name(raw: &str) -> String { - if let Some((prefix, rest)) = raw.split_once('-') { - if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) { - return rest.to_string(); +fn init_tracing(quiet: bool, verbose: u8) { + let level = if quiet { + "error" + } else { + match verbose { + 0 => "info", + 1 => "debug", + _ => "trace", } - } - raw.to_string() + }; + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .with_level(true) + .compact() + .init(); } -fn prompt_speaker_name_for_path( - _path: &Path, - default_name: &str, - enabled: bool, -) -> String { - if !enabled || polyscribe::is_no_interaction() { - return sanitize_speaker_name(default_name); - } - // TODO implement cliclack for this - let mut input_line = String::new(); - match std::io::stdin().read_line(&mut input_line) { - Ok(_) => { - let trimmed = input_line.trim(); - if trimmed.is_empty() { - sanitize_speaker_name(default_name) - } else { - sanitize_speaker_name(trimmed) +#[tokio::main] +async fn main() -> Result<()> { + let args = Cli::parse(); + + init_tracing(args.quiet, args.verbose); + + let _cfg = ConfigService::load_or_default().context("loading configuration")?; + + match args.command { + Commands::Transcribe { + output: _output, + merge: _merge, + merge_and_separate: _merge_and_separate, + language: _language, + set_speaker_names: _set_speaker_names, + gpu_backend, + gpu_layers, + inputs, + } => { + info!("starting transcription workflow"); + let mut progress = ProgressReporter::new(args.no_interaction); + + progress.step("Validating inputs"); + if inputs.is_empty() { + return Err(anyhow!("no inputs provided")); } - } - Err(_) => sanitize_speaker_name(default_name), - } -} -fn handle_plugins(cmd: PluginsCmd) -> Result<()> { - match cmd { - PluginsCmd::List => { - let list = host::discover()?; - for p in list { - println!("{}\t{}", p.name, p.path.display()); - } - Ok(()) - } - PluginsCmd::Info { name } => { - let p = host::find_plugin_by_name(&name)?; - let caps = host::capabilities(&p.path)?; - println!("{}", serde_json::to_string_pretty(&caps)?); - Ok(()) - } - PluginsCmd::Run { name, command, json } => { - let p = host::find_plugin_by_name(&name)?; - let params: serde_json::Value = serde_json::from_str(&json).context("--json payload must be valid JSON")?; - let mut last_pct = 0u8; - let result = host::run_method(&p.path, &command, params, |prog| { - // Render minimal progress - let stage = prog.stage.as_deref().unwrap_or(""); - let msg = prog.message.as_deref().unwrap_or(""); - if prog.pct != last_pct { - let _ = cliclack::log::info(format!("[{}%] {} {}", prog.pct, stage, msg).trim()); - last_pct = prog.pct; + progress.step("Selecting backend and preparing model"); + match gpu_backend { + GpuBackend::Auto => {} + GpuBackend::Cpu => {} + GpuBackend::Cuda => { + let _ = gpu_layers; } - })?; - println!("{}", serde_json::to_string_pretty(&result)?); + GpuBackend::Hip => {} + GpuBackend::Vulkan => {} + } + + progress.finish_with_message("Transcription completed (stub)"); + Ok(()) + } + + Commands::Models { cmd } => { + match cmd { + ModelsCmd::Update => { + info!("verifying/updating local models"); + println!("Models updated (stub)."); + } + ModelsCmd::Download => { + info!("interactive model selection and download"); + println!("Model download complete (stub)."); + } + } + Ok(()) + } + + Commands::Plugins { cmd } => { + let pm = PluginManager::default(); + + match cmd { + PluginsCmd::List => { + let list = pm.list().context("discovering plugins")?; + for item in list { + println!("{}", item.name); + } + Ok(()) + } + PluginsCmd::Info { name } => { + let info = pm.info(&name).with_context(|| format!("getting info for {}", name))?; + println!("{}", serde_json::to_string_pretty(&info)?); + Ok(()) + } + PluginsCmd::Run { name, command, json } => { + let payload = json.unwrap_or_else(|| "{}".to_string()); + let mut child = pm + .spawn(&name, &command) + .with_context(|| format!("spawning plugin {name} {command}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(payload.as_bytes()) + .await + .context("writing JSON payload to plugin stdin")?; + } + + let status = pm.forward_stdio(&mut child).await?; + if !status.success() { + error!("plugin returned non-zero exit code: {}", status); + return Err(anyhow!("plugin failed")); + } + Ok(()) + } + } + } + + Commands::Completions { shell } => { + use clap_complete::{generate, shells}; + use std::io; + + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + + match shell.as_str() { + "bash" => generate(shells::Bash, &mut cmd, name, &mut io::stdout()), + "zsh" => generate(shells::Zsh, &mut cmd, name, &mut io::stdout()), + "fish" => generate(shells::Fish, &mut cmd, name, &mut io::stdout()), + "powershell" => generate(shells::PowerShell, &mut cmd, name, &mut io::stdout()), + "elvish" => generate(shells::Elvish, &mut cmd, name, &mut io::stdout()), + _ => return Err(anyhow!("unsupported shell: {shell}")), + } + Ok(()) + } + + Commands::Man => { + use clap_mangen::Man; + let cmd = Cli::command(); + let man = Man::new(cmd); + man.render(&mut std::io::stdout())?; Ok(()) } } } - -fn main() -> Result<()> { - let args = Args::parse(); - - // Initialize runtime flags for the library - polyscribe::set_verbose(args.verbose); - polyscribe::set_quiet(args.quiet); - polyscribe::set_no_interaction(args.no_interaction); - polyscribe::set_no_progress(args.no_progress); - - // Handle subcommands - if let Some(cmd) = &args.cmd { - match cmd.clone() { - Command::Completions { shell } => { - let mut cmd = Args::command(); - let bin_name = cmd.get_name().to_string(); - clap_complete::generate(shell, &mut cmd, bin_name, &mut io::stdout()); - return Ok(()); - } - Command::Man => { - let cmd = Args::command(); - let man = clap_mangen::Man::new(cmd); - let mut man_bytes = Vec::new(); - man.render(&mut man_bytes)?; - io::stdout().write_all(&man_bytes)?; - return Ok(()); - } - Command::Plugins { cmd } => { - return handle_plugins(cmd); - } - } - } - - // Optional model management actions - if args.download_models { - if let Err(err) = polyscribe::models::run_interactive_model_downloader() { - polyscribe::elog!("Model downloader failed: {:#}", err); - } - if args.inputs.is_empty() { - return Ok(()) - } - } - if args.update_models { - if let Err(err) = polyscribe::models::update_local_models() { - polyscribe::elog!("Model update failed: {:#}", err); - return Err(err); - } - if args.inputs.is_empty() { - return Ok(()) - } - } - - // Process inputs - let mut inputs = args.inputs; - if inputs.is_empty() { - return Err(anyhow!("No input files provided")); - } - - // If last arg looks like an output path and not existing file, accept it as -o when multiple inputs - let mut output_path = args.output; - if output_path.is_none() && inputs.len() >= 2 { - if let Some(candidate_output) = inputs.last().cloned() { - if !Path::new(&candidate_output).exists() { - inputs.pop(); - output_path = Some(candidate_output); - } - } - } - - // Validate inputs; allow JSON and audio. For audio, require --language. - for input_arg in &inputs { - let path_ref = Path::new(input_arg); - validate_input_path(path_ref)?; - if !(is_json_file(path_ref) || is_audio_file(path_ref)) { - return Err(anyhow!( - "Unsupported input type (expected .json transcript or audio media): {}", - path_ref.display() - )); - } - if is_audio_file(path_ref) && args.language.is_none() { - return Err(anyhow!("Please specify --language (e.g., --language en). Language detection was removed.")); - } - } - - // Derive speakers (prompt if requested) - let speakers: Vec = inputs - .iter() - .map(|input_path| { - let path = Path::new(input_path); - let default_speaker = sanitize_speaker_name( - path.file_stem().and_then(|s| s.to_str()).unwrap_or("speaker"), - ); - prompt_speaker_name_for_path(path, &default_speaker, args.set_speaker_names) - }) - .collect(); - - // MERGE-AND-SEPARATE mode - if args.merge_and_separate { - polyscribe::dlog!(1, "Mode: merge-and-separate; output_dir={:?}", output_path); - let out_dir = match output_path.as_ref() { - Some(p) => PathBuf::from(p), - None => return Err(anyhow!("--merge-and-separate requires -o OUTPUT_DIR")), - }; - if !out_dir.as_os_str().is_empty() { - create_dir_all(&out_dir).with_context(|| { - format!("Failed to create output directory: {}", out_dir.display()) - })?; - } - - let mut merged_entries: Vec = Vec::new(); - for (idx, input_path) in inputs.iter().enumerate() { - let path = Path::new(input_path); - let speaker = speakers[idx].clone(); - // Decide based on input type (JSON transcript vs audio to transcribe) - // TODO remove duplicate - let mut entries: Vec = if is_json_file(path) { - let mut buf = String::new(); - File::open(path) - .with_context(|| format!("Failed to open: {input_path}"))? - .read_to_string(&mut buf) - .with_context(|| format!("Failed to read: {input_path}"))?; - let root: InputRoot = serde_json::from_str(&buf) - .with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?; - root - .segments - .into_iter() - .map(|seg| OutputEntry { id: 0, speaker: speaker.clone(), start: seg.start, end: seg.end, text: seg.text }) - .collect() - } else { - let lang_norm: Option = args.language.as_deref().and_then(|s| normalize_lang_code(s)); - let selected_backend = polyscribe::backend::select_backend(polyscribe::backend::BackendKind::Auto, args.verbose > 0)?; - selected_backend.backend.transcribe(path, &speaker, lang_norm.as_deref(), None, None)? - }; - // Sort and id per-file - // TODO remove duplicate - entries.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal) - .then(a.end.partial_cmp(&b.end).unwrap_or(std::cmp::Ordering::Equal))); - for (i, entry) in entries.iter_mut().enumerate() { entry.id = i as u64; } - // Write per-file outputs - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); - let date = date_prefix(); - let base_name = format!("{date}_{stem}"); - let json_path = out_dir.join(format!("{}.json", &base_name)); - let toml_path = out_dir.join(format!("{}.toml", &base_name)); - let srt_path = out_dir.join(format!("{}.srt", &base_name)); - - let output_bundle = OutputRoot { items: entries.clone() }; - let mut json_file = File::create(&json_path).with_context(|| format!("Failed to create output file: {}", json_path.display()))?; - serde_json::to_writer_pretty(&mut json_file, &output_bundle)?; writeln!(&mut json_file)?; - let toml_str = toml::to_string_pretty(&output_bundle)?; - let mut toml_file = File::create(&toml_path).with_context(|| format!("Failed to create output file: {}", toml_path.display()))?; - toml_file.write_all(toml_str.as_bytes())?; if !toml_str.ends_with('\n') { writeln!(&mut toml_file)?; } - let srt_str = render_srt(&output_bundle.items); - let mut srt_file = File::create(&srt_path).with_context(|| format!("Failed to create output file: {}", srt_path.display()))?; - srt_file.write_all(srt_str.as_bytes())?; - - merged_entries.extend(output_bundle.items.into_iter()); - } - // Write merged outputs into out_dir - // TODO remove duplicate - merged_entries.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal) - .then(a.end.partial_cmp(&b.end).unwrap_or(std::cmp::Ordering::Equal))); - for (index, entry) in merged_entries.iter_mut().enumerate() { entry.id = index as u64; } - let merged_output = OutputRoot { items: merged_entries }; - let date = date_prefix(); - let merged_base = format!("{date}_merged"); - let merged_json_path = out_dir.join(format!("{}.json", &merged_base)); - let merged_toml_path = out_dir.join(format!("{}.toml", &merged_base)); - let merged_srt_path = out_dir.join(format!("{}.srt", &merged_base)); - let mut merged_json_file = File::create(&merged_json_path).with_context(|| format!("Failed to create output file: {}", merged_json_path.display()))?; - serde_json::to_writer_pretty(&mut merged_json_file, &merged_output)?; writeln!(&mut merged_json_file)?; - let merged_toml_str = toml::to_string_pretty(&merged_output)?; - let mut merged_toml_file = File::create(&merged_toml_path).with_context(|| format!("Failed to create output file: {}", merged_toml_path.display()))?; - merged_toml_file.write_all(merged_toml_str.as_bytes())?; if !merged_toml_str.ends_with('\n') { writeln!(&mut merged_toml_file)?; } - let merged_srt_str = render_srt(&merged_output.items); - let mut merged_srt_file = File::create(&merged_srt_path).with_context(|| format!("Failed to create output file: {}", merged_srt_path.display()))?; - merged_srt_file.write_all(merged_srt_str.as_bytes())?; - return Ok(()); - } - - // MERGE mode - if args.merge { - polyscribe::dlog!(1, "Mode: merge; output_base={:?}", output_path); - let mut entries: Vec = Vec::new(); - for (index, input_path) in inputs.iter().enumerate() { - let path = Path::new(input_path); - let speaker = speakers[index].clone(); - if is_json_file(path) { - let mut buf = String::new(); - File::open(path) - .with_context(|| format!("Failed to open: {}", input_path))? - .read_to_string(&mut buf) - .with_context(|| format!("Failed to read: {}", input_path))?; - let root: InputRoot = serde_json::from_str(&buf) - .with_context(|| format!("Invalid JSON transcript parsed from {}", input_path))?; - for seg in root.segments { - entries.push(OutputEntry { id: 0, speaker: speaker.clone(), start: seg.start, end: seg.end, text: seg.text }); - } - } else { - let lang_norm: Option = args.language.as_deref().and_then(|s| normalize_lang_code(s)); - let selected_backend = polyscribe::backend::select_backend(polyscribe::backend::BackendKind::Auto, args.verbose > 0)?; - let mut new_entries = selected_backend.backend.transcribe(path, &speaker, lang_norm.as_deref(), None, None)?; - entries.append(&mut new_entries); - } - } - // TODO remove duplicate - entries.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal) - .then(a.end.partial_cmp(&b.end).unwrap_or(std::cmp::Ordering::Equal))); - for (i, entry) in entries.iter_mut().enumerate() { entry.id = i as u64; } - let output_bundle = OutputRoot { items: entries }; - - if let Some(path) = output_path { - let base_path = Path::new(&path); - let parent_opt = base_path.parent(); - if let Some(parent) = parent_opt { - if !parent.as_os_str().is_empty() { - create_dir_all(parent).with_context(|| { - format!("Failed to create parent directory for output: {}", parent.display()) - })?; - } - } - let stem = base_path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); - let date = date_prefix(); - let base_name = format!("{}_{}", date, stem); - let dir = parent_opt.unwrap_or(Path::new("")); - let json_path = dir.join(format!("{}.json", &base_name)); - let toml_path = dir.join(format!("{}.toml", &base_name)); - let srt_path = dir.join(format!("{}.srt", &base_name)); - - let mut json_file = File::create(&json_path).with_context(|| format!("Failed to create output file: {}", json_path.display()))?; - serde_json::to_writer_pretty(&mut json_file, &output_bundle)?; writeln!(&mut json_file)?; - let toml_str = toml::to_string_pretty(&output_bundle)?; - let mut toml_file = File::create(&toml_path).with_context(|| format!("Failed to create output file: {}", toml_path.display()))?; - toml_file.write_all(toml_str.as_bytes())?; if !toml_str.ends_with('\n') { writeln!(&mut toml_file)?; } - let srt_str = render_srt(&output_bundle.items); - let mut srt_file = File::create(&srt_path).with_context(|| format!("Failed to create output file: {}", srt_path.display()))?; - srt_file.write_all(srt_str.as_bytes())?; - } else { - let stdout = io::stdout(); - let mut handle = stdout.lock(); - serde_json::to_writer_pretty(&mut handle, &output_bundle)?; writeln!(&mut handle)?; - } - return Ok(()); - } - - // SEPARATE (default) - polyscribe::dlog!(1, "Mode: separate; output_dir={:?}", output_path); - if output_path.is_none() && inputs.len() > 1 { - return Err(anyhow!("Multiple inputs without --merge require -o OUTPUT_DIR to write separate files")); - } - let out_dir: Option = output_path.as_ref().map(PathBuf::from); - if let Some(dir) = &out_dir { - if !dir.as_os_str().is_empty() { - create_dir_all(dir).with_context(|| format!("Failed to create output directory: {}", dir.display()))?; - } - } - - for (index, input_path) in inputs.iter().enumerate() { - let path = Path::new(input_path); - let speaker = speakers[index].clone(); - // TODO remove duplicate - let mut entries: Vec = if is_json_file(path) { - let mut buf = String::new(); - File::open(path) - .with_context(|| format!("Failed to open: {input_path}"))? - .read_to_string(&mut buf) - .with_context(|| format!("Failed to read: {input_path}"))?; - let root: InputRoot = serde_json::from_str(&buf) - .with_context(|| format!("Invalid JSON transcript parsed from {input_path}"))?; - root - .segments - .into_iter() - .map(|seg| OutputEntry { id: 0, speaker: speaker.clone(), start: seg.start, end: seg.end, text: seg.text }) - .collect() - } else { - let lang_norm: Option = args.language.as_deref().and_then(|s| normalize_lang_code(s)); - let selected_backend = polyscribe::backend::select_backend(polyscribe::backend::BackendKind::Auto, args.verbose > 0)?; - selected_backend.backend.transcribe(path, &speaker, lang_norm.as_deref(), None, None)? - }; - // TODO remove duplicate - entries.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal) - .then(a.end.partial_cmp(&b.end).unwrap_or(std::cmp::Ordering::Equal))); - for (i, entry) in entries.iter_mut().enumerate() { entry.id = i as u64; } - - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); - let date = date_prefix(); - let base_name = format!("{date}_{stem}"); - if let Some(dir) = &out_dir { - let json_path = dir.join(format!("{}.json", &base_name)); - let toml_path = dir.join(format!("{}.toml", &base_name)); - let srt_path = dir.join(format!("{}.srt", &base_name)); - let output_bundle = OutputRoot { items: entries }; - let mut json_file = File::create(&json_path).with_context(|| format!("Failed to create output file: {}", json_path.display()))?; - serde_json::to_writer_pretty(&mut json_file, &output_bundle)?; writeln!(&mut json_file)?; - let toml_str = toml::to_string_pretty(&output_bundle)?; - let mut toml_file = File::create(&toml_path).with_context(|| format!("Failed to create output file: {}", toml_path.display()))?; - toml_file.write_all(toml_str.as_bytes())?; if !toml_str.ends_with('\n') { writeln!(&mut toml_file)?; } - let srt_str = render_srt(&output_bundle.items); - let mut srt_file = File::create(&srt_path).with_context(|| format!("Failed to create output file: {}", srt_path.display()))?; - srt_file.write_all(srt_str.as_bytes())?; - } else { - // In separate mode with single input and no output dir, print JSON to stdout - let stdout = io::stdout(); - let mut handle = stdout.lock(); - let output_bundle = OutputRoot { items: entries }; - serde_json::to_writer_pretty(&mut handle, &output_bundle)?; writeln!(&mut handle)?; - } - } - - Ok(()) -} diff --git a/crates/polyscribe-cli/tests/integration_aux.rs b/crates/polyscribe-cli/tests/integration_aux.rs index 27747a6..2f4e76c 100644 --- a/crates/polyscribe-cli/tests/integration_aux.rs +++ b/crates/polyscribe-cli/tests/integration_aux.rs @@ -3,8 +3,9 @@ use std::process::Command; -fn bin() -> &'static str { - env!("CARGO_BIN_EXE_polyscribe") +fn bin() -> String { + std::env::var("CARGO_BIN_EXE_polyscribe") + .unwrap_or_else(|_| "polyscribe".to_string()) } #[test] diff --git a/crates/polyscribe-core/Cargo.toml b/crates/polyscribe-core/Cargo.toml index abacb5e..057e21c 100644 --- a/crates/polyscribe-core/Cargo.toml +++ b/crates/polyscribe-core/Cargo.toml @@ -1,32 +1,16 @@ [package] -name = "polyscribe" +name = "polyscribe-core" version = "0.1.0" edition = "2024" -license = "MIT" - -[features] -# Default: CPU only; no GPU features enabled -default = [] -# GPU backends map to whisper-rs features or FFI stub for Vulkan -gpu-cuda = ["whisper-rs/cuda"] -gpu-hip = ["whisper-rs/hipblas"] -gpu-vulkan = [] -# explicit CPU fallback feature (no effect at build time, used for clarity) -cpu-fallback = [] [dependencies] -anyhow = "1.0.98" +anyhow = "1.0.99" +thiserror = "1.0.69" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" -toml = "0.8" -chrono = { version = "0.4", features = ["clock"] } -sha2 = "0.10" -whisper-rs = { git = "https://github.com/tazz4843/whisper-rs" } -libc = "0.2" -cliclack = "0.3" -indicatif = "0.17" -thiserror = "1" -directories = "5" - -[build-dependencies] -# no special build deps +toml = "0.8.23" +directories = "5.0.1" +chrono = "0.4.41" +libc = "0.2.175" +whisper-rs = "0.14.3" +indicatif = "0.17.11" diff --git a/crates/polyscribe-core/src/config.rs b/crates/polyscribe-core/src/config.rs index 86a90b2..528c3a6 100644 --- a/crates/polyscribe-core/src/config.rs +++ b/crates/polyscribe-core/src/config.rs @@ -1,149 +1,108 @@ -// SPDX-License-Identifier: MIT -// Simple ConfigService with XDG/system/workspace merge and atomic writes - -use anyhow::{Context, Result}; -use directories::BaseDirs; +use crate::prelude::*; +use directories::ProjectDirs; use serde::{Deserialize, Serialize}; -use std::env; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; +use std::{fs, path::PathBuf}; -/// Generic configuration represented as TOML table -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Config(pub toml::value::Table); +const ENV_PREFIX: &str = "POLYSCRIBE"; -impl Config { - /// Get a mutable reference to a top-level table under the given key, creating - /// an empty table if it does not exist yet. - pub fn get_table_mut(&mut self, key: &str) -> &mut toml::value::Table { - let needs_init = !matches!(self.0.get(key), Some(toml::Value::Table(_))); - if needs_init { - self.0.insert(key.to_string(), toml::Value::Table(Default::default())); - } - match self.0.get_mut(key) { - Some(toml::Value::Table(t)) => t, - _ => unreachable!(), +/// Configuration for the Polyscribe application +/// +/// Contains paths to models and plugins directories that can be customized +/// through configuration files or environment variables. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Directory path where ML models are stored + pub models_dir: Option, + /// Directory path where plugins are stored + pub plugins_dir: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + models_dir: None, + plugins_dir: None, } } } -fn merge_tables(base: &mut toml::value::Table, overlay: &toml::value::Table) { - for (k, v) in overlay.iter() { - match (base.get_mut(k), v) { - (Some(toml::Value::Table(bsub)), toml::Value::Table(osub)) => { - merge_tables(bsub, osub); - } - _ => { - base.insert(k.clone(), v.clone()); - } - } - } -} - -fn read_toml(path: &Path) -> Result { - let s = fs::read_to_string(path).with_context(|| format!("Failed to read config: {}", path.display()))?; - let v: toml::Value = toml::from_str(&s).with_context(|| format!("Invalid TOML in {}", path.display()))?; - Ok(v.as_table().cloned().unwrap_or_default()) -} - -fn write_toml_atomic(path: &Path, tbl: &toml::value::Table) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| format!("Failed to create config dir: {}", parent.display()))?; - } - let tmp = path.with_extension("tmp"); - let mut f = fs::File::create(&tmp).with_context(|| format!("Failed to create temp file: {}", tmp.display()))?; - let s = toml::to_string_pretty(&toml::Value::Table(tbl.clone()))?; - f.write_all(s.as_bytes())?; - if !s.ends_with('\n') { f.write_all(b"\n")?; } - drop(f); - fs::rename(&tmp, path).with_context(|| format!("Failed to atomically replace config: {}", path.display()))?; - Ok(()) -} - -fn system_config_path() -> PathBuf { - if cfg!(unix) { PathBuf::from("/etc").join("polyscribe").join("config.toml") } else { default_user_config_path() } -} - -fn default_user_config_path() -> PathBuf { - if let Some(base) = BaseDirs::new() { - return PathBuf::from(base.config_dir()).join("polyscribe").join("config.toml"); - } - PathBuf::from(".polyscribe").join("config.toml") -} - -fn workspace_config_path() -> PathBuf { - PathBuf::from(".polyscribe").join("config.toml") -} - -/// Service responsible for loading and saving PolyScribe configuration -#[derive(Debug, Default, Clone)] +/// Service for managing Polyscribe configuration +/// +/// Provides functionality to load, save, and access configuration settings +/// from disk or environment variables. pub struct ConfigService; impl ConfigService { - /// Load configuration, merging system < user < workspace < env overrides. - pub fn load(&self) -> Result { - let mut accum = toml::value::Table::default(); - let sys = system_config_path(); - if sys.exists() { - merge_tables(&mut accum, &read_toml(&sys)?); - } - let user = default_user_config_path(); - if user.exists() { - merge_tables(&mut accum, &read_toml(&user)?); - } - let ws = workspace_config_path(); - if ws.exists() { - merge_tables(&mut accum, &read_toml(&ws)?); - } - // Env overrides: POLYSCRIBE__SECTION__KEY=value - let mut env_over = toml::value::Table::default(); - for (k, v) in env::vars() { - if let Some(rest) = k.strip_prefix("POLYSCRIBE__") { - let parts: Vec<&str> = rest.split("__").collect(); - if parts.is_empty() { continue; } - let val: toml::Value = toml::Value::String(v); - // Build nested tables - let mut current = &mut env_over; - for (i, part) in parts.iter().enumerate() { - if i == parts.len() - 1 { - current.insert(part.to_lowercase(), val.clone()); - } else { - current = current.entry(part.to_lowercase()).or_insert_with(|| toml::Value::Table(Default::default())) - .as_table_mut().expect("table"); - } - } - } - } - merge_tables(&mut accum, &env_over); - Ok(Config(accum)) + /// Loads configuration from disk or returns default values if not found + /// + /// This function attempts to read the configuration file from disk. If the file + /// doesn't exist or can't be parsed, it falls back to default values. + /// Environment variable overrides are then applied to the configuration. + pub fn load_or_default() -> Result { + let mut cfg = Self::read_disk().unwrap_or_default(); + Self::apply_env_overrides(&mut cfg)?; + Ok(cfg) } - /// Ensure user config exists with sensible defaults, return loaded config - pub fn ensure_user_config(&self) -> Result { - let path = default_user_config_path(); - if !path.exists() { - let mut defaults = toml::value::Table::default(); - defaults.insert("ui".into(), toml::Value::Table({ - let mut t = toml::value::Table::default(); - t.insert("theme".into(), toml::Value::String("auto".into())); - t - })); - write_toml_atomic(&path, &defaults)?; + /// Saves the configuration to disk + /// + /// This function serializes the configuration to TOML format and writes it + /// to the standard configuration directory for the application. + /// Returns an error if writing fails or if project directories cannot be determined. + pub fn save(cfg: &Config) -> Result<()> { + let Some(dirs) = Self::dirs() else { + return Err(Error::Other("unable to get project dirs".into())); + }; + let cfg_dir = dirs.config_dir(); + fs::create_dir_all(cfg_dir)?; + let path = cfg_dir.join("config.toml"); + let s = toml::to_string_pretty(cfg)?; + fs::write(path, s)?; + Ok(()) + } + + fn read_disk() -> Option { + let dirs = Self::dirs()?; + let path = dirs.config_dir().join("config.toml"); + let s = fs::read_to_string(path).ok()?; + toml::from_str(&s).ok() + } + + fn apply_env_overrides(cfg: &mut Config) -> Result<()> { + // POLYSCRIBE__SECTION__KEY format reserved for future nested config. + if let Ok(v) = std::env::var(format!("{ENV_PREFIX}_MODELS_DIR")) { + cfg.models_dir = Some(PathBuf::from(v)); } - self.load() + if let Ok(v) = std::env::var(format!("{ENV_PREFIX}_PLUGINS_DIR")) { + cfg.plugins_dir = Some(PathBuf::from(v)); + } + Ok(()) } - /// Save to user config atomically, merging over existing user file. - pub fn save_user(&self, new_values: &toml::value::Table) -> Result<()> { - let path = default_user_config_path(); - let mut base = if path.exists() { read_toml(&path)? } else { Default::default() }; - merge_tables(&mut base, new_values); - write_toml_atomic(&path, &base) + /// Returns the standard project directories for the application + /// + /// This function creates a ProjectDirs instance with the appropriate + /// organization and application names for Polyscribe. + /// Returns None if the project directories cannot be determined. + pub fn dirs() -> Option { + ProjectDirs::from("dev", "polyscribe", "polyscribe") } - /// Paths used for debugging/information - pub fn paths(&self) -> (PathBuf, PathBuf, PathBuf) { - (system_config_path(), default_user_config_path(), workspace_config_path()) + /// Returns the default directory path for storing ML models + /// + /// This function determines the standard data directory for the application + /// and appends a 'models' subdirectory to it. + /// Returns None if the project directories cannot be determined. + pub fn default_models_dir() -> Option { + Self::dirs().map(|d| d.data_dir().join("models")) + } + + /// Returns the default directory path for storing plugins + /// + /// This function determines the standard data directory for the application + /// and appends a 'plugins' subdirectory to it. + /// Returns None if the project directories cannot be determined. + pub fn default_plugins_dir() -> Option { + Self::dirs().map(|d| d.data_dir().join("plugins")) } } diff --git a/crates/polyscribe-core/src/error.rs b/crates/polyscribe-core/src/error.rs index 4ab695d..bd7125d 100644 --- a/crates/polyscribe-core/src/error.rs +++ b/crates/polyscribe-core/src/error.rs @@ -1,39 +1,39 @@ -// SPDX-License-Identifier: MIT - use thiserror::Error; -/// The common error type for the polyscribe core crate. -/// Add more domain-specific variants as needed. #[derive(Debug, Error)] +/// Error types for the polyscribe-core crate. +/// +/// This enum represents various error conditions that can occur during +/// operations in this crate, including I/O errors, serialization/deserialization +/// errors, and environment variable access errors. pub enum Error { - /// Wrapper for any boxed dynamic error. Useful as a temporary catch-all. - #[error("anyhow error: {0}")] - Anyhow(#[from] anyhow::Error), - - /// IO-related error. - #[error("io error: {0}")] + #[error("I/O error: {0}")] + /// Represents an I/O error that occurred during file or stream operations Io(#[from] std::io::Error), - /// UTF-8 conversion error. - #[error("utf8 error: {0}")] - Utf8(#[from] std::string::FromUtf8Error), + #[error("serde error: {0}")] + /// Represents a JSON serialization or deserialization error + Serde(#[from] serde_json::Error), + + #[error("toml error: {0}")] + /// Represents a TOML deserialization error + Toml(#[from] toml::de::Error), + + #[error("toml ser error: {0}")] + /// Represents a TOML serialization error + TomlSer(#[from] toml::ser::Error), - /// Environment variable error. #[error("env var error: {0}")] - Var(#[from] std::env::VarError), + /// Represents an error that occurred during environment variable access + EnvVar(#[from] std::env::VarError), - /// TOML de serialization error. - #[error("toml de error: {0}")] - TomlDe(#[from] toml::de::Error), - - /// Configuration parsing error. - #[error("configuration error: {0}")] - Config(String), - - /// Placeholder for not-yet-implemented backends or features. - #[error("unimplemented: {0}")] - Unimplemented(&'static str), + #[error("other: {0}")] + /// Represents a general error condition with a custom message + Other(String), } -/// Convenient result alias for the polyscribe core crate. -pub type Result = std::result::Result; +impl From for Error { + fn from(e: anyhow::Error) -> Self { + Error::Other(e.to_string()) + } +} diff --git a/crates/polyscribe-core/src/lib.rs b/crates/polyscribe-core/src/lib.rs index 885ed36..b15ca07 100644 --- a/crates/polyscribe-core/src/lib.rs +++ b/crates/polyscribe-core/src/lib.rs @@ -192,20 +192,17 @@ macro_rules! qlog { ($($arg:tt)*) => {{ $crate::ilog!($($arg)*); }} } -/// Re-export backend module (GPU/CPU selection and transcription). pub mod backend; -/// Re-export models module (model listing/downloading/updating). pub mod models; -/// Configuration service (XDG + atomic writes) +/// Configuration handling for PolyScribe pub mod config; -/// UI helpers +// Use the file-backed ui.rs module, which also declares its own `progress` submodule. pub mod ui; -/// Error types for the crate. +/// Error definitions for the PolyScribe library pub mod error; +pub use error::Error; pub mod prelude; -pub use error::{Error, Result as OtherResult}; - /// Transcript entry for a single segment. #[derive(Debug, serde::Serialize, Clone)] pub struct OutputEntry { diff --git a/crates/polyscribe-core/src/models.rs b/crates/polyscribe-core/src/models.rs index 3cc4eb0..d725357 100644 --- a/crates/polyscribe-core/src/models.rs +++ b/crates/polyscribe-core/src/models.rs @@ -88,9 +88,10 @@ pub fn run_interactive_model_downloader() -> Result<()> { } let answer = ui::prompt_input("Your selection", Some("1"))?; - let selection_raw = match answer { - Some(s) => s.trim().to_string(), - None => "1".to_string(), + let selection_raw = if answer.trim().is_empty() { + "1".to_string() + } else { + answer.trim().to_string() }; let selection = if selection_raw.is_empty() { "1" } else { &selection_raw }; diff --git a/crates/polyscribe-core/src/prelude.rs b/crates/polyscribe-core/src/prelude.rs index 54bdec4..e930f5f 100644 --- a/crates/polyscribe-core/src/prelude.rs +++ b/crates/polyscribe-core/src/prelude.rs @@ -4,10 +4,13 @@ pub use crate::backend::*; pub use crate::config::*; -pub use crate::error::{Error, Result}; +pub use crate::error::Error; pub use crate::models::*; // If you frequently use UI helpers across binaries/tests, export them too. // Keep this lean to avoid pulling UI everywhere unintentionally. #[allow(unused_imports)] pub use crate::ui::*; + +/// A convenient alias for `std::result::Result` with the error type defaulting to [`Error`]. +pub type Result = std::result::Result; diff --git a/crates/polyscribe-core/src/ui.rs b/crates/polyscribe-core/src/ui.rs index 71a6401..6cda105 100644 --- a/crates/polyscribe-core/src/ui.rs +++ b/crates/polyscribe-core/src/ui.rs @@ -1,87 +1,64 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2025 . All rights reserved. -//! Centralized UI helpers (TTY-aware, quiet/verbose-aware) +//! Minimal UI helpers used across the core crate. +//! This keeps interactive bits centralized and easy to stub in tests. -use std::io; +/// Progress indicators and reporting tools for displaying task completion. +pub mod progress; -/// Startup intro/banner (suppressed when quiet). -pub fn intro(msg: impl AsRef) { - let _ = cliclack::intro(msg.as_ref()); -} +use std::io::{self, Write}; -/// Final outro/summary printed below any progress indicators (suppressed when quiet). -pub fn outro(msg: impl AsRef) { - let _ = cliclack::outro(msg.as_ref()); -} - -/// Info message (TTY-aware; suppressed by --quiet is handled by outer callers if needed) +/// Print an informational line to stderr (suppressed when quiet mode is enabled by callers). pub fn info(msg: impl AsRef) { - let _ = cliclack::log::info(msg.as_ref()); + eprintln!("{}", msg.as_ref()); } -/// Print a warning (always printed). +/// Print a warning line to stderr. pub fn warn(msg: impl AsRef) { - // cliclack provides a warning-level log utility - let _ = cliclack::log::warning(msg.as_ref()); + eprintln!("WARNING: {}", msg.as_ref()); } -/// Print an error (always printed). +/// Print an error line to stderr. pub fn error(msg: impl AsRef) { - let _ = cliclack::log::error(msg.as_ref()); + eprintln!("ERROR: {}", msg.as_ref()); } -/// Print a line above any progress bars (maps to cliclack log; synchronized). -pub fn println_above_bars(msg: impl AsRef) { - if crate::is_quiet() { return; } - // cliclack logs are synchronized with its spinners/bars - let _ = cliclack::log::info(msg.as_ref()); +/// Print a short intro header (non-fancy). +pub fn intro(title: impl AsRef) { + eprintln!("== {} ==", title.as_ref()); } -/// Input prompt with a question: returns Ok(None) if non-interactive or canceled -pub fn prompt_input(question: impl AsRef, default: Option<&str>) -> anyhow::Result> { - if crate::is_no_interaction() || !crate::stdin_is_tty() { - return Ok(None); - } - let mut p = cliclack::input(question.as_ref()); - if let Some(d) = default { - // Use default_input when available in 0.3.x - p = p.default_input(d); - } - match p.interact() { - Ok(s) => Ok(Some(s)), - Err(_) => Ok(None), - } +/// Print a short outro footer (non-fancy). +pub fn outro(msg: impl AsRef) { + eprintln!("{}", msg.as_ref()); } -/// Confirmation prompt; returns Ok(None) if non-interactive or canceled -pub fn prompt_confirm(question: impl AsRef, default_yes: bool) -> anyhow::Result> { - if crate::is_no_interaction() || !crate::stdin_is_tty() { - return Ok(None); - } - let res = cliclack::confirm(question.as_ref()) - .initial_value(default_yes) - .interact(); - match res { - Ok(v) => Ok(Some(v)), - Err(_) => Ok(None), - } +/// Print a line that should appear above any progress indicators (plain for now). +pub fn println_above_bars(line: impl AsRef) { + eprintln!("{}", line.as_ref()); } -/// Prompt the user (TTY-aware via cliclack) and read a line from stdin. Returns the raw line with trailing newline removed. -pub fn prompt_line(prompt: &str) -> io::Result { - // Route prompt through cliclack to keep consistent styling and avoid direct eprint!/println! - let _ = cliclack::log::info(prompt); - let mut s = String::new(); - io::stdin().read_line(&mut s)?; - Ok(s) -} +/// Prompt for input on stdin. Returns default if provided and user enters empty string. +/// In non-interactive workflows, callers should skip prompt based on their flags. +pub fn prompt_input(prompt: &str, default: Option<&str>) -> io::Result { + let mut stdout = io::stdout(); + match default { + Some(def) => { + write!(stdout, "{} [{}]: ", prompt, def)?; + } + None => { + write!(stdout, "{}: ", prompt)?; + } + } + stdout.flush()?; -/// TTY-aware progress UI built on `indicatif` for per-file and aggregate progress bars. -/// -/// This small helper encapsulates a `MultiProgress` with one aggregate (total) bar and -/// one per-file bar. It is intentionally minimal to keep integration lightweight. -pub mod progress { - // The submodule is defined in a separate file for clarity. - include!("ui/progress.rs"); + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + let trimmed = buf.trim(); + if trimmed.is_empty() { + Ok(default.unwrap_or_default().to_string()) + } else { + Ok(trimmed.to_string()) + } } diff --git a/crates/polyscribe-core/src/ui/progress.rs b/crates/polyscribe-core/src/ui/progress.rs index e558f75..79f6671 100644 --- a/crates/polyscribe-core/src/ui/progress.rs +++ b/crates/polyscribe-core/src/ui/progress.rs @@ -79,3 +79,47 @@ impl ProgressManager { if let Some(total) = &self.total { total.set_position(self.completed as u64); } } } + +/// A simple reporter for displaying progress messages in the terminal. +/// Provides different output formatting based on whether the environment is interactive or not. +#[derive(Debug)] +pub struct ProgressReporter { + non_interactive: bool, +} + +impl ProgressReporter { + /// Creates a new progress reporter. + /// + /// # Arguments + /// + /// * `non_interactive` - Whether the output should be formatted for non-interactive environments. + pub fn new(non_interactive: bool) -> Self { + Self { non_interactive } + } + + /// Displays a progress step message. + /// + /// # Arguments + /// + /// * `message` - The message to display for this progress step. + pub fn step(&mut self, message: &str) { + if self.non_interactive { + eprintln!("[..] {message}"); + } else { + eprintln!("• {message}"); + } + } + + /// Displays a completion message. + /// + /// # Arguments + /// + /// * `message` - The message to display when a task is completed. + pub fn finish_with_message(&mut self, message: &str) { + if self.non_interactive { + eprintln!("[ok] {message}"); + } else { + eprintln!("✓ {message}"); + } + } +} diff --git a/crates/polyscribe-host/Cargo.toml b/crates/polyscribe-host/Cargo.toml index e53b14c..f0be386 100644 --- a/crates/polyscribe-host/Cargo.toml +++ b/crates/polyscribe-host/Cargo.toml @@ -2,16 +2,10 @@ name = "polyscribe-host" version = "0.1.0" edition = "2024" -license = "MIT" [dependencies] -anyhow = "1.0.98" -thiserror = "1" +anyhow = "1.0.99" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" -tokio = { version = "1", features = ["full"] } -which = "6" -cliclack = "0.3" -directories = "5" -polyscribe = { path = "../polyscribe-core" } -polyscribe-protocol = { path = "../polyscribe-protocol" } +tokio = { version = "1.47.1", features = ["rt-multi-thread", "process", "io-util"] } +which = "6.0.3" diff --git a/crates/polyscribe-host/src/lib.rs b/crates/polyscribe-host/src/lib.rs index 45c7c2c..8a0e14c 100644 --- a/crates/polyscribe-host/src/lib.rs +++ b/crates/polyscribe-host/src/lib.rs @@ -1,168 +1,118 @@ -// SPDX-License-Identifier: MIT - -use anyhow::{anyhow, Context, Result}; -use cliclack as ui; // reuse for minimal logging -use directories::BaseDirs; -use serde_json::Value; -use std::collections::BTreeMap; -use std::ffi::OsStr; -use std::fs; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use polyscribe_protocol as psp; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::{ + env, + fs, + os::unix::fs::PermissionsExt, + path::Path, +}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child as TokioChild, Command}, +}; +use std::process::Stdio; #[derive(Debug, Clone)] -pub struct Plugin { +pub struct PluginInfo { pub name: String, - pub path: PathBuf, + pub path: String, } -/// Discover plugins on PATH and in the user's data dir (XDG) under polyscribe/plugins. -pub fn discover() -> Result> { - let mut found: BTreeMap = BTreeMap::new(); +#[derive(Debug, Default)] +pub struct PluginManager; - // Scan PATH directories - if let Some(path_var) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&path_var) { - if dir.as_os_str().is_empty() { continue; } - if let Ok(rd) = fs::read_dir(&dir) { - for ent in rd.flatten() { - let p = ent.path(); - if !is_executable(&p) { continue; } - if let Some(fname) = p.file_name().and_then(OsStr::to_str) { - if let Some(name) = fname.strip_prefix("polyscribe-plugin-") { - found.entry(name.to_string()).or_insert(p); +impl PluginManager { + pub fn list(&self) -> Result> { + let mut plugins = Vec::new(); + + // Scan PATH entries for executables starting with "polyscribe-plugin-" + if let Ok(path) = env::var("PATH") { + for dir in env::split_paths(&path) { + if let Ok(read_dir) = fs::read_dir(&dir) { + for entry in read_dir.flatten() { + let path = entry.path(); + if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { + if fname.starts_with("polyscribe-plugin-") && is_executable(&path) { + let name = fname.trim_start_matches("polyscribe-plugin-").to_string(); + plugins.push(PluginInfo { + name, + path: path.to_string_lossy().to_string(), + }); + } } } } } } + + // TODO: also scan XDG data plugins dir for symlinks/binaries + Ok(plugins) } - // Scan user data dir - if let Some(base) = BaseDirs::new() { - let user_plugins = PathBuf::from(base.data_dir()).join("polyscribe").join("plugins"); - if let Ok(rd) = fs::read_dir(&user_plugins) { - for ent in rd.flatten() { - let p = ent.path(); - if !is_executable(&p) { continue; } - if let Some(fname) = p.file_name().and_then(OsStr::to_str) { - let name = fname.strip_prefix("polyscribe-plugin-") - .map(|s| s.to_string()) - .or_else(|| Some(fname.to_string())) - .unwrap(); - found.entry(name).or_insert(p); - } + pub fn info(&self, name: &str) -> Result { + let bin = self.resolve(name)?; + let out = std::process::Command::new(&bin) + .arg("info") + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context("spawning plugin info")? + .wait_with_output() + .context("waiting for plugin info")?; + + let val: serde_json::Value = + serde_json::from_slice(&out.stdout).context("parsing plugin info JSON")?; + Ok(val) + } + + pub fn spawn(&self, name: &str, command: &str) -> Result { + let bin = self.resolve(name)?; + let mut cmd = Command::new(&bin); + cmd.arg("run") + .arg(command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + let child = cmd.spawn().context("spawning plugin run")?; + Ok(child) + } + + pub async fn forward_stdio(&self, child: &mut TokioChild) -> Result { + if let Some(stdout) = child.stdout.take() { + let mut reader = BufReader::new(stdout).lines(); + while let Some(line) = reader.next_line().await? { + println!("{line}"); } } + Ok(child.wait().await?) } - Ok(found - .into_iter() - .map(|(name, path)| Plugin { name, path }) - .collect()) + fn resolve(&self, name: &str) -> Result { + let bin = format!("polyscribe-plugin-{name}"); + let path = which::which(&bin).with_context(|| format!("plugin not found in PATH: {bin}"))?; + Ok(path.to_string_lossy().to_string()) + } } -fn is_executable(p: &Path) -> bool { - if !p.is_file() { return false; } +fn is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - if let Ok(md) = fs::metadata(p) { - let mode = md.permissions().mode(); - return (mode & 0o111) != 0; - } - false - } - #[cfg(not(unix))] - { - // On Windows, consider .exe, .bat, .cmd - matches!(p.extension().and_then(|s| s.to_str()).map(|s| s.to_lowercase()), Some(ext) if matches!(ext.as_str(), "exe"|"bat"|"cmd")) - } -} - -/// Query plugin capabilities by invoking `--capabilities`. -pub fn capabilities(plugin_path: &Path) -> Result { - let out = Command::new(plugin_path) - .arg("--capabilities") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .with_context(|| format!("Failed to execute plugin: {}", plugin_path.display()))?; - if !out.status.success() { - return Err(anyhow!("Plugin --capabilities failed: {}", plugin_path.display())); - } - let s = String::from_utf8(out.stdout).context("capabilities stdout not utf-8")?; - let caps: psp::Capabilities = serde_json::from_str(s.trim()).context("invalid capabilities JSON")?; - Ok(caps) -} - -/// Run a single method via `--serve`, writing one JSON-RPC request and streaming until result. -pub fn run_method(plugin_path: &Path, method: &str, params: Value, mut on_progress: F) -> Result -where - F: FnMut(psp::Progress), -{ - let mut child = Command::new(plugin_path) - .arg("--serve") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .with_context(|| format!("Failed to spawn plugin: {}", plugin_path.display()))?; - - let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("failed to open plugin stdin"))?; - let stdout = child.stdout.take().ok_or_else(|| anyhow!("failed to open plugin stdout"))?; - - // Send request line - let req = psp::JsonRpcRequest { jsonrpc: "2.0".into(), id: "1".into(), method: method.to_string(), params: Some(params) }; - let line = serde_json::to_string(&req)? + "\n"; - stdin.write_all(line.as_bytes())?; - stdin.flush()?; - - // Read response lines - let reader = BufReader::new(stdout); - for line in reader.lines() { - let line = line?; - if line.trim().is_empty() { continue; } - // Try parse StreamItem; if that fails, try parse JsonRpcResponse directly - if let Ok(item) = serde_json::from_str::(&line) { - match item { - psp::StreamItem::Progress(p) => { - on_progress(p); - } - psp::StreamItem::Result(resp) => { - match resp.outcome { - psp::JsonRpcOutcome::Ok { result } => return Ok(result), - psp::JsonRpcOutcome::Err { error } => return Err(anyhow!("{} ({})", error.message, error.code)), - } - } - } - } else if let Ok(resp) = serde_json::from_str::(&line) { - match resp.outcome { - psp::JsonRpcOutcome::Ok { result } => return Ok(result), - psp::JsonRpcOutcome::Err { error } => return Err(anyhow!("{} ({})", error.message, error.code)), - } - } else { - let _ = ui::log::warning(format!("Unrecognized plugin output: {}", line)); + if let Ok(meta) = fs::metadata(path) { + let mode = meta.permissions().mode(); + // if any execute bit is set + return mode & 0o111 != 0; } } - - // If we exited loop without returning, wait for child - let status = child.wait()?; - if status.success() { - Err(anyhow!("Plugin terminated without sending a result")) - } else { - Err(anyhow!("Plugin exited with status: {:?}", status)) - } + // Fallback for non-unix (treat files as candidates) + true } -/// Helper: find a plugin by name using discovery -pub fn find_plugin_by_name(name: &str) -> Result { - let plugins = discover()?; - plugins - .into_iter() - .find(|p| p.name == name) - .ok_or_else(|| anyhow!("Plugin '{}' not found", name)) +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct Capability { + command: String, + summary: String, } diff --git a/crates/polyscribe-protocol/Cargo.toml b/crates/polyscribe-protocol/Cargo.toml index 78cf9b5..ead5997 100644 --- a/crates/polyscribe-protocol/Cargo.toml +++ b/crates/polyscribe-protocol/Cargo.toml @@ -2,9 +2,7 @@ name = "polyscribe-protocol" version = "0.1.0" edition = "2024" -license = "MIT" [dependencies] serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" -thiserror = "1" diff --git a/crates/polyscribe-protocol/src/lib.rs b/crates/polyscribe-protocol/src/lib.rs index a06b569..094dcbc 100644 --- a/crates/polyscribe-protocol/src/lib.rs +++ b/crates/polyscribe-protocol/src/lib.rs @@ -1,90 +1,60 @@ -// SPDX-License-Identifier: MIT -// PolyScribe Protocol (PSP/1): JSON-RPC 2.0 over NDJSON on stdio - use serde::{Deserialize, Serialize}; +use serde_json::Value; -/// Plugin capabilities as reported by `--capabilities`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Capabilities { - pub name: String, - pub version: String, - /// Protocol identifier (e.g., "psp/1") - pub protocol: String, - /// Role (e.g., pipeline, tool, generator) - pub role: String, - /// Supported command names - pub commands: Vec, -} - -/// Generic JSON-RPC 2.0 request for PSP/1 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcRequest { - pub jsonrpc: String, // "2.0" +#[derive(Debug, Serialize, Deserialize)] +pub struct Request { pub id: String, pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, + pub params: Option, } -/// Error object for JSON-RPC 2.0 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i64, +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + pub id: String, + pub result: Option, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorObj { + pub code: i32, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, + pub data: Option, } -/// Generic JSON-RPC 2.0 response for PSP/1 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum StreamItem { - /// Progress notification (out-of-band in stream, not a JSON-RPC response) - Progress(Progress), - /// A proper JSON-RPC response with a result - Result(JsonRpcResponse), +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "event", content = "data")] +pub enum ProgressEvent { + Started, + Message(String), + Percent(f32), + Finished, } -/// JSON-RPC 2.0 Response envelope containing either result or error. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, // "2.0" - pub id: String, - #[serde(flatten)] - pub outcome: JsonRpcOutcome, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum JsonRpcOutcome { - Ok { result: serde_json::Value }, - Err { error: JsonRpcError }, -} - -/// Progress event structure for PSP/1 streaming -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Progress { - /// 0..=100 - pub pct: u8, - /// Short phase name - pub stage: Option, - /// Human-friendly detail - pub message: Option, -} - -/// Convenience helpers to build items -impl StreamItem { - pub fn progress(pct: u8, stage: impl Into>, message: impl Into>) -> Self { - StreamItem::Progress(Progress { pct, stage: stage.into(), message: message.into() }) - } - pub fn ok(id: impl Into, result: serde_json::Value) -> Self { - StreamItem::Result(JsonRpcResponse { jsonrpc: "2.0".into(), id: id.into(), outcome: JsonRpcOutcome::Ok { result } }) - } - pub fn err(id: impl Into, code: i64, message: impl Into, data: Option) -> Self { - StreamItem::Result(JsonRpcResponse { - jsonrpc: "2.0".into(), +impl Response { + pub fn ok(id: impl Into, result: Value) -> Self { + Self { id: id.into(), - outcome: JsonRpcOutcome::Err { error: JsonRpcError { code, message: message.into(), data } }, - }) + result: Some(result), + error: None, + } + } + + pub fn err( + id: impl Into, + code: i32, + message: impl Into, + data: Option, + ) -> Self { + Self { + id: id.into(), + result: None, + error: Some(ErrorObj { + code, + message: message.into(), + data, + }), + } } }