mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
Move executor skill bundle loading into the skills extension (#36877)
## What changed - Move parsing of pre-discovered executor skill bundles from `core-skills` to the skills extension. - Expose shared `SKILL.md` frontmatter parsing from `codex-skills` so direct and pre-discovered loading use the same validation and repair behavior. ## Testing - Add parity coverage for direct and pre-discovered executor skill catalogs, including plugin namespaces, metadata, product policy, and warnings. - Add a snapshot for the resulting pre-discovered executor catalog. GitOrigin-RevId: c5f888226fa5600bd8b90f5682400da39a5db5ff
This commit is contained in:
1 parent
2a16af8234
commit
4c25d6cc5c
17 files changed
+1138
-488
No files matched your search
Generated
+7
@@ -4087,6 +4087,9 @@ dependencies = [
|
|||||||
"codex-utils-absolute-path",
|
"codex-utils-absolute-path",
|
||||||
"codex-utils-path-uri",
|
"codex-utils-path-uri",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"pretty_assertions",
|
||||||
|
"serde",
|
||||||
|
"serde_yaml",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4104,14 +4107,18 @@ dependencies = [
|
|||||||
"codex-skills",
|
"codex-skills",
|
||||||
"codex-tools",
|
"codex-tools",
|
||||||
"codex-utils-absolute-path",
|
"codex-utils-absolute-path",
|
||||||
|
"codex-utils-cargo-bin",
|
||||||
"codex-utils-path-uri",
|
"codex-utils-path-uri",
|
||||||
"codex-utils-string",
|
"codex-utils-string",
|
||||||
"futures",
|
"futures",
|
||||||
|
"insta",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ mod namespace;
|
|||||||
|
|
||||||
pub use environment::EnvironmentSkillLoadOutcome;
|
pub use environment::EnvironmentSkillLoadOutcome;
|
||||||
pub use environment::EnvironmentSkillMetadata;
|
pub use environment::EnvironmentSkillMetadata;
|
||||||
pub use environment::EnvironmentSkillSnapshot;
|
|
||||||
pub use environment::EnvironmentSkillSnapshotOutcome;
|
|
||||||
pub use environment::load_environment_skills_from_discovery;
|
|
||||||
pub use environment::load_environment_skills_from_root;
|
pub use environment::load_environment_skills_from_root;
|
||||||
|
|
||||||
use crate::model::SkillDependencies;
|
use crate::model::SkillDependencies;
|
||||||
@@ -26,6 +23,9 @@ use codex_exec_server::ExecutorFileSystem;
|
|||||||
use codex_exec_server::LOCAL_FS;
|
use codex_exec_server::LOCAL_FS;
|
||||||
use codex_protocol::protocol::Product;
|
use codex_protocol::protocol::Product;
|
||||||
use codex_protocol::protocol::SkillScope;
|
use codex_protocol::protocol::SkillScope;
|
||||||
|
use codex_skills::ParsedSkillFrontmatter;
|
||||||
|
use codex_skills::SkillParseError;
|
||||||
|
use codex_skills::parse_skill_frontmatter_metadata;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
@@ -60,22 +60,6 @@ use tracing::error;
|
|||||||
// TODO(anp): Tune this eight-scan limit after revisiting byte-based backpressure.
|
// TODO(anp): Tune this eight-scan limit after revisiting byte-based backpressure.
|
||||||
pub const MAX_CONCURRENT_ROOT_SCANS: usize = 8;
|
pub const MAX_CONCURRENT_ROOT_SCANS: usize = 8;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct SkillFrontmatter {
|
|
||||||
#[serde(default)]
|
|
||||||
name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
metadata: SkillFrontmatterMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct SkillFrontmatterMetadata {
|
|
||||||
#[serde(default, rename = "short-description")]
|
|
||||||
short_description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct SkillMetadataFile {
|
struct SkillMetadataFile {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -128,13 +112,6 @@ struct DependencyTool {
|
|||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct ParsedSkillFrontmatter {
|
|
||||||
name: String,
|
|
||||||
description: String,
|
|
||||||
short_description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SKILLS_FILENAME: &str = "SKILL.md";
|
const SKILLS_FILENAME: &str = "SKILL.md";
|
||||||
const AGENTS_DIR_NAME: &str = ".agents";
|
const AGENTS_DIR_NAME: &str = ".agents";
|
||||||
const SKILLS_METADATA_DIR: &str = "agents";
|
const SKILLS_METADATA_DIR: &str = "agents";
|
||||||
@@ -165,31 +142,27 @@ struct ResolvedDiscoveredSkill {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum SkillParseError {
|
enum SkillLoadError {
|
||||||
Read(std::io::Error),
|
Read(std::io::Error),
|
||||||
MissingFrontmatter,
|
Parse(SkillParseError),
|
||||||
InvalidYaml(serde_yaml::Error),
|
|
||||||
MissingField(&'static str),
|
|
||||||
InvalidField { field: &'static str, reason: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for SkillParseError {
|
impl fmt::Display for SkillLoadError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
SkillParseError::Read(e) => write!(f, "failed to read file: {e}"),
|
Self::Read(error) => write!(f, "failed to read file: {error}"),
|
||||||
SkillParseError::MissingFrontmatter => {
|
Self::Parse(error) => write!(f, "{error}"),
|
||||||
write!(f, "missing YAML frontmatter delimited by ---")
|
|
||||||
}
|
|
||||||
SkillParseError::InvalidYaml(e) => write!(f, "invalid YAML: {e}"),
|
|
||||||
SkillParseError::MissingField(field) => write!(f, "missing field `{field}`"),
|
|
||||||
SkillParseError::InvalidField { field, reason } => {
|
|
||||||
write!(f, "invalid {field}: {reason}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error for SkillParseError {}
|
impl Error for SkillLoadError {}
|
||||||
|
|
||||||
|
impl From<SkillParseError> for SkillLoadError {
|
||||||
|
fn from(error: SkillParseError) -> Self {
|
||||||
|
Self::Parse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SkillRoot {
|
pub struct SkillRoot {
|
||||||
pub path: AbsolutePathBuf,
|
pub path: AbsolutePathBuf,
|
||||||
@@ -693,7 +666,7 @@ async fn parse_skill_file(
|
|||||||
scope: SkillScope,
|
scope: SkillScope,
|
||||||
plugin_identity: Option<&PluginIdentity>,
|
plugin_identity: Option<&PluginIdentity>,
|
||||||
plugin_root: Option<&AbsolutePathBuf>,
|
plugin_root: Option<&AbsolutePathBuf>,
|
||||||
) -> Result<SkillMetadata, SkillParseError> {
|
) -> Result<SkillMetadata, SkillLoadError> {
|
||||||
let metadata_path = path_uri
|
let metadata_path = path_uri
|
||||||
.parent()
|
.parent()
|
||||||
.and_then(|parent| parent.join(SKILLS_METADATA_DIR).ok())
|
.and_then(|parent| parent.join(SKILLS_METADATA_DIR).ok())
|
||||||
@@ -708,12 +681,12 @@ async fn parse_skill_file(
|
|||||||
fs.read_file_text(path_uri, /*sandbox*/ None),
|
fs.read_file_text(path_uri, /*sandbox*/ None),
|
||||||
load_skill_metadata(fs, path, &metadata, plugin_root),
|
load_skill_metadata(fs, path, &metadata, plugin_root),
|
||||||
);
|
);
|
||||||
let contents = contents.map_err(SkillParseError::Read)?;
|
let contents = contents.map_err(SkillLoadError::Read)?;
|
||||||
let ParsedSkillFrontmatter {
|
let ParsedSkillFrontmatter {
|
||||||
name: base_name,
|
name: base_name,
|
||||||
description,
|
description,
|
||||||
short_description,
|
short_description,
|
||||||
} = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))?;
|
} = parse_skill_frontmatter_metadata(&contents, || default_skill_name(path))?;
|
||||||
let LoadedSkillMetadata {
|
let LoadedSkillMetadata {
|
||||||
interface,
|
interface,
|
||||||
dependencies,
|
dependencies,
|
||||||
@@ -734,56 +707,6 @@ async fn parse_skill_file(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_skill_frontmatter_metadata_inner(
|
|
||||||
contents: &str,
|
|
||||||
default_name: impl FnOnce() -> String,
|
|
||||||
) -> Result<ParsedSkillFrontmatter, SkillParseError> {
|
|
||||||
let frontmatter = extract_frontmatter(contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
|
||||||
|
|
||||||
let parsed: SkillFrontmatter = match serde_yaml::from_str(&frontmatter) {
|
|
||||||
Ok(parsed) => Ok(parsed),
|
|
||||||
Err(original_error) => match repair_frontmatter_scalar_fields(&frontmatter) {
|
|
||||||
// Some third-party skills use prose like `description: Build for AWS: ECS`
|
|
||||||
// or `argument-hint: <duration: e.g. 7d>`. Keep the repair line-oriented
|
|
||||||
// so unrelated invalid YAML still surfaces.
|
|
||||||
Some(repaired_frontmatter) => {
|
|
||||||
serde_yaml::from_str(&repaired_frontmatter).map_err(|_| original_error)
|
|
||||||
}
|
|
||||||
None => Err(original_error),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
.map_err(SkillParseError::InvalidYaml)?;
|
|
||||||
|
|
||||||
let name = parsed
|
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.map(sanitize_single_line)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or_else(default_name);
|
|
||||||
let description = parsed
|
|
||||||
.description
|
|
||||||
.as_deref()
|
|
||||||
.map(sanitize_single_line)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let short_description = parsed
|
|
||||||
.metadata
|
|
||||||
.short_description
|
|
||||||
.as_deref()
|
|
||||||
.map(sanitize_single_line)
|
|
||||||
.filter(|value| !value.is_empty());
|
|
||||||
|
|
||||||
validate_len(&name, MAX_NAME_LEN, "name")?;
|
|
||||||
if description.is_empty() {
|
|
||||||
return Err(SkillParseError::MissingField("description"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ParsedSkillFrontmatter {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
short_description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
||||||
path.parent()
|
path.parent()
|
||||||
.and_then(|parent| {
|
.and_then(|parent| {
|
||||||
@@ -1067,91 +990,6 @@ fn sanitize_single_line(raw: &str) -> String {
|
|||||||
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn repair_frontmatter_scalar_fields(frontmatter: &str) -> Option<String> {
|
|
||||||
let mut changed = false;
|
|
||||||
let mut block_scalar_indent: Option<usize> = None;
|
|
||||||
let mut repaired_lines: Vec<String> = Vec::new();
|
|
||||||
for line in frontmatter.lines() {
|
|
||||||
let indent = line
|
|
||||||
.chars()
|
|
||||||
.take_while(|character| *character == ' ')
|
|
||||||
.count();
|
|
||||||
if let Some(block_indent) = block_scalar_indent {
|
|
||||||
if line.trim().is_empty() || indent > block_indent {
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
block_scalar_indent = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((key, value)) = line.split_once(':') else {
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if key.trim().is_empty() || !value.chars().next().is_none_or(char::is_whitespace) {
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmed_start = value.trim_start();
|
|
||||||
let leading_whitespace = &value[..value.len() - trimmed_start.len()];
|
|
||||||
let mut scalar = trimmed_start;
|
|
||||||
let mut comment = "";
|
|
||||||
for (index, character) in trimmed_start.char_indices() {
|
|
||||||
if character == '#'
|
|
||||||
&& (index == 0
|
|
||||||
|| trimmed_start[..index]
|
|
||||||
.chars()
|
|
||||||
.next_back()
|
|
||||||
.is_some_and(char::is_whitespace))
|
|
||||||
{
|
|
||||||
let comment_start = trimmed_start[..index].trim_end().len();
|
|
||||||
scalar = &trimmed_start[..comment_start];
|
|
||||||
comment = &trimmed_start[comment_start..];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let scalar = scalar.trim_end();
|
|
||||||
let Some(first_char) = scalar.chars().next() else {
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if matches!(first_char, '|' | '>') {
|
|
||||||
block_scalar_indent = Some(indent);
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if matches!(first_char, '\'' | '"') {
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut has_colon_separator = false;
|
|
||||||
let mut chars = scalar.chars().peekable();
|
|
||||||
while let Some(character) = chars.next() {
|
|
||||||
if character == ':'
|
|
||||||
&& matches!(chars.peek(), Some(next_character) if next_character.is_whitespace())
|
|
||||||
{
|
|
||||||
has_colon_separator = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let invalid_flow_like_scalar = matches!(first_char, '[' | '{' | '@' | '`')
|
|
||||||
&& serde_yaml::from_str::<serde_yaml::Value>(scalar).is_err();
|
|
||||||
if !has_colon_separator && !invalid_flow_like_scalar {
|
|
||||||
repaired_lines.push(line.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let quoted_scalar = format!("'{}'", scalar.replace('\'', "''"));
|
|
||||||
repaired_lines.push(format!(
|
|
||||||
"{key}:{leading_whitespace}{quoted_scalar}{comment}"
|
|
||||||
));
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
changed.then(|| repaired_lines.join("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_len(
|
fn validate_len(
|
||||||
value: &str,
|
value: &str,
|
||||||
max_len: usize,
|
max_len: usize,
|
||||||
@@ -1211,28 +1049,6 @@ fn resolve_color_str(value: Option<String>, field: &'static str) -> Option<Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_frontmatter(contents: &str) -> Option<String> {
|
|
||||||
let mut lines = contents.lines();
|
|
||||||
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut frontmatter_lines: Vec<&str> = Vec::new();
|
|
||||||
let mut found_closing = false;
|
|
||||||
for line in lines.by_ref() {
|
|
||||||
if line.trim() == "---" {
|
|
||||||
found_closing = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
frontmatter_lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
if frontmatter_lines.is_empty() || !found_closing {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(frontmatter_lines.join("\n"))
|
|
||||||
}
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn skill_roots_from_layer_stack(
|
pub(crate) async fn skill_roots_from_layer_stack(
|
||||||
fs: Arc<dyn ExecutorFileSystem>,
|
fs: Arc<dyn ExecutorFileSystem>,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use codex_exec_server::CapabilityRootDiscovery;
|
|
||||||
use codex_exec_server::ExecutorFileSystem;
|
use codex_exec_server::ExecutorFileSystem;
|
||||||
use codex_protocol::protocol::Product;
|
use codex_protocol::protocol::Product;
|
||||||
pub use codex_skills::EnvironmentSkillMetadata;
|
pub use codex_skills::EnvironmentSkillMetadata;
|
||||||
|
use codex_skills::ParsedSkillFrontmatter;
|
||||||
|
use codex_skills::parse_skill_frontmatter_metadata;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ use crate::model::SkillDependencies;
|
|||||||
use crate::model::SkillPolicy;
|
use crate::model::SkillPolicy;
|
||||||
|
|
||||||
use super::MAX_QUALIFIED_NAME_LEN;
|
use super::MAX_QUALIFIED_NAME_LEN;
|
||||||
use super::ParsedSkillFrontmatter;
|
|
||||||
use super::SkillMetadataFile;
|
use super::SkillMetadataFile;
|
||||||
use super::discovery::DirectorySymlinkPolicy;
|
use super::discovery::DirectorySymlinkPolicy;
|
||||||
use super::discovery::DiscoveredSkill;
|
use super::discovery::DiscoveredSkill;
|
||||||
@@ -22,7 +21,6 @@ use super::discovery::SkillDiscoveryOptions;
|
|||||||
use super::discovery::SkillMetadataDiscovery;
|
use super::discovery::SkillMetadataDiscovery;
|
||||||
use super::discovery::discover_skills;
|
use super::discovery::discover_skills;
|
||||||
use super::namespace::SkillNamespaceResolver;
|
use super::namespace::SkillNamespaceResolver;
|
||||||
use super::parse_skill_frontmatter_metadata_inner;
|
|
||||||
use super::resolve_dependencies;
|
use super::resolve_dependencies;
|
||||||
use super::resolve_policy;
|
use super::resolve_policy;
|
||||||
use super::sanitize_single_line;
|
use super::sanitize_single_line;
|
||||||
@@ -37,19 +35,6 @@ struct ParsedEnvironmentSkill {
|
|||||||
policy: Option<SkillPolicy>,
|
policy: Option<SkillPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed executor skill plus the instructions already materialized by capability discovery.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct EnvironmentSkillSnapshot {
|
|
||||||
pub metadata: EnvironmentSkillMetadata,
|
|
||||||
pub instructions: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct EnvironmentSkillSnapshotOutcome {
|
|
||||||
pub skills: Vec<EnvironmentSkillSnapshot>,
|
|
||||||
pub warnings: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParsedEnvironmentSkill {
|
impl ParsedEnvironmentSkill {
|
||||||
async fn load(
|
async fn load(
|
||||||
file_system: &dyn ExecutorFileSystem,
|
file_system: &dyn ExecutorFileSystem,
|
||||||
@@ -72,7 +57,7 @@ impl ParsedEnvironmentSkill {
|
|||||||
name: base_name,
|
name: base_name,
|
||||||
description,
|
description,
|
||||||
short_description,
|
short_description,
|
||||||
} = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(&skill.path))
|
} = parse_skill_frontmatter_metadata(&contents, || default_skill_name(&skill.path))
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let (dependencies, policy) = match &skill.metadata {
|
let (dependencies, policy) = match &skill.metadata {
|
||||||
SkillMetadataDiscovery::Present(_) | SkillMetadataDiscovery::Absent => {
|
SkillMetadataDiscovery::Present(_) | SkillMetadataDiscovery::Absent => {
|
||||||
@@ -197,130 +182,6 @@ pub async fn load_environment_skills_from_root(
|
|||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses an executor-produced manifest bundle without issuing additional filesystem requests.
|
|
||||||
pub fn load_environment_skills_from_discovery(
|
|
||||||
discovery: &CapabilityRootDiscovery,
|
|
||||||
restriction_product: Option<Product>,
|
|
||||||
) -> EnvironmentSkillSnapshotOutcome {
|
|
||||||
let mut outcome = EnvironmentSkillSnapshotOutcome {
|
|
||||||
warnings: discovery.warnings.clone(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
if let Some(error) = &discovery.error {
|
|
||||||
outcome.warnings.push(error.clone());
|
|
||||||
return outcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut plugin_namespaces = HashMap::new();
|
|
||||||
for (plugin_root, name) in discovery.namespace_manifests.iter().filter_map(|manifest| {
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct ManifestName {
|
|
||||||
#[serde(default)]
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugin_root = manifest.path.parent()?.parent()?;
|
|
||||||
let parsed = serde_json::from_str::<ManifestName>(&manifest.contents).ok()?;
|
|
||||||
let name = if parsed.name.trim().is_empty() {
|
|
||||||
plugin_root.basename()?
|
|
||||||
} else {
|
|
||||||
parsed.name
|
|
||||||
};
|
|
||||||
Some((plugin_root, name))
|
|
||||||
}) {
|
|
||||||
// Exec-server orders manifests by the same precedence as local discovery. Preserve the
|
|
||||||
// first manifest if an older or alternate server returns duplicates for one plugin root.
|
|
||||||
plugin_namespaces.entry(plugin_root).or_insert(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
for skill in &discovery.skills {
|
|
||||||
let plugin_namespace =
|
|
||||||
nearest_plugin_namespace(&skill.instructions.path, &plugin_namespaces);
|
|
||||||
let ParsedSkillFrontmatter {
|
|
||||||
name: base_name,
|
|
||||||
description,
|
|
||||||
short_description,
|
|
||||||
} = match parse_skill_frontmatter_metadata_inner(&skill.instructions.contents, || {
|
|
||||||
default_skill_name(&skill.instructions.path)
|
|
||||||
}) {
|
|
||||||
Ok(frontmatter) => frontmatter,
|
|
||||||
Err(error) => {
|
|
||||||
outcome.warnings.push(format!(
|
|
||||||
"Failed to load environment skill at {}: {error}",
|
|
||||||
skill.instructions.path
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let name = plugin_namespace
|
|
||||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
|
||||||
.unwrap_or(base_name);
|
|
||||||
if let Err(error) = validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name") {
|
|
||||||
outcome.warnings.push(format!(
|
|
||||||
"Failed to load environment skill at {}: {error}",
|
|
||||||
skill.instructions.path
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (dependencies, policy) = skill
|
|
||||||
.metadata
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|metadata| {
|
|
||||||
serde_yaml::from_str::<SkillMetadataFile>(&metadata.contents)
|
|
||||||
.map_err(|error| {
|
|
||||||
tracing::warn!(
|
|
||||||
path = %metadata.path,
|
|
||||||
"ignoring invalid discovered skill metadata: {error}"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.map(|metadata| {
|
|
||||||
(
|
|
||||||
resolve_dependencies(metadata.dependencies),
|
|
||||||
resolve_policy(metadata.policy),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((None, None));
|
|
||||||
let metadata = EnvironmentSkillMetadata {
|
|
||||||
path_to_skills_md: skill.instructions.path.clone(),
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
short_description,
|
|
||||||
dependencies,
|
|
||||||
policy,
|
|
||||||
};
|
|
||||||
if metadata.matches_product_restriction(restriction_product) {
|
|
||||||
outcome.skills.push(EnvironmentSkillSnapshot {
|
|
||||||
metadata,
|
|
||||||
instructions: skill.instructions.contents.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outcome.skills.sort_by(|left, right| {
|
|
||||||
left.metadata.name.cmp(&right.metadata.name).then_with(|| {
|
|
||||||
left.metadata
|
|
||||||
.path_to_skills_md
|
|
||||||
.to_string()
|
|
||||||
.cmp(&right.metadata.path_to_skills_md.to_string())
|
|
||||||
})
|
|
||||||
});
|
|
||||||
outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nearest_plugin_namespace<'a>(
|
|
||||||
skill_path: &PathUri,
|
|
||||||
plugin_namespaces: &'a HashMap<PathUri, String>,
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
let mut ancestor = skill_path.parent();
|
|
||||||
while let Some(path) = ancestor {
|
|
||||||
if let Some(namespace) = plugin_namespaces.get(&path) {
|
|
||||||
return Some(namespace);
|
|
||||||
}
|
|
||||||
ancestor = path.parent();
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
async fn read_skill_contents(
|
async fn read_skill_contents(
|
||||||
file_system: &dyn ExecutorFileSystem,
|
file_system: &dyn ExecutorFileSystem,
|
||||||
skill_path: &PathUri,
|
skill_path: &PathUri,
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use codex_core_skills::loader::EnvironmentSkillMetadata;
|
use codex_core_skills::loader::EnvironmentSkillMetadata;
|
||||||
use codex_core_skills::loader::load_environment_skills_from_discovery;
|
|
||||||
use codex_core_skills::loader::load_environment_skills_from_root;
|
use codex_core_skills::loader::load_environment_skills_from_root;
|
||||||
use codex_exec_server::CapabilityRootDiscoverRequest;
|
|
||||||
use codex_exec_server::CapabilityRootsDiscoverParams;
|
|
||||||
use codex_exec_server::CopyOptions;
|
use codex_exec_server::CopyOptions;
|
||||||
use codex_exec_server::CreateDirectoryOptions;
|
use codex_exec_server::CreateDirectoryOptions;
|
||||||
use codex_exec_server::ExecutorFileSystem;
|
use codex_exec_server::ExecutorFileSystem;
|
||||||
@@ -22,7 +19,6 @@ use codex_exec_server::ReadDirectoryEntry;
|
|||||||
use codex_exec_server::RemoveOptions;
|
use codex_exec_server::RemoveOptions;
|
||||||
use codex_exec_server::WalkOptions;
|
use codex_exec_server::WalkOptions;
|
||||||
use codex_exec_server::WalkOutcome;
|
use codex_exec_server::WalkOutcome;
|
||||||
use codex_exec_server::discover_capability_roots;
|
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
@@ -547,142 +543,3 @@ async fn host_loading_reuses_walk_inventory_for_symlinked_skill_pack() {
|
|||||||
1
|
1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn executor_bundle_parser_matches_the_existing_environment_loader() {
|
|
||||||
let root = tempdir().expect("tempdir");
|
|
||||||
let plugin_manifest = root.path().join(".codex-plugin/plugin.json");
|
|
||||||
let nested_manifest = root.path().join("nested/.claude-plugin/plugin.json");
|
|
||||||
let deploy_skill = root.path().join("skills/deploy/SKILL.md");
|
|
||||||
let deploy_metadata = root.path().join("skills/deploy/agents/openai.yaml");
|
|
||||||
let audit_skill = root.path().join("nested/skills/audit/SKILL.md");
|
|
||||||
for (path, contents) in [
|
|
||||||
(&plugin_manifest, r#"{"name":"demo"}"#),
|
|
||||||
(&nested_manifest, r#"{"name":"nested"}"#),
|
|
||||||
(
|
|
||||||
&deploy_skill,
|
|
||||||
"---\nname: deploy\ndescription: Deploy the service.\n---\n\nDeploy.\n",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
&deploy_metadata,
|
|
||||||
"policy:\n allow_implicit_invocation: false\n",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
&audit_skill,
|
|
||||||
"---\nname: audit\ndescription: Audit the service.\n---\n\nAudit.\n",
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
fs::create_dir_all(path.parent().expect("test file parent")).expect("test directory");
|
|
||||||
fs::write(path, contents).expect("test file");
|
|
||||||
}
|
|
||||||
|
|
||||||
let root_uri = PathUri::from_host_native_path(root.path()).expect("root URI");
|
|
||||||
let existing = load_environment_skills_from_root(
|
|
||||||
LOCAL_FS.as_ref(),
|
|
||||||
&root_uri,
|
|
||||||
/*restriction_product*/ None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let response = discover_capability_roots(
|
|
||||||
LOCAL_FS.as_ref(),
|
|
||||||
CapabilityRootsDiscoverParams {
|
|
||||||
roots: vec![CapabilityRootDiscoverRequest {
|
|
||||||
id: "demo@1".to_string(),
|
|
||||||
path: root_uri,
|
|
||||||
sandbox: None,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("capability discovery");
|
|
||||||
let bundled = load_environment_skills_from_discovery(
|
|
||||||
response.roots.first().expect("discovered root"),
|
|
||||||
/*restriction_product*/ None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(bundled.warnings, existing.warnings);
|
|
||||||
assert_eq!(
|
|
||||||
bundled
|
|
||||||
.skills
|
|
||||||
.iter()
|
|
||||||
.map(|skill| skill.metadata.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
existing.skills
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
bundled
|
|
||||||
.skills
|
|
||||||
.iter()
|
|
||||||
.map(|skill| skill.instructions.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![
|
|
||||||
"---\nname: deploy\ndescription: Deploy the service.\n---\n\nDeploy.\n",
|
|
||||||
"---\nname: audit\ndescription: Audit the service.\n---\n\nAudit.\n",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn executor_bundle_preserves_parent_namespace_and_manifest_precedence() {
|
|
||||||
let plugin = tempdir().expect("tempdir");
|
|
||||||
for (relative_path, name) in [
|
|
||||||
(".codex-plugin/plugin.json", "codex-name"),
|
|
||||||
(".claude-plugin/plugin.json", "claude-name"),
|
|
||||||
(".cursor-plugin/plugin.json", "cursor-name"),
|
|
||||||
] {
|
|
||||||
let manifest = plugin.path().join(relative_path);
|
|
||||||
fs::create_dir_all(manifest.parent().expect("manifest parent"))
|
|
||||||
.expect("manifest directory");
|
|
||||||
fs::write(&manifest, format!(r#"{{"name":"{name}"}}"#)).expect("manifest");
|
|
||||||
}
|
|
||||||
let skills_root = plugin.path().join("skills");
|
|
||||||
let skill_path = skills_root.join("search/SKILL.md");
|
|
||||||
fs::create_dir_all(skill_path.parent().expect("skill parent")).expect("skill directory");
|
|
||||||
fs::write(
|
|
||||||
&skill_path,
|
|
||||||
"---\nname: search\ndescription: Search the project.\n---\n\nSearch.\n",
|
|
||||||
)
|
|
||||||
.expect("skill");
|
|
||||||
|
|
||||||
let root_uri = PathUri::from_host_native_path(&skills_root).expect("skills root URI");
|
|
||||||
let existing = load_environment_skills_from_root(
|
|
||||||
LOCAL_FS.as_ref(),
|
|
||||||
&root_uri,
|
|
||||||
/*restriction_product*/ None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let response = discover_capability_roots(
|
|
||||||
LOCAL_FS.as_ref(),
|
|
||||||
CapabilityRootsDiscoverParams {
|
|
||||||
roots: vec![CapabilityRootDiscoverRequest {
|
|
||||||
id: "skills-only".to_string(),
|
|
||||||
path: root_uri,
|
|
||||||
sandbox: None,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("capability discovery");
|
|
||||||
let discovery = response.roots.first().expect("discovered root");
|
|
||||||
let bundled =
|
|
||||||
load_environment_skills_from_discovery(discovery, /*restriction_product*/ None);
|
|
||||||
|
|
||||||
assert_eq!(discovery.plugin, None);
|
|
||||||
assert_eq!(discovery.namespace_manifests.len(), 1);
|
|
||||||
assert!(
|
|
||||||
discovery.namespace_manifests[0]
|
|
||||||
.path
|
|
||||||
.to_string()
|
|
||||||
.ends_with("/.codex-plugin/plugin.json")
|
|
||||||
);
|
|
||||||
assert_eq!(bundled.warnings, existing.warnings);
|
|
||||||
assert_eq!(
|
|
||||||
bundled
|
|
||||||
.skills
|
|
||||||
.iter()
|
|
||||||
.map(|skill| skill.metadata.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
existing.skills
|
|
||||||
);
|
|
||||||
assert_eq!(bundled.skills[0].metadata.name, "codex-name:search");
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ futures = { workspace = true }
|
|||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
serde_yaml = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "time"] }
|
tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
@@ -34,6 +35,9 @@ url = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
codex-models-manager = { workspace = true }
|
codex-models-manager = { workspace = true }
|
||||||
codex-utils-absolute-path = { workspace = true }
|
codex-utils-absolute-path = { workspace = true }
|
||||||
|
codex-utils-cargo-bin = { workspace = true }
|
||||||
|
insta = { workspace = true }
|
||||||
opentelemetry_sdk = { workspace = true }
|
opentelemetry_sdk = { workspace = true }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
@@ -4,6 +4,7 @@ mod config;
|
|||||||
mod dynamic_skill_selector;
|
mod dynamic_skill_selector;
|
||||||
mod extension;
|
mod extension;
|
||||||
mod fragments;
|
mod fragments;
|
||||||
|
mod loader;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
mod render;
|
mod render;
|
||||||
mod render_observability;
|
mod render_observability;
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use codex_exec_server::CapabilityRootDiscovery;
|
||||||
|
use codex_protocol::protocol::Product;
|
||||||
|
use codex_skills::EnvironmentSkillMetadata;
|
||||||
|
use codex_skills::ParsedSkillFrontmatter;
|
||||||
|
use codex_skills::parse_skill_frontmatter_metadata;
|
||||||
|
use codex_utils_path_uri::PathUri;
|
||||||
|
|
||||||
|
use super::MAX_QUALIFIED_NAME_LEN;
|
||||||
|
use super::metadata::SkillMetadataFile;
|
||||||
|
use super::metadata::resolve_dependencies;
|
||||||
|
use super::metadata::resolve_policy;
|
||||||
|
use super::metadata::sanitize_single_line;
|
||||||
|
use super::metadata::validate_len;
|
||||||
|
|
||||||
|
/// Parsed executor skill plus the instructions already materialized by capability discovery.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct EnvironmentSkillSnapshot {
|
||||||
|
pub metadata: EnvironmentSkillMetadata,
|
||||||
|
pub instructions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct EnvironmentSkillSnapshotOutcome {
|
||||||
|
pub skills: Vec<EnvironmentSkillSnapshot>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an executor-produced manifest bundle without issuing additional filesystem requests.
|
||||||
|
pub fn load_environment_skills_from_discovery(
|
||||||
|
discovery: &CapabilityRootDiscovery,
|
||||||
|
restriction_product: Option<Product>,
|
||||||
|
) -> EnvironmentSkillSnapshotOutcome {
|
||||||
|
let mut outcome = EnvironmentSkillSnapshotOutcome {
|
||||||
|
warnings: discovery.warnings.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if let Some(error) = &discovery.error {
|
||||||
|
outcome.warnings.push(error.clone());
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut plugin_namespaces = HashMap::new();
|
||||||
|
for (plugin_root, name) in discovery.namespace_manifests.iter().filter_map(|manifest| {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ManifestName {
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_root = manifest.path.parent()?.parent()?;
|
||||||
|
let parsed = serde_json::from_str::<ManifestName>(&manifest.contents).ok()?;
|
||||||
|
let name = if parsed.name.trim().is_empty() {
|
||||||
|
plugin_root.basename()?
|
||||||
|
} else {
|
||||||
|
parsed.name
|
||||||
|
};
|
||||||
|
Some((plugin_root, name))
|
||||||
|
}) {
|
||||||
|
// Exec-server orders manifests by the same precedence as local discovery. Preserve the
|
||||||
|
// first manifest if an older or alternate server returns duplicates for one plugin root.
|
||||||
|
plugin_namespaces.entry(plugin_root).or_insert(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for skill in &discovery.skills {
|
||||||
|
let plugin_namespace =
|
||||||
|
nearest_plugin_namespace(&skill.instructions.path, &plugin_namespaces);
|
||||||
|
let ParsedSkillFrontmatter {
|
||||||
|
name: base_name,
|
||||||
|
description,
|
||||||
|
short_description,
|
||||||
|
} = match parse_skill_frontmatter_metadata(&skill.instructions.contents, || {
|
||||||
|
default_skill_name(&skill.instructions.path)
|
||||||
|
}) {
|
||||||
|
Ok(frontmatter) => frontmatter,
|
||||||
|
Err(error) => {
|
||||||
|
outcome.warnings.push(format!(
|
||||||
|
"Failed to load environment skill at {}: {error}",
|
||||||
|
skill.instructions.path
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let name = plugin_namespace
|
||||||
|
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||||
|
.unwrap_or(base_name);
|
||||||
|
if let Err(error) = validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name") {
|
||||||
|
outcome.warnings.push(format!(
|
||||||
|
"Failed to load environment skill at {}: {error}",
|
||||||
|
skill.instructions.path
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (dependencies, policy) = skill
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metadata| {
|
||||||
|
serde_yaml::from_str::<SkillMetadataFile>(&metadata.contents)
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %metadata.path,
|
||||||
|
"ignoring invalid discovered skill metadata: {error}"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.map(|metadata| {
|
||||||
|
(
|
||||||
|
resolve_dependencies(metadata.dependencies),
|
||||||
|
resolve_policy(metadata.policy),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
let metadata = EnvironmentSkillMetadata {
|
||||||
|
path_to_skills_md: skill.instructions.path.clone(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
short_description,
|
||||||
|
dependencies,
|
||||||
|
policy,
|
||||||
|
};
|
||||||
|
if metadata.matches_product_restriction(restriction_product) {
|
||||||
|
outcome.skills.push(EnvironmentSkillSnapshot {
|
||||||
|
metadata,
|
||||||
|
instructions: skill.instructions.contents.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome.skills.sort_by(|left, right| {
|
||||||
|
left.metadata.name.cmp(&right.metadata.name).then_with(|| {
|
||||||
|
left.metadata
|
||||||
|
.path_to_skills_md
|
||||||
|
.to_string()
|
||||||
|
.cmp(&right.metadata.path_to_skills_md.to_string())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nearest_plugin_namespace<'a>(
|
||||||
|
skill_path: &PathUri,
|
||||||
|
plugin_namespaces: &'a HashMap<PathUri, String>,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
let mut ancestor = skill_path.parent();
|
||||||
|
while let Some(path) = ancestor {
|
||||||
|
if let Some(namespace) = plugin_namespaces.get(&path) {
|
||||||
|
return Some(namespace);
|
||||||
|
}
|
||||||
|
ancestor = path.parent();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_skill_name(path: &PathUri) -> String {
|
||||||
|
path.parent()
|
||||||
|
.and_then(|parent| parent.basename())
|
||||||
|
.map(|name| sanitize_single_line(&name))
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| "skill".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "environment_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use codex_core_skills::loader::load_environment_skills_from_root;
|
||||||
|
use codex_exec_server::CapabilityRootDiscoverRequest;
|
||||||
|
use codex_exec_server::CapabilityRootsDiscoverParams;
|
||||||
|
use codex_exec_server::LOCAL_FS;
|
||||||
|
use codex_exec_server::discover_capability_roots;
|
||||||
|
use codex_utils_path_uri::PathUri;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::load_environment_skills_from_discovery;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn executor_bundle_parser_matches_the_existing_environment_loader() {
|
||||||
|
let root = tempdir().expect("tempdir");
|
||||||
|
let plugin_manifest = root.path().join(".codex-plugin/plugin.json");
|
||||||
|
let nested_manifest = root.path().join("nested/.claude-plugin/plugin.json");
|
||||||
|
let deploy_skill = root.path().join("skills/deploy/SKILL.md");
|
||||||
|
let deploy_metadata = root.path().join("skills/deploy/agents/openai.yaml");
|
||||||
|
let audit_skill = root.path().join("nested/skills/audit/SKILL.md");
|
||||||
|
for (path, contents) in [
|
||||||
|
(&plugin_manifest, r#"{"name":"demo"}"#),
|
||||||
|
(&nested_manifest, r#"{"name":"nested"}"#),
|
||||||
|
(
|
||||||
|
&deploy_skill,
|
||||||
|
"---\nname: deploy\ndescription: Deploy the service.\n---\n\nDeploy.\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&deploy_metadata,
|
||||||
|
"policy:\n allow_implicit_invocation: false\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&audit_skill,
|
||||||
|
"---\nname: audit\ndescription: Audit the service.\n---\n\nAudit.\n",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
fs::create_dir_all(path.parent().expect("test file parent")).expect("test directory");
|
||||||
|
fs::write(path, contents).expect("test file");
|
||||||
|
}
|
||||||
|
|
||||||
|
let root_uri = PathUri::from_host_native_path(root.path()).expect("root URI");
|
||||||
|
let existing = load_environment_skills_from_root(
|
||||||
|
LOCAL_FS.as_ref(),
|
||||||
|
&root_uri,
|
||||||
|
/*restriction_product*/ None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let response = discover_capability_roots(
|
||||||
|
LOCAL_FS.as_ref(),
|
||||||
|
CapabilityRootsDiscoverParams {
|
||||||
|
roots: vec![CapabilityRootDiscoverRequest {
|
||||||
|
id: "demo@1".to_string(),
|
||||||
|
path: root_uri,
|
||||||
|
sandbox: None,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capability discovery");
|
||||||
|
let bundled = load_environment_skills_from_discovery(
|
||||||
|
response.roots.first().expect("discovered root"),
|
||||||
|
/*restriction_product*/ None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(bundled.warnings, existing.warnings);
|
||||||
|
assert_eq!(
|
||||||
|
bundled
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| skill.metadata.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
existing.skills
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bundled
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| skill.instructions.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
"---\nname: deploy\ndescription: Deploy the service.\n---\n\nDeploy.\n",
|
||||||
|
"---\nname: audit\ndescription: Audit the service.\n---\n\nAudit.\n",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn executor_bundle_preserves_parent_namespace_and_manifest_precedence() {
|
||||||
|
let plugin = tempdir().expect("tempdir");
|
||||||
|
for (relative_path, name) in [
|
||||||
|
(".codex-plugin/plugin.json", "codex-name"),
|
||||||
|
(".claude-plugin/plugin.json", "claude-name"),
|
||||||
|
(".cursor-plugin/plugin.json", "cursor-name"),
|
||||||
|
] {
|
||||||
|
let manifest = plugin.path().join(relative_path);
|
||||||
|
fs::create_dir_all(manifest.parent().expect("manifest parent"))
|
||||||
|
.expect("manifest directory");
|
||||||
|
fs::write(&manifest, format!(r#"{{"name":"{name}"}}"#)).expect("manifest");
|
||||||
|
}
|
||||||
|
let skills_root = plugin.path().join("skills");
|
||||||
|
let skill_path = skills_root.join("search/SKILL.md");
|
||||||
|
fs::create_dir_all(skill_path.parent().expect("skill parent")).expect("skill directory");
|
||||||
|
fs::write(
|
||||||
|
&skill_path,
|
||||||
|
"---\nname: search\ndescription: Search the project.\n---\n\nSearch.\n",
|
||||||
|
)
|
||||||
|
.expect("skill");
|
||||||
|
|
||||||
|
let root_uri = PathUri::from_host_native_path(&skills_root).expect("skills root URI");
|
||||||
|
let existing = load_environment_skills_from_root(
|
||||||
|
LOCAL_FS.as_ref(),
|
||||||
|
&root_uri,
|
||||||
|
/*restriction_product*/ None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let response = discover_capability_roots(
|
||||||
|
LOCAL_FS.as_ref(),
|
||||||
|
CapabilityRootsDiscoverParams {
|
||||||
|
roots: vec![CapabilityRootDiscoverRequest {
|
||||||
|
id: "skills-only".to_string(),
|
||||||
|
path: root_uri,
|
||||||
|
sandbox: None,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("capability discovery");
|
||||||
|
let discovery = response.roots.first().expect("discovered root");
|
||||||
|
let bundled =
|
||||||
|
load_environment_skills_from_discovery(discovery, /*restriction_product*/ None);
|
||||||
|
|
||||||
|
assert_eq!(discovery.plugin, None);
|
||||||
|
assert_eq!(discovery.namespace_manifests.len(), 1);
|
||||||
|
assert!(
|
||||||
|
discovery.namespace_manifests[0]
|
||||||
|
.path
|
||||||
|
.to_string()
|
||||||
|
.ends_with("/.codex-plugin/plugin.json")
|
||||||
|
);
|
||||||
|
assert_eq!(bundled.warnings, existing.warnings);
|
||||||
|
assert_eq!(
|
||||||
|
bundled
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| skill.metadata.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
existing.skills
|
||||||
|
);
|
||||||
|
assert_eq!(bundled.skills[0].metadata.name, "codex-name:search");
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use codex_protocol::protocol::Product;
|
||||||
|
use codex_skills::SkillDependencies;
|
||||||
|
use codex_skills::SkillParseError;
|
||||||
|
use codex_skills::SkillPolicy;
|
||||||
|
use codex_skills::SkillToolDependency;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::MAX_DEPENDENCY_COMMAND_LEN;
|
||||||
|
use super::MAX_DEPENDENCY_DESCRIPTION_LEN;
|
||||||
|
use super::MAX_DEPENDENCY_TRANSPORT_LEN;
|
||||||
|
use super::MAX_DEPENDENCY_TYPE_LEN;
|
||||||
|
use super::MAX_DEPENDENCY_URL_LEN;
|
||||||
|
use super::MAX_DEPENDENCY_VALUE_LEN;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(super) struct SkillMetadataFile {
|
||||||
|
// Keep parsing the host-only interface fields so malformed interface metadata
|
||||||
|
// invalidates the file exactly as it does in the legacy loader.
|
||||||
|
#[serde(default, rename = "interface")]
|
||||||
|
_interface: Option<Interface>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) dependencies: Option<Dependencies>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) policy: Option<Policy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct Interface {
|
||||||
|
#[serde(rename = "display_name")]
|
||||||
|
_display_name: Option<String>,
|
||||||
|
#[serde(rename = "short_description")]
|
||||||
|
_short_description: Option<String>,
|
||||||
|
#[serde(rename = "icon_small")]
|
||||||
|
_icon_small: Option<PathBuf>,
|
||||||
|
#[serde(rename = "icon_large")]
|
||||||
|
_icon_large: Option<PathBuf>,
|
||||||
|
#[serde(rename = "brand_color")]
|
||||||
|
_brand_color: Option<String>,
|
||||||
|
#[serde(rename = "default_prompt")]
|
||||||
|
_default_prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(super) struct Dependencies {
|
||||||
|
#[serde(default)]
|
||||||
|
tools: Vec<DependencyTool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct Policy {
|
||||||
|
#[serde(default)]
|
||||||
|
allow_implicit_invocation: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
products: Vec<Product>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct DependencyTool {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
value: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
transport: Option<String>,
|
||||||
|
command: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_dependencies(
|
||||||
|
dependencies: Option<Dependencies>,
|
||||||
|
) -> Option<SkillDependencies> {
|
||||||
|
let dependencies = dependencies?;
|
||||||
|
let tools = dependencies
|
||||||
|
.tools
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(resolve_dependency_tool)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if tools.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(SkillDependencies { tools })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_policy(policy: Option<Policy>) -> Option<SkillPolicy> {
|
||||||
|
policy.map(|policy| SkillPolicy {
|
||||||
|
allow_implicit_invocation: policy.allow_implicit_invocation,
|
||||||
|
products: policy.products,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_dependency_tool(tool: DependencyTool) -> Option<SkillToolDependency> {
|
||||||
|
let r#type = resolve_required_str(
|
||||||
|
tool.kind,
|
||||||
|
MAX_DEPENDENCY_TYPE_LEN,
|
||||||
|
"dependencies.tools.type",
|
||||||
|
)?;
|
||||||
|
let value = resolve_required_str(
|
||||||
|
tool.value,
|
||||||
|
MAX_DEPENDENCY_VALUE_LEN,
|
||||||
|
"dependencies.tools.value",
|
||||||
|
)?;
|
||||||
|
let description = resolve_str(
|
||||||
|
tool.description,
|
||||||
|
MAX_DEPENDENCY_DESCRIPTION_LEN,
|
||||||
|
"dependencies.tools.description",
|
||||||
|
);
|
||||||
|
let transport = resolve_str(
|
||||||
|
tool.transport,
|
||||||
|
MAX_DEPENDENCY_TRANSPORT_LEN,
|
||||||
|
"dependencies.tools.transport",
|
||||||
|
);
|
||||||
|
let command = resolve_str(
|
||||||
|
tool.command,
|
||||||
|
MAX_DEPENDENCY_COMMAND_LEN,
|
||||||
|
"dependencies.tools.command",
|
||||||
|
);
|
||||||
|
let url = resolve_str(tool.url, MAX_DEPENDENCY_URL_LEN, "dependencies.tools.url");
|
||||||
|
|
||||||
|
Some(SkillToolDependency {
|
||||||
|
r#type,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
transport,
|
||||||
|
command,
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sanitize_single_line(raw: &str) -> String {
|
||||||
|
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_len(
|
||||||
|
value: &str,
|
||||||
|
max_len: usize,
|
||||||
|
field_name: &'static str,
|
||||||
|
) -> Result<(), SkillParseError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(SkillParseError::MissingField(field_name));
|
||||||
|
}
|
||||||
|
if value.chars().count() > max_len {
|
||||||
|
return Err(SkillParseError::InvalidField {
|
||||||
|
field: field_name,
|
||||||
|
reason: format!("exceeds maximum length of {max_len} characters"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_str(value: Option<String>, max_len: usize, field: &'static str) -> Option<String> {
|
||||||
|
let value = value?;
|
||||||
|
let value = sanitize_single_line(&value);
|
||||||
|
if value.is_empty() {
|
||||||
|
tracing::warn!("ignoring {field}: value is empty");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if value.chars().count() > max_len {
|
||||||
|
tracing::warn!("ignoring {field}: exceeds maximum length of {max_len} characters");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_required_str(
|
||||||
|
value: Option<String>,
|
||||||
|
max_len: usize,
|
||||||
|
field: &'static str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
tracing::warn!("ignoring {field}: value is missing");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
resolve_str(Some(value), max_len, field)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
mod environment;
|
||||||
|
mod metadata;
|
||||||
|
|
||||||
|
pub(crate) use environment::load_environment_skills_from_discovery;
|
||||||
|
|
||||||
|
pub(super) const MAX_NAME_LEN: usize = 64;
|
||||||
|
pub(super) const MAX_QUALIFIED_NAME_LEN: usize = 128;
|
||||||
|
pub(super) const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||||
|
pub(super) const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN;
|
||||||
|
pub(super) const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN;
|
||||||
|
pub(super) const MAX_DEPENDENCY_VALUE_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||||
|
pub(super) const MAX_DEPENDENCY_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||||
|
pub(super) const MAX_DEPENDENCY_COMMAND_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||||
|
pub(super) const MAX_DEPENDENCY_URL_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use codex_core_skills::loader::load_environment_skills_from_discovery;
|
|
||||||
use codex_core_skills::loader::load_environment_skills_from_root;
|
use codex_core_skills::loader::load_environment_skills_from_root;
|
||||||
use codex_exec_server::EnvironmentManager;
|
use codex_exec_server::EnvironmentManager;
|
||||||
use codex_exec_server::FileSystemSandboxContext;
|
use codex_exec_server::FileSystemSandboxContext;
|
||||||
@@ -20,6 +19,7 @@ use crate::catalog::SkillReadResult;
|
|||||||
use crate::catalog::SkillResourceId;
|
use crate::catalog::SkillResourceId;
|
||||||
use crate::catalog::SkillSearchResult;
|
use crate::catalog::SkillSearchResult;
|
||||||
use crate::catalog::SkillSourceKind;
|
use crate::catalog::SkillSourceKind;
|
||||||
|
use crate::loader::load_environment_skills_from_discovery;
|
||||||
use crate::provider::MAX_SKILL_RESOURCE_CONTENT_BYTES;
|
use crate::provider::MAX_SKILL_RESOURCE_CONTENT_BYTES;
|
||||||
use crate::provider::SkillListQuery;
|
use crate::provider::SkillListQuery;
|
||||||
use crate::provider::SkillProvider;
|
use crate::provider::SkillProvider;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::sync::atomic::Ordering;
|
|||||||
use codex_core_skills::HostSkillsSnapshot;
|
use codex_core_skills::HostSkillsSnapshot;
|
||||||
use codex_core_skills::loader::MAX_CONCURRENT_ROOT_SCANS;
|
use codex_core_skills::loader::MAX_CONCURRENT_ROOT_SCANS;
|
||||||
use codex_core_skills::loader::SkillRoot;
|
use codex_core_skills::loader::SkillRoot;
|
||||||
|
use codex_core_skills::loader::load_environment_skills_from_root as load_legacy_environment_skills_from_root;
|
||||||
use codex_core_skills::loader::load_skills_from_roots;
|
use codex_core_skills::loader::load_skills_from_roots;
|
||||||
use codex_exec_server::CopyOptions;
|
use codex_exec_server::CopyOptions;
|
||||||
use codex_exec_server::CreateDirectoryOptions;
|
use codex_exec_server::CreateDirectoryOptions;
|
||||||
@@ -24,6 +25,7 @@ use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|||||||
use codex_protocol::models::PermissionProfile;
|
use codex_protocol::models::PermissionProfile;
|
||||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||||
|
use codex_protocol::protocol::Product;
|
||||||
use codex_protocol::protocol::SkillScope;
|
use codex_protocol::protocol::SkillScope;
|
||||||
use codex_skills_extension::ExecutorSkillProvider;
|
use codex_skills_extension::ExecutorSkillProvider;
|
||||||
use codex_skills_extension::catalog::SkillAuthority;
|
use codex_skills_extension::catalog::SkillAuthority;
|
||||||
@@ -350,6 +352,295 @@ async fn selected_root_id_distinguishes_identical_executor_paths() {
|
|||||||
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn executor_discovery_routes_produce_equivalent_catalog_metadata() {
|
||||||
|
let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let test_root = std::env::temp_dir().join(format!(
|
||||||
|
"codex-executor-skill-parity-{}-{id}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let plugin_manifest = test_root.join(".codex-plugin/plugin.json");
|
||||||
|
let deploy_skill = test_root.join("skills/deploy/SKILL.md");
|
||||||
|
let deploy_metadata = test_root.join("skills/deploy/agents/openai.yaml");
|
||||||
|
let excluded_skill = test_root.join("skills/excluded/SKILL.md");
|
||||||
|
let excluded_metadata = test_root.join("skills/excluded/agents/openai.yaml");
|
||||||
|
let repaired_skill = test_root.join("skills/repaired/SKILL.md");
|
||||||
|
let invalid_skill = test_root.join("skills/invalid/SKILL.md");
|
||||||
|
let invalid_metadata_skill = test_root.join("skills/invalid-metadata/SKILL.md");
|
||||||
|
let invalid_metadata = test_root.join("skills/invalid-metadata/agents/openai.yaml");
|
||||||
|
for path in [
|
||||||
|
&plugin_manifest,
|
||||||
|
&deploy_skill,
|
||||||
|
&deploy_metadata,
|
||||||
|
&excluded_skill,
|
||||||
|
&excluded_metadata,
|
||||||
|
&repaired_skill,
|
||||||
|
&invalid_skill,
|
||||||
|
&invalid_metadata_skill,
|
||||||
|
&invalid_metadata,
|
||||||
|
] {
|
||||||
|
std::fs::create_dir_all(path.parent().expect("test file parent"))
|
||||||
|
.expect("create test directory");
|
||||||
|
}
|
||||||
|
std::fs::write(&plugin_manifest, r#"{"name":"catalog"}"#).expect("write plugin manifest");
|
||||||
|
std::fs::write(
|
||||||
|
&deploy_skill,
|
||||||
|
"---\nname: deploy\ndescription: Deploy the service.\nmetadata:\n short-description: Deploy safely.\n---\n\nDeploy.\n",
|
||||||
|
)
|
||||||
|
.expect("write deploy skill");
|
||||||
|
std::fs::write(
|
||||||
|
&deploy_metadata,
|
||||||
|
"dependencies:\n tools:\n - type: mcp\n value: deployer\n description: Deployment server.\npolicy:\n allow_implicit_invocation: false\n products:\n - codex\n",
|
||||||
|
)
|
||||||
|
.expect("write deploy metadata");
|
||||||
|
std::fs::write(
|
||||||
|
&excluded_skill,
|
||||||
|
"---\nname: excluded\ndescription: Excluded skill.\n---\n",
|
||||||
|
)
|
||||||
|
.expect("write excluded skill");
|
||||||
|
std::fs::write(&excluded_metadata, "policy:\n products:\n - chatgpt\n")
|
||||||
|
.expect("write excluded metadata");
|
||||||
|
std::fs::write(
|
||||||
|
&repaired_skill,
|
||||||
|
"---\ndescription: Build for AWS: ECS\n---\n",
|
||||||
|
)
|
||||||
|
.expect("write repaired skill");
|
||||||
|
std::fs::write(&invalid_skill, "---\nname: invalid\n---\n").expect("write invalid skill");
|
||||||
|
std::fs::write(
|
||||||
|
&invalid_metadata_skill,
|
||||||
|
"---\nname: invalid-metadata\ndescription: Invalid optional metadata.\n---\n",
|
||||||
|
)
|
||||||
|
.expect("write invalid metadata skill");
|
||||||
|
std::fs::write(
|
||||||
|
&invalid_metadata,
|
||||||
|
"interface: []\ndependencies:\n tools:\n - type: mcp\n value: must-be-ignored\n",
|
||||||
|
)
|
||||||
|
.expect("write invalid metadata");
|
||||||
|
|
||||||
|
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||||
|
let provider = ExecutorSkillProvider::new_with_restriction_product(
|
||||||
|
Arc::clone(&manager),
|
||||||
|
Some(Product::Codex),
|
||||||
|
);
|
||||||
|
let executor_roots = vec![SelectedCapabilityRoot {
|
||||||
|
id: "parity-root".to_string(),
|
||||||
|
location: CapabilityRootLocation::Environment {
|
||||||
|
environment_id: "local".to_string(),
|
||||||
|
path: PathUri::from_host_native_path(&test_root).expect("skill root URI"),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
let query = |executor_capability_discovery| SkillListQuery {
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
executor_roots: executor_roots.clone(),
|
||||||
|
resolved_executor_roots: Vec::new(),
|
||||||
|
host_snapshot: None,
|
||||||
|
include_host_skills: false,
|
||||||
|
include_bundled_skills: true,
|
||||||
|
include_orchestrator_skills: false,
|
||||||
|
mcp_resources: None,
|
||||||
|
executor_capability_discovery,
|
||||||
|
};
|
||||||
|
|
||||||
|
let legacy_file_system = manager
|
||||||
|
.get_environment("local")
|
||||||
|
.expect("local environment")
|
||||||
|
.get_filesystem();
|
||||||
|
let legacy = load_legacy_environment_skills_from_root(
|
||||||
|
legacy_file_system.as_ref(),
|
||||||
|
&PathUri::from_host_native_path(&test_root).expect("skill root URI"),
|
||||||
|
Some(Product::Codex),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let direct = provider
|
||||||
|
.list(query(None))
|
||||||
|
.await
|
||||||
|
.expect("list directly discovered executor skills");
|
||||||
|
let discovery = ExecutorCapabilityDiscoveryCache::new(manager)
|
||||||
|
.snapshot(&executor_roots, &Default::default())
|
||||||
|
.await;
|
||||||
|
let bundled = provider
|
||||||
|
.list(query(Some(discovery)))
|
||||||
|
.await
|
||||||
|
.expect("list bundled executor skills");
|
||||||
|
|
||||||
|
assert_eq!(bundled.warnings, direct.warnings);
|
||||||
|
let comparable_entries = |catalog: &codex_skills_extension::catalog::SkillCatalog| {
|
||||||
|
catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.id.clone(),
|
||||||
|
entry.authority.clone(),
|
||||||
|
entry.name.clone(),
|
||||||
|
entry.description.clone(),
|
||||||
|
entry.short_description.clone(),
|
||||||
|
entry.display_path.clone(),
|
||||||
|
entry.dependencies.clone(),
|
||||||
|
entry.enabled,
|
||||||
|
entry.prompt_visible,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(comparable_entries(&bundled), comparable_entries(&direct));
|
||||||
|
assert_eq!(legacy.warnings, direct.warnings);
|
||||||
|
assert_eq!(direct.warnings.len(), 1);
|
||||||
|
assert!(direct.warnings[0].contains("missing field `description`"));
|
||||||
|
let legacy_metadata = legacy
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| {
|
||||||
|
(
|
||||||
|
skill.name.clone(),
|
||||||
|
skill.description.clone(),
|
||||||
|
skill.short_description.clone(),
|
||||||
|
skill.dependencies.clone(),
|
||||||
|
skill.allows_implicit_invocation(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let catalog_metadata = direct
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.name.clone(),
|
||||||
|
entry.description.clone(),
|
||||||
|
entry.short_description.clone(),
|
||||||
|
entry.dependencies.clone(),
|
||||||
|
entry.prompt_visible,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(catalog_metadata, legacy_metadata);
|
||||||
|
let entry = direct
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "catalog:deploy")
|
||||||
|
.expect("deploy skill");
|
||||||
|
assert_eq!(
|
||||||
|
entry
|
||||||
|
.dependencies
|
||||||
|
.as_ref()
|
||||||
|
.expect("skill dependencies")
|
||||||
|
.tools[0]
|
||||||
|
.value,
|
||||||
|
"deployer"
|
||||||
|
);
|
||||||
|
assert!(!entry.prompt_visible);
|
||||||
|
let repaired = direct
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "catalog:repaired")
|
||||||
|
.expect("repaired skill");
|
||||||
|
assert_eq!(repaired.description, "Build for AWS: ECS");
|
||||||
|
let invalid_metadata = direct
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == "catalog:invalid-metadata")
|
||||||
|
.expect("invalid metadata skill");
|
||||||
|
assert_eq!(invalid_metadata.dependencies, None);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_discovered_executor_catalog_snapshot() {
|
||||||
|
let test_root = create_local_skill_root("snapshot").expect("create local skill root");
|
||||||
|
let manifest_dir = test_root.join(".codex-plugin");
|
||||||
|
let metadata_dir = test_root.join("skill/agents");
|
||||||
|
std::fs::create_dir_all(&manifest_dir).expect("create plugin manifest directory");
|
||||||
|
std::fs::create_dir_all(&metadata_dir).expect("create skill metadata directory");
|
||||||
|
std::fs::write(
|
||||||
|
manifest_dir.join("plugin.json"),
|
||||||
|
r#"{"name":"snapshot-plugin"}"#,
|
||||||
|
)
|
||||||
|
.expect("write plugin manifest");
|
||||||
|
std::fs::write(
|
||||||
|
metadata_dir.join("openai.yaml"),
|
||||||
|
"dependencies:\n tools:\n - type: mcp\n value: deployer\n description: Deployment server.\npolicy:\n allow_implicit_invocation: false\n",
|
||||||
|
)
|
||||||
|
.expect("write skill metadata");
|
||||||
|
|
||||||
|
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||||
|
let provider = ExecutorSkillProvider::new_with_restriction_product(
|
||||||
|
Arc::clone(&manager),
|
||||||
|
/*restriction_product*/ None,
|
||||||
|
);
|
||||||
|
let executor_roots = vec![SelectedCapabilityRoot {
|
||||||
|
id: "snapshot-root".to_string(),
|
||||||
|
location: CapabilityRootLocation::Environment {
|
||||||
|
environment_id: "local".to_string(),
|
||||||
|
path: PathUri::from_host_native_path(&test_root).expect("skill root URI"),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
let executor_capability_discovery = ExecutorCapabilityDiscoveryCache::new(manager)
|
||||||
|
.snapshot(&executor_roots, &Default::default())
|
||||||
|
.await;
|
||||||
|
let catalog = provider
|
||||||
|
.list(SkillListQuery {
|
||||||
|
turn_id: "turn-1".to_string(),
|
||||||
|
executor_roots,
|
||||||
|
resolved_executor_roots: Vec::new(),
|
||||||
|
host_snapshot: None,
|
||||||
|
include_host_skills: false,
|
||||||
|
include_bundled_skills: true,
|
||||||
|
include_orchestrator_skills: false,
|
||||||
|
mcp_resources: None,
|
||||||
|
executor_capability_discovery: Some(executor_capability_discovery),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("list pre-discovered executor skills");
|
||||||
|
|
||||||
|
let mut snapshot = serde_json::json!({
|
||||||
|
"warnings": &catalog.warnings,
|
||||||
|
"entries": catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| serde_json::json!({
|
||||||
|
"authority": {
|
||||||
|
"kind": entry.authority.kind.to_string(),
|
||||||
|
"id": &entry.authority.id,
|
||||||
|
},
|
||||||
|
"name": &entry.name,
|
||||||
|
"description": &entry.description,
|
||||||
|
"short_description": entry.short_description.as_deref(),
|
||||||
|
"dependencies": entry.dependencies.as_ref().map(|dependencies| {
|
||||||
|
dependencies.tools.iter().map(|tool| serde_json::json!({
|
||||||
|
"type": &tool.r#type,
|
||||||
|
"value": &tool.value,
|
||||||
|
"description": tool.description.as_deref(),
|
||||||
|
"transport": tool.transport.as_deref(),
|
||||||
|
"command": tool.command.as_deref(),
|
||||||
|
"url": tool.url.as_deref(),
|
||||||
|
})).collect::<Vec<_>>()
|
||||||
|
}),
|
||||||
|
"enabled": entry.enabled,
|
||||||
|
"prompt_visible": entry.prompt_visible,
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
snapshot.sort_all_objects();
|
||||||
|
let snapshot_file = codex_utils_cargo_bin::find_resource!(
|
||||||
|
"tests/snapshots/executor_file_system_authority__pre_discovered_executor_catalog.snap"
|
||||||
|
)
|
||||||
|
.expect("resolve catalog snapshot");
|
||||||
|
let snapshot_dir = snapshot_file
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| panic!("snapshot file has no parent: {}", snapshot_file.display()));
|
||||||
|
let mut settings = insta::Settings::clone_current();
|
||||||
|
settings.set_snapshot_path(snapshot_dir);
|
||||||
|
settings.bind(|| {
|
||||||
|
insta::assert_snapshot!(
|
||||||
|
"pre_discovered_executor_catalog",
|
||||||
|
serde_json::to_string_pretty(&snapshot).expect("serialize catalog snapshot")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
|
async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
|
||||||
let test_root = create_local_skill_root("materialized").expect("create local skill root");
|
let test_root = create_local_skill_root("materialized").expect("create local skill root");
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
source: ext/skills/tests/executor_file_system_authority.rs
|
||||||
|
expression: "serde_json::to_string_pretty(&snapshot).expect(\"serialize catalog snapshot\")"
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"authority": {
|
||||||
|
"id": "snapshot-root",
|
||||||
|
"kind": "executor"
|
||||||
|
},
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"command": null,
|
||||||
|
"description": "Deployment server.",
|
||||||
|
"transport": null,
|
||||||
|
"type": "mcp",
|
||||||
|
"url": null,
|
||||||
|
"value": "deployer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Synthetic executor skill.",
|
||||||
|
"enabled": true,
|
||||||
|
"name": "snapshot-plugin:synthetic",
|
||||||
|
"prompt_visible": false,
|
||||||
|
"short_description": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
@@ -18,4 +18,9 @@ codex-protocol = { workspace = true }
|
|||||||
codex-utils-absolute-path = { workspace = true }
|
codex-utils-absolute-path = { workspace = true }
|
||||||
codex-utils-path-uri = { workspace = true }
|
codex-utils-path-uri = { workspace = true }
|
||||||
include_dir = { workspace = true }
|
include_dir = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_yaml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod model;
|
mod model;
|
||||||
|
mod parser;
|
||||||
|
|
||||||
pub use model::EnvironmentSkillMetadata;
|
pub use model::EnvironmentSkillMetadata;
|
||||||
pub use model::SkillConfigRule;
|
pub use model::SkillConfigRule;
|
||||||
@@ -9,6 +10,9 @@ pub use model::SkillInterface;
|
|||||||
pub use model::SkillMetadata;
|
pub use model::SkillMetadata;
|
||||||
pub use model::SkillPolicy;
|
pub use model::SkillPolicy;
|
||||||
pub use model::SkillToolDependency;
|
pub use model::SkillToolDependency;
|
||||||
|
pub use parser::ParsedSkillFrontmatter;
|
||||||
|
pub use parser::SkillParseError;
|
||||||
|
pub use parser::parse_skill_frontmatter_metadata;
|
||||||
|
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use include_dir::Dir;
|
use include_dir::Dir;
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const MAX_NAME_LEN: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SkillFrontmatter {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
metadata: SkillFrontmatterMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct SkillFrontmatterMetadata {
|
||||||
|
#[serde(default, rename = "short-description")]
|
||||||
|
short_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated metadata parsed from a `SKILL.md` frontmatter block.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParsedSkillFrontmatter {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub short_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error produced while parsing or validating `SKILL.md` metadata.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SkillParseError {
|
||||||
|
#[error("missing YAML frontmatter delimited by ---")]
|
||||||
|
MissingFrontmatter,
|
||||||
|
#[error("invalid YAML: {0}")]
|
||||||
|
InvalidYaml(#[source] serde_yaml::Error),
|
||||||
|
#[error("missing field `{0}`")]
|
||||||
|
MissingField(&'static str),
|
||||||
|
#[error("invalid {field}: {reason}")]
|
||||||
|
InvalidField { field: &'static str, reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and validates the metadata frontmatter from `SKILL.md` contents.
|
||||||
|
pub fn parse_skill_frontmatter_metadata(
|
||||||
|
contents: &str,
|
||||||
|
default_name: impl FnOnce() -> String,
|
||||||
|
) -> Result<ParsedSkillFrontmatter, SkillParseError> {
|
||||||
|
let frontmatter = extract_frontmatter(contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
||||||
|
|
||||||
|
let parsed: SkillFrontmatter = match serde_yaml::from_str(&frontmatter) {
|
||||||
|
Ok(parsed) => Ok(parsed),
|
||||||
|
Err(original_error) => match repair_frontmatter_scalar_fields(&frontmatter) {
|
||||||
|
// Some third-party skills use prose like `description: Build for AWS: ECS`
|
||||||
|
// or `argument-hint: <duration: e.g. 7d>`. Keep the repair line-oriented
|
||||||
|
// so unrelated invalid YAML still surfaces.
|
||||||
|
Some(repaired_frontmatter) => {
|
||||||
|
serde_yaml::from_str(&repaired_frontmatter).map_err(|_| original_error)
|
||||||
|
}
|
||||||
|
None => Err(original_error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.map_err(SkillParseError::InvalidYaml)?;
|
||||||
|
|
||||||
|
let name = parsed
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(sanitize_single_line)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(default_name);
|
||||||
|
let description = parsed
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.map(sanitize_single_line)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let short_description = parsed
|
||||||
|
.metadata
|
||||||
|
.short_description
|
||||||
|
.as_deref()
|
||||||
|
.map(sanitize_single_line)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
|
validate_len(&name, MAX_NAME_LEN, "name")?;
|
||||||
|
if description.is_empty() {
|
||||||
|
return Err(SkillParseError::MissingField("description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedSkillFrontmatter {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
short_description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_single_line(raw: &str) -> String {
|
||||||
|
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_frontmatter_scalar_fields(frontmatter: &str) -> Option<String> {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut block_scalar_indent: Option<usize> = None;
|
||||||
|
let mut repaired_lines = Vec::new();
|
||||||
|
for line in frontmatter.lines() {
|
||||||
|
let indent = line
|
||||||
|
.chars()
|
||||||
|
.take_while(|character| *character == ' ')
|
||||||
|
.count();
|
||||||
|
if let Some(block_indent) = block_scalar_indent {
|
||||||
|
if line.trim().is_empty() || indent > block_indent {
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
block_scalar_indent = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((key, value)) = line.split_once(':') else {
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if key.trim().is_empty() || !value.chars().next().is_none_or(char::is_whitespace) {
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed_start = value.trim_start();
|
||||||
|
let leading_whitespace = &value[..value.len() - trimmed_start.len()];
|
||||||
|
let mut scalar = trimmed_start;
|
||||||
|
let mut comment = "";
|
||||||
|
for (index, character) in trimmed_start.char_indices() {
|
||||||
|
if character == '#'
|
||||||
|
&& (index == 0
|
||||||
|
|| trimmed_start[..index]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_some_and(char::is_whitespace))
|
||||||
|
{
|
||||||
|
let comment_start = trimmed_start[..index].trim_end().len();
|
||||||
|
scalar = &trimmed_start[..comment_start];
|
||||||
|
comment = &trimmed_start[comment_start..];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scalar = scalar.trim_end();
|
||||||
|
let Some(first_char) = scalar.chars().next() else {
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(first_char, '|' | '>') {
|
||||||
|
block_scalar_indent = Some(indent);
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(first_char, '\'' | '"') {
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut has_colon_separator = false;
|
||||||
|
let mut chars = scalar.chars().peekable();
|
||||||
|
while let Some(character) = chars.next() {
|
||||||
|
if character == ':'
|
||||||
|
&& matches!(chars.peek(), Some(next_character) if next_character.is_whitespace())
|
||||||
|
{
|
||||||
|
has_colon_separator = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let invalid_flow_like_scalar = matches!(first_char, '[' | '{' | '@' | '`')
|
||||||
|
&& serde_yaml::from_str::<serde_yaml::Value>(scalar).is_err();
|
||||||
|
if !has_colon_separator && !invalid_flow_like_scalar {
|
||||||
|
repaired_lines.push(line.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let quoted_scalar = format!("'{}'", scalar.replace('\'', "''"));
|
||||||
|
repaired_lines.push(format!(
|
||||||
|
"{key}:{leading_whitespace}{quoted_scalar}{comment}"
|
||||||
|
));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
changed.then(|| repaired_lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_len(
|
||||||
|
value: &str,
|
||||||
|
max_len: usize,
|
||||||
|
field_name: &'static str,
|
||||||
|
) -> Result<(), SkillParseError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(SkillParseError::MissingField(field_name));
|
||||||
|
}
|
||||||
|
if value.chars().count() > max_len {
|
||||||
|
return Err(SkillParseError::InvalidField {
|
||||||
|
field: field_name,
|
||||||
|
reason: format!("exceeds maximum length of {max_len} characters"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||||
|
let mut lines = contents.lines();
|
||||||
|
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut frontmatter_lines = Vec::new();
|
||||||
|
let mut found_closing = false;
|
||||||
|
for line in lines.by_ref() {
|
||||||
|
if line.trim() == "---" {
|
||||||
|
found_closing = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
frontmatter_lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontmatter_lines.is_empty() || !found_closing {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(frontmatter_lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "parser_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use super::ParsedSkillFrontmatter;
|
||||||
|
use super::parse_skill_frontmatter_metadata;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_repairs_and_sanitizes_frontmatter() {
|
||||||
|
let parsed = parse_skill_frontmatter_metadata(
|
||||||
|
"---\nname: deploy service\ndescription: Build for AWS: ECS\nmetadata:\n short-description: Deploy safely\n---\n",
|
||||||
|
|| "fallback".to_string(),
|
||||||
|
)
|
||||||
|
.expect("valid frontmatter");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
ParsedSkillFrontmatter {
|
||||||
|
name: "deploy service".to_string(),
|
||||||
|
description: "Build for AWS: ECS".to_string(),
|
||||||
|
short_description: Some("Deploy safely".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_default_name_and_requires_description() {
|
||||||
|
let parsed = parse_skill_frontmatter_metadata("---\ndescription: Demo skill\n---\n", || {
|
||||||
|
"demo".to_string()
|
||||||
|
})
|
||||||
|
.expect("valid frontmatter");
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
ParsedSkillFrontmatter {
|
||||||
|
name: "demo".to_string(),
|
||||||
|
description: "Demo skill".to_string(),
|
||||||
|
short_description: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let error =
|
||||||
|
parse_skill_frontmatter_metadata("---\nname: demo\n---\n", || "fallback".to_string())
|
||||||
|
.expect_err("description should be required");
|
||||||
|
assert_eq!(error.to_string(), "missing field `description`");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user