Skip to main content

standard_plugin_cli/
lib.rs

1//! `standard-plugin`: scaffold, build, check and pack Standard Code plugins
2//! (runtime v2, `standard:plugin@2.0.0`). Also runs as `cargo standard-plugin`.
3
4pub mod build;
5pub mod check;
6pub mod component;
7pub mod guide;
8pub mod install;
9pub mod pack;
10pub mod project;
11pub mod scaffold;
12pub mod world;
13
14use std::ffi::OsString;
15use std::path::{Path, PathBuf};
16use std::process::ExitCode;
17
18use clap::{Parser, Subcommand};
19
20use crate::project::Plugin;
21use crate::scaffold::{NewKind, SdkSource};
22
23#[derive(Debug, Parser)]
24#[command(name = "standard-plugin", version, about)]
25struct Cli {
26    #[command(subcommand)]
27    command: Command,
28}
29
30#[derive(Debug, Subcommand)]
31enum Command {
32    /// Create a plugin crate with a manifest whose grants carry reasons.
33    New {
34        /// The plugin id: lowercase letters, digits and dashes.
35        name: String,
36        #[arg(long, value_enum, default_value = "ui")]
37        kind: NewKind,
38        /// Where to create it (default: the current directory).
39        #[arg(long, default_value = ".")]
40        dir: PathBuf,
41        /// Depend on the SDK at this path instead of the default.
42        #[arg(long)]
43        sdk_path: Option<PathBuf>,
44    },
45    /// Build the component for wasm32-wasip2 into bundle/, then check it.
46    Build {
47        #[arg(long, default_value = ".")]
48        dir: PathBuf,
49        /// Build the dev profile instead of release.
50        #[arg(long)]
51        debug: bool,
52    },
53    /// Check the manifest, its grants and the built component.
54    Check {
55        #[arg(long, default_value = ".")]
56        dir: PathBuf,
57        /// Treat warnings as errors.
58        #[arg(long)]
59        deny_warnings: bool,
60    },
61    /// Pack the plugin (or a companion pair) into the deterministic account
62    /// package and print its hash.
63    Pack {
64        #[arg(long, default_value = ".")]
65        dir: PathBuf,
66        /// Output file (default: target/standard-plugin/<id>-<version>.tar
67        /// in the plugin directory).
68        #[arg(long)]
69        out: Option<PathBuf>,
70    },
71    /// Check, pack and install on the signed-in account (runs `standard
72    /// plugin install`, which shows the grants and asks first).
73    Install {
74        #[arg(long, default_value = ".")]
75        dir: PathBuf,
76    },
77    /// List the SDK's guides, or print one: the guides of the SDK the
78    /// plugin's Cargo.lock resolves, else those of this CLI's SDK.
79    Guide {
80        /// The guide to print (`agent-guide`, `manifest`, ...); lists them
81        /// when absent.
82        name: Option<String>,
83        #[arg(long, default_value = ".")]
84        dir: PathBuf,
85    },
86}
87
88/// Runs the CLI on `args` (the program name first).
89pub fn run(args: impl IntoIterator<Item = OsString>) -> ExitCode {
90    let cli = match Cli::try_parse_from(args) {
91        Ok(cli) => cli,
92        Err(error) => {
93            let _ = error.print();
94            return if error.use_stderr() {
95                ExitCode::from(2)
96            } else {
97                ExitCode::SUCCESS
98            };
99        }
100    };
101    match execute(cli.command) {
102        Ok(true) => ExitCode::SUCCESS,
103        Ok(false) => ExitCode::FAILURE,
104        Err(error) => {
105            eprintln!("error: {error}");
106            ExitCode::FAILURE
107        }
108    }
109}
110
111fn heading(plugin: &Plugin) -> String {
112    match plugin.manifest() {
113        Ok(manifest) => format!(
114            "{} {} ({}) in {}",
115            manifest.id,
116            manifest.version,
117            manifest.kind,
118            plugin.dir.display()
119        ),
120        Err(_) => plugin.dir.display().to_string(),
121    }
122}
123
124/// Checks one plugin's bundle and prints the report; whether it passed.
125fn check_plugin(plugin: &Plugin, deny_warnings: bool) -> Result<bool, String> {
126    let bytes = plugin.manifest_bytes()?;
127    let module = check::check(&bytes, None)
128        .manifest
129        .map(|manifest| plugin.bundle.join(manifest.module));
130    let component = match &module {
131        Some(path) if path.is_file() => {
132            Some(std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?)
133        }
134        _ => None,
135    };
136    let report = check::check(&bytes, component.as_deref());
137    println!("{}", heading(plugin));
138    print!("{report}");
139    if component.is_none() && report.is_ok() {
140        println!("  note: not built yet; the component was not checked (standard-plugin build)");
141    }
142    let ok = report.is_ok() && !(deny_warnings && !report.warnings.is_empty());
143    println!(
144        "  {}: {} error(s), {} warning(s)",
145        if ok { "ok" } else { "failed" },
146        report.errors.len(),
147        report.warnings.len()
148    );
149    Ok(ok)
150}
151
152/// Checks every plugin in `dir`, then packs them as one account package.
153fn pack_dir(dir: &Path, out: Option<&Path>) -> Result<Option<(PathBuf, pack::Packed)>, String> {
154    for plugin in project::plugins(dir)? {
155        if !check_plugin(&plugin, false)? {
156            return Ok(None);
157        }
158    }
159    let packed = pack::package(dir)?;
160    let out = out.map(Path::to_path_buf).unwrap_or_else(|| {
161        dir.join("target/standard-plugin").join(format!(
162            "{}-{}.tar",
163            packed.manifest.id, packed.manifest.version
164        ))
165    });
166    if let Some(parent) = out.parent() {
167        std::fs::create_dir_all(parent)
168            .map_err(|error| format!("{}: {error}", parent.display()))?;
169    }
170    std::fs::write(&out, &packed.bytes).map_err(|error| format!("{}: {error}", out.display()))?;
171    let names = pack::entry_names(&packed.bytes)?;
172    println!(
173        "  packed {} ({} files, {} bytes)\n  sha256 {}",
174        out.display(),
175        names.len(),
176        packed.bytes.len(),
177        pack::content_hash(&packed.bytes)
178    );
179    Ok(Some((out, packed)))
180}
181
182fn execute(command: Command) -> Result<bool, String> {
183    match command {
184        Command::New {
185            name,
186            kind,
187            dir,
188            sdk_path,
189        } => {
190            let sdk = match sdk_path {
191                Some(path) => SdkSource::Path(
192                    path.canonicalize()
193                        .map_err(|error| format!("{}: {error}", path.display()))?,
194                ),
195                None => SdkSource::default_for_this_build(),
196            };
197            let root = scaffold::scaffold(&dir, &name, kind, &sdk).map_err(|e| e.to_string())?;
198            println!("created {}", root.display());
199            println!("next: cd {} && standard-plugin build", root.display());
200            Ok(true)
201        }
202        Command::Build { dir, debug } => {
203            let mut ok = true;
204            for plugin in project::plugins(&dir)? {
205                let built = build::build(&plugin, !debug)?;
206                println!("built {}", built.component.display());
207                match &built.browser {
208                    Ok(path) => println!("browser artifact: {}", path.display()),
209                    Err(reason) => println!("browser artifact: {reason}"),
210                }
211                ok &= check_plugin(&plugin, false)?;
212            }
213            Ok(ok)
214        }
215        Command::Check { dir, deny_warnings } => {
216            let mut ok = true;
217            for plugin in project::plugins(&dir)? {
218                ok &= check_plugin(&plugin, deny_warnings)?;
219            }
220            Ok(ok)
221        }
222        Command::Pack { dir, out } => Ok(pack_dir(&dir, out.as_deref())?.is_some()),
223        Command::Install { dir } => {
224            if pack_dir(&dir, None)?.is_none() {
225                return Ok(false);
226            }
227            // `standard` uploads the package with the signed-in account's
228            // credentials and asks before it installs.
229            Err(install::exec(&dir))
230        }
231        Command::Guide { name, dir } => {
232            let found = guide::guides(&dir);
233            match name {
234                None => {
235                    print!("{}", found.listing());
236                    Ok(true)
237                }
238                Some(name) => match found.find(&name) {
239                    Some(guide) => {
240                        print!("{}", guide.text);
241                        Ok(true)
242                    }
243                    None => Err(format!(
244                        "no guide named {name:?}; `standard-plugin guide` lists them"
245                    )),
246                },
247            }
248        }
249    }
250}