merge: plugin authoring cli

This commit is contained in:
Keisuke Hirata 2026-06-20 15:50:55 +09:00
commit 0430ed982d
No known key found for this signature in database
6 changed files with 1364 additions and 24 deletions

View File

@ -45,6 +45,12 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
),
},
PluginTemplateResource {
path: "plugin.component.wasm",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm"
),
},
PluginTemplateResource {
path: "README.md",
contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
@ -518,6 +524,23 @@ pub struct DiscoveredPluginPackage {
pub entries: BTreeSet<String>,
}
/// Fully materialized package content used by local authoring checks and pack.
///
/// This is data-only metadata and bytes. Constructing it parses manifests and
/// validates package/archive shape, but it does not load, instantiate, or
/// execute Plugin code.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MaterializedPluginPackage {
pub package: DiscoveredPluginPackage,
pub files: BTreeMap<String, Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackedPluginPackage {
pub output_path: PathBuf,
pub package: DiscoveredPluginPackage,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PluginDiscoveryReport {
pub packages: Vec<DiscoveredPluginPackage>,
@ -1362,7 +1385,146 @@ fn read_package(
.with_source(source)
.with_package(label)
})?;
let archive = parse_stored_zip(&bytes, label, source, limits)?;
materialize_archive(path, label, source, &bytes, limits)
.map(|materialized| materialized.package)
}
pub fn read_plugin_package_file(
path: &Path,
source: PluginSourceKind,
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let label = package_label(path);
let metadata = fs::metadata(path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
if !metadata.is_file() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Discovery,
"plugin package candidate is not a regular file",
)
.with_source(source)
.with_package(label));
}
if metadata.len() > limits.max_package_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package exceeds the configured package size bound",
)
.with_source(source)
.with_package(label));
}
let bytes = fs::read(path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package content could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
materialize_archive(path, &label, source, &bytes, limits)
}
pub fn read_plugin_directory(
path: &Path,
source: PluginSourceKind,
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let label = package_label(path);
let root = fs::canonicalize(path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
let metadata = fs::metadata(&root).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
if !metadata.is_dir() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Discovery,
"plugin directory input is not a directory",
)
.with_source(source)
.with_package(label));
}
let mut files = BTreeMap::new();
collect_directory_files(&root, &root, &label, source, limits, &mut files)?;
materialize_files(path, label, source, files, limits)
}
pub fn write_plugin_package_file(
materialized: &MaterializedPluginPackage,
output_path: &Path,
limits: &PluginDiscoveryLimits,
) -> Result<PackedPluginPackage, PluginDiagnostic> {
write_stored_zip_file(output_path, &materialized.files, limits)?;
let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?;
Ok(PackedPluginPackage {
output_path: output_path.to_path_buf(),
package: package.package,
})
}
impl DiscoveredPluginPackage {
pub fn source(&self) -> PluginSourceKind {
self.identity.source
}
}
fn materialize_archive(
path: &Path,
label: &str,
source: PluginSourceKind,
bytes: &[u8],
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let archive = parse_stored_zip(bytes, label, source, limits)?;
materialize_files(path, label.to_string(), source, archive.files, limits)
}
fn materialize_files(
path: &Path,
label: String,
source: PluginSourceKind,
files: BTreeMap<String, Vec<u8>>,
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let archive = StoredArchive {
files: files.clone(),
};
let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Missing,
@ -1370,7 +1532,7 @@ fn read_package(
"plugin package is missing root plugin.toml",
)
.with_source(source)
.with_package(label)
.with_package(label.clone())
})?;
if manifest_bytes.len() > limits.max_manifest_size_bytes {
return Err(PluginDiagnostic::new(
@ -1388,7 +1550,7 @@ fn read_package(
"plugin.toml is not valid UTF-8",
)
.with_source(source)
.with_package(label)
.with_package(label.clone())
})?;
let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| {
PluginDiagnostic::new(
@ -1397,20 +1559,282 @@ fn read_package(
safe_toml_parse_message(&error),
)
.with_source(source)
.with_package(label)
.with_package(label.clone())
})?;
validate_manifest(&manifest, &archive, label, source)?;
validate_manifest(&manifest, &archive, &label, source)?;
let digest = deterministic_digest(&archive.files);
let identity = SourceQualifiedPluginId::new(source, manifest.id.clone());
Ok(DiscoveredPluginPackage {
let package = DiscoveredPluginPackage {
identity,
package_path: path.to_path_buf(),
package_label: label.to_string(),
package_label: label,
digest,
manifest,
entries: archive.files.keys().cloned().collect(),
})
};
Ok(MaterializedPluginPackage { package, files })
}
fn collect_directory_files(
root: &Path,
dir: &Path,
label: &str,
source: PluginSourceKind,
limits: &PluginDiscoveryLimits,
files: &mut BTreeMap<String, Vec<u8>>,
) -> Result<(), PluginDiagnostic> {
let mut entries = fs::read_dir(dir)
.map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory could not be listed: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
entries.sort();
for path in entries {
let metadata = fs::symlink_metadata(&path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory entry metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?;
if metadata.file_type().is_symlink() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory contains a symlink entry",
)
.with_source(source)
.with_package(label.to_string()));
}
if metadata.is_dir() {
collect_directory_files(root, &path, label, source, limits, files)?;
continue;
}
if !metadata.is_file() {
continue;
}
if metadata.len() > limits.max_file_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin directory file exceeds the configured per-file bound",
)
.with_source(source)
.with_package(label.to_string()));
}
let canonical = fs::canonicalize(&path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory file could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?;
if !canonical.starts_with(root) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory file escapes the package root",
)
.with_source(source)
.with_package(label.to_string()));
}
let relative = canonical.strip_prefix(root).map_err(|_| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory file escapes the package root",
)
.with_source(source)
.with_package(label.to_string())
})?;
let normalized = relative
.components()
.map(|component| component.as_os_str().to_str())
.collect::<Option<Vec<_>>>()
.and_then(|parts| normalize_archive_path(&parts.join("/")))
.ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory contains an unsafe relative path",
)
.with_source(source)
.with_package(label.to_string())
})?;
let content = fs::read(&path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory file could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?;
files.insert(normalized, content);
if files.len() > limits.max_entries_per_package {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin directory contains more files than the configured bound",
)
.with_source(source)
.with_package(label.to_string()));
}
let expanded_size = files
.values()
.map(|content| content.len() as u64)
.sum::<u64>();
if expanded_size > limits.max_expanded_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin directory expanded size exceeds the configured bound",
)
.with_source(source)
.with_package(label.to_string()));
}
}
Ok(())
}
fn write_stored_zip_file(
output_path: &Path,
files: &BTreeMap<String, Vec<u8>>,
limits: &PluginDiscoveryLimits,
) -> Result<(), PluginDiagnostic> {
if files.len() > limits.max_entries_per_package {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package contains more entries than the configured bound",
));
}
let mut bytes = Vec::new();
let mut central = Vec::new();
for (name, content) in files {
let name = normalize_archive_path(name).ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin package entry path escapes the archive root",
)
})?;
if content.len() as u64 > limits.max_file_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package entry exceeds the configured per-file bound",
));
}
let local_offset = bytes.len() as u32;
write_u32_vec(&mut bytes, ZIP_LOCAL_FILE);
write_u16_vec(&mut bytes, 20);
write_u16_vec(&mut bytes, 0x0800);
write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED);
write_u16_vec(&mut bytes, 0);
write_u16_vec(&mut bytes, 0);
write_u32_vec(&mut bytes, 0);
write_u32_vec(&mut bytes, content.len() as u32);
write_u32_vec(&mut bytes, content.len() as u32);
write_u16_vec(&mut bytes, name.len() as u16);
write_u16_vec(&mut bytes, 0);
bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(content);
write_u32_vec(&mut central, ZIP_CENTRAL_DIRECTORY);
write_u16_vec(&mut central, 20);
write_u16_vec(&mut central, 20);
write_u16_vec(&mut central, 0x0800);
write_u16_vec(&mut central, ZIP_COMPRESSION_STORED);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u32_vec(&mut central, 0);
write_u32_vec(&mut central, content.len() as u32);
write_u32_vec(&mut central, content.len() as u32);
write_u16_vec(&mut central, name.len() as u16);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u32_vec(&mut central, 0);
write_u32_vec(&mut central, local_offset);
central.extend_from_slice(name.as_bytes());
}
let central_offset = bytes.len() as u32;
bytes.extend_from_slice(&central);
write_u32_vec(&mut bytes, ZIP_EOCD);
write_u16_vec(&mut bytes, 0);
write_u16_vec(&mut bytes, 0);
write_u16_vec(&mut bytes, files.len() as u16);
write_u16_vec(&mut bytes, files.len() as u16);
write_u32_vec(&mut bytes, central.len() as u32);
write_u32_vec(&mut bytes, central_offset);
write_u16_vec(&mut bytes, 0);
if bytes.len() as u64 > limits.max_package_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package exceeds the configured package size bound",
));
}
if let Some(parent) = output_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package output directory could not be created: {}",
safe_io_error(&error)
),
)
})?;
}
fs::write(output_path, bytes).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package output could not be written: {}",
safe_io_error(&error)
),
)
})?;
Ok(())
}
fn write_u16_vec(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32_vec(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn validate_manifest(

View File

@ -429,10 +429,58 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
"yoi plugin requires `list` or `show <ref>`".to_string(),
"yoi plugin requires `new`, `check`, `pack`, `list`, or `show <ref>`".to_string(),
));
};
match subcommand.as_str() {
"new" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
match positional.as_slice() {
[template, destination] => Ok(plugin_cli::PluginCliCommand::New {
template: template.clone(),
destination: PathBuf::from(destination),
args: plugin_args,
}),
[] | [_] => Err(ParseError(
"yoi plugin new requires a template and destination".to_string(),
)),
_ => Err(ParseError(
"yoi plugin new accepts exactly a template and destination".to_string(),
)),
}
}
"check" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
match positional.as_slice() {
[input] => Ok(plugin_cli::PluginCliCommand::Check {
input: PathBuf::from(input),
args: plugin_args,
}),
[] => Err(ParseError(
"yoi plugin check requires a plugin directory or .yoi-plugin path".to_string(),
)),
_ => Err(ParseError(
"yoi plugin check accepts exactly one plugin directory or .yoi-plugin path"
.to_string(),
)),
}
}
"pack" => {
let (plugin_args, positional, output) = parse_plugin_pack_args(rest)?;
match positional.as_slice() {
[input] => Ok(plugin_cli::PluginCliCommand::Pack {
input: PathBuf::from(input),
output,
args: plugin_args,
}),
[] => Err(ParseError(
"yoi plugin pack requires a plugin directory".to_string(),
)),
_ => Err(ParseError(
"yoi plugin pack accepts exactly one plugin directory".to_string(),
)),
}
}
"list" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
if !positional.is_empty() {
@ -513,8 +561,36 @@ fn parse_plugin_common_args(
Ok((parsed, positional))
}
fn parse_plugin_pack_args(
args: &[String],
) -> Result<(plugin_cli::PluginCliArgs, Vec<String>, Option<PathBuf>), ParseError> {
let mut normalized = Vec::new();
let mut output = None;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--output" {
index += 1;
let Some(value) = args.get(index) else {
return Err(ParseError("--output requires a value".to_string()));
};
output = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--output=") {
if value.is_empty() {
return Err(ParseError("--output requires a value".to_string()));
}
output = Some(PathBuf::from(value));
} else {
normalized.push(arg.clone());
}
index += 1;
}
let (plugin_args, positional) = parse_plugin_common_args(&normalized)?;
Ok((plugin_args, positional, output))
}
fn plugin_usage() -> &'static str {
"usage: yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
"usage: yoi plugin new rust-component-tool <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
}
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
@ -547,7 +623,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}

