Skip to content

Commit a4b477b

Browse files
committed
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
1 parent e9970db commit a4b477b

File tree

2 files changed

+217
-12
lines changed

2 files changed

+217
-12
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
fix: use block-style YAML sequences in generated SKILL.md frontmatter
6+
7+
Replace flow sequences (`bins: ["gws"]`, `skills: [...]`) with block-style
8+
sequences (`bins:\n - gws`) in all generated SKILL.md frontmatter templates.
9+
10+
Flow sequences are valid YAML but rejected by `strictyaml`, which the
11+
Agent Skills reference implementation (`agentskills validate`) uses to parse
12+
frontmatter. This caused all 93 generated skills to fail validation.
13+
14+
Fixes #521

src/generate_skills.rs

Lines changed: 203 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,8 @@ metadata:
391391
openclaw:
392392
category: "productivity"
393393
requires:
394-
bins: ["gws"]
394+
bins:
395+
- gws
395396
cliHelp: "gws {alias} --help"
396397
---
397398
@@ -527,7 +528,8 @@ metadata:
527528
openclaw:
528529
category: "{category}"
529530
requires:
530-
bins: ["gws"]
531+
bins:
532+
- gws
531533
cliHelp: "gws {alias} {cmd_name} --help"
532534
---
533535
@@ -674,7 +676,8 @@ metadata:
674676
openclaw:
675677
category: "productivity"
676678
requires:
677-
bins: ["gws"]
679+
bins:
680+
- gws
678681
---
679682
680683
# gws — Shared Reference
@@ -755,13 +758,13 @@ gws <service> <resource> [sub-resource] <method> [flags]
755758
fn render_persona_skill(persona: &PersonaEntry) -> String {
756759
let mut out = String::new();
757760

758-
// metadata JSON string for skills array
761+
// Block-style YAML for skills array
759762
let required_skills = persona
760763
.services
761764
.iter()
762-
.map(|s| format!("\"gws-{s}\""))
765+
.map(|s| format!(" - gws-{s}"))
763766
.collect::<Vec<_>>()
764-
.join(", ");
767+
.join("\n");
765768

766769
let trigger_desc = truncate_desc(&persona.description);
767770

@@ -774,8 +777,10 @@ metadata:
774777
openclaw:
775778
category: "persona"
776779
requires:
777-
bins: ["gws"]
778-
skills: [{skills}]
780+
bins:
781+
- gws
782+
skills:
783+
{skills}
779784
---
780785
781786
# {title}
@@ -829,9 +834,9 @@ fn render_recipe_skill(recipe: &RecipeEntry) -> String {
829834
let required_skills = recipe
830835
.services
831836
.iter()
832-
.map(|s| format!("\"gws-{s}\""))
837+
.map(|s| format!(" - gws-{s}"))
833838
.collect::<Vec<_>>()
834-
.join(", ");
839+
.join("\n");
835840

836841
let trigger_desc = truncate_desc(&recipe.description);
837842

@@ -845,8 +850,10 @@ metadata:
845850
category: "recipe"
846851
domain: "{category}"
847852
requires:
848-
bins: ["gws"]
849-
skills: [{skills}]
853+
bins:
854+
- gws
855+
skills:
856+
{skills}
850857
---
851858
852859
# {title}
@@ -1187,4 +1194,188 @@ mod tests {
11871194
fn test_product_name_from_title_adds_google() {
11881195
assert_eq!(product_name_from_title("Drive API"), "Google Drive");
11891196
}
1197+
1198+
/// Extract the YAML frontmatter (between `---` delimiters) from a skill string.
1199+
fn extract_frontmatter(content: &str) -> &str {
1200+
let content = content.strip_prefix("---").expect("no opening ---");
1201+
let (frontmatter, _) = content.split_once("\n---").expect("no closing ---");
1202+
frontmatter
1203+
}
1204+
1205+
/// Asserts that the frontmatter uses block-style YAML sequences.
1206+
///
1207+
/// Detects flow sequences by checking whether YAML values start with `[`,
1208+
/// rather than looking for brackets anywhere in a line. This avoids false
1209+
/// positives from string values that legitimately contain brackets
1210+
/// (e.g., `description: 'Note: [INTERNAL] ticket was filed'`).
1211+
fn assert_block_style_sequences(frontmatter: &str) {
1212+
for (i, line) in frontmatter.lines().enumerate() {
1213+
let trimmed = line.trim();
1214+
// Skip lines that don't look like YAML values (e.g., comments, empty)
1215+
if trimmed.is_empty() || trimmed.starts_with('#') {
1216+
continue;
1217+
}
1218+
// A YAML flow sequence is "key: [...]". Check the value after `:`.
1219+
if let Some(colon_pos) = trimmed.find(':') {
1220+
let value = trimmed[colon_pos + 1..].trim();
1221+
// A flow sequence is not quoted. A quoted string is a scalar.
1222+
let is_quoted = value.starts_with('"') || value.starts_with('\'');
1223+
assert!(
1224+
is_quoted || !value.starts_with('['),
1225+
"Flow sequence found on line {} of frontmatter: {:?}\n\
1226+
Use block-style sequences instead (e.g., `- value`)",
1227+
i + 1,
1228+
trimmed
1229+
);
1230+
}
1231+
}
1232+
}
1233+
1234+
#[test]
1235+
fn test_service_skill_frontmatter_uses_block_sequences() {
1236+
let entry = &services::SERVICES[0]; // first service
1237+
let doc = crate::discovery::RestDescription {
1238+
name: entry.api_name.to_string(),
1239+
title: Some("Test API".to_string()),
1240+
description: Some(entry.description.to_string()),
1241+
..Default::default()
1242+
};
1243+
let cli = crate::commands::build_cli(&doc);
1244+
let helpers: Vec<&Command> = cli
1245+
.get_subcommands()
1246+
.filter(|s| s.get_name().starts_with('+'))
1247+
.collect();
1248+
let resources: Vec<&Command> = cli
1249+
.get_subcommands()
1250+
.filter(|s| !s.get_name().starts_with('+'))
1251+
.collect();
1252+
let product_name = product_name_from_title("Test API");
1253+
let md = render_service_skill(
1254+
entry.aliases[0],
1255+
entry,
1256+
&helpers,
1257+
&resources,
1258+
&product_name,
1259+
&doc,
1260+
);
1261+
let fm = extract_frontmatter(&md);
1262+
assert_block_style_sequences(fm);
1263+
assert!(
1264+
fm.contains("bins:\n"),
1265+
"frontmatter should contain 'bins:' on its own line"
1266+
);
1267+
assert!(
1268+
fm.contains("- gws"),
1269+
"frontmatter should contain '- gws' block entry"
1270+
);
1271+
}
1272+
1273+
#[test]
1274+
fn test_shared_skill_frontmatter_uses_block_sequences() {
1275+
let tmp = tempfile::tempdir().unwrap();
1276+
generate_shared_skill(tmp.path()).unwrap();
1277+
let content = std::fs::read_to_string(tmp.path().join("gws-shared/SKILL.md")).unwrap();
1278+
let fm = extract_frontmatter(&content);
1279+
assert_block_style_sequences(fm);
1280+
assert!(
1281+
fm.contains("- gws"),
1282+
"shared skill frontmatter should contain '- gws'"
1283+
);
1284+
}
1285+
1286+
#[test]
1287+
fn test_persona_skill_frontmatter_uses_block_sequences() {
1288+
let persona = PersonaEntry {
1289+
name: "test-persona".to_string(),
1290+
title: "Test Persona".to_string(),
1291+
description: "A test persona for unit tests.".to_string(),
1292+
services: vec!["gmail".to_string(), "calendar".to_string()],
1293+
workflows: vec![],
1294+
instructions: vec!["Do this.".to_string()],
1295+
tips: vec![],
1296+
};
1297+
let md = render_persona_skill(&persona);
1298+
let fm = extract_frontmatter(&md);
1299+
assert_block_style_sequences(fm);
1300+
assert!(
1301+
fm.contains("- gws"),
1302+
"persona frontmatter should contain '- gws'"
1303+
);
1304+
assert!(
1305+
fm.contains("- gws-gmail"),
1306+
"persona frontmatter should contain '- gws-gmail'"
1307+
);
1308+
assert!(
1309+
fm.contains("- gws-calendar"),
1310+
"persona frontmatter should contain '- gws-calendar'"
1311+
);
1312+
}
1313+
1314+
#[test]
1315+
fn test_recipe_skill_frontmatter_uses_block_sequences() {
1316+
let recipe = RecipeEntry {
1317+
name: "test-recipe".to_string(),
1318+
title: "Test Recipe".to_string(),
1319+
description: "A test recipe for unit tests.".to_string(),
1320+
category: "testing".to_string(),
1321+
services: vec!["drive".to_string(), "sheets".to_string()],
1322+
steps: vec!["Step one.".to_string()],
1323+
caution: None,
1324+
};
1325+
let md = render_recipe_skill(&recipe);
1326+
let fm = extract_frontmatter(&md);
1327+
assert_block_style_sequences(fm);
1328+
assert!(
1329+
fm.contains("- gws"),
1330+
"recipe frontmatter should contain '- gws'"
1331+
);
1332+
assert!(
1333+
fm.contains("- gws-drive"),
1334+
"recipe frontmatter should contain '- gws-drive'"
1335+
);
1336+
assert!(
1337+
fm.contains("- gws-sheets"),
1338+
"recipe frontmatter should contain '- gws-sheets'"
1339+
);
1340+
}
1341+
1342+
#[test]
1343+
fn test_helper_skill_frontmatter_uses_block_sequences() {
1344+
// Use a service known to have helpers, e.g., drive
1345+
let entry = services::SERVICES
1346+
.iter()
1347+
.find(|s| s.api_name == "drive")
1348+
.unwrap();
1349+
1350+
let doc = crate::discovery::RestDescription {
1351+
name: entry.api_name.to_string(),
1352+
title: Some("Test API".to_string()),
1353+
description: Some(entry.description.to_string()),
1354+
..Default::default()
1355+
};
1356+
let cli = crate::commands::build_cli(&doc);
1357+
let helper = cli
1358+
.get_subcommands()
1359+
.find(|s| s.get_name().starts_with('+'))
1360+
.expect("No helper command found for test");
1361+
1362+
let product_name = product_name_from_title("Test API");
1363+
let md = render_helper_skill(
1364+
entry.aliases[0],
1365+
helper.get_name(),
1366+
helper,
1367+
entry,
1368+
&product_name,
1369+
);
1370+
let fm = extract_frontmatter(&md);
1371+
assert_block_style_sequences(fm);
1372+
assert!(
1373+
fm.contains("bins:\n"),
1374+
"frontmatter should contain 'bins:' on its own line"
1375+
);
1376+
assert!(
1377+
fm.contains("- gws"),
1378+
"frontmatter should contain '- gws' block entry"
1379+
);
1380+
}
11901381
}

0 commit comments

Comments
 (0)