From 923a1d6e2a02803630a1873683504f028af7d04b Mon Sep 17 00:00:00 2001 From: Adem Date: Thu, 19 Mar 2026 01:30:36 +0300 Subject: [PATCH] fix: use block-style YAML sequences in SKILL.md frontmatter Replace flow sequences (bins: ["gws"], skills: [...]) with block-style sequences in all generated SKILL.md frontmatter templates. Flow sequences are valid YAML but rejected by strictyaml, which the Agent Skills reference implementation (agentskills validate) uses to parse frontmatter. This caused all 93 generated skills to fail validation. Also adds unit tests verifying that service, shared, persona, and recipe skill templates produce block-style sequences only. Fixes #521 --- .../fix-skill-frontmatter-flow-sequences.md | 14 ++ src/generate_skills.rs | 173 ++++++++++++++++-- 2 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-skill-frontmatter-flow-sequences.md diff --git a/.changeset/fix-skill-frontmatter-flow-sequences.md b/.changeset/fix-skill-frontmatter-flow-sequences.md new file mode 100644 index 00000000..0ca2eeb2 --- /dev/null +++ b/.changeset/fix-skill-frontmatter-flow-sequences.md @@ -0,0 +1,14 @@ +--- +"@googleworkspace/cli": patch +--- + +fix: use block-style YAML sequences in generated SKILL.md frontmatter + +Replace flow sequences (`bins: ["gws"]`, `skills: [...]`) with block-style +sequences (`bins:\n - gws`) in all generated SKILL.md frontmatter templates. + +Flow sequences are valid YAML but rejected by `strictyaml`, which the +Agent Skills reference implementation (`agentskills validate`) uses to parse +frontmatter. This caused all 93 generated skills to fail validation. + +Fixes #521 diff --git a/src/generate_skills.rs b/src/generate_skills.rs index 121d4c79..7f65fe2b 100644 --- a/src/generate_skills.rs +++ b/src/generate_skills.rs @@ -391,7 +391,8 @@ metadata: openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws {alias} --help" --- @@ -527,7 +528,8 @@ metadata: openclaw: category: "{category}" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws {alias} {cmd_name} --help" --- @@ -674,7 +676,8 @@ metadata: openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws --- # gws — Shared Reference @@ -755,13 +758,13 @@ gws [sub-resource] [flags] fn render_persona_skill(persona: &PersonaEntry) -> String { let mut out = String::new(); - // metadata JSON string for skills array + // Block-style YAML for skills array let required_skills = persona .services .iter() - .map(|s| format!("\"gws-{s}\"")) + .map(|s| format!(" - gws-{s}")) .collect::>() - .join(", "); + .join("\n"); let trigger_desc = truncate_desc(&persona.description); @@ -774,8 +777,10 @@ metadata: openclaw: category: "persona" requires: - bins: ["gws"] - skills: [{skills}] + bins: + - gws + skills: +{skills} --- # {title} @@ -829,9 +834,9 @@ fn render_recipe_skill(recipe: &RecipeEntry) -> String { let required_skills = recipe .services .iter() - .map(|s| format!("\"gws-{s}\"")) + .map(|s| format!(" - gws-{s}")) .collect::>() - .join(", "); + .join("\n"); let trigger_desc = truncate_desc(&recipe.description); @@ -845,8 +850,10 @@ metadata: category: "recipe" domain: "{category}" requires: - bins: ["gws"] - skills: [{skills}] + bins: + - gws + skills: +{skills} --- # {title} @@ -1187,4 +1194,146 @@ mod tests { fn test_product_name_from_title_adds_google() { assert_eq!(product_name_from_title("Drive API"), "Google Drive"); } + + /// Extract the YAML frontmatter (between `---` delimiters) from a skill string. + fn extract_frontmatter(content: &str) -> &str { + let start = content.find("---").expect("no opening ---") + 3; + let end = start + content[start..].find("---").expect("no closing ---"); + &content[start..end] + } + + /// Asserts that the frontmatter uses block-style YAML sequences. + /// + /// Detects flow sequences by checking whether YAML values start with `[`, + /// rather than looking for brackets anywhere in a line. This avoids false + /// positives from string values that legitimately contain brackets + /// (e.g., `description: 'Note: [INTERNAL] ticket was filed'`). + fn assert_block_style_sequences(frontmatter: &str) { + for (i, line) in frontmatter.lines().enumerate() { + let trimmed = line.trim(); + // Skip lines that don't look like YAML values (e.g., comments, empty) + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + // A YAML flow sequence is "key: [...]". Check the value after `:`. + if let Some(colon_pos) = trimmed.find(':') { + let value = trimmed[colon_pos + 1..].trim(); + assert!( + !value.starts_with('['), + "Flow sequence found on line {} of frontmatter: {:?}\n\ + Use block-style sequences instead (e.g., `- value`)", + i + 1, + trimmed + ); + } + } + } + + #[test] + fn test_service_skill_frontmatter_uses_block_sequences() { + let entry = &services::SERVICES[0]; // first service + let doc = crate::discovery::RestDescription { + name: entry.api_name.to_string(), + title: Some("Test API".to_string()), + description: Some(entry.description.to_string()), + ..Default::default() + }; + let cli = crate::commands::build_cli(&doc); + let helpers: Vec<&Command> = cli + .get_subcommands() + .filter(|s| s.get_name().starts_with('+')) + .collect(); + let resources: Vec<&Command> = cli + .get_subcommands() + .filter(|s| !s.get_name().starts_with('+')) + .collect(); + let product_name = product_name_from_title("Test API"); + let md = render_service_skill( + entry.aliases[0], + entry, + &helpers, + &resources, + &product_name, + &doc, + ); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains("bins:\n"), + "frontmatter should contain 'bins:' on its own line" + ); + assert!( + fm.contains("- gws"), + "frontmatter should contain '- gws' block entry" + ); + } + + #[test] + fn test_shared_skill_frontmatter_uses_block_sequences() { + let tmp = tempfile::tempdir().unwrap(); + generate_shared_skill(tmp.path()).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("gws-shared/SKILL.md")).unwrap(); + let fm = extract_frontmatter(&content); + assert_block_style_sequences(fm); + assert!( + fm.contains("- gws"), + "shared skill frontmatter should contain '- gws'" + ); + } + + #[test] + fn test_persona_skill_frontmatter_uses_block_sequences() { + let persona = PersonaEntry { + name: "test-persona".to_string(), + title: "Test Persona".to_string(), + description: "A test persona for unit tests.".to_string(), + services: vec!["gmail".to_string(), "calendar".to_string()], + workflows: vec![], + instructions: vec!["Do this.".to_string()], + tips: vec![], + }; + let md = render_persona_skill(&persona); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains("- gws"), + "persona frontmatter should contain '- gws'" + ); + assert!( + fm.contains("- gws-gmail"), + "persona frontmatter should contain '- gws-gmail'" + ); + assert!( + fm.contains("- gws-calendar"), + "persona frontmatter should contain '- gws-calendar'" + ); + } + + #[test] + fn test_recipe_skill_frontmatter_uses_block_sequences() { + let recipe = RecipeEntry { + name: "test-recipe".to_string(), + title: "Test Recipe".to_string(), + description: "A test recipe for unit tests.".to_string(), + category: "testing".to_string(), + services: vec!["drive".to_string(), "sheets".to_string()], + steps: vec!["Step one.".to_string()], + caution: None, + }; + let md = render_recipe_skill(&recipe); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains("- gws"), + "recipe frontmatter should contain '- gws'" + ); + assert!( + fm.contains("- gws-drive"), + "recipe frontmatter should contain '- gws-drive'" + ); + assert!( + fm.contains("- gws-sheets"), + "recipe frontmatter should contain '- gws-sheets'" + ); + } }