diff --git a/crates/context/src/context_cli.rs b/crates/context/src/context_cli.rs index b05343258..d0576b50f 100644 --- a/crates/context/src/context_cli.rs +++ b/crates/context/src/context_cli.rs @@ -344,14 +344,83 @@ fn build_target_selection_directive(sel: &TargetSelection) -> String { // ─── Update check ────────────────────────────────────────────────────────── -static VERSION_RE: Lazy = Lazy::new(|| Regex::new(r"(?m)^version:\s*(.+)$").unwrap()); +static FRONTMATTER_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?s)^---[ \t]*\r?\n(.*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)").unwrap() +}); +static METADATA_KEY_RE: Lazy = + Lazy::new(|| Regex::new(r"^metadata:\s*(?:#.*)?$").unwrap()); +static VERSION_RE: Lazy = Lazy::new(|| Regex::new(r"^version:\s*(.+?)\s*$").unwrap()); + +/// JS: context.mjs#parseSkillFrontmatterVersion +/// +/// Codex's validator rejects unknown top-level keys, so the Codex and +/// `.agents` skills carry `version` under the spec-defined `metadata:` map +/// (#703). A metadata version wins; a legacy top-level one still reads. +fn parse_skill_frontmatter_version(content: &str) -> Option { + let caps = FRONTMATTER_RE.captures(content)?; + let body = caps.get(1)?.as_str(); + + let mut metadata_version: Option = None; + let mut top_level_version: Option = None; + let mut in_metadata = false; + let mut metadata_indent: Option = None; + + for line in body.split('\n') { + let line = line.strip_suffix('\r').unwrap_or(line); + let trimmed_start = line.trim_start(); + if trimmed_start.is_empty() || trimmed_start.starts_with('#') { + continue; + } + let indent_text: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect(); + // JS `indentText.replace(/\t/g, ' ').length`. + let indent = indent_text.replace('\t', " ").chars().count(); + + if indent == 0 { + in_metadata = METADATA_KEY_RE.is_match(line); + metadata_indent = None; + if let Some(m) = VERSION_RE.captures(line) { + top_level_version = Some(m[1].to_string()); + } + continue; + } + if !in_metadata { + continue; + } + if metadata_indent.is_none() { + metadata_indent = Some(indent); + } + if metadata_indent != Some(indent) { + continue; + } + if let Some(m) = VERSION_RE.captures(line.trim()) { + metadata_version = Some(m[1].to_string()); + } + } + + let value = metadata_version.or(top_level_version)?; + let v = js_trim(&value); + if v.is_empty() { + return None; + } + Some(strip_matched_quotes(v)) +} + +/// JS `.replace(/^(["'])(.*)\1$/, '$2')`: only a matched pair is stripped. +fn strip_matched_quotes(v: &str) -> String { + let chars: Vec = v.chars().collect(); + if chars.len() >= 2 { + let first = chars[0]; + if (first == '"' || first == '\'') && chars[chars.len() - 1] == first { + return chars[1..chars.len() - 1].iter().collect(); + } + } + v.to_string() +} fn read_local_skill_version(provider: &Provider) -> Option { let p = provider.skill_md_path()?; let content = safe_read(&p)?; - let m = VERSION_RE.captures(&content)?; - let v = js_trim(&m[1]); - Some(strip_one_quote_each_end(v)) + parse_skill_frontmatter_version(&content) } fn update_cache_path(env: &Env) -> String { @@ -619,3 +688,38 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { io.out(&format!("{}\n", parts.join("\n\n---\n\n"))); 0 } + +#[cfg(test)] +mod skill_version_tests { + use super::parse_skill_frontmatter_version as v; + + /// Values recorded from origin/main's `parseSkillFrontmatterVersion` (#703). + #[test] + fn frontmatter_version_shapes() { + assert_eq!(v("---\nname: impeccable\nversion: 4.1.3\n---\n\nbody\n").as_deref(), Some("4.1.3")); + assert_eq!(v("---\nname: impeccable\nversion: \"4.1.3\"\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(v("---\nname: impeccable\nversion: '4.1.3'\n---\n").as_deref(), Some("4.1.3")); + assert_eq!( + v("---\nname: impeccable\nmetadata:\n version: 4.1.3\n argument-hint: \"[t]\"\n---\n").as_deref(), + Some("4.1.3") + ); + // A metadata version wins over a legacy top-level one, in either order. + assert_eq!(v("---\nversion: 1.0.0\nmetadata:\n version: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!( + v("---\nmetadata:\n version: 4.1.3\nname: x\nversion: 2.0.0\n---\n").as_deref(), + Some("4.1.3") + ); + // Only the map's own indent level counts, so a deeper key is ignored. + assert_eq!( + v("---\nmetadata:\n a:\n version: 9.9.9\n version: 4.1.3\n---\n").as_deref(), + Some("4.1.3") + ); + assert_eq!(v("---\nmetadata:\n\tversion: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(v("---\nmetadata: # note\n version: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(v("---\r\nmetadata:\r\n version: 4.1.3\r\n---\r\n").as_deref(), Some("4.1.3")); + assert_eq!(v("---\n# version: 9.9.9\nversion: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(v("--- \nversion: 4.1.3\n--- \n").as_deref(), Some("4.1.3")); + assert_eq!(v("version: 4.1.3\n"), None); + assert_eq!(v("---\nversion:\n---\n"), None); + } +} diff --git a/crates/skills/src/providers.rs b/crates/skills/src/providers.rs index 8b954091d..7c56b4533 100644 --- a/crates/skills/src/providers.rs +++ b/crates/skills/src/providers.rs @@ -507,24 +507,104 @@ pub fn is_real_skill_dir(skills_dir: &str, name: &str) -> bool { util::is_real_dir(&full) && util::exists(&jsp::join(&[&full, "SKILL.md"])) } -/// `^version:\s*(.+)$` (multiline) with surrounding quotes stripped. +/// JS: skills.mjs#parseSkillFrontmatterVersion +/// +/// Codex's validator rejects unknown top-level keys, so the Codex and +/// `.agents` skills carry `version` under the spec-defined `metadata:` map +/// (#703). A metadata version wins; a legacy top-level one still reads. fn extract_version(content: &str) -> Option { - for line in content.split('\n') { + let body = frontmatter_body(content)?; + + let mut metadata_version: Option = None; + let mut top_level_version: Option = None; + let mut in_metadata = false; + let mut metadata_indent: Option = None; + + for line in body.split('\n') { let line = line.strip_suffix('\r').unwrap_or(line); - if let Some(rest) = line.strip_prefix("version:") { - let v = rest.trim_start_matches(|c: char| c.is_whitespace()); - if v.is_empty() { - continue; + let trimmed_start = line.trim_start(); + if trimmed_start.is_empty() || trimmed_start.starts_with('#') { + continue; + } + let indent_text: String = line.chars().take_while(|c| *c == ' ' || *c == '\t').collect(); + // JS `indentText.replace(/\t/g, ' ').length`. + let indent = indent_text.replace('\t', " ").chars().count(); + + if indent == 0 { + in_metadata = is_metadata_key_line(line); + metadata_indent = None; + if let Some(v) = version_value(line) { + top_level_version = Some(v); } - let v = v.trim(); - let v = v.strip_prefix(['"', '\'']).unwrap_or(v); - let v = v.strip_suffix(['"', '\'']).unwrap_or(v); - return Some(v.to_string()); + continue; + } + if !in_metadata { + continue; + } + if metadata_indent.is_none() { + metadata_indent = Some(indent); + } + if metadata_indent != Some(indent) { + continue; + } + if let Some(v) = version_value(line.trim()) { + metadata_version = Some(v); } } + + let value = metadata_version.or(top_level_version)?; + let v = value.trim(); + if v.is_empty() { + return None; + } + // JS `.replace(/^(["'])(.*)\1$/, '$2')`: only a matched pair is stripped. + let chars: Vec = v.chars().collect(); + if chars.len() >= 2 { + let first = chars[0]; + if (first == '"' || first == '\'') && chars[chars.len() - 1] == first { + return Some(chars[1..chars.len() - 1].iter().collect()); + } + } + Some(v.to_string()) +} + +/// JS `/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/`. +fn frontmatter_body(content: &str) -> Option<&str> { + let rest = content.strip_prefix("---")?; + let rest = rest.trim_start_matches([' ', '\t']); + let rest = rest.strip_prefix("\r\n").or_else(|| rest.strip_prefix('\n'))?; + let mut from = 0usize; + while let Some(idx) = rest[from..].find("\n---") { + let at = from + idx; + let after = &rest[at + 4..]; + let tail = after.trim_start_matches([' ', '\t']); + if tail.is_empty() || tail.starts_with('\n') || tail.starts_with("\r\n") { + let body = &rest[..at]; + return Some(body.strip_suffix('\r').unwrap_or(body)); + } + from = at + 1; + } None } +/// JS `/^metadata:\s*(?:#.*)?$/`. +fn is_metadata_key_line(line: &str) -> bool { + let Some(rest) = line.strip_prefix("metadata:") else { return false }; + let rest = rest.trim_start_matches(|c: char| c.is_whitespace()); + rest.is_empty() || rest.starts_with('#') +} + +/// JS `/^version:\s*(.+?)\s*$/` on an already-trimmed line. +fn version_value(line: &str) -> Option { + let rest = line.strip_prefix("version:")?; + let v = rest.trim(); + if v.is_empty() { + None + } else { + Some(v.to_string()) + } +} + /// JS: getFlagValue(flags, name): `--name=value` or `--name value`. pub fn get_flag_value<'a>(flags: &'a [String], name: &str) -> Option<&'a str> { let prefix = format!("{name}="); @@ -680,8 +760,31 @@ mod tests { #[test] fn version_extraction() { + // Values recorded from origin/main's parseSkillFrontmatterVersion (#703). assert_eq!(extract_version("---\nname: x\nversion: \"9.9.9\"\n---").as_deref(), Some("9.9.9")); assert_eq!(extract_version("---\nname: x\n---"), None); + assert_eq!(extract_version("---\nname: x\nversion: 4.1.3\n---\n\nbody\n").as_deref(), Some("4.1.3")); + assert_eq!(extract_version("---\nname: x\nversion: '4.1.3'\n---\n").as_deref(), Some("4.1.3")); + assert_eq!( + extract_version("---\nname: x\nmetadata:\n version: 4.1.3\n argument-hint: \"[t]\"\n---\n").as_deref(), + Some("4.1.3") + ); + assert_eq!(extract_version("---\nversion: 1.0.0\nmetadata:\n version: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!( + extract_version("---\nmetadata:\n version: 4.1.3\nname: x\nversion: 2.0.0\n---\n").as_deref(), + Some("4.1.3") + ); + assert_eq!( + extract_version("---\nmetadata:\n a:\n version: 9.9.9\n version: 4.1.3\n---\n").as_deref(), + Some("4.1.3") + ); + assert_eq!(extract_version("---\nmetadata:\n\tversion: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(extract_version("---\nmetadata: # note\n version: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(extract_version("---\r\nmetadata:\r\n version: 4.1.3\r\n---\r\n").as_deref(), Some("4.1.3")); + assert_eq!(extract_version("---\n# version: 9.9.9\nversion: 4.1.3\n---\n").as_deref(), Some("4.1.3")); + assert_eq!(extract_version("--- \nversion: 4.1.3\n--- \n").as_deref(), Some("4.1.3")); + assert_eq!(extract_version("version: 4.1.3\n"), None); + assert_eq!(extract_version("---\nversion:\n---\n"), None); } #[test]