use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::Path; /// The manifest filename, lives in every project root. pub const MANIFEST_FILE: &str = "notdouble.toml"; /// Everything in the manifest is optional except `name`. /// Unknown fields are preserved (flattened into `extra`) so the schema /// can grow without breaking old manifests — nothing here is set in stone. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Manifest { /// Required. Becomes the url slug: "My Cool App" -> /p/my-cool-app pub name: String, /// "app" (default) or "template" — templates are project skeletons /// that agents pull with nd download instead of hand-scaffolding #[serde(default)] pub kind: String, pub version: String, /// One-liner shown on cards. pub description: String, /// 1-3 sentences written for humans AND llms. Powers search. pub llm_description: String, /// Topic keywords ("todo", "bot", "automation") — what it's about. pub keywords: Vec, /// Code/tech keywords ("rust", "axum", "react") — how it's built. pub code_keywords: Vec, pub languages: Vec, pub author: String, pub license: String, pub homepage: String, /// Files an llm (or human) should read first. pub entry_points: Vec, /// Shell command to install. pub install: String, /// Shell command to run. pub run: String, /// Where the vibes came from. pub vibe: Option, /// Machine specs gathered at upload time (opt-in via `nd upload --specs`). #[serde(skip_serializing_if = "Option::is_none")] pub specs: Option, /// Any unknown field lands here and round-trips untouched. #[serde(flatten)] pub extra: BTreeMap, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Vibe { pub model: String, pub prompt_summary: String, #[serde(flatten)] pub extra: BTreeMap, } pub const TEMPLATE: &str = r#"# notdouble.toml — manifest for notdoublehub (https://notdouble.com) # # Everything in here is searchable on the hub. Fill it in properly: # description + llm_description + keywords are how other people and # llms find your project. Unknown fields are preserved, so adding # custom metadata is always safe — the schema is not frozen. # Required. Becomes the url slug: "My Cool App" -> /p/my-cool-app # kind: "app" (default) or "template" — templates are project # skeletons agents can pull and start from without scaffolding by hand name = "my-project" version = "0.1.0" # One-liner shown on search cards. description = "" # 1-3 sentences written for humans AND llms: what it does, how to run # it, why it exists. This is the main field other agents search. llm_description = "" # Topic keywords — what the project IS about ("todo", "game", "cli"). keywords = [] # Code/tech keywords — what it's built WITH ("rust", "axum", "sqlite"). code_keywords = [] languages = [] author = "" license = "" homepage = "" # Files to read first (relative paths). entry_points = [] # Shell commands, shown on the project page. install = "" run = "" # Where the vibes came from (all optional). [vibe] model = "" prompt_summary = "" "#; impl Manifest { pub fn validate(&self) -> Result<()> { if self.name.trim().is_empty() { anyhow::bail!("manifest is missing `name`"); } Ok(()) } pub fn load(dir: &Path) -> Result { let path = dir.join(MANIFEST_FILE); let raw = std::fs::read_to_string(&path) .with_context(|| format!("missing {}: run `nd init`", path.display()))?; let m: Manifest = toml::from_str(&raw).with_context(|| format!("bad manifest {}", path.display()))?; m.validate()?; Ok(m) } pub fn to_json(&self) -> Result { Ok(serde_json::to_string(self)?) } } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip_unknown_fields() { let raw = r#" name = "x" kind = "template" custom_field = 42 [nested_thing] a = "b" "#; let m: Manifest = toml::from_str(raw).unwrap(); assert_eq!(m.name, "x"); assert_eq!(m.kind, "template"); assert!(m.extra.contains_key("custom_field")); assert!(m.extra.contains_key("nested_thing")); assert!(m.to_json().unwrap().contains("custom_field")); } }