use std::{
any::Any,
collections::BTreeMap,
env, fs,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, anyhow, bail};
use clap::{Args, Subcommand, ValueEnum};
use lenso_app_plan::{
CapabilityEndpointPlan, ExecutionClassId, PluginInstancePlan, ResolvedAppPlan,
};
use lenso_kernel::{CancellationToken, ExecutionAdapter, InvocationContext, RuntimeFailure};
use lenso_plugin_bundle::{
SourcePluginBuild, VerifiedBundle, build_source_plugin_bundle, extract_plugin_descriptor,
verify_bundle_directory,
};
use lenso_runtime_codec::{ArtifactCatalog, ArtifactHandle, JsonCapabilityCodec};
use lenso_wasm_component_adapter::{EXECUTION_CLASS, WasmComponentAdapter};
use serde::Deserialize;
use serde_json::Value;
const GUEST_SDK_VERSION: &str = "0.2.0";
const WASM_TARGET: &str = "wasm32-unknown-unknown";
#[derive(Clone, Debug, Subcommand)]
pub enum PluginCommand {
New(PluginNewArgs),
Dev(PluginDevArgs),
Check(PluginCheckArgs),
Pack(PluginPackArgs),
}
#[derive(Args, Clone, Debug)]
pub struct PluginNewArgs {
plugin_id: String,
#[arg(long)]
repo_root: Option<PathBuf>,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = PluginRuntimeArg::Rust)]
runtime: PluginRuntimeArg,
#[arg(long)]
no_install: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum PluginRuntimeArg {
Rust,
Bun,
QuickJs,
Process,
NativeDylib,
}
#[derive(Args, Clone, Debug)]
pub struct PluginCheckArgs {
#[arg(long)]
repo_root: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Args, Clone, Debug)]
pub struct PluginDevArgs {
#[arg(long)]
repo_root: Option<PathBuf>,
#[arg(long)]
operation: Option<String>,
#[arg(long, default_value = "{}")]
request_json: String,
#[arg(long)]
json: bool,
}
#[derive(Args, Clone, Debug)]
pub struct PluginPackArgs {
#[arg(long)]
repo_root: Option<PathBuf>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Debug, Deserialize)]
struct CargoDocument {
package: CargoPackage,
}
#[derive(Debug, Deserialize)]
struct CargoPackage {
name: String,
version: String,
metadata: CargoMetadata,
}
#[derive(Debug, Deserialize)]
struct CargoTargetMetadata {
target_directory: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CargoMetadata {
lenso: LensoMetadata,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct LensoMetadata {
plugin_id: String,
}
#[derive(Debug, Deserialize)]
struct PluginDescriptor {
abi: String,
capabilities: Vec<PluginCapability>,
}
#[derive(Debug, Deserialize)]
struct PluginCapability {
capability_id: String,
descriptor_version: String,
request_operations: Vec<String>,
}
pub async fn plugin(command: PluginCommand) -> anyhow::Result<()> {
match command {
PluginCommand::New(args) => create(args),
PluginCommand::Dev(args) => dev(args).await,
PluginCommand::Check(args) => check(args),
PluginCommand::Pack(args) => pack(args),
}
}
fn create(args: PluginNewArgs) -> anyhow::Result<()> {
if args.runtime != PluginRuntimeArg::Rust {
bail!(
"Plugin runtime `{}` is not supported yet; the first public Plugin shape is Rust/Wasm (`--runtime rust`)",
args.runtime
.to_possible_value()
.expect("ValueEnum variant")
.get_name()
);
}
validate_plugin_id(&args.plugin_id)?;
let base = args.repo_root.unwrap_or(env::current_dir()?);
let target = args
.dir
.map_or_else(|| base.join(&args.plugin_id), |dir| base.join(dir));
if target.exists() {
bail!(
"Plugin project directory already exists: {}",
target.display()
);
}
let files = plugin_scaffold(&args.plugin_id);
if args.dry_run {
println!("Plugin dry run for {}:", target.display());
for path in files.keys() {
println!(" {}", path.display());
}
return Ok(());
}
fs::create_dir_all(&base)
.with_context(|| format!("create Plugin project parent {}", base.display()))?;
let staging = tempfile::Builder::new()
.prefix(".lenso-plugin-new-")
.tempdir_in(&base)
.context("create Plugin scaffold staging directory")?;
for (path, contents) in files {
let destination = staging.path().join(path);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::write(destination, contents)?;
}
fs::rename(staging.path(), &target)
.with_context(|| format!("publish Plugin scaffold {}", target.display()))?;
if !args.no_install {
run_cargo(&target, &["generate-lockfile"], "generate Plugin lockfile")?;
run_cargo(
&target,
&["check", "--locked", "--target", WASM_TARGET],
"check generated Plugin",
)?;
}
println!("Created Plugin project at {}.", target.display());
Ok(())
}
#[allow(clippy::too_many_lines)]
fn plugin_scaffold(plugin_id: &str) -> BTreeMap<PathBuf, String> {
let package_name = plugin_id.replace('.', "-");
let input_schema = serde_json::json!({
"additionalProperties": false,
"properties": { "text": { "maxLength": 4096, "type": "string" } },
"required": ["text"],
"type": "object",
})
.to_string();
let catalog = serde_json::json!({
"tools": [{
"name": plugin_id,
"description": "Process one UTF-8 string.",
"input_schema_json": input_schema,
"execution": "parallel_safe",
}],
})
.to_string();
BTreeMap::from([
(
PathBuf::from("Cargo.toml"),
format!(
r#"[package]
name = "{package_name}"
version = "0.1.0"
edition = "2024"
publish = false
[package.metadata.lenso]
plugin-id = "{plugin_id}"
root-slot = "tools"
[lib]
crate-type = ["cdylib"]
[dependencies]
lenso-guest-sdk = "{GUEST_SDK_VERSION}"
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
wit-bindgen = "0.60"
[workspace]
"#
),
),
(
PathBuf::from("src/lib.rs"),
format!(
r###"use serde::{{Deserialize, Serialize}};
wit_bindgen::generate!({{
path: "wit",
world: "plugin",
}});
const CAPABILITY: &str = "lenso.agent.tool-provider@2";
const TOOL: &str = "{plugin_id}";
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ExecuteRequest {{
name: String,
arguments_json: String,
}}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ToolArguments {{
text: String,
}}
#[derive(Serialize)]
struct ExecuteResponse {{
content_type: &'static str,
content: String,
metadata_json: &'static str,
}}
struct PluginComponent;
lenso_guest_sdk::guest_request_plugin! {{
impl Guest for PluginComponent {{
provides: {{
capability_id: "lenso.agent.tool-provider@2",
descriptor_version: "2.0.0",
requests: ["catalog", "execute"],
}}
fn invoke(
capability: String,
operation: String,
request_json: String,
) -> Result<String, String> {{
if capability != CAPABILITY {{
return Err("\"not_found\"".to_owned());
}}
match operation.as_str() {{
"catalog" => Ok(r##"{catalog}"##.to_owned()),
"execute" => {{
let request = serde_json::from_str::<ExecuteRequest>(&request_json)
.map_err(|_| "\"invalid_arguments\"".to_owned())?;
if request.name != TOOL {{
return Err("\"not_found\"".to_owned());
}}
let arguments = serde_json::from_str::<ToolArguments>(&request.arguments_json)
.map_err(|_| "\"invalid_arguments\"".to_owned())?;
serde_json::to_string(&ExecuteResponse {{
content_type: "text",
content: arguments.text,
metadata_json: r#"{{"provider":"external-wasm"}}"#,
}})
.map_err(|_| "\"execution_failed\"".to_owned())
}}
_ => Err("\"not_found\"".to_owned()),
}}
}}
}}
}}
export!(PluginComponent);
"###
),
),
(
PathBuf::from("wit/world.wit"),
"package lenso:runtime@1.0.0;\n\nworld plugin {\n export describe: func() -> string;\n export invoke: func(capability: string, operation: string, request-json: string) -> result<string, string>;\n}\n".to_owned(),
),
(
PathBuf::from("README.md"),
format!(
"# {plugin_id}\n\nRust/Wasm Tool Plugin for the Lenso Agent Harness. Edit `src/lib.rs`, then use one Plugin workflow:\n\n```sh\nlenso plugin check\nlenso plugin dev --operation execute --request-json '{{\"name\":\"{plugin_id}\",\"arguments_json\":\"{{\\\"text\\\":\\\"hello\\\"}}\"}}'\nlenso plugin pack\n```\n\nCreate another project with `lenso plugin new <id>`.\n"
),
),
])
}
fn check(args: PluginCheckArgs) -> anyhow::Result<()> {
let root = project_root(args.repo_root)?;
let temporary = tempfile::tempdir().context("create Plugin check directory")?;
let output = temporary.path().join("checked.lenso-plugin");
let verified = materialize(&root, &output)?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": 1,
"kind": "lenso.plugin-check",
"status": "passed",
"plugin_id": verified.plugin_id,
"release_version": verified.release_version,
"manifest_digest": verified.manifest_digest,
}))?
);
} else {
println!(
"Plugin check passed: {}@{}",
verified.plugin_id, verified.release_version
);
}
Ok(())
}
async fn dev(args: PluginDevArgs) -> anyhow::Result<()> {
let root = project_root(args.repo_root)?;
let temporary = tempfile::tempdir().context("create Plugin dev directory")?;
let output = temporary.path().join("dev.lenso-plugin");
let verified = materialize(&root, &output)?;
let component_path = output.join("plugin.wasm");
let component = fs::read(&component_path)?;
let descriptor = parse_descriptor(&component)?;
let capability = one_capability(&descriptor)?;
let operation = args.operation.unwrap_or_else(|| {
capability
.request_operations
.first()
.expect("validated operation")
.clone()
});
if !capability.request_operations.contains(&operation) {
bail!(
"Plugin Capability `{}` does not declare operation `{operation}`",
capability.capability_id
);
}
let request: Value = serde_json::from_str(&args.request_json)
.context("Plugin development request is not valid JSON")?;
let digest = verified
.artifact_digests
.first()
.ok_or_else(|| anyhow!("Plugin Bundle contains no executable artifact"))?;
let artifact = ArtifactHandle::open(&component_path, digest, component.len() as u64)
.map_err(|error| runtime_error("open Plugin artifact", &error))?;
let artifacts = ArtifactCatalog::new()
.with_artifact("plugin", artifact)
.map_err(|error| runtime_error("register Plugin artifact", &error))?;
let codec = DynamicJsonCodec::new(capability);
let adapter = WasmComponentAdapter::new(artifacts).with_codec(codec);
let plan = ResolvedAppPlan::new(
vec![
PluginInstancePlan::new("plugin", &verified.plugin_id)
.with_entrypoint("plugin")
.with_execution_class(ExecutionClassId::new(EXECUTION_CLASS))
.with_capability(CapabilityEndpointPlan::new(
&capability.capability_id,
&capability.descriptor_version,
capability.request_operations.clone(),
)),
],
Vec::new(),
);
let generation = adapter
.recreate(&plan, "plugin")
.map_err(|error| runtime_error("prepare Plugin generation", &error))?;
let endpoint = generation
.endpoints()
.first()
.ok_or_else(|| anyhow!("Plugin produced no request endpoint"))?;
let outcome = endpoint
.invoke(
&operation,
Box::new(request),
InvocationContext::new(1, None, CancellationToken::new()),
)
.await
.map_err(|error| runtime_error("invoke Plugin", &error))?;
let response = outcome
.map_err(|error| {
error.downcast::<Value>().map_or_else(
|_| anyhow!("Plugin returned an unknown Domain Error"),
|value| anyhow!("Plugin returned Domain Error: {value}"),
)
})?
.downcast::<Value>()
.map_err(|_| anyhow!("Plugin returned a response with an unexpected type"))?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": 1,
"kind": "lenso.plugin-dev",
"plugin_id": verified.plugin_id,
"capability_id": capability.capability_id,
"operation": operation,
"response": *response,
}))?
);
} else {
println!("Plugin {} returned {}", verified.plugin_id, response);
}
Ok(())
}
fn pack(args: PluginPackArgs) -> anyhow::Result<()> {
let root = project_root(args.repo_root)?;
let package = read_package(&root.join("Cargo.toml"))?;
let output = args.output.unwrap_or_else(|| {
root.join("dist").join(format!(
"{}-{}.lenso-plugin",
package.metadata.lenso.plugin_id, package.version
))
});
let verified = materialize(&root, &output)?;
let reopened = verify_bundle_directory(&output)
.with_context(|| format!("reopen packed Plugin `{}`", output.display()))?;
if verified != reopened {
bail!("packed Plugin verification result changed after publication");
}
print_verified(&reopened, Some(&output), args.json)
}
fn materialize(root: &Path, output: &Path) -> anyhow::Result<VerifiedBundle> {
let manifest = root.join("Cargo.toml");
let package = read_package(&manifest)?;
synchronize_plugin_lock(root, &package)?;
let target_directory = cargo_target_directory(root)?;
run_cargo(
root,
&["build", "--locked", "--release", "--target", WASM_TARGET],
"build Plugin Wasm",
)?;
let artifact = target_directory
.join(WASM_TARGET)
.join("release")
.join(format!("{}.wasm", package.name.replace('-', "_")));
build_source_plugin_bundle(&SourcePluginBuild {
package_manifest: manifest,
wasm_module: artifact,
output: output.to_path_buf(),
})
.with_context(|| format!("package Plugin `{}`", package.metadata.lenso.plugin_id))
}
fn synchronize_plugin_lock(root: &Path, package: &CargoPackage) -> anyhow::Result<()> {
run_cargo(
root,
&[
"update",
"--offline",
"--package",
&package.name,
"--precise",
&package.version,
],
"synchronize Plugin version in Cargo.lock",
)
}
fn cargo_target_directory(root: &Path) -> anyhow::Result<PathBuf> {
let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let output = Command::new(cargo)
.args(["metadata", "--locked", "--format-version", "1", "--no-deps"])
.current_dir(root)
.output()
.context("inspect Plugin Cargo target directory")?;
if !output.status.success() {
bail!(
"inspect Plugin Cargo target directory failed with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
let metadata: CargoTargetMetadata =
serde_json::from_slice(&output.stdout).context("parse Plugin Cargo metadata")?;
Ok(metadata.target_directory)
}
fn read_package(manifest: &Path) -> anyhow::Result<CargoPackage> {
let bytes = fs::read(manifest)
.with_context(|| format!("read Plugin manifest {}", manifest.display()))?;
let document: CargoDocument =
toml::from_slice(&bytes).context("parse Plugin Cargo manifest")?;
validate_plugin_id(&document.package.metadata.lenso.plugin_id)?;
Ok(document.package)
}
fn parse_descriptor(component: &[u8]) -> anyhow::Result<PluginDescriptor> {
let bytes = extract_plugin_descriptor(component)?;
let descriptor: PluginDescriptor =
serde_json::from_slice(&bytes).context("parse generated Plugin descriptor")?;
if descriptor.abi != "lenso.json-request@1" {
bail!(
"unsupported Plugin descriptor ABI `{}`; expected request-only V1",
descriptor.abi
);
}
one_capability(&descriptor)?;
Ok(descriptor)
}
fn one_capability(descriptor: &PluginDescriptor) -> anyhow::Result<&PluginCapability> {
let [capability] = descriptor.capabilities.as_slice() else {
bail!("the first public Plugin shape requires exactly one provided Capability");
};
if capability.request_operations.is_empty() {
bail!("Plugin Capability must declare at least one request operation");
}
Ok(capability)
}
fn project_root(root: Option<PathBuf>) -> anyhow::Result<PathBuf> {
root.map_or_else(
|| env::current_dir().context("resolve Plugin project root"),
Ok,
)
}
fn validate_plugin_id(plugin_id: &str) -> anyhow::Result<()> {
if plugin_id.is_empty()
|| !plugin_id
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-".contains(&byte))
|| !plugin_id
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_lowercase())
{
bail!(
"Plugin id must start with a lowercase letter and contain only lowercase letters, digits, `.` or `-`"
);
}
Ok(())
}
fn run_cargo(root: &Path, args: &[&str], action: &str) -> anyhow::Result<()> {
let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let status = Command::new(cargo)
.args(args)
.current_dir(root)
.status()
.with_context(|| action.to_owned())?;
if !status.success() {
bail!("{action} failed with {status}");
}
Ok(())
}
fn runtime_error(action: &str, error: &RuntimeFailure) -> anyhow::Error {
anyhow!("{action}: {error:?}")
}
fn print_verified(
verified: &VerifiedBundle,
output: Option<&Path>,
json: bool,
) -> anyhow::Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": 1,
"kind": "lenso.plugin-pack",
"plugin_id": verified.plugin_id,
"release_version": verified.release_version,
"manifest_digest": verified.manifest_digest,
"artifact_digests": verified.artifact_digests,
"output": output.map(|path| path.display().to_string()),
}))?
);
} else {
println!("Packed {}@{}", verified.plugin_id, verified.release_version);
if let Some(output) = output {
println!("output {}", output.display());
}
println!("manifest {}", verified.manifest_digest);
}
Ok(())
}
#[derive(Debug)]
struct DynamicJsonCodec {
capability_id: &'static str,
descriptor_version: &'static str,
request_operations: &'static [&'static str],
}
impl DynamicJsonCodec {
fn new(capability: &PluginCapability) -> Self {
let operations = capability
.request_operations
.iter()
.map(|operation| Box::leak(operation.clone().into_boxed_str()) as &'static str)
.collect::<Vec<_>>()
.into_boxed_slice();
Self {
capability_id: Box::leak(capability.capability_id.clone().into_boxed_str()),
descriptor_version: Box::leak(capability.descriptor_version.clone().into_boxed_str()),
request_operations: Box::leak(operations),
}
}
}
impl JsonCapabilityCodec for DynamicJsonCodec {
fn capability_id(&self) -> &'static str {
self.capability_id
}
fn descriptor_version(&self) -> &'static str {
self.descriptor_version
}
fn request_operations(&self) -> &'static [&'static str] {
self.request_operations
}
fn encode_request(&self, _: &str, request: &dyn Any) -> Result<Value, RuntimeFailure> {
request
.downcast_ref::<Value>()
.cloned()
.ok_or(RuntimeFailure::ProtocolViolation {
capability: self.capability_id,
})
}
fn decode_response(&self, _: &str, value: Value) -> Result<Box<dyn Any>, RuntimeFailure> {
Ok(Box::new(value))
}
fn decode_domain_error(&self, _: &str, value: Value) -> Result<Box<dyn Any>, RuntimeFailure> {
Ok(Box::new(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_plugin_scaffold_has_one_plugin_authority_and_four_commands() {
let files = plugin_scaffold("uppercase");
let all = files.values().cloned().collect::<String>();
assert!(all.contains("guest_request_plugin!"));
assert!(all.contains("plugin-id = \"uppercase\""));
assert!(all.contains("lenso.agent.tool-provider@2"));
assert!(all.contains("requests: [\"catalog\", \"execute\"]"));
assert!(all.contains("lenso plugin new"));
assert!(all.contains("lenso plugin dev"));
assert!(all.contains("lenso plugin check"));
assert!(all.contains("lenso plugin pack"));
for forbidden in [
"#[module]",
"defineModule",
"MODULE.md",
"module_contributions",
"template.json",
"fn describe",
] {
assert!(!all.contains(forbidden), "unexpected `{forbidden}`");
}
}
#[test]
fn unsupported_plugin_runtime_fails_with_the_supported_shape() {
let root = tempfile::tempdir().unwrap();
let error = create(PluginNewArgs {
plugin_id: "uppercase".to_owned(),
repo_root: Some(root.path().to_path_buf()),
dir: None,
runtime: PluginRuntimeArg::Bun,
no_install: true,
dry_run: false,
})
.unwrap_err();
assert!(error.to_string().contains("Rust/Wasm"));
}
#[test]
fn duplicate_plugin_identity_is_rejected() {
let root = tempfile::tempdir().unwrap();
let manifest = root.path().join("Cargo.toml");
fs::write(
&manifest,
r#"[package]
name = "duplicate"
version = "0.1.0"
[package.metadata.lenso]
plugin-id = "first"
plugin-id = "second"
"#,
)
.unwrap();
assert!(read_package(&manifest).is_err());
}
#[test]
fn malformed_plugin_package_is_rejected() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("lenso-plugin.json"), b"{}\n").unwrap();
assert!(verify_bundle_directory(root.path()).is_err());
}
#[tokio::test]
#[ignore = "clean-room test downloads released crates and compiles wasm32"]
async fn clean_room_plugin_runs_new_check_dev_and_pack() {
let root = tempfile::tempdir().unwrap();
create(PluginNewArgs {
plugin_id: "uppercase".to_owned(),
repo_root: Some(root.path().to_path_buf()),
dir: None,
runtime: PluginRuntimeArg::Rust,
no_install: false,
dry_run: false,
})
.unwrap();
let project = root.path().join("uppercase");
check(PluginCheckArgs {
repo_root: Some(project.clone()),
json: true,
})
.unwrap();
dev(PluginDevArgs {
repo_root: Some(project.clone()),
operation: Some("execute".to_owned()),
request_json: r#"{"name":"uppercase","arguments_json":"{\"text\":\"hello\"}"}"#
.to_owned(),
json: true,
})
.await
.unwrap();
let output = project.join("dist/uppercase.lenso-plugin");
pack(PluginPackArgs {
repo_root: Some(project.clone()),
output: Some(output.clone()),
json: true,
})
.unwrap();
verify_bundle_directory(&output).unwrap();
fs::write(output.join("plugin.wasm"), b"changed after pack").unwrap();
assert!(verify_bundle_directory(&output).is_err());
assert!(
pack(PluginPackArgs {
repo_root: Some(project),
output: Some(output),
json: false,
})
.is_err()
);
}
}