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
15pub 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 Exec { command: String, args: Vec<String> },
60 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 Sync {
100 #[arg(long)]
102 prune: bool,
103 },
104 Update {
106 name: Option<String>,
108 },
109 List,
111 Verify,
113 Install {
115 source: String,
117 #[arg(long)]
119 force: bool,
120 },
121 Run {
123 wasm: std::path::PathBuf,
125 #[command(subcommand)]
126 action: RunAction,
127 #[arg(long, value_delimiter = ',')]
130 cap: Vec<String>,
131 #[arg(long = "var", value_parser = parse_kv)]
133 vars: Vec<(String, String)>,
134 #[arg(long = "export", value_parser = parse_kv)]
136 exports: Vec<(String, String)>,
137 #[arg(long, default_value = ".")]
139 cwd: std::path::PathBuf,
140 #[arg(long = "allow-exec")]
142 allow_exec: Vec<String>,
143 #[arg(long = "sandbox-root")]
145 sandbox_root: Option<std::path::PathBuf>,
146 #[arg(long, default_value_t = 5000)]
148 timeout: u64,
149 #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
151 format: OutputFormat,
152 },
153 Test {
155 #[arg(default_value = "tests")]
157 path: std::path::PathBuf,
158 #[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 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 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 }
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 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}