View File

@ -2,13 +2,17 @@ use std::collections::BTreeMap;
use std::error::Error;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use manifest::plugin::{
PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiscoveryLimits,
PluginDiscoveryOptions, PluginDiscoveryReport, PluginPackageManifest, PluginPermission,
PluginResolution, PluginSourceKind, PluginSurface, ResolvedPlugin, ResolvedPluginRecord,
SourceQualifiedPluginId, discover_plugins, resolve_enabled_plugins,
MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind,
PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport,
PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission,
PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE,
ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins,
read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins,
write_plugin_package_file,
};
use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths};
use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static};
@ -35,17 +39,607 @@ pub(crate) enum PluginCliCommand {
reference: String,
args: PluginCliArgs,
},
New {
template: String,
destination: PathBuf,
args: PluginCliArgs,
},
Check {
input: PathBuf,
args: PluginCliArgs,
},
Pack {
input: PathBuf,
output: Option<PathBuf>,
args: PluginCliArgs,
},
}
pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
if let PluginCliCommand::Check { input, args } = command {
let report = build_check_report(&input);
let rendered = render_check_report(&report, &args)?;
print!("{rendered}");
if report.status == "rejected" {
return Err("plugin check failed; see diagnostics above".into());
}
return Ok(());
}
let rendered = match command {
PluginCliCommand::List(args) => render_list(&args)?,
PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?,
PluginCliCommand::New {
template,
destination,
args,
} => render_new(&template, &destination, &args)?,
PluginCliCommand::Check { .. } => unreachable!("handled above"),
PluginCliCommand::Pack {
input,
output,
args,
} => render_pack(&input, output.as_deref(), &args)?,
};
print!("{rendered}");
Ok(())
}
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
if template != "rust-component-tool" {
return Err(format!(
"unsupported plugin template `{template}` (supported: rust-component-tool)"
)
.into());
}
materialize_template(destination)?;
let report = NewReport {
command: "new",
template: "rust-component-tool",
destination: destination.display().to_string(),
files: RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.map(|resource| resource.path.to_string())
.collect(),
safety: AuthoringSafetyReport::default(),
next_steps: vec![
"Review plugin.toml and generated Rust source.".to_string(),
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
],
};
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
}
render_new_human(&report)
}
fn materialize_template(destination: &Path) -> Result<()> {
match fs::symlink_metadata(destination) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!(
"plugin template destination `{}` is a symlink; refusing to follow it",
destination.display()
)
.into());
}
if !metadata.is_dir() {
return Err(format!(
"plugin template destination `{}` already exists and is not a directory",
destination.display()
)
.into());
}
if fs::read_dir(destination)?.next().is_some() {
return Err(format!(
"plugin template destination `{}` is not empty",
destination.display()
)
.into());
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir_all(destination)?;
}
Err(error) => return Err(error.into()),
}
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
let relative = safe_template_relative_path(resource.path)?;
let path = destination.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, resource.contents)?;
}
Ok(())
}
fn safe_template_relative_path(path: &str) -> Result<&Path> {
let relative = Path::new(path);
if relative.is_absolute()
|| relative
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(format!("embedded plugin template path `{path}` is unsafe").into());
}
Ok(relative)
}
#[cfg(test)]
fn render_check(input: &Path, args: &PluginCliArgs) -> Result<String> {
let report = build_check_report(input);
render_check_report(&report, args)
}
fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result<String> {
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(report)?));
}
render_check_human(report)
}
fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Result<String> {
let limits = PluginDiscoveryLimits::default();
let materialized = read_plugin_directory(input, PluginSourceKind::Project, &limits)
.map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?;
let output_path = output
.map(Path::to_path_buf)
.unwrap_or_else(|| default_package_output_path(input));
let packed = write_plugin_package_file(&materialized, &output_path, &limits)
.map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?;
let report = PackReport {
command: "pack",
status: "packed",
input_path: input.display().to_string(),
output_path: packed.output_path.display().to_string(),
package: PackageReport::from_materialized(&MaterializedPluginPackage {
package: packed.package,
files: materialized.files,
}),
safety: AuthoringSafetyReport::default(),
};
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
}
render_pack_human(&report)
}
fn default_package_output_path(input: &Path) -> PathBuf {
let name = input
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("plugin");
input.with_file_name(format!("{name}.yoi-plugin"))
}
fn build_check_report(input: &Path) -> CheckReport {
let limits = PluginDiscoveryLimits::default();
let input_kind = if input.is_dir() {
"directory"
} else {
"package"
};
let result = if input.is_dir() {
read_plugin_directory(input, PluginSourceKind::Project, &limits)
} else {
read_plugin_package_file(input, PluginSourceKind::Project, &limits)
};
match result {
Ok(materialized) => {
let static_inspection = inspect_materialized_package(&materialized);
let static_diagnostics = static_inspection_diagnostics(&static_inspection);
let placeholder_diagnostic = placeholder_component_diagnostic(&materialized);
let status = if !static_diagnostics.is_empty() {
"rejected"
} else if placeholder_diagnostic.is_some() {
"partial"
} else {
"active"
};
let mut diagnostics = static_diagnostics;
diagnostics.extend(placeholder_diagnostic);
let reference = package_reference(&materialized.package.identity);
CheckReport {
command: "check",
status,
input_path: input.display().to_string(),
input_kind,
package: Some(PackageReport::from_materialized(&materialized)),
diagnostics,
static_inspection: Some(StaticInspectionReport::from_inspection(
&static_inspection,
)),
safety: AuthoringSafetyReport::default(),
next_steps: check_next_steps(status, &reference),
}
}
Err(diagnostic) => CheckReport {
command: "check",
status: "rejected",
input_path: input.display().to_string(),
input_kind,
package: None,
diagnostics: vec![PluginDiagnosticReport::from_diagnostic(&diagnostic)],
static_inspection: None,
safety: AuthoringSafetyReport::default(),
next_steps: vec![
"Fix the reported package diagnostic and run `yoi plugin check` again.".to_string(),
],
},
}
}
fn inspect_materialized_package(
materialized: &MaterializedPluginPackage,
) -> PluginStaticInspection {
let requested_permissions = materialized.package.manifest.permissions.clone();
let record = ResolvedPluginRecord {
identity: materialized.package.identity.clone(),
source: materialized.package.identity.source,
package_path: materialized.package.package_path.clone(),
package_label: materialized.package.package_label.clone(),
digest: materialized.package.digest.clone(),
version: materialized.package.manifest.version.clone(),
manifest: materialized.package.manifest.clone(),
enabled_surfaces: materialized.package.manifest.surfaces.clone(),
grants: PluginGrantConfig {
id: Some(materialized.package.identity.to_string()),
version: Some(PluginExactVersion(
materialized.package.manifest.version.clone(),
)),
digest: Some(materialized.package.digest.clone()),
permissions: requested_permissions,
https: Vec::new(),
fs: Vec::new(),
},
config: None,
};
inspect_resolved_plugin_static(&record)
}
fn static_inspection_diagnostics(
inspection: &PluginStaticInspection,
) -> Vec<PluginDiagnosticReport> {
let mut diagnostics = Vec::new();
if let Some(message) = &inspection.runtime.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "malformed".to_string(),
phase: "resolution".to_string(),
message: bound_text(message.clone()),
});
}
for api in &inspection.host_apis {
if let Some(message) = &api.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "grant".to_string(),
phase: "resolution".to_string(),
message: bound_text(message.clone()),
});
}
}
for tool in &inspection.tools {
if let Some(message) = &tool.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "malformed".to_string(),
phase: "resolution".to_string(),
message: bound_text(message.clone()),
});
}
}
diagnostics
}
fn placeholder_component_diagnostic(
materialized: &MaterializedPluginPackage,
) -> Option<PluginDiagnosticReport> {
let runtime = materialized.package.manifest.runtime.as_ref()?;
let component = runtime.component.as_deref()?;
let component_bytes = materialized.files.get(component)?;
let placeholder_bytes = RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.find(|resource| resource.path == "plugin.component.wasm")?
.contents
.as_bytes();
if component_bytes != placeholder_bytes {
return None;
}
Some(PluginDiagnosticReport {
kind: "placeholder".to_string(),
phase: "runtime".to_string(),
message: format!(
"plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before enabling or execution"
),
})
}
fn check_next_steps(status: &str, reference: &str) -> Vec<String> {
match status {
"active" => vec![
"Package metadata is valid without executing Plugin code.".to_string(),
format!(
"To enable after review, add an explicit plugin enablement entry for `{reference}` with matching digest and grants."
),
"Run `yoi plugin pack <path>` to create a deterministic .yoi-plugin archive."
.to_string(),
],
"partial" => vec![
"Replace the generated placeholder component artifact with a real built component."
.to_string(),
"Run `yoi plugin check <path>` again before enabling or execution.".to_string(),
"Do not enable this Plugin while check status is partial.".to_string(),
],
_ => {
vec!["Fix the reported diagnostics before enabling or packing this Plugin.".to_string()]
}
}
}
fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String {
format!("{context} failed: {}", diagnostic.message)
}
fn render_new_human(report: &NewReport) -> Result<String> {
let mut out = String::new();
writeln!(
out,
"created plugin template `{}` at {}",
report.template, report.destination
)?;
writeln!(out, "files:")?;
for file in &report.files {
writeln!(out, " - {file}")?;
}
writeln!(
out,
"safety: no network; embedded template only; no secrets generated"
)?;
writeln!(out, "next steps:")?;
for step in &report.next_steps {
writeln!(out, " - {step}")?;
}
Ok(out)
}
fn render_check_human(report: &CheckReport) -> Result<String> {
let mut out = String::new();
writeln!(
out,
"plugin check: {} [{}] input_kind={}",
report.input_path, report.status, report.input_kind
)?;
if let Some(package) = &report.package {
writeln!(
out,
"package: {} version={} digest={} entries={} source={} surfaces={} tools={}",
package.reference,
package.version,
package.digest,
package.entries.len(),
package.source,
join_or_none(&package.surfaces),
package.tools.len()
)?;
match report.status {
"active" => writeln!(
out,
"enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config",
package.reference, package.digest
)?,
"partial" => writeln!(
out,
"enablement guidance: not ready to enable; replace the generated placeholder component and rerun check; this command does not mutate config"
)?,
_ => writeln!(
out,
"enablement guidance: not ready to enable; fix diagnostics first; this command does not mutate config"
)?,
}
}
if report.diagnostics.is_empty() {
writeln!(out, "diagnostics: none")?;
} else {
writeln!(out, "diagnostics:")?;
for diagnostic in &report.diagnostics {
writeln!(
out,
" - kind={} phase={} message={}",
diagnostic.kind, diagnostic.phase, diagnostic.message
)?;
}
}
writeln!(
out,
"safety: no Plugin execution; no enablement config mutation; no secrets generated"
)?;
writeln!(out, "next steps:")?;
for step in &report.next_steps {
writeln!(out, " - {step}")?;
}
Ok(out)
}
fn render_pack_human(report: &PackReport) -> Result<String> {
let mut out = String::new();
writeln!(
out,
"plugin pack: {} [{}]",
report.output_path, report.status
)?;
writeln!(
out,
"package: {} version={} digest={} entries={}",
report.package.reference,
report.package.version,
report.package.digest,
report.package.entries.len()
)?;
writeln!(
out,
"safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation"
)?;
Ok(out)
}
#[derive(Serialize)]
struct AuthoringSafetyReport {
no_network: bool,
no_plugin_execution: bool,
no_enablement_config_mutation: bool,
no_secrets_generated: bool,
}
impl Default for AuthoringSafetyReport {
fn default() -> Self {
Self {
no_network: true,
no_plugin_execution: true,
no_enablement_config_mutation: true,
no_secrets_generated: true,
}
}
}
#[derive(Serialize)]
struct NewReport {
command: &'static str,
template: &'static str,
destination: String,
files: Vec<String>,
safety: AuthoringSafetyReport,
next_steps: Vec<String>,
}
#[derive(Serialize)]
struct CheckReport {
command: &'static str,
status: &'static str,
input_path: String,
input_kind: &'static str,
package: Option<PackageReport>,
diagnostics: Vec<PluginDiagnosticReport>,
static_inspection: Option<StaticInspectionReport>,
safety: AuthoringSafetyReport,
next_steps: Vec<String>,
}
#[derive(Serialize)]
struct PackReport {
command: &'static str,
status: &'static str,
input_path: String,
output_path: String,
package: PackageReport,
safety: AuthoringSafetyReport,
}
#[derive(Serialize)]
struct PackageReport {
reference: String,
package: String,
source: String,
version: String,
schema_version: u32,
digest: String,
package_path: String,
entries: Vec<String>,
surfaces: Vec<String>,
tools: Vec<String>,
permissions: Vec<String>,
}
impl PackageReport {
fn from_materialized(materialized: &MaterializedPluginPackage) -> Self {
Self {
reference: package_reference(&materialized.package.identity),
package: materialized.package.manifest.id.clone(),
source: materialized.package.identity.source.to_string(),
version: materialized.package.manifest.version.clone(),
schema_version: materialized.package.manifest.schema_version,
digest: materialized.package.digest.clone(),
package_path: materialized.package.package_path.display().to_string(),
entries: materialized.package.entries.iter().cloned().collect(),
surfaces: materialized
.package
.manifest
.surfaces
.iter()
.map(ToString::to_string)
.collect(),
tools: materialized
.package
.manifest
.tools
.iter()
.map(|tool| tool.name.clone())
.collect(),
permissions: materialized
.package
.manifest
.permissions
.iter()
.map(|permission| permission_name(permission.clone()).to_string())
.collect(),
}
}
}
#[derive(Serialize)]
struct PluginDiagnosticReport {
kind: String,
phase: String,
message: String,
}
impl PluginDiagnosticReport {
fn from_diagnostic(diagnostic: &PluginDiagnostic) -> Self {
Self {
kind: diagnostic_kind(&diagnostic.kind).to_string(),
phase: diagnostic_phase(&diagnostic.phase).to_string(),
message: bound_text(diagnostic.message.clone()),
}
}
}
fn diagnostic_phase(phase: &PluginDiagnosticPhase) -> &'static str {
match phase {
PluginDiagnosticPhase::Discovery => "discovery",
PluginDiagnosticPhase::Manifest => "manifest",
PluginDiagnosticPhase::Resolution => "resolution",
}
}
fn package_reference(identity: &SourceQualifiedPluginId) -> String {
identity.to_string()
}
fn permission_name(permission: PluginPermission) -> String {
permission.label()
}
#[derive(Serialize)]
struct StaticInspectionReport {
status: String,
diagnostics: usize,
}
impl StaticInspectionReport {
fn from_inspection(inspection: &PluginStaticInspection) -> Self {
let diagnostics = static_inspection_diagnostics(inspection).len();
let status = if diagnostics == 0 {
"active"
} else {
"rejected"
};
Self {
status: status.to_string(),
diagnostics,
}
}
}
fn render_list(args: &PluginCliArgs) -> Result<String> {
let snapshot = build_snapshot(args)?;
if args.json {
@ -1254,6 +1848,222 @@ mod tests {
assert!(show_output.contains("eligible=false"));
}
#[test]
fn plugin_new_creates_template_files_and_refuses_non_empty_destination() {
let dir = tempdir().unwrap();
let destination = dir.path().join("my-plugin");
let output = render_new(
"rust-component-tool",
&destination,
&PluginCliArgs::default(),
)
.unwrap();
assert!(output.contains("created plugin template"));
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
assert!(
destination.join(resource.path).is_file(),
"missing {}",
resource.path
);
}
let check_json = render_check(
&destination,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let check_value: serde_json::Value = serde_json::from_str(&check_json).unwrap();
assert_eq!(check_value["status"], "partial");
assert_eq!(check_value["diagnostics"][0]["kind"], "placeholder");
assert!(
check_value["diagnostics"][0]["message"]
.as_str()
.unwrap()
.contains("generated placeholder")
);
assert!(
check_value["next_steps"]
.as_array()
.unwrap()
.iter()
.any(|step| step.as_str().unwrap_or_default().contains("Do not enable"))
);
let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap();
assert!(human_check.contains("[partial]"));
assert!(human_check.contains("not ready to enable"));
let error = render_new(
"rust-component-tool",
&destination,
&PluginCliArgs::default(),
)
.unwrap_err()
.to_string();
assert!(error.contains("not empty"));
}
#[cfg(unix)]
#[test]
fn plugin_new_refuses_symlink_destination_without_following_it() {
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::create_dir_all(&target).unwrap();
let link = dir.path().join("linkdest");
std::os::unix::fs::symlink(&target, &link).unwrap();
let error = render_new("rust-component-tool", &link, &PluginCliArgs::default())
.unwrap_err()
.to_string();
assert!(error.contains("symlink"));
assert!(!target.join("plugin.toml").exists());
}
#[test]
fn plugin_check_accepts_valid_directory_and_reports_json_shape() {
let dir = tempdir().unwrap();
let plugin = dir.path().join("plugin");
fs::create_dir_all(&plugin).unwrap();
fs::write(
plugin.join("plugin.toml"),
plugin_manifest("echo", "echo", "object", &["echo"]),
)
.unwrap();
fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap();
let human = render_check(&plugin, &PluginCliArgs::default()).unwrap();
assert!(human.contains("[active]"));
assert!(human.contains("digest="));
assert!(human.contains("does not mutate config"));
let json = render_check(
&plugin,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["command"], "check");
assert_eq!(value["status"], "active");
assert_eq!(value["input_kind"], "directory");
assert_eq!(value["package"]["reference"], "project:echo");
assert_eq!(value["safety"]["no_plugin_execution"], true);
}
#[test]
fn plugin_check_rejects_invalid_manifest_and_missing_runtime_artifact() {
let dir = tempdir().unwrap();
let invalid = dir.path().join("invalid");
fs::create_dir_all(&invalid).unwrap();
fs::write(
invalid.join("plugin.toml"),
"schema_version = 1\nid = [\"bad\"]\n",
)
.unwrap();
let invalid_json = render_check(
&invalid,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let invalid_value: serde_json::Value = serde_json::from_str(&invalid_json).unwrap();
assert_eq!(invalid_value["status"], "rejected");
assert_eq!(invalid_value["diagnostics"][0]["phase"], "manifest");
let missing = dir.path().join("missing-runtime");
fs::create_dir_all(&missing).unwrap();
fs::write(
missing.join("plugin.toml"),
plugin_manifest_missing_runtime_entry("missing_runtime"),
)
.unwrap();
let missing_output = render_check(&missing, &PluginCliArgs::default()).unwrap();
assert!(missing_output.contains("rejected"));
assert!(missing_output.contains("path not present"));
}
#[test]
fn plugin_check_rejects_unsafe_package_archive() {
let dir = tempdir().unwrap();
let package = dir.path().join("unsafe.yoi-plugin");
write_stored_zip(
&package,
&[
(
"plugin.toml",
plugin_manifest("unsafe", "Echo", "object", &["Echo"]).as_bytes(),
),
("../escape.wasm", b"not wasm"),
],
);
let output = render_check(&package, &PluginCliArgs::default()).unwrap();
assert!(output.contains("rejected"));
assert!(output.contains("escapes"));
}
#[test]
fn plugin_pack_is_deterministic_and_discoverable() {
let dir = tempdir().unwrap();
let plugin = dir.path().join("plugin");
fs::create_dir_all(&plugin).unwrap();
fs::write(
plugin.join("plugin.toml"),
plugin_manifest("echo", "echo", "object", &["echo"]),
)
.unwrap();
fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap();
let first = dir.path().join("first.yoi-plugin");
let second = dir.path().join("second.yoi-plugin");
let first_json = render_pack(
&plugin,
Some(&first),
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let second_json = render_pack(
&plugin,
Some(&second),
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
assert_eq!(fs::read(&first).unwrap(), fs::read(&second).unwrap());
let first_value: serde_json::Value = serde_json::from_str(&first_json).unwrap();
let second_value: serde_json::Value = serde_json::from_str(&second_json).unwrap();
assert_eq!(first_value["command"], "pack");
assert_eq!(first_value["status"], "packed");
assert_eq!(
first_value["package"]["digest"],
second_value["package"]["digest"]
);
let workspace = dir.path().join("workspace");
fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap();
fs::copy(&first, workspace.join(".yoi/plugins/echo.yoi-plugin")).unwrap();
let discovery = discover_plugins(&PluginDiscoveryOptions {
workspace_root: workspace,
user_data_home: None,
limits: PluginDiscoveryLimits::default(),
});
assert_eq!(discovery.packages.len(), 1);
assert_eq!(discovery.packages[0].identity.to_string(), "project:echo");
}
#[test]
fn ambiguous_ref_is_bounded_error() {
let snapshot = PluginInspectionSnapshot {

View File

@ -23,11 +23,11 @@ Implemented foundation:
- first-party Rust PDK helpers for Component Model Tool guests;
- embedded Rust Component Tool starter template;
- `https` and `fs` host APIs for Tool runtime;
- read-only `yoi plugin list/show` inspection.
- read-only `yoi plugin list/show` inspection;
- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`.
Still intentionally separate/future work:
- `yoi plugin new/check/pack` authoring commands;
- multi-language SDK/PDK crates;
- Service / Ingress surfaces;
- WebSocket or inbound HTTP for bidirectional bridges;
@ -54,6 +54,37 @@ A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it w
The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes.
## Authoring CLI
Use the local authoring commands for first-party deterministic authoring. These commands never fetch remote templates, never run Plugin code, never mutate enablement configuration, and never generate or embed secrets.
Create a Rust Component Tool starter from embedded resources:
```bash
yoi plugin new rust-component-tool ./my-plugin
```
`new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin.
Validate a source directory or an existing `.yoi-plugin` archive:
```bash
yoi plugin check ./my-plugin
yoi plugin check ./my-plugin --json
yoi plugin check ./my-plugin.yoi-plugin --json
```
`check` performs bounded static validation of the directory/archive shape, manifest, runtime declaration, referenced artifact presence, Tool schemas, permission declarations, host API declarations, archive safety, and deterministic digest when a package can be materialized. Component-world validation is metadata-only: it verifies the declared world string and runtime manifest shape, but it does not instantiate or execute the component. A generated placeholder component produces `status = "partial"` plus a diagnostic and is not enablement-ready until replaced. Invalid checks print the same structured report and exit non-zero.
Pack a source directory into a deterministic stored `.yoi-plugin` archive:
```bash
yoi plugin pack ./my-plugin
yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json
```
`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you.
## Manifest: `plugin.toml`
A minimal Component Model Tool Plugin manifest looks like this:

View File

@ -26,14 +26,12 @@ If this template is copied elsewhere before crates.io publication exists, pin a
yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" }
```
Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred to later authoring-tooling work.
`plugin.component.wasm` in the template is a text placeholder so `yoi plugin check` and `yoi plugin pack` can exercise deterministic local package validation immediately. Replace it with a real built component before enabling or executing the Plugin.
## Next steps
1. Replace package/plugin ids, names, descriptions, and Tool schema.
2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic.
3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment.
4. Package `plugin.toml` and `plugin.component.wasm` into a `.yoi-plugin` archive.
5. Use `yoi plugin list` / `yoi plugin show` plus focused runtime tests to inspect and validate the package.
The exact component build/pack command is not part of this template yet because deterministic `yoi plugin new/check/pack` authoring commands are a separate planned Ticket.
3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment, replacing the placeholder `plugin.component.wasm`.
4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`.
5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review.

View File

@ -0,0 +1 @@
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.