1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
21pub enum NewKind {
22 Ui,
24 Daemon,
26 Companion,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum SdkSource {
33 Path(PathBuf),
34 Version(String),
35}
36
37impl SdkSource {
38 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
57fn 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
88const 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
105const 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
160fn 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
187fn 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
228pub 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}