@@ -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]
755758fn 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