standard-plugin-cli 0.1.1

standard-plugin: scaffold, build, check and pack Standard Code plugins
Documentation
//! `standard-plugin new`: a plugin crate (or, for a companion, a UI crate
//! and its daemon) with a manifest whose grants carry reasons.
//!
//! The source templates are the SDK's compiled examples with the example's
//! id replaced, so a scaffold always starts from code that builds.

use std::path::{Path, PathBuf};

use serde_json::json;
use standard_plugin_manifest::manifest::is_identifier;

use standard_plugin::sources::examples;

const UI_TEMPLATE: &str = examples::CLOCK_CARD;
const DAEMON_TEMPLATE: &str = examples::HEARTBEAT_DAEMON;
const COMPANION_UI_TEMPLATE: &str = examples::TOGETHER_UI;
const COMPANION_TEMPLATE: &str = examples::TOGETHER_COMPANION;

/// What `new --kind` creates.
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum NewKind {
    /// A UI plugin: a sidebar card in the cell model.
    Ui,
    /// A daemon plugin.
    Daemon,
    /// A UI plugin and its companion daemon, sharing one id.
    Companion,
}

/// Where the SDK comes from in a new crate's `Cargo.toml`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SdkSource {
    Path(PathBuf),
    Version(String),
}

impl SdkSource {
    /// The SDK this CLI was built beside when it runs from a Standard Code
    /// checkout that still has it, else the crates.io release line of this
    /// CLI (the SDK and CLI share one version).
    pub fn default_for_this_build() -> Self {
        let beside = Path::new(env!("CARGO_MANIFEST_DIR")).join("../standard-plugin-sdk");
        match beside.canonicalize() {
            Ok(path) if path.join("Cargo.toml").is_file() => Self::Path(path),
            _ => Self::Version(release_line(env!("CARGO_PKG_VERSION"))),
        }
    }

    fn dependency(&self) -> String {
        match self {
            Self::Path(path) => format!("{{ path = {:?} }}", path.display().to_string()),
            Self::Version(version) => format!("{version:?}"),
        }
    }
}

/// `major.minor` of a version: the requirement a new plugin writes, so it
/// takes the SDK's compatible fixes.
fn release_line(version: &str) -> String {
    version.splitn(3, '.').take(2).collect::<Vec<_>>().join(".")
}

