Skip to main content

yosh_plugin_manager/
lib.rs

1pub mod config;
2pub mod github;
3pub mod install;
4pub mod lockfile;
5pub mod metadata_extract;
6pub mod precompile;
7pub mod resolve;
8pub mod runner;
9pub mod scenario;
10pub mod sync;
11pub mod test_host;
12pub mod update;
13pub mod verify;
14
15/// wasmtime bindgen for the `plugin-world` WIT contract.
16///
17/// Path is `wit/` inside this crate. The canonical source lives in
18/// `yosh-plugin-api/wit/`; `build.rs` verifies the bundled copy matches
19/// when built inside the workspace. The copy is required because
20/// `cargo install yosh-plugin-manager` extracts each crate standalone,
21/// so a sibling-relative path (`../yosh-plugin-api/wit`) is unresolvable
22/// from `~/.cargo/registry/src/.../yosh-plugin-manager-<ver>/`.
23///
24/// This is independent from the host's bindgen invocation in
25/// `src/plugin/mod.rs` — the two crates produce separate generated
26/// types, so we cannot share. The host needs `HostContext` as the store
27/// type and full host imports; the manager needs `MetadataCtx` and
28/// deny-only imports.
29pub mod generated {
30    wasmtime::component::bindgen!({
31        path: "wit",
32        world: "plugin-world",
33        async: false,
34    });
35}
36
37use clap::{Parser, Subcommand};
38
39const VERSION: &str = concat!(
40    env!("CARGO_PKG_VERSION"),
41    " (",
42    env!("YOSH_GIT_HASH"),
43    " ",
44    env!("YOSH_BUILD_DATE"),
45    ")"
46);
47
48#[derive(Parser)]
49#[command(name = "yosh-plugin", about = "Manage yosh shell plugins")]
50#[command(version = VERSION)]
51struct Cli {
52    #[command(subcommand)]
53    command: Commands,
54}
55
56#[derive(Subcommand)]
57pub enum RunAction {
58    /// Call `plugin/exec` with the given command and argv.
59    Exec { command: String, args: Vec<String> },
60    /// Call one hook.
61    Hook {
62        #[command(subcommand)]
63        which: HookKind,
64    },
65}
66
67#[derive(Subcommand)]
68pub enum HookKind {
69    PreExec {
70        command_line: String,
71    },
72    PostExec {
73        command_line: String,
74        exit_code: i32,
75    },
76    OnCd {
77        old: String,
78        new: String,
79    },
80    PrePrompt,
81}
82
83#[derive(Copy, Clone, clap::ValueEnum, Debug)]
84pub enum OutputFormat {
85    Human,
86    Json,
87}
88
89fn parse_kv(s: &str) -> Result<(String, String), String> {
90    let (k, v) = s
91        .split_once('=')
92        .ok_or_else(|| format!("expected KEY=VALUE, got `{}`", s))?;
93    Ok((k.to_string(), v.to_string()))
94}
95
96#[derive(Subcommand)]
97enum Commands {
98    /// Install plugins from plugins.toml
99    Sync {
100        /// Remove plugins not in plugins.toml
101        #[arg(long)]
102        prune: bool,
103    },
104    /// Update installed plugins to latest version
105    Update {
106        /// Only update the named plugin
107        name: Option<String>,
108    },
109    /// List installed plugins
110    List,
111    /// Verify plugin integrity (SHA-256)
112    Verify,
113    /// Add a plugin from a GitHub URL or local path to plugins.toml
114    Install {
115        /// GitHub URL (https://github.com/owner/repo[@version]) or local file path
116        source: String,
117        /// Overwrite existing plugin with the same name
118        #[arg(long)]
119        force: bool,
120    },
121    /// Run a single exec / hook against a plugin wasm with an in-memory host.
122    Run {
123        /// Path to the wasm component.
124        wasm: std::path::PathBuf,
125        #[command(subcommand)]
126        action: RunAction,
127        /// Capabilities to grant (comma-separated, e.g. `io,variables:read`).
128        /// Defaults to the plugin's declared `required_capabilities`.
129        #[arg(long, value_delimiter = ',')]
130        cap: Vec<String>,
131        /// Seed a shell variable: `--var KEY=VALUE` (repeatable).
132        #[arg(long = "var", value_parser = parse_kv)]
133        vars: Vec<(String, String)>,
134        /// Seed an exported variable.
135        #[arg(long = "export", value_parser = parse_kv)]
136        exports: Vec<(String, String)>,
137        /// Virtual cwd.
138        #[arg(long, default_value = ".")]
139        cwd: std::path::PathBuf,
140        /// Allowlist pattern for `commands:exec` (repeatable).
141        #[arg(long = "allow-exec")]
142        allow_exec: Vec<String>,
143        /// If set, files:* operate on the real FS scoped here.
144        #[arg(long = "sandbox-root")]
145        sandbox_root: Option<std::path::PathBuf>,
146        /// Watchdog deadline in milliseconds.
147        #[arg(long, default_value_t = 5000)]
148        timeout: u64,
149        /// Output format.
150        #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
151        format: OutputFormat,
152    },
153    /// Run declarative scenarios (TOML) from a directory.
154    Test {
155        /// Directory or single file. Default: `tests/`.
156        #[arg(default_value = "tests")]
157        path: std::path::PathBuf,
158        /// Regex filter over the scenario file path.
159        #[arg(long)]
160        filter: Option<String>,
161        #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
162        format: OutputFormat,
163    },
164}
165
166pub fn run() -> i32 {
167    let cli = Cli::parse();
168    match cli.command {
169        Commands::Sync { prune } => cmd_sync(prune),
170        Commands::Update { name } => cmd_update(name.as_deref()),
171        Commands::List => cmd_list(),
172        Commands::Verify => cmd_verify(),
173        Commands::Install { source, force } => cmd_install(&source, force),
174        Commands::Run {
175            wasm,
176            action,
177            cap,
178            vars,
179            exports,
180            cwd,
181            allow_exec,
182            sandbox_root,
183            timeout,
184            format,
185        } => cmd_run(
186            wasm,
187            action,
188            cap,
189            vars,
190            exports,
191            cwd,
192            allow_exec,
193            sandbox_root,
194            timeout,
195            format,
196        ),
197        Commands::Test {
198            path,
199            filter,
200            format,
201        } => cmd_test(path, filter, format),
202    }
203}
204
205fn cmd_test(path: std::path::PathBuf, filter: Option<String>, format: OutputFormat) -> i32 {
206    let reports = crate::scenario::run_dir(&path, filter.as_deref());
207    let all_passed = reports.iter().all(|r| r.passed());
208    match format {
209        OutputFormat::Human => print!("{}", crate::scenario::format_summary_human(&reports)),
210        OutputFormat::Json => print!("{}", crate::scenario::format_summary_json(&reports)),
211    }
212    if all_passed { 0 } else { 1 }
213}
214
215#[allow(clippy::too_many_arguments)]
216fn cmd_run(
217    wasm: std::path::PathBuf,
218    action: RunAction,
219    cap: Vec<String>,
220    vars: Vec<(String, String)>,
221    exports: Vec<(String, String)>,
222    cwd: std::path::PathBuf,
223    allow_exec: Vec<String>,
224    sandbox_root: Option<std::path::PathBuf>,
225    timeout: u64,
226    format: OutputFormat,
227) -> i32 {
228    use crate::runner::{
229        HookCall, format_human, format_json, invoke_exec, invoke_hook, load_plugin,
230    };
231    use crate::test_host::TestState;
232    use yosh_plugin_api::pattern::CommandPattern;
233    use yosh_plugin_api::{capabilities_to_bitflags, parse_capability};
234
235    // Build TestState.
236    let mut state = TestState::default();
237    let parsed_caps: Vec<_> = cap.iter().filter_map(|s| parse_capability(s)).collect();
238    state.caps = if cap.is_empty() {
239        // Fall back to plugin-declared capabilities. We need them from
240        // the cached metadata, which requires reading plugins.lock OR
241        // running metadata_extract. For local-run UX, run metadata_extract
242        // inline on the same wasm bytes.
243        let bytes = match std::fs::read(&wasm) {
244            Ok(b) => b,
245            Err(e) => {
246                eprintln!("yosh-plugin: read {}: {}", wasm.display(), e);
247                return 99;
248            }
249        };
250        let engine = match crate::precompile::make_engine() {
251            Ok(e) => e,
252            Err(e) => {
253                eprintln!("yosh-plugin: engine: {}", e);
254                return 99;
255            }
256        };
257        match crate::metadata_extract::extract(&engine, &bytes) {
258            Ok(m) => {
259                let caps: Vec<_> = m
260                    .required_capabilities
261                    .iter()
262                    .filter_map(|s| parse_capability(s))
263                    .collect();
264                capabilities_to_bitflags(&caps)
265            }
266            Err(e) => {
267                eprintln!("yosh-plugin: metadata: {}", e);
268                return 99;
269            }
270        }
271    } else {
272        capabilities_to_bitflags(&parsed_caps)
273    };
274
275    for (k, v) in vars {
276        state.vars.insert(k, v);
277    }
278    for (k, v) in exports {
279        state.vars.insert(k.clone(), v);
280        state.exported.insert(k);
281    }
282    state.cwd = cwd;
283    state.allow_exec = allow_exec
284        .iter()
285        .filter_map(|p| match CommandPattern::parse(p) {
286            Ok(pat) => Some(pat),
287            Err(e) => {
288                eprintln!(
289                    "yosh-plugin: ignoring invalid --allow-exec pattern {:?}: {}",
290                    p, e
291                );
292                None
293            }
294        })
295        .collect();
296    state.sandbox_root = sandbox_root.map(|p| std::fs::canonicalize(&p).unwrap_or(p));
297
298    let loaded = match load_plugin(&wasm, state, std::time::Duration::from_millis(timeout)) {
299        Ok(l) => l,
300        Err(e) => {
301            eprintln!("yosh-plugin: {}", e);
302            return 99;
303        }
304    };
305
306    let outcome = match action {
307        RunAction::Exec { command, args } => invoke_exec(loaded, &command, &args),
308        RunAction::Hook { which } => {
309            let call = match which {
310                HookKind::PreExec { command_line } => HookCall::PreExec { command_line },
311                HookKind::PostExec {
312                    command_line,
313                    exit_code,
314                } => HookCall::PostExec {
315                    command_line,
316                    exit_code,
317                },
318                HookKind::OnCd { old, new } => HookCall::OnCd { old, new },
319                HookKind::PrePrompt => HookCall::PrePrompt,
320            };
321            invoke_hook(loaded, call)
322        }
323    };
324
325    match format {
326        OutputFormat::Human => print!("{}", format_human(&outcome)),
327        OutputFormat::Json => println!("{}", format_json(&outcome)),
328    }
329
330    match outcome.error_kind {
331        Some(_) => 99,
332        None => outcome.exit_code.unwrap_or(0),
333    }
334}
335
336fn cmd_install(source: &str, force: bool) -> i32 {
337    let config_path = sync::config_path();
338    match install::install(source, force, &config_path, None) {
339        Ok(msg) => {
340            eprintln!("{}", msg);
341            if source.starts_with("https://github.com/") {
342                eprintln!("Run 'yosh plugin sync' to download.");
343            }
344            0
345        }
346        Err(e) => {
347            eprintln!("yosh-plugin: {}", e);
348            1
349        }
350    }
351}
352
353fn cmd_sync(prune: bool) -> i32 {
354    let result = match sync::sync(prune) {
355        Ok(r) => r,
356        Err(e) => {
357            eprintln!("yosh-plugin: {}", e);
358            return 2;
359        }
360    };
361
362    for name in &result.succeeded {
363        eprintln!("  \u{2713} {}", name);
364    }
365    for (name, err) in &result.failed {
366        eprintln!("  \u{2717} {}: {}", name, err);
367    }
368
369    if result.failed.is_empty() {
370        eprintln!(
371            "yosh-plugin: sync complete ({} plugins)",
372            result.succeeded.len()
373        );
374        0
375    } else {
376        eprintln!(
377            "yosh-plugin: sync partial ({} succeeded, {} failed)",
378            result.succeeded.len(),
379            result.failed.len()
380        );
381        1
382    }
383}
384
385fn cmd_update(name_filter: Option<&str>) -> i32 {
386    let config_path = sync::config_path();
387    let client = github::GitHubClient::new();
388    let outcome = match update::update(&config_path, name_filter, &client) {
389        Ok(o) => o,
390        Err(e) => {
391            eprintln!("yosh-plugin: {}", e);
392            return 2;
393        }
394    };
395
396    for result in &outcome.results {
397        match &result.status {
398            update::UpdateStatus::Updated { from, to } => {
399                eprintln!("  {} {} \u{2192} {}", result.name, from, to);
400            }
401            update::UpdateStatus::AlreadyLatest { current } => {
402                eprintln!("  {} {} (already latest)", result.name, current);
403            }
404            update::UpdateStatus::Failed(e) => {
405                eprintln!("  \u{2717} {}: {}", result.name, e);
406            }
407            update::UpdateStatus::Skipped(_) => {
408                // Silent: matches HEAD's behavior of not surfacing
409                // name_filter mismatches or local-source skips.
410            }
411        }
412    }
413
414    if outcome.any_updated {
415        return cmd_sync(false);
416    }
417
418    0
419}
420
421fn cmd_list() -> i32 {
422    let lock_path = sync::lock_path();
423    let lockfile = match lockfile::load_lockfile(&lock_path) {
424        Ok(l) => l,
425        Err(e) => {
426            eprintln!("yosh-plugin: {}", e);
427            return 2;
428        }
429    };
430
431    if lockfile.plugin.is_empty() {
432        eprintln!("no plugins installed (run 'yosh-plugin sync' first)");
433        return 0;
434    }
435
436    for entry in &lockfile.plugin {
437        let version = entry.version.as_deref().unwrap_or("-");
438        let verified =
439            match verify::verify_checksum(&config::expand_tilde_path(&entry.path), &entry.sha256) {
440                Ok(true) => "\u{2713} verified",
441                Ok(false) => "\u{2717} checksum mismatch",
442                Err(_) => "\u{2717} file missing",
443            };
444        // "cached" reflects whether a precompiled cwasm is present AND
445        // matches the manager's pinned wasmtime version. A mismatched
446        // version means the host will fall back to in-memory precompile
447        // at startup — not a hard failure, but worth surfacing here so
448        // the user can re-sync.
449        let cached = match (&entry.cwasm_path, &entry.wasmtime_version) {
450            (Some(p), Some(wv))
451                if std::path::Path::new(&config::expand_tilde_path(p)).exists()
452                    && wv == precompile::WASMTIME_VERSION =>
453            {
454                "\u{2713} cached"
455            }
456            _ => "\u{2717} stale",
457        };
458        let caps = entry
459            .required_capabilities
460            .as_ref()
461            .map(|v| {
462                if v.is_empty() {
463                    "[- (no capabilities)]".to_string()
464                } else {
465                    format!("[{}]", v.join(", "))
466                }
467            })
468            .unwrap_or_else(|| "[?]".into());
469        println!(
470            "{:<16} {:<8} {:<48} {} {} {}",
471            entry.name, version, entry.source, verified, cached, caps
472        );
473    }
474
475    0
476}
477
478fn cmd_verify() -> i32 {
479    let lock_path = sync::lock_path();
480    let lockfile = match lockfile::load_lockfile(&lock_path) {
481        Ok(l) => l,
482        Err(e) => {
483            eprintln!("yosh-plugin: {}", e);
484            return 2;
485        }
486    };
487
488    let mut all_ok = true;
489    for entry in &lockfile.plugin {
490        let path = config::expand_tilde_path(&entry.path);
491        match verify::verify_checksum(&path, &entry.sha256) {
492            Ok(true) => {
493                eprintln!("  \u{2713} {}", entry.name);
494            }
495            Ok(false) => {
496                eprintln!("  \u{2717} {}: checksum mismatch", entry.name);
497                all_ok = false;
498            }
499            Err(e) => {
500                eprintln!("  \u{2717} {}: {}", entry.name, e);
501                all_ok = false;
502            }
503        }
504    }
505
506    if all_ok { 0 } else { 1 }
507}