Skip to main content

standard_plugin_cli/
scaffold.rs

1//! `standard-plugin new`: a plugin crate (or, for a companion, a UI crate
2//! and its daemon) with a manifest whose grants carry reasons.
3//!
4//! The source templates are the SDK's compiled examples with the example's
5//! id replaced, so a scaffold always starts from code that builds.
6
7use std::path::{Path, PathBuf};
8
9use serde_json::json;
10use standard_plugin_manifest::manifest::is_identifier;
11
12use standard_plugin::sources::examples;
13
14const UI_TEMPLATE: &str = examples::CLOCK_CARD;
15const DAEMON_TEMPLATE: &str = examples::HEARTBEAT_DAEMON;
16const COMPANION_UI_TEMPLATE: &str = examples::TOGETHER_UI;
17const COMPANION_TEMPLATE: &str = examples::TOGETHER_COMPANION;
18
19/// What `new --kind` creates.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
21pub enum NewKind {
22    /// A UI plugin: a sidebar card in the cell model.
23    Ui,
24    /// A daemon plugin.
25    Daemon,
26    /// A UI plugin and its companion daemon, sharing one id.
27    Companion,
28}
29
30/// Where the SDK comes from in a new crate's `Cargo.toml`.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum SdkSource {
33    Path(PathBuf),
34    Version(String),
35}
36
37impl SdkSource {
38    /// The SDK this CLI was built beside when it runs from a Standard Code
39    /// checkout that still has it, else the crates.io release line of this
40    /// CLI (the SDK and CLI share one version).
41    pub fn default_for_this_build() -> Self {
42        let beside = Path::new(env!("CARGO_MANIFEST_DIR")).join("../standard-plugin-sdk");
43        match beside.canonicalize() {
44            Ok(path) if path.join("Cargo.toml").is_file() => Self::Path(path),
45            _ => Self::Version(release_line(env!("CARGO_PKG_VERSION"))),
46        }
47    }
48
49    fn dependency(&self) -> String {
50        match self {
51            Self::Path(path) => format!("{{ path = {:?} }}", path.display().to_string()),
52            Self::Version(version) => format!("{version:?}"),
53        }
54    }
55}
56
57/// `major.minor` of a version: the requirement a new plugin writes, so it
58/// takes the SDK's compatible fixes.
59fn release_line(version: &str) -> String {
60    version.splitn(3, '.').take(2).collect::<Vec<_>>().join(".")
61}
62
63#[derive(Debug, thiserror::Error)]
64pub enum ScaffoldError {
65    #[error("{0:?} is not a plugin id: use lowercase letters, digits and dashes")]
66    Name(String),
67    #[error("{0} already exists")]
68    Exists(PathBuf),
69    #[error("{0}")]
70    Io(#[from] std::io::Error),
71}
72
73const PROFILES: &str = r#"
74# Small components: plugins ship as bytes to every viewer.
75[profile.release]
76opt-level = "s"
77lto = true
78codegen-units = 1
79panic = "abort"
80strip = true
81
82[profile.dev]
83panic = "abort"
84"#;
85
86const GITIGNORE: &str = "target/\nbundle/*.wasm\nbundle/browser/\n";
87
88/// Where a coding agent finds the guides. It names no SDK version and
89/// quotes no rule, so it stays true as the SDK changes: the guides come
90/// from the SDK the project's `Cargo.lock` resolves.
91const AGENTS_MD: &str = "# Notes for coding agents
92
93This is a Standard Code plugin, written in Rust with `standard-plugin-sdk`.
94Read the SDK's guides before you change it. They come from the SDK version
95in this project's `Cargo.lock`:
96
97- `standard-plugin guide` lists them, and `standard-plugin guide <name>`
98  prints one. Start with `standard-plugin guide agent-guide`.
99- The same files are the `docs/` directory of the `standard-plugin-sdk`
100  crate that `cargo metadata` names.
101- `cargo doc --open -p standard-plugin-sdk` shows the API, with the guides
102  under `standard_plugin::guide`.
103";
104
105/// Claude Code reads `CLAUDE.md`; it imports the notes above.
106const CLAUDE_MD: &str = "@AGENTS.md\n";
107
108fn module_name(crate_name: &str) -> String {
109    format!("{}.wasm", crate_name.replace('-', "_"))
110}
111
112fn cargo_toml(crate_name: &str, sdk: &SdkSource, standalone: bool) -> String {
113    let mut toml = format!(
114        r#"[package]
115name = "{crate_name}"
116version = "0.1.0"
117edition = "2024"
118publish = false
119
120[lib]
121# cdylib: the component; rlib: the plugin's own `cargo test` links it.
122crate-type = ["cdylib", "rlib"]
123
124[dependencies]
125serde = {{ version = "1", default-features = false, features = ["derive"] }}
126standard-plugin-sdk = {}
127"#,
128        sdk.dependency()
129    );
130    if standalone {
131        toml.push_str("\n# Its own workspace, even inside another project.\n[workspace]\n");
132        toml.push_str(PROFILES);
133    }
134    toml
135}
136
137fn manifest(
138    id: &str,
139    kind: &str,
140    module: &str,
141    surfaces: serde_json::Value,
142    grants: serde_json::Value,
143) -> String {
144    let mut manifest = json!({
145        "apiVersion": 2,
146        "kind": kind,
147        "id": id,
148        "version": "0.1.0",
149        "module": module,
150    });
151    if !surfaces.is_null() {
152        manifest["surfaces"] = surfaces;
153    }
154    manifest["grants"] = grants;
155    let mut text = serde_json::to_string_pretty(&manifest).expect("a JSON value serializes");
156    text.push('\n');
157    text
158}
159
160/// A UI plugin is `no_std` in the component and `std` under `cargo test`,
161/// where the SDK's harness runs it; the scaffold starts with one test.
162fn testable_ui(source: &str, id: &str, plugin: &str, surface: &str) -> String {
163    let source = source.replacen("#![no_std]", "#![cfg_attr(not(test), no_std)]", 1);
164    format!(
165        r#"{source}
166#[cfg(test)]
167mod tests {{
168    use super::*;
169    use standard_plugin::Power;
170    use standard_plugin::surface::Model;
171    use standard_plugin::testing::{{Harness, MockHost}};
172
173    #[test]
174    fn it_paints_once_the_viewer_lays_it_out() {{
175        let host = MockHost::new("{id}").surface("{surface}", Model::Cells);
176        let mut harness = Harness::<{plugin}>::activate(host, "{{}}");
177        harness.resize("{surface}", Geometry::cells(24, 2, (0, 0)));
178        harness.show("{surface}", true);
179        harness.frame(1_000, 16, Power::Mains);
180        assert!(!harness.commits("{surface}").is_empty());
181    }}
182}}
183"#
184    )
185}
186
187/// Replaces the example's id with the new plugin's.
188fn personalise(
189    template: &str,
190    example_id: &str,
191    id: &str,
192    example_module: &str,
193    module: &str,
194) -> String {
195    template
196        .replace(&format!("\"{example_id}.",), &format!("\"{id}."))
197        .replace(&format!("`{example_id}."), &format!("`{id}."))
198        .replace(
199            &format!("\"id\": \"{example_id}\""),
200            &format!("\"id\": \"{id}\""),
201        )
202        .replace(&format!("call:{example_id}"), &format!("call:{id}"))
203        .replace(example_module, module)
204}
205
206fn write_crate(
207    dir: &Path,
208    crate_name: &str,
209    sdk: &SdkSource,
210    standalone: bool,
211    source: &str,
212    manifest: &str,
213) -> Result<(), ScaffoldError> {
214    std::fs::create_dir_all(dir.join("src"))?;
215    std::fs::create_dir_all(dir.join("bundle"))?;
216    std::fs::write(
217        dir.join("Cargo.toml"),
218        cargo_toml(crate_name, sdk, standalone),
219    )?;
220    std::fs::write(dir.join("src/lib.rs"), source)?;
221    std::fs::write(dir.join("bundle/standard-plugin.json"), manifest)?;
222    if standalone {
223        std::fs::write(dir.join(".gitignore"), GITIGNORE)?;
224    }
225    Ok(())
226}
227
228/// Creates `<parent>/<name>`. Returns the directory made.
229pub fn scaffold(
230    parent: &Path,
231    name: &str,
232    kind: NewKind,
233    sdk: &SdkSource,
234) -> Result<PathBuf, ScaffoldError> {
235    if !is_identifier(name) {
236        return Err(ScaffoldError::Name(name.into()));
237    }
238    let root = parent.join(name);
239    if root.exists() {
240        return Err(ScaffoldError::Exists(root));
241    }
242    match kind {
243        NewKind::Ui => {
244            let module = module_name(name);
245            let source = testable_ui(
246                &personalise(UI_TEMPLATE, "clock-card", name, "clock_card.wasm", &module),
247                name,
248                "ClockCard",
249                "card",
250            );
251            let manifest = manifest(
252                name,
253                "ui",
254                &module,
255                json!([{ "id": "card", "anchor": "sidebar.card", "model": "cells", "height": 2 }]),
256                json!({}),
257            );
258            write_crate(&root, name, sdk, true, &source, &manifest)?;
259        }
260        NewKind::Daemon => {
261            let module = module_name(name);
262            let source = personalise(
263                DAEMON_TEMPLATE,
264                "heartbeat",
265                name,
266                "heartbeat_daemon.wasm",
267                &module,
268            );
269            let manifest = manifest(name, "daemon", &module, serde_json::Value::Null, json!({}));
270            write_crate(&root, name, sdk, true, &source, &manifest)?;
271        }
272        NewKind::Companion => {
273            std::fs::create_dir_all(&root)?;
274            std::fs::write(
275                root.join("Cargo.toml"),
276                format!(
277                    "# A UI plugin and its companion daemon: one plugin id, two components.\n\
278                     [workspace]\nmembers = [\"ui\", \"companion\"]\nresolver = \"2\"\n{PROFILES}"
279                ),
280            )?;
281            std::fs::write(
282                root.join(".gitignore"),
283                GITIGNORE.replace("bundle", "*/bundle"),
284            )?;
285            let ui_crate = format!("{name}-ui");
286            let ui_module = module_name(&ui_crate);
287            let ui_source = testable_ui(
288                &personalise(
289                    COMPANION_UI_TEMPLATE,
290                    "together",
291                    name,
292                    "together_ui.wasm",
293                    &ui_module,
294                ),
295                name,
296                "Together",
297                "card",
298            );
299            let ui_manifest = manifest(
300                name,
301                "ui",
302                &ui_module,
303                json!([{ "id": "card", "anchor": "sidebar.card", "model": "cells", "height": 1 }]),
304                json!({
305                    format!("call:{name}"): "Asks the companion on your machine to start a build"
306                }),
307            );
308            write_crate(
309                &root.join("ui"),
310                &ui_crate,
311                sdk,
312                false,
313                &ui_source,
314                &ui_manifest,
315            )?;
316            let companion_crate = format!("{name}-companion");
317            let companion_module = module_name(&companion_crate);
318            let companion_source = personalise(
319                COMPANION_TEMPLATE,
320                "together",
321                name,
322                "together_companion.wasm",
323                &companion_module,
324            );
325            let companion_manifest = manifest(
326                name,
327                "companion",
328                &companion_module,
329                serde_json::Value::Null,
330                json!({
331                    "process.exec:make": "Runs make in your project when you ask for a build"
332                }),
333            );
334            write_crate(
335                &root.join("companion"),
336                &companion_crate,
337                sdk,
338                false,
339                &companion_source,
340                &companion_manifest,
341            )?;
342        }
343    }
344    std::fs::write(root.join("AGENTS.md"), AGENTS_MD)?;
345    std::fs::write(root.join("CLAUDE.md"), CLAUDE_MD)?;
346    Ok(root)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn scaffolds_pass_check_and_carry_the_new_id() {
355        let temp = tempfile::tempdir().unwrap();
356        let sdk = SdkSource::Version("0.1.0".into());
357        let ui = scaffold(temp.path(), "my-card", NewKind::Ui, &sdk).unwrap();
358        let pair = scaffold(temp.path(), "builds", NewKind::Companion, &sdk).unwrap();
359        let daemon = scaffold(temp.path(), "beat", NewKind::Daemon, &sdk).unwrap();
360        for root in [&ui, &pair, &daemon] {
361            assert!(root.join("AGENTS.md").is_file() && root.join("CLAUDE.md").is_file());
362        }
363        for dir in [ui, pair.join("ui"), pair.join("companion"), daemon] {
364            let manifest = std::fs::read(dir.join("bundle/standard-plugin.json")).unwrap();
365            let report = crate::check::check(&manifest, None);
366            assert!(report.is_ok(), "{dir:?}: {report}");
367            assert!(report.warnings.is_empty(), "{dir:?}: {report}");
368        }
369        let source = std::fs::read_to_string(pair.join("ui/src/lib.rs")).unwrap();
370        assert!(source.contains("\"builds.status\""));
371        assert!(source.contains("#![cfg_attr(not(test), no_std)]"));
372        let toml = std::fs::read_to_string(pair.join("ui/Cargo.toml")).unwrap();
373        assert!(toml.contains(r#"crate-type = ["cdylib", "rlib"]"#));
374        assert!(!source.contains("\"together."));
375        assert!(matches!(
376            scaffold(temp.path(), "Bad Name", NewKind::Ui, &sdk),
377            Err(ScaffoldError::Name(_))
378        ));
379        assert!(matches!(
380            scaffold(temp.path(), "my-card", NewKind::Ui, &sdk),
381            Err(ScaffoldError::Exists(_))
382        ));
383    }
384}