#[derive(Debug, thiserror::Error)]
pub enum ScaffoldError {
    #[error("{0:?} is not a plugin id: use lowercase letters, digits and dashes")]
    Name(String),
    #[error("{0} already exists")]
    Exists(PathBuf),
    #[error("{0}")]
    Io(#[from] std::io::Error),
}

const PROFILES: &str = r#"
# Small components: plugins ship as bytes to every viewer.
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
strip = true

[profile.dev]
panic = "abort"
"#;

const GITIGNORE: &str = "target/\nbundle/*.wasm\nbundle/browser/\n";

/// Where a coding agent finds the guides. It names no SDK version and
/// quotes no rule, so it stays true as the SDK changes: the guides come
/// from the SDK the project's `Cargo.lock` resolves.
const AGENTS_MD: &str = "# Notes for coding agents

This is a Standard Code plugin, written in Rust with `standard-plugin-sdk`.
Read the SDK's guides before you change it. They come from the SDK version
in this project's `Cargo.lock`:

- `standard-plugin guide` lists them, and `standard-plugin guide <name>`
  prints one. Start with `standard-plugin guide agent-guide`.
- The same files are the `docs/` directory of the `standard-plugin-sdk`
  crate that `cargo metadata` names.
- `cargo doc --open -p standard-plugin-sdk` shows the API, with the guides
  under `standard_plugin::guide`.
";

/// Claude Code reads `CLAUDE.md`; it imports the notes above.
const CLAUDE_MD: &str = "@AGENTS.md\n";

fn module_name(crate_name: &str) -> String {
    format!("{}.wasm", crate_name.replace('-', "_"))
}

fn cargo_toml(crate_name: &str, sdk: &SdkSource, standalone: bool) -> String {
    let mut toml = format!(
        r#"[package]
name = "{crate_name}"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
# cdylib: the component; rlib: the plugin's own `cargo test` links it.
crate-type = ["cdylib", "rlib"]

[dependencies]
serde = {{ version = "1", default-features = false, features = ["derive"] }}
standard-plugin-sdk = {}
"#,
        sdk.dependency()
    );
    if standalone {
        toml.push_str("\n# Its own workspace, even inside another project.\n[workspace]\n");
        toml.push_str(PROFILES);
    }
    toml
}

fn manifest(
    id: &str,
    kind: &str,
    module: &str,
    surfaces: serde_json::Value,
    grants: serde_json::Value,
) -> String {
    let mut manifest = json!({
        "apiVersion": 2,
        "kind": kind,
        "id": id,
        "version": "0.1.0",
        "module": module,
    });
    if !surfaces.is_null() {
        manifest["surfaces"] = surfaces;
    }
    manifest["grants"] = grants;
    let mut text = serde_json::to_string_pretty(&manifest).expect("a JSON value serializes");
    text.push('\n');
    text
}

/// A UI plugin is `no_std` in the component and `std` under `cargo test`,
/// where the SDK's harness runs it; the scaffold starts with one test.
fn testable_ui(source: &str, id: &str, plugin: &str, surface: &str) -> String {
    let source = source.replacen("#![no_std]", "#![cfg_attr(not(test), no_std)]", 1);
    format!(
        r#"{source}
#[cfg(test)]
mod tests {{
    use super::*;
    use standard_plugin::Power;
    use standard_plugin::surface::Model;
    use standard_plugin::testing::{{Harness, MockHost}};

    #[test]
    fn it_paints_once_the_viewer_lays_it_out() {{
        let host = MockHost::new("{id}").surface("{surface}", Model::Cells);
        let mut harness = Harness::<{plugin}>::activate(host, "{{}}");
        harness.resize("{surface}", Geometry::cells(24, 2, (0, 0)));
        harness.show("{surface}", true);
        harness.frame(1_000, 16, Power::Mains);
        assert!(!harness.commits("{surface}").is_empty());
    }}
}}
"#
    )
}

/// Replaces the example's id with the new plugin's.
fn personalise(
    template: &str,
    example_id: &str,
    id: &str,
    example_module: &str,
    module: &str,
) -> String {
    template
        .replace(&format!("\"{example_id}.",), &format!("\"{id}."))
        .replace(&format!("`{example_id}."), &format!("`{id}."))
        .replace(
            &format!("\"id\": \"{example_id}\""),
            &format!("\"id\": \"{id}\""),
        )
        .replace(&format!("call:{example_id}"), &format!("call:{id}"))
        .replace(example_module, module)
}

fn write_crate(
    dir: &Path,
    crate_name: &str,
    sdk: &SdkSource,
    standalone: bool,
    source: &str,
    manifest: &str,
) -> Result<(), ScaffoldError> {
    std::fs::create_dir_all(dir.join("src"))?;
    std::fs::create_dir_all(dir.join("bundle"))?;
    std::fs::write(
        dir.join("Cargo.toml"),
        cargo_toml(crate_name, sdk, standalone),
    )?;
    std::fs::write(dir.join("src/lib.rs"), source)?;
    std::fs::write(dir.join("bundle/standard-plugin.json"), manifest)?;
    if standalone {
        std::fs::write(dir.join(".gitignore"), GITIGNORE)?;
    }
    Ok(())
}

/// Creates `<parent>/<name>`. Returns the directory made.
pub fn scaffold(
    parent: &Path,
    name: &str,
    kind: NewKind,
    sdk: &SdkSource,
) -> Result<PathBuf, ScaffoldError> {
    if !is_identifier(name) {
        return Err(ScaffoldError::Name(name.into()));
    }
    let root = parent.join(name);
    if root.exists() {
        return Err(ScaffoldError::Exists(root));
    }
    match kind {
        NewKind::Ui => {
            let module = module_name(name);
            let source = testable_ui(
                &personalise(UI_TEMPLATE, "clock-card", name, "clock_card.wasm", &module),
                name,
                "ClockCard",
                "card",
            );
            let manifest = manifest(
                name,
                "ui",
                &module,
                json!([{ "id": "card", "anchor": "sidebar.card", "model": "cells", "height": 2 }]),
                json!({}),
            );
            write_crate(&root, name, sdk, true, &source, &manifest)?;
        }
        NewKind::Daemon => {
            let module = module_name(name);
            let source = personalise(
                DAEMON_TEMPLATE,
                "heartbeat",
                name,
                "heartbeat_daemon.wasm",
                &module,
            );
            let manifest = manifest(name, "daemon", &module, serde_json::Value::Null, json!({}));
            write_crate(&root, name, sdk, true, &source, &manifest)?;
        }
        NewKind::Companion => {
            std::fs::create_dir_all(&root)?;
            std::fs::write(
                root.join("Cargo.toml"),
                format!(
                    "# A UI plugin and its companion daemon: one plugin id, two components.\n\
                     [workspace]\nmembers = [\"ui\", \"companion\"]\nresolver = \"2\"\n{PROFILES}"
                ),
            )?;
            std::fs::write(
                root.join(".gitignore"),
                GITIGNORE.replace("bundle", "*/bundle"),
            )?;
            let ui_crate = format!("{name}-ui");
            let ui_module = module_name(&ui_crate);
            let ui_source = testable_ui(
                &personalise(
                    COMPANION_UI_TEMPLATE,
                    "together",
                    name,
                    "together_ui.wasm",
                    &ui_module,
                ),
                name,
                "Together",
                "card",
            );
            let ui_manifest = manifest(
                name,
                "ui",
                &ui_module,
                json!([{ "id": "card", "anchor": "sidebar.card", "model": "cells", "height": 1 }]),
                json!({
                    format!("call:{name}"): "Asks the companion on your machine to start a build"
                }),
            );
            write_crate(
                &root.join("ui"),
                &ui_crate,
                sdk,
                false,
                &ui_source,
                &ui_manifest,
            )?;
            let companion_crate = format!("{name}-companion");
            let companion_module = module_name(&companion_crate);
            let companion_source = personalise(
                COMPANION_TEMPLATE,
                "together",
                name,
                "together_companion.wasm",
                &companion_module,
            );
            let companion_manifest = manifest(
                name,
                "companion",
                &companion_module,
                serde_json::Value::Null,
                json!({
                    "process.exec:make": "Runs make in your project when you ask for a build"
                }),
            );
            write_crate(
                &root.join("companion"),
                &companion_crate,
                sdk,
                false,
                &companion_source,
                &companion_manifest,
            )?;
        }
    }
    std::fs::write(root.join("AGENTS.md"), AGENTS_MD)?;
    std::fs::write(root.join("CLAUDE.md"), CLAUDE_MD)?;
    Ok(root)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scaffolds_pass_check_and_carry_the_new_id() {
        let temp = tempfile::tempdir().unwrap();
        let sdk = SdkSource::Version("0.1.0".into());
        let ui = scaffold(temp.path(), "my-card", NewKind::Ui, &sdk).unwrap();
        let pair = scaffold(temp.path(), "builds", NewKind::Companion, &sdk).unwrap();
        let daemon = scaffold(temp.path(), "beat", NewKind::Daemon, &sdk).unwrap();
        for root in [&ui, &pair, &daemon] {
            assert!(root.join("AGENTS.md").is_file() && root.join("CLAUDE.md").is_file());
        }
        for dir in [ui, pair.join("ui"), pair.join("companion"), daemon] {
            let manifest = std::fs::read(dir.join("bundle/standard-plugin.json")).unwrap();
            let report = crate::check::check(&manifest, None);
            assert!(report.is_ok(), "{dir:?}: {report}");
            assert!(report.warnings.is_empty(), "{dir:?}: {report}");
        }
        let source = std::fs::read_to_string(pair.join("ui/src/lib.rs")).unwrap();
        assert!(source.contains("\"builds.status\""));
        assert!(source.contains("#![cfg_attr(not(test), no_std)]"));
        let toml = std::fs::read_to_string(pair.join("ui/Cargo.toml")).unwrap();
        assert!(toml.contains(r#"crate-type = ["cdylib", "rlib"]"#));
        assert!(!source.contains("\"together."));
        assert!(matches!(
            scaffold(temp.path(), "Bad Name", NewKind::Ui, &sdk),
            Err(ScaffoldError::Name(_))
        ));
        assert!(matches!(
            scaffold(temp.path(), "my-card", NewKind::Ui, &sdk),
            Err(ScaffoldError::Exists(_))
        ));
    }
}